Skip to content

feat(remote): R2 desktop kernel — backend router, adapter, event bridge, connection, boot - #589

Merged
vastsa merged 15 commits into
mainfrom
feat/remote-host-kernel
Sep 18, 2026
Merged

vastsa merged 15 commits into
mainfrom
feat/remote-host-kernel

Conversation

@vastsa

@vastsa vastsa commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Summary

The desktop-side R2 kernel (ADR 0286, D449) for driving a paired remote pi-host via RACP-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:

commit scope
83487089a refactor(remote): extract headless runtime service packages/host-runtime lift
e2c8384c9 feat(remote): implement RACP-WS transport packages/racp full impl
1c90b3ec5 refactor(host-runtime): share workspace file and diff helpers dedup
cb8898a86 feat(pi-host): headless RACP-WS agent host apps/pi-host bundle
2e7992f02 Merge origin/main into feat/remote-host-kernel catch-up
1d4208f86 test(racp): stop the ws-binding refusal test flaking under load flake fix
74aa3949f ci(release): publish the pi-host bundle with a checksum release pipeline
78decd490 feat(remote): add the backend-router seam for remote-host sessions router seam
1241ea2e7 feat(remote): translate renderer IPC into RACP for remote sessions 17-channel adapter
c3127b24a feat(remote): translate RACP events into renderer IPC events event bridge
cababa43f feat(remote): coordinate router registration for a remote host per-host coordinator
35dbd1998 feat(remote): add RACP client adapter and encrypted host registry RacpClient adapter + safeStorage-backed registry
fa68bd6c6 feat(remote): boot paired remote hosts from startup and close them on quit startup + shutdown wiring
aa7df468f docs(remote): ADR 0286 and R2 rollout note for the desktop kernel ADR 0286 + spec + zh mirror
952c75cb9 Merge origin/main into feat/remote-host-kernel rebase to latest main, ADR 0283 renumber collisions, fix pre-existing test-source-contract drift

Full 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 typecheck
  • pnpm --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 verified
  • pnpm --filter @pi-desktop/docs check:locales — 78 EN/zh-CN pairs
  • node scripts/check-architecture.mjs — index.ts stays exactly 1500 / 1500
  • test:e2e:remote-host — not applicable to R2a (kernel is dead code until R2b lands pairing)
  • cargo — no Rust changes on this branch; not run

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

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.
Copilot AI lite review requested due to automatic review settings September 18, 2026 10:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vastsa
vastsa merged commit 0726e0f into main Sep 18, 2026
1 of 4 checks passed

This branch was successfully deployed

1 active deployment
Preview — 952c75cb Deployed Sep 18, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants