ACP host runtime core
Child of epic 7191 "Converge every runner on one agent contract: ACP-native". Settled inputs: PRD (task 7189), architecture (task 7190). Architecture boundary C5. This child carries the security surface (permission channel, identity, key-in-env); run the autonomous security review lane.
## Original requirements (PRD, verbatim)
**R1 — ACP is the wire.** The node is an ACP client (JSON-RPC 2.0 over the agent's stdio: `initialize`, `session/new`, `session/load` or `session/resume`, `session/prompt`, `session/update`, `session/request_permission`, `session/cancel`). Every runner engine Jaah dispatches is driven as an ACP agent. A vendor CLI that does not serve ACP natively is driven through a provider shim that speaks ACP outward and the vendor's host protocol inward, never the TUI through tmux, for runner dispatch.
**R3 — Common code in the node runtime, not in the SDK and not in a shim.** One implementation each of: session store with app ids primary and provider-native ids as refs (stored at open, never discovered from a filename); one normalised event stream per run (turn-item vocabulary: assistant_message, reasoning, command_execution, file_change, approval_request, user_input_request, subagent, usage, error, session_end); usage accounting and rate-limit recovery with a reset time; the runtime-request handshake (approval | user_input) with one decision enum; supervision (process exit ends the attempt, never a vendor event); worktree/git integration; guards; capability flags; the runtime-mode vocabulary.
**R5 — Live control.** A running session can be steered (new prompt mid-run), interrupted (`session/cancel`), and have a pending permission / user-input request answered, from the node runtime. The existing question handshake (`task.question`, Full Auto blocker-only rule) and the security-surface approve/do-not-merge gate route through the one runtime-request shape.
## Approach (architecture, verbatim)
**C5: ACP host runtime core.** `apps/jaahd/acp/*`, `attempt_end.py` extraction, the runtime verbs (§3.2), the normaliser (§3.1), requests (§3.3), modes, the session store, supervision, the `_jaah.ai/*` bridge, the daemon → CP event forwarding, and the replay-fixture harness with a fake ACP agent. Adds the ADR entry "ACP is the wire; one host per run" and the present-tense runtime section of `apps/jaahd/README.md`, routed from the hub (AC-19). Also includes the wait lane (§3.2), the `deliver_answer` / `runtime_control` intent branches, and a host self-check event `{kind:"session_start", data:{key_in_env:false, cli_versions:{…}}}` that scans `/proc/<pid>/environ` of every process in the tree under the host (engine, MCP children, tool subprocesses, re-scanned at each `turn_end`) for every known model-key variable name (values never read out). A key found later emits `error{class:"auth", message:"model key found in environment of <comm>"}`. A test plants a key in a grandchild and asserts the check turns red. Uses the new verb names, and works with either name through the aliases, so it does not depend on the rename child.
Path correction: the settle/fail ladder to extract is `rs_settle` in `apps/jaahd/hooks/runner-status.py:252` (not a top-level hooks dir); extract to `apps/jaahd/attempt_end.py`, imported by both the hook and the host.
Components: `apps/jaahd/acp/` (`jsonrpc.py`, `client.py`, `host.py` entry `jaah-acp-host --launch <launch.json>`): stdio JSON-RPC 2.0, `initialize` (advertises `_meta["jaah.ai"]`, `elicitation`, `fs` off, `terminal` off), `session/new|resume|load|prompt|cancel|close`, server→client `session/request_permission`, `elicitation/create`, `_jaah.ai/*` requests forwarded to daemon verbs. `events.py` (normaliser), `requests.py`, `modes.py` (reported only, every launch `full-access`), `session_store.py` + `runner_registry.record` (app session id primary; `native_session_ref` captured from the `session/new` response).
**launch.json (§3.4b, the seam the switch child writes):** `{task_id, session_id, provider, auth_kind, worktree, additional_dirs, charter_path, prompt_path, runtime, argv, env, session_meta, mcp_servers}`. The host reads exactly this. Refuse to launch if `env` carries a key value. Add `AcpLaunch` dataclass and the optional `acp_launch(ctx) -> AcpLaunch` member on the `AgentAdapter` protocol (`apps/jaahd/agents/base.py`): `argv`, `env`, `session_meta`, `mcp_servers` (always includes the Jaah "control" stdio server), `prepare` (callable, never in launch.json; run before spawn where `adapter.prepare` runs today).
**D-ACP-1.** ACP protocol version 1 plus the stabilized capabilities `sessionCapabilities.resume/list/close`, `additionalDirectories`, `elicitation`, `usage_update`, `configOptions`. Resume fallback order: `session/resume`, then `session/load`, then capability `sessions.resume=false`.
**D-HOST-1.** One host process per run, launched as the tmux pane root (static) or by the guest's `launch.sh` (MicroVM); the engine is its stdio child, so ADR-0083 identity (pane-pid ancestry walk `runner_registry.py:536-566`; per-VM vsock binding `cred_broker.py:352-380`) is unchanged.
**D-HOST-2.** Live control by long-poll `runtime.control_wait` over the daemon socket.
**D-EVT-1.** Host batches events into `runtime.events_append` (≤500 ms or 64 events); daemon appends to the run's events file (one JSON object per line) and forwards live to the CP `POST /api/agent/sessions/{sid}/events` (idempotent on `seq`; built by the CP-ingest child — forward against that seam).
**D-Q-1.** One question handshake, two entries: ACP `elicitation/create` → `user_input` runtime request (host holds the ACP response, re-issues `runtime.request_wait`); or MCP `input_request` which returns at once `{status:"pending", request_id}` with "end your turn; the answer arrives as your next message"; the answer is delivered as the next `session/prompt` once the current turn has ended. Never tmux answer-paste for ACP runs.
**D-PERM-1.** Host answers `session/request_permission` with `allow_always` (or `allow_once` if only option), records an `approval_request` event with `decided_by: "policy:full-access"`, never parks the task. Security gate approve/do-not-merge stays a `user_input` request.
**D-SUP-1.** Engine exit or `session/prompt` returning with the task open runs the same settle/fail ladder (`attempt_end.py`). `stopReason=end_turn` with the task open: one nudge prompt, then today's autoclose idle rule. Exception: while a `user_input` request is open, no nudge and no autoclose. A vendor event never ends an attempt.
**D-USE-1.** Usage tokens from `_meta["jaah.ai"].usage = {model, input_tokens, cache_write_tokens, cache_read_tokens, output_tokens, reasoning_tokens}`; cost-only agents record `reported_cost_usd` with `source:"agent_reported"`.
§3.1 event (schema v1): `{"v":1,"seq":<assigned by daemon>,"ts","session_id","native_session_ref","task_id","provider","turn_id","item_id","kind","status":"in_progress|completed|failed|declined","data":{...}}`; `kind` ∈ `session_start | turn_start | assistant_message | reasoning | command_execution | file_change | tool_call | approval_request | user_input_request | subagent | plan | usage | rate_limit | error | turn_end | session_end`. Mapping: `agent_message_chunk`→assistant_message (coalesced per item, final text stored whole, never truncated); `agent_thought_chunk`→reasoning; `tool_call`/`tool_call_update` `kind:"execute"`→command_execution, `edit|delete|move`→file_change, else tool_call (`data.name` full tool name, `data.paths` full paths); `plan`→plan; `usage_update`→usage; `request_permission`→approval_request; `elicitation/create` or MCP `input_request`→user_input_request; adapter subagent `_meta`→subagent; `stopReason`→turn_end `data.stop_reason`; process exit→session_end `data.exit_code, data.cause`. `usage.data` = `{model, input_tokens, cache_write_tokens, cache_read_tokens, output_tokens, reasoning_tokens, context_used, context_size, reported_cost_usd, source}`; `rate_limit.data` = `{window, resets_at, account_slug}` feeding the existing spillover; `error.data` = `{class:"usage_limit"|"auth"|"api"|"transport"|"agent", message:<vendor text verbatim>, resets_at?}`.
§3.2 runtime verbs (TASK scope, identity by ancestry/VM, never model tools): `runtime.events_append {session_id, events}` → `{first_seq, last_seq}` (daemon assigns `seq` from a per-session counter persisted under `daemon_state_dir("acp-runs")/<session_id>.seq`; retries dedup on `(session_id, batch_id)`); `runtime.control_wait {session_id, after}` → `{intents:[{id, type:"steer"|"interrupt"|"answer"|"close", payload}], cursor}` held ≤50 s, intents retained until acked, ≤24 h; `runtime.request_open {session_id, request:{id, type:"user_input", questions}}` → `{request_id}` (user_input only); `runtime.request_wait {request_id}` → `{decision, answers?}` held ≤50 s.
**Wait lane:** broker caps `_MAX_INFLIGHT = 8` / `_MAX_INFLIGHT_PER_PEER = 2` (`apps/jaahd/cred_broker.py:240-241`). Wait verbs flagged `Verb.wait=True`, parked on an asyncio condition (no executor thread), bounded at 3 per peer and `3 × max runners on the node` per node; beyond → `busy`, host retries with jittered backoff. Test with N = 24 concurrent hosts proving ordinary verbs still complete within their timeouts.
**Downward controls:** for a run whose dispatch fact is `runtime: "acp"`, reconcile's `deliver_answer` handler (`apps/jaahd/reconcile.py:81`) resolves the matching `request_id` and completes the pending `runtime.request_wait` instead of pasting into tmux; legacy path unchanged. New node intent `runtime_control {session_id, intent:{id, type, payload}}` is enqueued by reconcile into the session's control queue, drained by `runtime.control_wait` (the CP route that issues it belongs to the chat-view child).
§3.3: `RuntimeRequest = {id, type:"user_input", questions:[{id, header, question, options:[{label, description}], multi_select}]}`; approval exists only as event data `{tool_call:{name, kind, paths, command}, options, decision, decided_by:"policy:full-access"}`. `Decision = "accept" | "accept_for_session" | "accept_always" | "decline" | "cancel"`. ACP mapping: `allow_once`→accept, `allow_always`→accept_always, `reject_once`→decline, `reject_always`→decline, `cancelled`→cancel; elicitation `accept|decline|cancel` 1:1.
**Replay harness:** `apps/jaahd/tests/acp_replay/` + recorder `tools/record_acp_fixture.py`; recorded ACP frames replayed through host → daemon → a CP stub, with a fake ACP agent; runs in `./dc check full`. Provider children add their own fixtures. Include harness tests for: end_turn with an open request answered after >10 minutes (simulated clock) asserting no nudge and no settle; a usage-limit frame classified `usage_limit` with `resets_at`; key planted in a grandchild turning the self-check red.
Credentials & reach: AC-5–AC-11, AC-19 (gates, replay, conformance) | worktree only (`./dc check full`, `tools/check_context.py`) | needs nothing beyond the worktree. AC-3 `/proc/<pid>/environ`, AC-8 `--version`, AC-13 "no parser invoked" | read in the console via this child's `session_start` self-check event | verified (mechanism built here).
Out of scope: Do NOT add provider launch grammar for any real vendor (provider children), the CP ingest route, the switch, or the chat UI. PRD §5 verbatim:
- MUST NOT delete the legacy paths (v1 SDK `apps/jaahd/vendor/agent-sdk`, `tools/jaah_agent_run.py` v1 mode, per-vendor transcript parsers in `apps/jaahd/agents/*.py`, Claude-shaped hook scripts, `clauded_wt` TUI driving). MUST NOT file that task (D5).
- MUST NOT publish (D2). MUST NOT change runner permission posture: no runner moves off full-access, no native sandbox enforcement per persona (D6).
- MUST NOT drop or regress any current Claude Code capability (R10). MUST NOT remove the console terminal/TUI view (D3). MUST NOT submit an upstream ACP RFD (D8). MUST NOT integrate with T3 Code's RPC or MCP API.
- MUST NOT change the task lifecycle states or the merge lane. MUST NOT change billing beyond ingesting ACP-reported usage. MUST NOT add vendors. MUST NOT touch `apps/mobile`.
- MUST NOT put a token in the agent's environment or weaken process-ancestry identity (ADR-0083); MUST NOT add a second verb table (one verb, two front ends).
Done means:
- AC-5 (R3) One event stream, one shape (node half). → verify: replaying two different fake-agent fixtures produces event files with identical schema (same item kinds and fields per §3.1); usage totals in the stream equal the fixture's totals (a stream that drops `usage` fails).
- AC-6 (R3) Rate-limit recovery (mechanism). → verify: replay fixture of a usage-limit frame; observe the run is classified usage_limit with a reset time and the existing spillover still applies.
- AC-9 (harness). → verify: `./dc check full` runs the replay-fixture suite covering dispatch → events → question → settle on the fake agent, and passes.
- Wait lane: the N = 24 concurrent-host test passes with ordinary verbs inside their timeouts.
- Identity: a test proves engine and MCP child under `jaah-acp-host` resolve to the task via the existing ancestry walk; the key-in-env grandchild test turns red; launch.json with a key value is refused.
- Supervision: tests prove process exit ends the attempt via `attempt_end.py`, the hook still uses the same module, and no nudge/settle fires while a `user_input` request is open.
- AC-19 (part) → verify: ADR entry "ACP is the wire; one host per run" and the present-tense runtime section of `apps/jaahd/README.md`, routed from the hub; `python3 tools/check_context.py` passes.
Required checks: ./dc check full
Outcome
Built the ACP host runtime: host, normaliser, runtime.* verbs on a wait lane, attempt_end, key-in-env self-check, replay harness, ADR-0165. Merge lane ran the full gate; security lane passed. MicroVM fence follow-up filed as draft 7317.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)