Principle (decided 2026-08-10): local and remote share ALL non-frontend code. The honest assessment of the campaign up to 2.275.0: parity tests and shared string modules are scaffolding, not the cure — they keep two implementations from drifting; they do not remove the second implementation.
This document is the anchor for actually removing it. The generalized end-state architecture (frontend / orchestrator / device layer, fat-device with worker isolation) is docs/design-three-tier.md — this doc remains the per-module migration ledger.
hostIdis a parameter, never a branch. Server-side logic is written ONCE against a machine handle. The only thing that differs between this machine, an ssh host and a dialed-in device is the transport underneath that handle.
Corollary — the reason the asymmetry keeps producing bugs:
When the same feature has a local twin and a remote twin, whoever fixes one never exercises the other. Every incident in the audit followed this shape: the remote fix that took 6 weeks to reach the local scanner; the writer sweep that protected ssh and dial but not local; the
&host=the gap endpoint never accepted; the prelude copies that drifted between spawn builders.
The local daemon has been a full DeviceManager since 2.158.0 — same binary, same mux protocol, same op surface (fs-op, run-cmd, run-stream, open-session, open-pipe-session, discovery-snapshot, tcp-connect, …). The transport is the only difference. But it lived in a server.js variable that hosts.device(id) could not return, because that method began with this.get(id) (a host-record lookup). So no consumer could be written once, even when the author wanted to.
hosts.setLocalDevice(dm) + hosts.device(falsy) → device #0 removes that wall. deviceBounded inherits it. A consumer now writes:
const dm = await hosts.deviceBounded(hostId, 8000); // hostId null ⇒ this machine
await dm.runCmd(…); await dm.fsWrite(…); await dm.openPipeSession(…);Each row is either UNIFIED (one implementation, hostId is a parameter), TRANSPORT-ONLY (the difference is genuinely the transport and cannot be otherwise), or DIVERGENT (two implementations = a bug generator, must be migrated).
| Area | Status | Notes |
|---|---|---|
| Pre-resume writer sweep | UNIFIED (2.276.0) | src/writer-sweep.js; local gained the sweep it never had; ssh keeps its per-op channel as fallback, dial correctly has none |
| Remote shell prelude / node finder | UNIFIED (2.274.0) | src/remote-shell.js + drift-guard test |
| Session text filter | UNIFIED (2.275.0) | sessionMatchesFilter in utils.js |
| Usage ledger walk | PARITY-GUARDED (2.275.0) | the remote scanner must ship as ONE self-contained file (a host has no checkout) ⇒ code sharing is impossible; behavioural parity test instead. This is the one legitimate exception to the rule. |
File operations (/api/file*) |
DOCUMENTED EXCEPTION (2.279.1) | local = SafeFs worker pool; remote = RemoteFs over dm.fs*. Deliberately NOT unified: SafeFs's worker isolation is load-bearing (a hung FUSE mount fills a libuv pool — §2.108.3), and the daemon also carries live session pipes; routing local file ops through it would hand FUSE hangs to the process keeping sessions alive. Revisit ONLY if the daemon gains its own worker/deadline isolation. Until then this is a justified branch, reviewed and recorded — not drift. |
| Session discovery facts | PARTLY (2.278.0) | Collectors still 3 (local rich sweep / daemon snapshot / ssh script fallback) — but ALL INTERPRETATION is single-sourced in src/discovery-facts.js (bundled into the daemon by esbuild — code sharing IS possible there, unlike the ssh one-file scanner): naming rule (was DRIFTED — local first-line vs remote whole-message-collapsed = one session, two names), tail-id extraction (was 3 implementations feeding claimJsonls with different mention windows), and the PID-reuse guard (the daemon snapshot verified only liveness — a recycled pid = phantom running session; the hole local closed years ago). Remaining: collapse the local collector onto device #0's snapshot (needs snapshot to gain tmux facts + webui-pid enrichment without regressing the 5s-poll hot path). |
| Spawn ladder | PARTLY (2.279.0) | The five remote COMMAND-LINE builders collapsed into buildRemoteExec (remote-shell.js): one composition (cd → prelude → resolve → ambient-oat strip → token/acct assigns → exec env parts/tail), every difference a named parameter; AMBIENT_OAT_UNSET is structural now (it took five hand-edits in 2.267.0), and the redundant double-REMOTE_PRELUDE two builders carried is gone. Drift guard: test-remote-shell fails if ws-handler ever hand-assembles an exec env line again. Remaining: the LADDER around the line (tool shipping, account placement, adopt-vs-spawn decisions) is still per-transport control flow. |
| Ctx sync | UNIFIED (2.277.0) | src/ctx-sync.js — hashed newer-wins sync over the device link for ALL transports; ssh keeps rsync only as the down-link fallback; caps raised to 1000 files/16MB and every skip is REPORTED (serverNotice) — the old dial cap ate a 3MB file invisibly. Local = no sync is TRANSPORT-ONLY (direct dir). |
| Transcript fetch/cache | PARTLY | _fetchRemoteByFind handles ssh+dial uniformly; local reads the file directly (legitimate — no transport needed). |
| Design folder read / Tweaks layer / tokens / user.json write | UNIFIED (dc-twins, 2026-10-05) | Was an unlisted twin (2.369.202): this machine = O_NOFOLLOW JS reads + an O_EXCL temp/rename write; every other machine = sh scripts through RemoteFs.runScript — so a Windows agent (fs-portable, no sh) could not open a design folder. Now ONE module, src/design-fs.js: in-process for device #0, the daemon's capability-gated design-fs op elsewhere (RemoteFs.deviceWith); the sh scripts are the fallback rung for daemon-less ssh hosts only. Test: scripts/test-design-routes.mjs §3b. |
| Incident capture scene (process words, lock files, state listings, CLI versions) | UNIFIED (dc-twins, 2026-10-05) | Missed twin (2.239.0): captureLocal (JS) and the remote probe script hard-coded claude/codex separately. Both now read each harness descriptor's store.scene row (the transcript identity already went through store.locate / store.remoteFind, B-8ebb); the probe stays a script because it runs over the roster channel (ssh AND dial). Test: scripts/test-incident.mjs (fake harness). |
| Machine snapshot (sysinfo) | UNIFIED (2.314.0) | Was a MISSED twin (added 2.226.3, predating the metric): local = src/sysinfo.js in-process, remote = a separate ssh script — and the interpretations of "used memory" DRIFTED (the false-100% incident: raw memory.current vs working set; the remote script had been right all along). Now the daemon bundles the module and serves a capability-gated sysinfo op; the ssh script is the fallback rung for daemon-less hosts only. Found by the user, post-closure — evidence the metric needs a standing sweep, not a one-time audit. Test: scripts/test-sysinfo-op.mjs. |
Branch count (ws-handler 33 · hosts 26 · server 28 · files 18 · sessions 7 · remote-fs 7 as of 2.279.1) stays roughly FLAT by design: a branch that selects a transport for one shared implementation is fine. What the campaign kills is parallel implementations — the same semantics written twice. That count:
- Start of campaign: 9 identified twin-sets (prelude ×11 copies, node finder ×4, writer sweep ×3-with-local-missing, ctx sync ×2 different semantics, spawn line ×5, naming rule ×2 drifted, tail-ids ×3, session filter ×2, usage walk ×2).
- Now: 0 unjustified twins. 7 unified (
remote-shell.js,writer-sweep.js,ctx-sync.js,discovery-facts.js×2 rules,buildRemoteExec,sessionMatchesFilter); 2 documented exceptions (usage scanner — must ship as one file to a checkout-less host; file ops — SafeFs isolation is load-bearing), each with a drift/parity guard test.
A NEW parallel implementation needs a table row with its justification, or it's a regression.
- ✅ Keystone:
hosts.device(falsy)→ device #0 (+ live proof scripts/test-local-device.mjs — real daemon, real socket, real runCmd/fs/snapshot; absence throws honestly). - ✅ Writer sweep →
writer-sweep.js(local gained it; kills self-report). - ✅ Ctx sync →
ctx-sync.js(one semantics; honest caps 1000/16MB). - ✅ Discovery interpretation →
discovery-facts.js(naming un-drifted; one tail-id rule; daemon gains the PID-reuse guard). - ✅ Spawn lines →
buildRemoteExec(five builders, one composition; ambient-oat strip structural). - ✅ File ops → documented exception (above).
Discovery collectorsDONE (2.291.0 remote switched; 2.319.0 local viaagentd.localDiscovery, flag-gated) — collapse the local sweep onto device #0's snapshot (snapshot must first gain tmux facts + webui-pid enrichment without regressing the 5s-poll hot path).- Spawn ladder control flow — the decisions AROUND the line (tool shipping, account placement, adopt-vs-spawn) are still per-transport.
- Daemon-side fs isolation — the unlock condition for the file-ops exception.