Skip to content

Latest commit

 

History

History
431 lines (292 loc) · 34.2 KB

File metadata and controls

431 lines (292 loc) · 34.2 KB

LinkCode development

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.

Prerequisites

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 shell

Without devenv, install these yourself:

  • Node.js 24 or newer (.nvmrc pins 26 for CI) and pnpm 11. Always pnpm, never npm or npx.
  • rustup. The version, rustfmt, clippy, and rust-analyzer all come from rust-toolchain.toml — rustup reads it automatically and installs that exact toolchain the first time you run cargo in this repo, so a given commit always builds with the same compiler. Bumping the pin also needs devenv update rust-overlay: the devenv-side toolchain can only resolve versions present in the locked rust-overlay manifests.
  • A platform native toolchain (below). Neither devenv nor rustup provides the system C/C++ toolchain, and both the PTY sidecar's linker and the native node modules (better-sqlite3) need it.

Native toolchain per platform

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 chromium

If 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 --install

Packaging (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 is not a general setup script

.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.

Install dependencies

pnpm install

Run the app

Daemon + desktop together

devenv shell -- app

app 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 dev

Root 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.

Daemon only

devenv shell -- daemon
# without devenv:
pnpm -F @linkcode/daemon run build:rust
pnpm -F @linkcode/daemon run dev

Desktop only

devenv shell -- desktop
# without devenv:
pnpm -F @linkcode/desktop run dev   # scripts/dev.mts: vite builds + dev server + electron

Webview (browser renderer)

There is no devenv script for the webview. Run it directly:

pnpm -F @linkcode/webview run dev   # vite

Mobile (Expo, iOS)

devenv shell -- mobile
# without devenv:
pnpm -F @linkcode/mobile run ios    # expo start --ios

Rust PTY sidecar

The 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 --release

The daemon resolves the binary (resolveSidecarPath, apps/daemon/src/pty/sidecar.ts) in this order:

  1. LINKCODE_PTY_SIDECAR_PATH — always wins when set.
  2. Dev (running .ts source under tsx): <repoRoot>/target/release/linkcode-pty (linkcode-pty.exe on Windows).
  3. Prod (a tsup .js bundle 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.

Build semantics

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 --releasetarget/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.

Testing

One runner

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/contract is only for consumer contracts exercised through a workspace's public exports; it must not import private src modules.
  • tests/integration is 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 tests

CI runs Vitest and process acceptance separately

CI (.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:startup

Every 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.

PTY test layers

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:

  1. apps/daemon/src/pty/__tests__/codec.test.ts — pure TS frame codec.
  2. apps/daemon/src/pty/__tests__/sidecar.test.tsSidecarPtyBackend with vi.mock('node:child_process'); no real binary.
  3. apps/daemon/tests/integration/pty-sidecar.test.ts — real backend against the real compiled linkcode-pty (cross-boundary wire check).
  4. crates/linkcode-pty/tests/smoke.rs — Rust, unix-only, self-builds via CARGO_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.ts

cargo 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.ts

For 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.

Desktop E2E procedures

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:packaged

The 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).

  • userData for any unpackaged run on macOS is ~/Library/Application Support/LinkCode Development (the development channel pins the app name), shared with your daily dev instance. An E2E run must still pass an independent --user-data-dir=<temp>, or requestSingleInstanceLock against the daily instance makes the second Electron exit 0 (looks like nothing launched). Back up that dir's settings.json first.
  • Isolated daemon: HOME=<tempdir> LINKCODE_PORT=<port> pnpm -F @linkcode/daemon run dev. Pass the same fake HOME to Electron and it auto-connects via runtime.json (precondition: the real settings.json has no daemonUrl). Use a fresh fake HOME per 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 fake HOME has 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 test

Agent 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.

Webview and mobile entry smoke tests

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:browser

The 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:export

Debugging and triage

Daemon will not start

  1. curl 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.
  2. Logs: packaged ~/Library/Logs/LinkCode/main.log; dev — the terminal (turbo TUI).
  3. Exit code 3 = another daemon already serves this universe (one per channel × profile, not a crash). Kill the pid from that universe's runtime.json. A daemon of another channel or profile is not the cause — those coexist by design.
  4. The packaged supervisor gives up after 5 fast (<30s) exits ("giving up" in the log).
  5. Crash-on-boot is usually a bundle issue (missing native module / "Dynamic require not supported") — check tsup externals and that apps/daemon/dist built before the desktop bundle.

Agent will not spawn

  1. Confirm installAsarSpawnFix ran — spawn ENOTDIR on an app.asar path means the child path wasn't unpacked/rewritten.
  2. 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_DIR override — check asset.list over the wire or look for <store>/agent/<kind>/<version>/ on disk) → detected user install (brew / ~/.local/bin, probed at daemon boot — check agent-runtime.list over the wire or re-run --version by 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.
  3. opencode self-spawns its server via PATH; pi runs in-process and spawns nothing.
  4. Detection re-probes only at daemon boot — after installing/upgrading a CLI, restart the daemon.

Terminals are broken

Three sidecar degradation signatures:

  • "pty sidecar not configured: terminals are unavailable on this host"resolveSidecarPath returned '' (a prod bundle with no LINKCODE_PTY_SIDECAR_PATH). Set the env var or run a dev build.
  • "pty sidecar exited" (with onChildGone) — the path resolved but the binary is missing/unspawnable. Common in dev before a build — run pnpm -F @linkcode/daemon run build:rust.
  • "pty open timed out" — spawned but no OPENED/ERROR within 10s (OPEN_TIMEOUT_MS). The sidecar spawns lazily on first open and respawns after a crash.

Client cannot connect

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.

Log locations

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.

Reset daemon state

pnpm -F @linkcode/daemon run dev:clean

This 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).

Sentry (local)

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)

Recover changes lost by a failed commit hook

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)"

Identity: channel × profile isolation

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.

  • channelCHANNEL === '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_NAME is '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> (or LINKCODE_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 as LINKCODE_PROFILE into 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 own runtime.json. The devenv daemon/desktop/app scripts 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_CHANNELrelease), 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 DevLinkCode 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"

What each channel owns on disk

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).

Formatting and linting

JavaScript/TypeScript use ESLint for linting and Biome for formatting (Biome's linter is disabled — it only formats):

pnpm lint
pnpm format:check

Lint 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 2 is what produced the ERR_WORKER_OUT_OF_MEMORY that 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 machine auto wants 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:fix

Rust — run the forms CI enforces (unscoped, --locked):

cargo fmt --check
cargo clippy --all-targets --locked -- -D warnings
cargo test --locked

The 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

Notes

  • Use pnpm, never npm or npx.
  • The daemon inherits sidecar diagnostics from stderr; protocol data must stay on stdin/stdout.