The local runbook: run the apps, run the tests, debug a stuck daemon. For architecture and package boundaries read ARCHITECTURE.md; for engineering discipline read ../AGENTS.md; for every environment variable the project reads read ENVIRONMENT.md.
Note
devenv is recommended: it pins Node.js 26, pnpm 11, the Rust toolchain from rust-toolchain.toml, and the prek pre-commit hooks. It is not required if your local toolchain already matches.
With devenv, enter the pinned environment:
devenv shellWithout devenv, install these yourself:
- Node.js 24 or newer (
.nvmrcpins 26 for CI) and pnpm 11. Always pnpm, never npm or npx. - rustup. The version,
rustfmt,clippy, andrust-analyzerall come fromrust-toolchain.toml— rustup reads it automatically and installs that exact toolchain the first time you runcargoin this repo, so a given commit always builds with the same compiler. Bumping the pin also needsdevenv update rust-overlay: the devenv-side toolchain can only resolve versions present in the locked rust-overlay manifests. - A platform native toolchain (below). Neither
devenvnor rustup provides the system C/C++ toolchain, and both the PTY sidecar's linker and the native node modules (better-sqlite3) need it.
Ubuntu / Debian. Build tools plus the Electron/Chromium runtime libraries:
sudo apt-get install -y build-essential pkg-config python3 ca-certificates curl xz-utils
# Electron/Chromium runtime libraries — playwright resolves the per-release package names for you
# (needs `pnpm install` first; this is what CI runs):
pnpm -F @linkcode/desktop exec playwright-core install-deps chromiumIf you would rather not go through playwright, the equivalent explicit set is libasound2 libatk-bridge2.0-0 libdrm2 libgbm1 libgtk-3-0 libnss3 libxkbcommon0 libxss1 (on 24.04 the t64 packages provide these names). Headless runs additionally need xvfb and dbus-x11, and e2e:window-bounds needs a window manager — openbox is what CI uses, because maximize is a window-manager operation. Cross-building the sidecar for linux-arm64 needs gcc-aarch64-linux-gnu.
macOS. Xcode Command Line Tools are enough for everything except packaging:
xcode-select --installPackaging (package, package:devshell) additionally needs full Xcode 26 or newer: electron-builder runs actool over assets/linkcode.icon to emit Assets.car, and older actool cannot read the Icon Composer source.
Windows. Install Visual Studio 2022 Build Tools with the Desktop development with C++ workload — that provides the MSVC v143 toolset and the Windows SDK, which the *-pc-windows-msvc Rust target's linker and node-gyp both require. NSIS packaging needs nothing beyond it. Cross-building the sidecar for win-arm64 additionally needs the Microsoft.VisualStudio.Component.VC.Tools.ARM64 component (CI installs it in .github/actions/build-sidecar).
.agents/setup provisions a fresh Amp orb container (Ubuntu): it apt-installs the native and VNC packages, installs Nix and devenv, then runs pnpm install --frozen-lockfile and cargo fetch --locked. It assumes a root-capable throwaway container and installs a system-wide Nix daemon — do not run it on your own machine. Use the steps above instead.
pnpm installdevenv shell -- appapp first builds the Rust PTY sidecar, then starts the daemon and desktop dev processes in parallel. Without devenv, run the same sequence:
pnpm -F @linkcode/daemon run build:rust
pnpm --filter @linkcode/daemon --filter @linkcode/desktop --parallel devRoot pnpm dev (= turbo run dev) is different: it starts the three persistent dev tasks at once — daemon, desktop, and webview. Use -F/--filter to run a subset. apps/mobile and the packages/*/* have no dev script.
devenv shell -- daemon
# without devenv:
pnpm -F @linkcode/daemon run build:rust
pnpm -F @linkcode/daemon run devdevenv shell -- desktop
# without devenv:
pnpm -F @linkcode/desktop run dev # scripts/dev.mts: vite builds + dev server + electronThere is no devenv script for the webview. Run it directly:
pnpm -F @linkcode/webview run dev # vitedevenv shell -- mobile
# without devenv:
pnpm -F @linkcode/mobile run ios # expo start --iosThe daemon drives terminals through the Rust crate at crates/linkcode-pty; its wire protocol is in crates/linkcode-pty/PROTOCOL.md. Build it for local dev:
pnpm -F @linkcode/daemon run build:rust # cargo build -p linkcode-pty --releaseThe daemon resolves the binary (resolveSidecarPath, apps/daemon/src/pty/sidecar.ts) in this order:
LINKCODE_PTY_SIDECAR_PATH— always wins when set.- Dev (running
.tssource under tsx):<repoRoot>/target/release/linkcode-pty(linkcode-pty.exeon Windows). - Prod (a tsup
.jsbundle can't trust that relative depth): with no override it logs an error and returns''— terminals are unconfigured.
The release build is used in dev on purpose, so the daemon's fallback path matches the build script. If terminals fail in dev, rebuild the sidecar first, then see the terminal triage below.
Four different things are called "build"; none of them implies the others.
| Command | What it actually does |
|---|---|
pnpm build (root) |
turbo run build — each workspace's own build script in dependency order (dist/, out/, build/, .vite/). It does not invoke cargo, does not package the desktop app, and skips apps/mobile, which has no build script. |
pnpm -F @linkcode/daemon build:rust |
cargo build -p linkcode-pty --release → target/release/linkcode-pty, the path the dev daemon falls back to. Deliberately outside the turbo graph. For CI parity in tests use the debug build instead: cargo build --locked -p linkcode-pty. |
pnpm -F @linkcode/desktop package / package:devshell |
The full product. Both first run stage:host-runtime (build the daemon bundle, then stage-sidecar.mts, which cargo-builds the sidecar and copies it to apps/desktop/sidecar/<arch> for electron-builder's extraResources), then go through scripts/package-app.mts — never a bare electron-builder. package is the production variant; package:devshell adds --mode devshell + --dir for the unsigned LinkCode Development.app. Release packaging is CI-only; there is no dist script. |
pnpm -F @linkcode/mobile smoke:export |
Production Expo Router exports for Android and iOS (expo-export/), asserting Hermes bytecode, source maps, and that the expected routes were bundled. It is an app-entry bundle gate, not a native build: real binaries come from expo run:ios / expo run:android locally and from EAS profiles (apps/mobile/eas.json) for distribution. |
stage-sidecar.mts --all additionally cross-builds the platform's second release arch (CROSS_BUILDS); that path is CI's and needs the extra rustup target plus the cross linker from the platform notes above.
There is exactly one test runner: root pnpm test (= vitest run), driven by a single root vitest.config.ts with environment: 'node'. Test placement follows the boundary under test:
- Module unit and behavior tests stay beside their source under package/app
src/**/__tests__. tests/contractis only for consumer contracts exercised through a workspace's public exports; it must not import privatesrcmodules.tests/integrationis for real process or runtime boundaries: loopback network servers, native databases, CLI/child processes, compiled binaries, or multiple runtimes wired together. A temporary filesystem by itself does not make a test an integration test.- App
e2e/tests launch the real app entry and drive it externally; renderer component tests are not E2E.
Shared external-test fixtures live under tests/support and are type-checked but are not test entry points. Test files use *.test.ts or *.test.tsx; DOM component tests opt in per file with @vitest-environment jsdom. No app or package has its own test script and turbo.json has no test task, so turbo run test does nothing. Run one area by passing a path or name filter:
pnpm test apps/daemon/src/pty # just the PTY unit testsCI (.github/workflows/ci.yml) has six jobs: typescript (format:check, lint, typecheck, a debug linkcode-pty build, required-sidecar Vitest, then the compiled-daemon process acceptance), desktop (unpackaged Electron entry, window-state persistence, plus an unsigned packaged devshell), webview (the production bundle in Chromium, followed by the bundled mock entry and wire-compatible mock host), mobile (Android and iOS Expo Router production exports), rust (cargo fmt --check, clippy, test), and an All Green aggregate gate over all five required jobs. check:ci still excludes Vitest and app acceptance, so run the applicable commands below before every commit rather than treating any one command as the complete gate. A tsconfig that excludes its own test files silently hides test type errors (agent-adapter once hid 6 this way).
The daemon acceptance driver is deliberately outside Vitest: it starts dist/index.js as an external process with an isolated HOME, waits for runtime.json, checks the HTTP identity, connects a public LinkCodeClient through Socket.IO, reads the migrated native SQLite database, and opens a shell through the real PTY sidecar. Run the same boundary locally with:
cargo build --locked -p linkcode-pty
pnpm -F @linkcode/daemon build
LINKCODE_PTY_SIDECAR_PATH="$PWD/target/debug/linkcode-pty" pnpm -F @linkcode/daemon e2e:startupEvery workspace with a root tests/ directory must provide tests/tsconfig.json, extending its production config with the workspace root as rootDir, and the root tsconfig.json must reference it. Vitest discovery alone does not type-check every support file.
Store tests load the better-sqlite3 native binding (allow-listed under allowBuilds: in pnpm-workspace.yaml). If that build was skipped, pnpm test fails at require time loading the store — a native-binding error, not a test-logic failure.
The PTY subsystem has four layers, and the frame protocol is implemented twice (Rust proto.rs, TS codec.ts) — only layers 3–4 catch a mismatch between them:
apps/daemon/src/pty/__tests__/codec.test.ts— pure TS frame codec.apps/daemon/src/pty/__tests__/sidecar.test.ts—SidecarPtyBackendwithvi.mock('node:child_process'); no real binary.apps/daemon/tests/integration/pty-sidecar.test.ts— real backend against the real compiledlinkcode-pty(cross-boundary wire check).crates/linkcode-pty/tests/smoke.rs— Rust, unix-only, self-builds viaCARGO_BIN_EXE_linkcode-pty.
Local silent-skip trap: layer 3 is describe.skipIf(!BINARY) and skips when linkcode-pty isn't built (it looks for target/debug then target/release, first existing wins, else skip), so plain local pnpm test may not exercise the real wire protocol. CI builds the debug binary and sets LINKCODE_REQUIRE_PTY_SIDECAR=1, which turns a missing binary into a hard failure before either critical suite can skip. To run the same boundary locally, build the binary first:
pnpm -F @linkcode/daemon run build:rust
LINKCODE_REQUIRE_PTY_SIDECAR=1 pnpm test apps/daemon/tests/integration/pty-sidecar.test.ts apps/daemon/tests/integration/terminal-flood.test.tscargo test --locked also runs smoke.rs self-contained in the Rust job; the required TypeScript suites are the separate check that the TS and Rust implementations agree over the real process boundary.
The multi-device terminal contract has a separate in-process integration check. It opens from a desktop peer, attaches a late mobile controller through the Hub, verifies replay plus live output, rejects stale desktop input, and keeps the PTY alive after the desktop peer disconnects:
pnpm test packages/host/engine/src/__tests__/terminal-takeover.test.tsFor the manual flow, open a terminal in desktop and produce recognizable output, then open the same terminal from mobile and take control. The old output must appear before new live bytes; input and resize must come only from mobile after takeover. Closing the desktop tab detaches its view and must not terminate the mobile-controlled PTY. Use the explicit terminate action when process death is the intended operation.
The CI entry smoke tests are e2e:unpackaged, e2e:window-bounds, and e2e:packaged. The first launches the built main/preload/renderer and calls the sandbox preload bridge. The window check relaunches Electron to prove first-run sizing and persisted normal/maximized bounds; a headless Xvfb run needs a window manager such as Openbox because Linux maximize is a window-manager operation. The packaged check builds and launches the unsigned devshell directory product, proves that its renderer crosses the connection gate, then exercises its preload bridge, supervised bundled daemon, native SQLite migrations, staged PTY sidecar, and graceful app/daemon shutdown. Run all three on Linux with xvfb-run; the packaged check is intentionally not evidence for signing or notarization:
xvfb-run -a pnpm -F @linkcode/desktop e2e:unpackaged
xvfb-run -a sh -c 'openbox >/tmp/linkcode-openbox.log 2>&1 & wm_pid=$!; trap "kill $wm_pid 2>/dev/null || true" EXIT; pnpm -F @linkcode/desktop e2e:window-bounds'
xvfb-run -a pnpm -F @linkcode/desktop e2e:packagedThe feature E2E apps/desktop/e2e/notifications.e2e.mts (pnpm -F @linkcode/desktop e2e:notifications, playwright-core devDependency) self-orchestrates an isolated daemon + built desktop app and asserts the OS-notification chain end to end — use it as the template for flows that need a real agent (fresh fake HOME, --user-data-dir, --use-mock-keychain, main-process interception via app.evaluate). Those agent-dependent E2Es remain manual. Hard rule: packaging verification must actually launch the packaged product — launch-only bugs (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, a dev-shell exit-0 lock theft) never reproduce in dev.
Procedure from prior sessions — re-verify each step as you go. Script names and paths below are repo-verified; the keychain service name is observed behavior of the vendored CLI (re-check with
security find-generic-password -s 'Claude Code-credentials'); the launch/driving switches are memory-sourced Chromium/Electron flags.
Unpackaged run. Build first (pnpm -F @linkcode/desktop run build; product in out/, main at out/main/index.js), then _electron.launch({ executablePath: <repo>/node_modules/electron/dist/.../Electron, args: [<repo>/apps/desktop] }). Drive the main process with app.evaluate(({ Menu, nativeTheme, app }) => …).
Isolation (do not skip).
userDatafor any unpackaged run on macOS is~/Library/Application Support/LinkCode Development(thedevelopmentchannel pins the app name), shared with your daily dev instance. An E2E run must still pass an independent--user-data-dir=<temp>, orrequestSingleInstanceLockagainst the daily instance makes the second Electron exit 0 (looks like nothing launched). Back up that dir'ssettings.jsonfirst.- Isolated daemon:
HOME=<tempdir> LINKCODE_PORT=<port> pnpm -F @linkcode/daemon run dev. Pass the same fakeHOMEto Electron and it auto-connects viaruntime.json(precondition: the realsettings.jsonhas nodaemonUrl). Use a fresh fakeHOMEper run — a reused one carries an old daemon DB and leaves the composer disabled ("Create or pick a thread first"). - Always launch Electron with
--use-mock-keychain: a fakeHOMEhas no login keychain, and without the flag macOS pops a blocking "Keychain Not Found / reset" dialog on the developer's screen at every launch. - Playwright pins
colorScheme: 'light'. Theme/dark-mode E2E must pass_electron.launch({ colorScheme: null })or dark mode falsely looks broken.
Keychain (macOS) — exact. The vendored claude CLI reads OAuth from the login Keychain service Claude Code-credentials (acct = <username>), not ~/.claude/.credentials.json. A fake HOME breaks that; symlink it back:
ln -sfn ~/Library/Keychains <fakeHOME>/Library/Keychains
HOME=<fakeHOME> <vendored>/claude -p 'Reply ok' --model haiku # smoke testAgent files land under <fakeHOME>/LinkCode (chatWorkspaceRoot = homedir()/LinkCode). Detect turn-end by form button[type="submit"] reappearing (it is type=button while a run is active / showing Stop). Auto/bypass here is the approval-policy "Bypass permissions" option wired through the SDK's setPermissionMode (claude-code.ts) — there is no daemon env var for it (the CLI-side CLAUDE_CODE_ENABLE_AUTO_MODE concerns Bedrock/gateway auth only; see packages/host/agent-adapter/AGENTS.md).
Packaged product. Build with pnpm -F @linkcode/desktop run package:devshell (package is the production variant — use the devshell one for E2E). It stages the host runtime, runs node scripts/build.mts --mode devshell, then electron-builder --dir --config electron-builder.devshell.yml (productName: LinkCode Development, identity: null — unsigned by design), so you launch LinkCode Development.app. dev:mock (scripts/dev.mts --mode mock) exists for the CDP-attach flow. Memory-only driving switches (real flags, not repo-verifiable): --use-mock-keychain for the keychain modal, a packaged-vs-unpackaged --user-data-dir inversion, and the asar-unpack debug trick. Electron flags such as --remote-debugging-port and --profile pass through dev/dev:mock with or without a -- separator (e.g. pnpm -F @linkcode/desktop dev --remote-debugging-port=9222).
Feature gotchas (memory-sourced). The composer is disabled with no thread ("Create or pick a thread first") — click the Chats + first. "Ask permissions" stalls a Task at approval — switch to "Bypass permissions" before spawning an agent. Clicking a sidebar thread row needs Playwright force: true. Match the active row by classList.contains('bg-sidebar-accent') exactly (className.includes also matches the hover variant). Webview artifact E2E (pnpm -F @linkcode/webview run dev:mock): mock blob: URLs can't cross the Electron webview process (promotion fails ERR_FILE_NOT_FOUND) — use a real http URL.
The webview browser smoke builds the daemon and production bundle, then launches Chromium against the emitted assets with that isolated daemon as its real Socket.IO host and drives the index/Settings routes. It next starts the Vite app in mock mode and sends prompts through the workbench's real wire-compatible mock transport before and after a full reload. Install Playwright's Chromium shell once, then run:
pnpm -F @linkcode/webview exec playwright-core install chromium --only-shell
pnpm -F @linkcode/webview e2e:browserThe mobile smoke performs separate production exports from the real Expo Router entry for Android and iOS. It requires Hermes bytecode and source maps, then verifies that the root layout, startup route, host route, and terminal route were all included by Metro. This is an app-entry bundle gate, not simulator/device E2E and not evidence that native modules load on a device:
pnpm -F @linkcode/mobile smoke:exportcurl http://127.0.0.1:19523/linkcode— a JSON identity means it is up (possibly on a hunted port; the actual bound endpoint is in~/.linkcode/runtime.json). A development daemon answers on 19533 instead, and advertises in~/.linkcode.development/runtime.json.- Logs: packaged
~/Library/Logs/LinkCode/main.log; dev — the terminal (turbo TUI). - Exit code
3= another daemon already serves this universe (one per channel × profile, not a crash). Kill the pid from that universe'sruntime.json. A daemon of another channel or profile is not the cause — those coexist by design. - The packaged supervisor gives up after 5 fast (<30s) exits ("giving up" in the log).
- Crash-on-boot is usually a bundle issue (missing native module / "Dynamic require not supported") — check tsup externals and that
apps/daemon/distbuilt before the desktop bundle.
- Confirm
installAsarSpawnFixran —spawn ENOTDIRon anapp.asarpath means the child path wasn't unpacked/rewritten. - claude-code/codex resolve in order (CODE-110/111/114): managed install from the daemon's asset store (
@linkcode/assets; platform data dir such as~/Library/Application Support/LinkCode/assets,LINKCODE_ASSETS_DIRoverride — checkasset.listover the wire or look for<store>/agent/<kind>/<version>/on disk) → detected user install (brew /~/.local/bin, probed at daemon boot — checkagent-runtime.listover the wire or re-run--versionby hand) → SDK self-resolution from node_modules. Packaged apps ship no agent binaries: on a machine with no local claude/codex CLI the daemon downloads the SDK-pinned pair in the background at boot; until that install lands only pi is usable. - opencode self-spawns its server via PATH; pi runs in-process and spawns nothing.
- Detection re-probes only at daemon boot — after installing/upgrading a CLI, restart the daemon.
Three sidecar degradation signatures:
"pty sidecar not configured: terminals are unavailable on this host"—resolveSidecarPathreturned''(a prod bundle with noLINKCODE_PTY_SIDECAR_PATH). Set the env var or run a dev build."pty sidecar exited"(withonChildGone) — the path resolved but the binary is missing/unspawnable. Common in dev before a build — runpnpm -F @linkcode/daemon run build:rust."pty open timed out"— spawned but noOPENED/ERRORwithin 10s (OPEN_TIMEOUT_MS). The sidecar spawns lazily on first open and respawns after a crash.
The daemon advertises its bound endpoints in ~/.linkcode/runtime.json; a client with no daemonUrl in settings.json auto-discovers through that file. If a client can't reach it, confirm the daemon is up (the curl above), then check that runtime.json exists and wasn't written under a different HOME than the client reads.
Packaged: the supervisor pipes the daemon child's stdout to electron-log info and stderr to warn, in a file keyed on the Electron app name (LinkCode release / LinkCode Development dev shell; a --profile suffixes (<name>)):
- macOS
~/Library/Logs/<appName>/main.log - Windows
%APPDATA%/<appName>/logs/main.log - Linux
~/.config/<appName>/logs/main.log
tail -f "$HOME/Library/Logs/LinkCode/main.log"Dev-mode daemon logs go to its own stdout/stderr (console lines prefixed [linkcode/daemon]), not a file; under pnpm dev they appear in the turbo TUI.
pnpm -F @linkcode/daemon run dev:cleanThis deletes ~/.linkcode/daemon.db and ~/.linkcode/runtime.json, then starts dev. It wipes real user state (the session registry) — the only scripted command that touches ~/.linkcode, not a temp copy. The plain dev script is tsx watch --import ./src/instrument.ts src/index.ts (Sentry instrument preloaded; no-ops unless LINKCODE_SENTRY_DSN is set).
DSNs are publishable ids; without them the SDKs no-op (the default for local dev).
| Surface | Env | Repo secret (CI / release) |
|---|---|---|
| Desktop main + renderer + packaged daemon | MAIN_VITE_SENTRY_DSN (build-time; supervisor copies it to LINKCODE_SENTRY_DSN) |
SENTRY_DSN_DESKTOP (signed desktop builds only) |
Daemon (standalone / pnpm -F @linkcode/daemon dev) |
LINKCODE_SENTRY_DSN |
— (set at process env) |
| Webview | VITE_SENTRY_DSN |
SENTRY_DSN_WEBVIEW |
| Mobile | EXPO_PUBLIC_SENTRY_DSN |
SENTRY_DSN_MOBILE (also set on EAS project env for eas build) |
prek stashes unstaged tracked changes to .devenv/state/prek/patches/<timestamp>-<pid>.patch on every git commit and restores them after the hooks run; if that restore fails (or the hook run is interrupted) the working tree silently reverts to HEAD. The stash is a plain patch file — it does not appear in git stash list. Recover the newest patch:
git apply --3way "$(ls -t .devenv/state/prek/patches/*.patch | head -1)"The identity is two orthogonal axes (apps/desktop/src/main/constants.ts), and the desktop's app name, userData dir, single-instance lock, and OS keychain (safeStorage) all derive from them; src/main/identity.ts applies the identity as main's first import, and boot logs a userData: <path> line as self-evidence. Since CODE-460 the daemon's state follows the same two axes, so a local build and an installed release share nothing at all.
- channel —
CHANNEL === 'development'for any build that is not the released app:MODE !== 'production' || !app.isPackaged(a production bundle run by the dev Electron binary is still a dev shell).APP_NAMEis'LinkCode Development'for dev,'LinkCode'for release. Skipping any isolation axis clobbers release settings, steals its instance lock (the second instance exits 0 silently), or writes a safeStorage key under the dev binary's code signature — after which the release app prompts for the keychain password on first launch (macOS keychain ACLs pin the creator cdhash). - profile — an optional isolated universe within a channel:
--profile=<name>(orLINKCODE_PROFILE;[a-z0-9-], ≤32 chars, invalid aborts boot). It suffixes the app name (LinkCode Development (alpha)) — forking the same four axes again — and is injected asLINKCODE_PROFILEinto the supervised daemon, which forks its state dir and cloud device identity with it. Profiles run side by side: daemons hunt past each other's ports, and each desktop follows its ownruntime.json. The devenvdaemon/desktop/appscripts pass no profile — the development channel is already its own universe; pass one yourself only to fork a second universe within that channel.
The daemon is a separate process and cannot see app.isPackaged, so it resolves its own channel (apps/daemon/src/paths.ts): the desktop supervisor's injected LINKCODE_CHANNEL wins, else the build-time stamp tsup bakes in (process.env.LINKCODE_BUILD_CHANNEL → release), else development. Running the TS source is therefore a development daemon with nothing to remember, while a packaged one is release — and the devshell pack, which ships a release-stamped bundle inside a development shell, is corrected by the injection. Resolution is per call, never cached at module load: instrument.ts derives a state path in its module body and --import runs it before index.ts.
Clean a polluted machine (also after the LinkCode Dev → LinkCode Development rename, which orphaned the old dir and keychain entry by design — a rename migration would carry ciphertext the new keychain entry cannot decrypt):
security delete-generic-password -s "LinkCode Safe Storage"
security delete-generic-password -s "LinkCode Dev Safe Storage" # pre-rename leftover
rm -rf "$HOME/Library/Application Support/LinkCode Dev" # pre-rename leftover
# The daemon's own secret-vault master key (CODE-371) — a separate service per channel × profile.
# Deleting it makes that universe's secrets.json undecryptable: the daemon reads as signed out and
# its stored provider/account credentials are gone for good, so delete the file with it.
security delete-generic-password -s "LinkCode" -a secret-vault-key
security delete-generic-password -s "LinkCode Development" -a secret-vault-key
rm -f "$HOME/.linkcode/secrets.json" "$HOME/.linkcode.development/secrets.json"Names come from packages/foundation/schema/src/product.ts — the one file a fork edits to rename its footprint.
| release | development | |
|---|---|---|
daemon state (config.json, daemon.db, runtime.json, cloud.json, secrets.json, keys/) |
~/.linkcode |
~/.linkcode.development |
| workspaces + daemon chat root | ~/LinkCode |
~/LinkCode Development |
| managed asset store | …/Application Support/LinkCode/assets |
…/Application Support/LinkCode Development/assets |
| daemon secret-vault master key (OS keyring service) | LinkCode |
LinkCode Development |
The daemon holds no credential in those files: secrets.json is AES-256-GCM ciphertext under the master key above, and config.json / cloud.json keep structure only (CODE-371 — full custody, migration, and reset semantics in apps/daemon/AGENTS.md). Two consequences bite in development: a fake $HOME has no macOS login keychain, so an isolated daemon always logs the plaintext-fallback warning and writes protection: "plaintext"; and copying a state dir between machines or users carries no secrets, only the shape of them.
A profile appends -<name> to the state directory only (~/.linkcode.development-alpha); workspaces and the asset store fork by channel alone. The development suffix is dot-separated on purpose: profile names forbid dots, so --profile=development can never reach the development channel's directory.
Two things stay shared across channels by design: the agent CLIs' own homes (~/.claude, ~/.codex — separating them would force a second agent login and cut you off from the CLI you use in a terminal), and the linkcode:// scheme's OS-global nature, which is why the dev shell claims linkcode-dev:// instead (CODE-182).
Ports fork with the state: release hunts 19523–19532, development 19533–19542. The ranges are disjoint deliberately. The identity's channel field is the precise signal, but it cannot defend against a daemon shipped before that field existed — an old peer parses the newer identity through a schema without the key, zod strips it, and its profile-only comparison reads two default profiles as equal. Such a release would exit 3 against a development daemon sitting on 19523 and its supervisor would stand down, leaving the release shell to fall back to that same port and dial the wrong daemon. Never letting the channels reach one another's ports is the only fix that reaches binaries already in the wild.
Before CODE-460 the daemon state and workspaces were shared, and a dev daemon on the default profile contended for ~/.linkcode/19523 with an installed release: whichever bound first won, the loser's client dialed a peer on a different WIRE_PROTOCOL_VERSION, every frame was silently dropped, and it surfaced only as "Unable to connect to the daemon". package:devshell uses electron-builder.devshell.yml; release packaging is CI-only (the old dist script was removed).
JavaScript/TypeScript use ESLint for linting and Biome for formatting (Biome's linter is disabled — it only formats):
pnpm lint
pnpm format:checkLint is single-threaded, and local and CI run the identical command: --concurrency=off plus a
content-keyed cache. The cross-platform Node launcher gives ESLint an 8192 MB heap, so local runs
get the same limit as CI (pnpm 11 ignores node-options in .npmrc).
Multithread linting duplicates the typescript-eslint program per worker instead of splitting it. Every worker rebuilds the program set for each tsconfig project it touches, and because workers pull from one global file queue, every worker touches every project. Measured over this repo (1225 files, cold cache, 18-core host):
--concurrency |
wall | CPU | peak RSS | |
|---|---|---|---|---|
off, default 4 GB heap |
40.6 s | 88.7 s | 4.74 GB | OOM |
2, default heap |
35.7 s | 139.1 s | 9.46 GB | OOM |
4, default heap |
48.4 s | 294.2 s | 18.62 GB | ok |
auto (= cores/2 = 9), default heap |
32.5 s | 339.9 s | 38.70 GB | ok |
off, 8 GB heap |
48.2 s | 70.5 s | 7.40 GB | ok |
RSS scales linearly with the worker count at a constant ~4.7 GB each. Two consequences:
- Fewer workers OOM first. A worker holds the accumulated type information for every file it
processes, so halving the worker count doubles that per heap. Narrowing to
2is what produced theERR_WORKER_OUT_OF_MEMORYthat broke every branch in 2026-07 (#312) — it is not a safe "use less memory" knob, it is the most expensive setting per worker. - Concurrency only pays on a cold full run, and only when the RAM is genuinely free. On a warm
cache — the common case, including the pre-commit hook — single-threaded is 1.9 s / 0.5 GB against
3.3 s / 3.8 GB at
auto, because nine workers each pay startup and each read the whole cache file. On a 36 GB machineautowants more than the free RAM and degrades into swap thrash.
LINT_CONCURRENCY overrides the default when you want to trade RAM for a faster cold run; set it to
4 in your shell before running pnpm lint. Budget ~4.7 GB per worker, and never set it to 2.
Auto-fix — finish the task first, then run these and re-check (most issues auto-fix):
pnpm format
pnpm lint:fixRust — run the forms CI enforces (unscoped, --locked):
cargo fmt --check
cargo clippy --all-targets --locked -- -D warnings
cargo test --lockedThe Cargo workspace has a single member (crates/linkcode-pty), so -p linkcode-pty-scoped forms are equivalent today, but match CI to stay correct as the workspace grows.
Before every commit run the full JS check set (exactly what CI's TypeScript job runs), then the tests separately — check:ci does not include them:
pnpm check:ci # = format:check && lint && typecheck
pnpm test- Use
pnpm, nevernpmornpx. - The daemon inherits sidecar diagnostics from stderr; protocol data must stay on stdin/stdout.