ADR 0284: Headless runtime boundary in packages/host-runtime
- Status: Accepted for implementation
- Date: 2026-09-18
- Decision: D447
- Related: ADR 0205 (D373 / D374 / D375), ADR 0213 (D386),
02-architecture/05-remote-agent-control.md§4 and §11,03-runtime/07-process-model.md§4,06-delivery/07-remote-control-rollout.mdR2
Context
Rollout R1 delivered the headless Agent Host module (packages/agent-host): admission, the turn queue, the approval broker, the event log, and the snapshot builder run without Electron. Everything underneath it did not. The stdio transports to host-core and the agent sidecar, the restart supervisor, the durable turn lifecycle (session.beginTurn → prompt → session.endTurn), transcript persistence, the in-flight reply checkpoint, and approved Plan/Goal execution all lived in apps/desktop/electron/main, and the Electron AgentHost bridge reached them by invoking the desktop's own IPC handlers. HostProcess resolved the binary from process.resourcesPath, and AgentSidecar could only start the sidecar as process.execPath + ELECTRON_RUN_AS_NODE.
R2 needs the same runtime on a machine that has no Electron: the pi-host bundle (02-architecture/05-remote-agent-control.md §5.2) must run the Agent Host module, the sidecar, and host-core behind a RACP server. Re-implementing the turn lifecycle in a second place would create a second source of truth for exactly the invariants the desktop spent a year fixing (abort locks, stale terminal events, single-flight finalization, queue release after durable settlement).
Decision
- A new workspace package,
packages/host-runtime, owns the Electron-independent runtime layer. It depends onshared,agent-host, andagent-runtimeonly, and never onelectronor onapps/desktop. Its modules are:HostProcessandAgentSidecar: the stdio NDJSON JSON-RPC transports, moved from Electron main unchanged in behavior. Both take their launch as options —binaryPath/dataDir/ extraenvfor host-core,{ command, args, env }for the sidecar — and anonStderrsink. The sidecar'shost.proxyallowlist, local-tool short circuit, Plan-mode gate, vendor-auth binding check, and trusted-extension bridge are unchanged.HostProcessaccepts an optionaldiagnoseFailurehook so an embedding host can name a boot refusal it can phrase.RuntimeSupervisor: the restart policy of process-model §4 (0.5s → 1s → 2s capped at 4s, three restarts per two-minute window, single flight per child, never during shutdown, stop on the first unrecoverable failure). The embedding host suppliesstart,afterRestart,isUnrecoverable, and an event sink.RuntimeService:RuntimePortfor the Agent Host module on top of the two transports — prompt admission with the durable turn row and the user message appended before the sidecar starts, steer, stop, abort with the abort lock, asktool answers, manual compaction, andfinishTurnwith the same claim-before-await rule, stale-terminal guard, and settlement announcement as Electron main.TurnEventPipelineandTurnPersistence: the per-event pass (tool-call tracking, D299 checkpoints, terminal events, completed rows) and an in-memory ordered append queue with a bounded retry while host-core restarts. Api-hostprocess ends only with its supervisor, so the desktop's file-backed outbox is not duplicated.createHeadlessLaunchResolver: launch resolution from host-core's own registries (providers and secrets, the effective command shell, project instructions and memory, user skills, subagent definitions). It refuses vendor OAuth accounts and plugin agents, which need the desktop.PlanExecutionDispatcher: approved Plan/Goal execution (D189) with the same claim, no-replay, and finalization rules.- Host-core adapters for the module's
SessionPort,QueueStore(schema v15), andpermissions.pending, plus the session-message ledger lookup, the inflight checkpointer, and the plan-execution decoders, moved out of Electron main.
- Electron main keeps thin adapters.
electron/main/host-process.tsandagent-sidecar.tssubclass the package classes and add only what Electron knows: the packaged binary and bundled-plugin locations, theELECTRON_RUN_AS_NODElaunch, redacted stderr logging, and the schema-too-new / glibc diagnoses.runtime/lifecycle.tsdrives the shared supervisor and keeps the renderer status pushes, plugin locale resync, and approved-plan drain. The Agent Host bridge uses the shared host-core ports instead of its own copies. The local prompt path (agent-ipc.ts), with its attachments, slash expansion, plugin tools, MCP relay, vendor accounts, and notifications, is unchanged and still owned by the desktop. TurnStartRequestgains an optionaluserMessageIdso a client can keep its optimistic user row id through the headless runtime, exactly as the renderer does through IPC (D288). Additive; no caller is required to send it.- No wire contract changes. Electron IPC, the sidecar JSON-RPC, host-core RPC, and the Plugin SDK are untouched; the desktop's observable behavior, defaults, and persisted data are unchanged.
Consequences
pi-host(R2) can composeHostProcess + AgentSidecar + RuntimeService + AgentHost + RuntimeSupervisorin plain Node; the RACP server binds to the module, never to Electron IPC.- The restart policy, the turn lifecycle, and the transports now have unit tests that run without Electron (
packages/host-runtime/src/*.test.ts). - The desktop's source-contract tests that pinned
host-process.tsandagent-sidecar.tsnow read the package sources; the renderer-facing status and diagnosis assertions still read the Electron adapters. - Local Electron main still runs its own prompt path through IPC. Moving the desktop onto
RuntimeServicefor local sessions is a later, behavior-preserving step; it is not required for R2 and is not done here. - The headless resolver deliberately supports less than the desktop: no prompt attachments, no plugin tools, no desktop MCP relay, no vendor OAuth. Those return typed errors rather than degrading silently.
Alternatives considered
- Extract only the transports and re-implement the turn lifecycle in
pi-host. Rejected: the lifecycle invariants are the hard part, and a second copy would drift from the desktop's. - Keep the modules in
apps/desktop/electron/mainand import them frompi-host. Rejected:packages/*must not depend on desktop implementation code, and the modules would keep growing Electron imports. - Put the runtime into
packages/agent-host. Rejected: the module is the transport-free semantic core with no process or filesystem knowledge, and the RACP server and integrations depend on it staying that way.