feat(remote): R2 desktop kernel — backend router, adapter, event bridge, connection, boot - #589
Merged
Merged
Conversation
Rollout R1 made the Agent Host module Electron-free, but everything under it still lived in Electron main: the host-core and sidecar stdio transports, the restart supervisor, the durable turn lifecycle, transcript persistence, and approved-plan dispatch. The R2 pi-host bundle must run that layer on a machine without Electron, and re-implementing the turn lifecycle there would create a second copy of the abort-lock, stale terminal event, and queue-release invariants the desktop already fixed. Move the layer into packages/host-runtime (ADR 0282, D445). HostProcess and AgentSidecar take their launch as options instead of resolving Electron resource paths and forcing ELECTRON_RUN_AS_NODE; the restart policy of process-model section 4 becomes RuntimeSupervisor; RuntimeService implements the module's RuntimePort with the same claim-before-await finalization; a headless launch resolver reads host-core's own registries. Electron main keeps thin subclasses that add binary locations, redacted stderr, and the schema/glibc diagnoses, and the Agent Host bridge reuses the shared host-core ports. The local prompt path, IPC, sidecar RPC, host-core RPC, and persisted data are unchanged; TurnStartRequest gains an optional userMessageId. Source-contract tests that pinned the moved transports now read the package sources; the supervisor, turn lifecycle, persistence, ports, and launch resolver have unit tests that run without Electron.
R0 froze the RACP contract and R1 delivered the headless Agent Host module, but no code moved a JSON-RPC frame between processes: the module was only driven by Electron IPC. R2 needs the normative RACP-WS binding on both sides of the SSH tunnel and the device-token pairing security §3.4 describes. Add packages/racp (ADR 0283, D446): a server core that dispatches every catalog operation through its role rule onto the Agent Host module and a RacpHostOperations interface the Host implements, a client core that correlates requests, answers server-initiated requests, tracks the last durable cursor per session, and reconnects with bounded backoff without re-sending in-flight calls, plus the ws binding on loopback and the header-profile authenticator with hashed device and single-use pairing tokens. Three catalog operations (connection/pair, project/register, project/browse), server.hostId, the events/closed notification, and the remote connection error codes join the shared contract; the schema fixture is regenerated from the typebox source. The conformance behaviors that need no machine boundary run as package tests over an in-memory link and a real loopback socket.
The headless pi-host reuses the same workspace path-containment and git-diff parsing that the Electron main process relies on. Keeping that logic under apps/desktop/electron/main forced pi-host to depend on the desktop app, which breaks the headless runtime boundary. Move fs-panel.ts and git-diff.ts into @pi-desktop/host-runtime as workspace-files.ts and workspace-diff.ts, re-export them from the package entry, and repoint the desktop main-process importers and the affected node --test suites. No behavior change: the moves are byte identical (R100) and only import specifiers were updated.
Add apps/pi-host, a headless entry point that runs the Agent Host module, the pi sidecar, and host-core behind the existing RACP-WS transport, so a Remote Host can execute sessions with no Electron or renderer present. The desktop app remains the local host; this only adds a second, headless deployment of the same runtime. pi-host owns its own config, credential handling, terminal (node-pty as an optional dependency so the bundle degrades gracefully when it is absent), and host operations, reusing @pi-desktop/host-runtime for the shared runtime service and workspace helpers. scripts/bundle.mjs produces a self-contained per-platform bundle (dist-bundle, ignored). Also add scripts/e2e-remote-host.mjs, the host half of E2E-231: it boots a real pi-host on a throwaway data dir, pairs a device over the loopback socket, exercises project/session/workspace flows, and checks reconnect-by-cursor. node-pty is added to the pnpm allowBuilds list.
Refresh the headless remote-host branch against the latest main, which shipped the compaction-summary-retry work (ADR 0282, D445/D446) and the completion-notice silence changes. Both sides had allocated ADR 0282 and decisions D445/D446, so this merge renumbers the remote-host additions to avoid the collision: - ADR 0282 (headless runtime boundary) -> ADR 0283 - ADR 0283 (RACP-WS transport) -> ADR 0284 - D445 (headless runtime boundary) -> D447 - D446 (RACP-WS transport and pairing) -> D448 All cross-references are updated to match: docs/adr/README.md, the ADR bodies, the decisions log, the process-model / remote-agent-control / remote-control-rollout specs, their zh-CN mirrors, the zh-CN ADR index, and the D446/ADR references embedded in shared, racp, and pi-host code comments. Main's own ADR 0282 and D445/D446 (compaction and silent completion notices) are left untouched.
The binary-frame case asserted connectionCount() on the same tick the client observed its own close frame. The server drops the connection on its side of the socket's close event, which can lag behind the client's under parallel test load, so the count was intermittently still 1. Poll for the server to drain the connection (bounded to ~1s) before asserting zero, matching how the first test already waits after a client close. Test-only change; the transport behavior is unchanged.
The desktop SSH bootstrap for remote-control R2 downloads the headless pi-host bundle from the GitHub Release at the desktop's version and verifies a published SHA-256 before installing it on the remote machine (rollout 06-delivery/07-remote-control-rollout.md §6 acceptance 4). The release pipeline never produced that asset, so the bootstrap had nothing to fetch. Add a pi-host-bundle job (needs: verify) that builds host-core, the workspace, the agent-runtime sidecar, and pi-host on ubuntu-22.04 — the same glibc floor as the desktop Linux build so the bundled binary loads on the same distros — then assembles the bundle, tars it to pi-host-<version>-linux-<arch>.tar.gz, and writes a .sha256 sidecar. The matrix is shaped so arm64 can be added in lockstep when the desktop starts publishing it. publish now needs [build, pi-host-bundle]; its existing files: dist/* glob already carries the tarball and checksum into the Release. Also add the test:e2e:remote-host script alias so the host-half E2E (scripts/e2e-remote-host.mjs) is a runnable gate.
R2 makes the desktop drive a session that lives on a paired remote pi-host over RACP-WS, and the renderer must treat that session exactly like a local one apart from a badge. This lands the contract and the single interception seam the later stages build on, with no behavior change: no session is remote yet, so every call runs the existing local handler byte-for-byte. - SessionSource gains "remote": the transcript-authority tag the renderer already carries, resolved to local/remote only in main. - New electron/main/remote/backend-router.ts owns the sessionId namespacing (remote:<hostKey>:<hostSessionId>, mirroring the proven native-pi: prefix) and a sessionId->RemoteBackend map. route() returns a ROUTE_LOCAL sentinel for every local or native-pi id, for a channel a backend does not cover, and until a backend is registered. - register.ts consults route() from inside its handle() wrapper — the only edit to the per-domain IPC handlers. Internal invokes never reach this wrapper, so the agent-host bridge and MCP control stay local. - index.ts/startup.ts wire the router through the composition root as pure plumbing (a getter and a null cell); startup creates it before the first window can issue IPC. The 11 remote/host/pairing error codes that the RACP-WS transport already registered (ADR 0284) were never documented in 08-error-codes, which the error-code-registry contract test flagged. Add a §3.8 to the spec and its zh-CN mirror.
Stage 2 of the remote-host kernel. Given a `remote:<hostKey>:<id>` session
already surfaced through the backend-router seam (Stage 1), translate the
renderer's per-session IPC calls into RACP requests and reshape the
results back into the exact response shapes the renderer already consumes
from a local session. The adapter lives entirely in Electron Main; the
renderer never learns the transport (spec §3.4).
Channels the remote profile does not cover — `agentSteer`, attachment
uploads, and any desktop-only settings — either return false from
`handles` or throw `CAPABILITY_UNAVAILABLE`, so the call falls back to
the local handler while the session's transcript stays remote.
Three shape decisions constrain the adapter:
- Tool permission resolution carries only `{requestId, decision}` with no
session id. The request id itself must name the session, so the router
encodes it as `<remoteSessionId>#racp-approval:<hostApprovalId>` and
`sessionIdForCall` decodes it. The router stays stateless.
- `plansResolve` returns `PlanResolutionResult`, but the RACP
`approval/respond` returns only `RacpApprovalResult`. The backend
synthesizes a minimal proposal from the request identity to dismiss the
card optimistically; the authoritative snapshot arrives on the
follow-up `session.changed` event.
- `QueuedTurnSummary.content` cannot be recovered from a snapshot —
`RacpTurn` carries no prompt text — so `agentQueueList` renders queued
entries with `content: ""`. Live `agentQueuePush` calls populate
content from the local request.
The connection layer that instantiates a `RemoteRacpClient` and calls
`registerBackend` on `session.created` is Stage 3; `bootstrap/startup.ts`
is unchanged here.
Covered by `apps/desktop/test/remote-backend.test.mjs` with a fake RACP
client fixture. Existing router tests continue to pass; full desktop
`node --test` suite (2093 tests) is green.
Second half of the remote-session data path: while `electron/main/remote/remote-backend.ts` turns renderer IPC calls into outgoing RACP requests, this event bridge turns the paired host's incoming RACP event stream into the exact renderer IPC events already consumed for a local session. The renderer never learns the transport (spec §3.4); the same event kinds fire, just under a namespaced session id. The bridge is pure translation. It forwards to an injected `emit`, and signals `session.created`/`changed`/`archived` through an optional `onLifecycle` callback so the connection layer (Stage 3) can add or remove backend router registrations without re-subscribing to the RACP event stream from a second place. Mapping decisions worth calling out: - Item, turn, and tool.progress kinds already ride a local `AgentEvent` in their payload (agent-host attaches it via `payload.event`); the bridge forwards it verbatim under an `AgentEventEnvelope` keyed by the remote session id. - `approval.requested` of kind `tool` is synthesized into a local `tool_permission_request` whose `requestId` is `<remoteSessionId>#racp-approval:<hostApprovalId>` — the encoding the router already knows how to decode when the renderer resolves the card. `plan` and `goal` approvals ride the following `planning_state` event and are dropped here. - `input.requested` becomes an `asktool_request` keyed by the RACP input id; the renderer echoes it back on `askToolResolve`. - `session.changed` at session scope carries either a `PlanningStateEvent` (forwarded as `planning_state`) or a status-refresh payload; the desktop derives its status from turn events, so the status-refresh flavor is dropped. - Terminal, resync, and host.changed kinds are silently dropped — Stage 5 owns terminals; resync/reconnect belongs on the connection layer. Covered by `apps/desktop/test/remote-event-bridge.test.mjs` (12 cases). Full desktop `node --test` suite green.
Stage 3 kernel wiring, in-process. `RemoteHostConnection` composes the
Stage-2 backend adapter, the Stage-2 event bridge, and the Stage-1
backend router into one desktop-side coordinator per paired `pi-host`.
Ownership of the transport itself stays with the caller: this layer
only speaks the two interfaces `RemoteHostClient` exposes (`request`
and a multi-listener `subscribe`), so Stage 3b can build it around a
real `RacpClient` — or a WSL/SSH-tunneled one — without changing this
file.
`open()` sequence, chosen to close the create-race between listing
sessions and receiving lifecycle events:
1. Attach the raw event listener.
2. Subscribe host scope (any `session.created` between here and step
3 arrives as an event and lands via the lifecycle path).
3. `session/list` and register a backend for each returned id.
4. Per-session event subscribes.
Lifecycle wiring keeps the router in sync with the host without
touching the renderer: `session.created` registers a fresh backend and
selects the new remote id in the sidebar; `session.archived`
unregisters the id and the router falls through to the local handler
for anything that still references it. All registration paths are
idempotent — `open()` on an already-open connection is a no-op, and a
`session.created` for a session already registered by the initial list
is dropped by the router's Set.
Compatibility: with no connection open, the router has no remote
backends and every renderer call hits the local handler byte-for-byte.
A host-side failure — network drop, protocol mismatch, `session/list`
error — never escalates into a local regression: `close()` unregisters
every session and the fallback route resumes.
Covered by `apps/desktop/test/remote-host-connection.test.mjs`
(7 cases) with a fake `RemoteHostClient`. Full desktop `node --test`
suite is 2112/2112 green.
Two pieces of Stage 3b's kernel work, kept in one commit because neither one stands on its own — the adapter needs a persistent list of hosts to serve, and the registry needs a client to hand a device token to. RacpRemoteHostClient (`electron/main/remote/racp-remote-host-client.ts`) is the only file in `electron/main/remote/` that speaks the RACP wire. It wraps `RacpClient` from `@pi-desktop/racp` (added as a workspace dep) and fans its single-slot `onEvent` callback out to every listener that attaches through `subscribe()`. That fan-out is what `RemoteHostConnection.open()` needs — Stage 3b's resync watchdog and Stage 5's terminal client will attach here without a second subscription to the same stream. A listener that throws is logged and the others still receive the event. `@pi-desktop/racp` exposes `./test-harness` as an additional exports subpath so consumers can build fixtures against the same in-memory `MemoryLink` its own tests use; the adapter test does exactly that, exercising the real `RacpClient` state machine without opening a socket. RemoteHostRegistry (`electron/main/remote/remote-host-registry.ts`) persists the paired-host list at `<dataDir>/remote-hosts.json`. Device tokens are encrypted through an injected `EncryptionPort` (production wires it to Electron's `safeStorage`), so a stolen backup without OS keychain access reveals only the URL and label. `upsert` refuses to write when the keychain is unavailable; `list` drops records it cannot decrypt rather than surfacing a placeholder token that would auth-fail downstream. The write path is atomic (write-then-rename); a shape or JSON error on read is logged and treated as an empty list, leaving the file on disk so a corrupt-file rescue is still possible. Covered by `apps/desktop/test/racp-remote-host-client.test.mjs` (4 cases against the RACP harness) and `apps/desktop/test/remote-host-registry.test.mjs` (8 cases with a reversible fake encryption and `os.tmpdir` scaffolding).
… quit Wires the four Stage 2/3 modules the desktop kernel needs into the existing composition root. `bootstrap/remote-hosts.ts` reads the registry, and for every host record builds one RacpRemoteHostClient + one RemoteHostConnection and opens both. `bootstrap/startup.ts` fires `open()` in the background — a slow or unreachable host must not delay the first window; per-host failures are logged and skipped. `bootstrap/shutdown.ts` calls `closeAll()` from the existing shutdown promise, before the local host-core is torn down, so any in-flight remote turn's abort still goes over a live socket. `state.backendRouter.registerBackend` finally fires. When the registry is empty (the default install) it does not — the router still returns ROUTE_LOCAL for every call and the renderer keeps hitting the local handler byte-for-byte. This is the zero-behavior default the ratchet demands until pairing (R2b) can land a UX. `apps/desktop/electron/main/index.ts` stays at exactly 1500 LOC. A module-level `activeRemoteHostsBoot` handle inside `bootstrap/remote-hosts.ts` bridges the two lifecycle sites without adding a field to `StartupState` or the `registerShutdownHandlers` call in `index.ts`. When R2b lets a broader remote-hosts service object hang off StartupState, the handle can retire cleanly. The pinned source-contract test in `apps/desktop/test/rpc-lifecycle-contract.test.mjs` grows its `Promise.allSettled` array by one entry to match the new `remoteHostsShutdown` slot. Full desktop `node --test` suite is 2128/2128 green.
ADR 0285 records the decisions D449 locks in for the R2 desktop-side kernel: the router seam, the transport-agnostic backend, the two synthesis rules that reconcile schema mismatches (tool-approval requestId encoding, plansResolve placeholder), the pure event bridge, the coordinator, the multi-listener client seam, the encrypted-at-rest registry, and the boot hook. The Consequences section calls out the new workspace dep on `@pi-desktop/racp`, the module-level startup/shutdown handle chosen to keep `index.ts` under its 1500-LOC ceiling, and the seams that Stage 4–7 will plug into without revisiting transport. `06-delivery/07-remote-control-rollout.md` gains a short preamble on the R2 section splitting into R2a (delivered, kernel) and R2b (pairing, SSH bootstrap, terminal, reverse tool relay). Exit criteria are unchanged and stay bound to R2b. The zh-CN mirror gets the same split as an ordered sub-list under R2 so `docs:check` and `check:locales` stay green.
Rebase the remote-host desktop kernel onto latest `origin/main`. The kernel
work itself is unchanged; the merge resolves three kinds of drift:
- ADR number collision: origin/main took ADR 0283 for remote MCP OAuth
while this branch held ADR 0283 (headless runtime boundary), 0284
(RACP-WS transport), and 0285 (remote-host desktop kernel). Renumber
the three to 0284 / 0285 / 0286 and update every cross-reference in the
ADR files, `docs/adr/README.md`, `docs/zh-CN/adr/index.md`, the R2
rollout spec, the process-model spec, the error-codes spec, and the
decisions log for D447 / D448.
- Import conflict in `bootstrap/shutdown.ts`: keep both origin's
`McpOAuthManager` import and this branch's `getActiveRemoteHostsBoot`
handle for the remote-hosts shutdown chain.
- `apps/desktop/electron/main/index.ts` came in at 1503 LOC (over the
1500 ratchet) after picking up origin's mcpOAuth wiring on top of this
branch's `backendRouter` field. Collapse the two-line
`window-preferences` import block to one line to land at exactly
1500/1500.
Also fixes source-contract test drift that origin/main introduced
independently of this branch — pre-existing failures that would have
prevented CI from being green regardless of this merge:
- `test/window-resize.test.mjs` and `test/work-panel.test.mjs`:
`bootstrap/window.ts` now uses `initialMin{Width,Height}` (a
work-area-clamped `windowMin*`), so the regex targets need to shift
accordingly.
- `test/transcript-file-chips.test.mjs`: `openUrl(target.url)` was
renamed to `openHttpUrl(target.url)`.
- `test/agent-capability-settings.test.mjs` and `test/extensions-page.test.mjs`:
Rust `fs::copy(&source_path, &target)` became `fs::copy(source, target)`.
- `test/bounds-watchdog-platform.test.mjs`: the watchdog is now
`NodeJS.Timeout | null` gated on `process.platform === "darwin"`; the
cadence closing dropped its trailing semicolon; the guard now uses
`if (boundsWatchdog) clearInterval(...)`.
- `test/session-message-presentation.test.mjs`: MessageRow.tsx started
importing `../../../lib/hosted-search-ui`; add the mock.
Validation: `pnpm --filter @pi-desktop/desktop typecheck` and
`pnpm --filter @pi-desktop/desktop test` (2166 / 2166 green),
`scripts/check-architecture.mjs` (index.ts 1500 / 1500),
`pnpm --filter @pi-desktop/docs check:docs`,
`pnpm --filter @pi-desktop/docs check:locales`. Cargo tests not run —
no Rust changes on this branch.
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The desktop-side R2 kernel (ADR 0286, D449) for driving a paired remote
pi-hostviaRACP-WS. Zero-behavior default: with no<dataDir>/remote-hosts.json(which is the case on every existing install) the kernel is dead code — router has no remote backends and every renderer call still hits the local handler byte-for-byte.Nine commits on top of latest
origin/main:83487089arefactor(remote): extract headless runtime servicee2c8384c9feat(remote): implement RACP-WS transport1c90b3ec5refactor(host-runtime): share workspace file and diff helperscb8898a86feat(pi-host): headless RACP-WS agent host2e7992f02Merge origin/main into feat/remote-host-kernel1d4208f86test(racp): stop the ws-binding refusal test flaking under load74aa3949fci(release): publish the pi-host bundle with a checksum78decd490feat(remote): add the backend-router seam for remote-host sessions1241ea2e7feat(remote): translate renderer IPC into RACP for remote sessionsc3127b24afeat(remote): translate RACP events into renderer IPC eventscababa43ffeat(remote): coordinate router registration for a remote host35dbd1998feat(remote): add RACP client adapter and encrypted host registryfa68bd6c6feat(remote): boot paired remote hosts from startup and close them on quitaa7df468fdocs(remote): ADR 0286 and R2 rollout note for the desktop kernel952c75cb9Merge origin/main into feat/remote-host-kernelFull design in ADR 0286 (
docs/adr/0286-remote-host-desktop-kernel.md). R2 rollout spec updated with an R2a/R2b split — R2a (this PR) delivers the kernel; R2b (pairing UX, SSH bootstrap, terminal, reverse tool relay) is future work.Test plan
pnpm --filter @pi-desktop/desktop typecheckpnpm --filter @pi-desktop/desktop test— 2166 / 2166 green (includes 55 new tests: 24 backend, 12 event-bridge, 7 connection, 4 adapter, 8 registry, 4 boot-hook)pnpm --filter @pi-desktop/docs check:docs— 469 pages verifiedpnpm --filter @pi-desktop/docs check:locales— 78 EN/zh-CN pairsnode scripts/check-architecture.mjs—index.tsstays exactly 1500 / 1500test:e2e:remote-host— not applicable to R2a (kernel is dead code until R2b lands pairing)cargo— no Rust changes on this branch; not runCross-references: ADR 0286 (this branch, D449), ADR 0285 (this branch, D448, RACP-WS transport), ADR 0284 (this branch, D447, headless runtime), ADR 0205 R2 rollout (spec
06-delivery/07-remote-control-rollout.md).🤖 Generated with Claude Code