ADR 0032: CLI sessions use each harness's hooks, not tmux, for recording and AgentBus injection¶
- Status: Partially implemented (2026-09-07: auto-trigger + Claude Code inject job)
- Date: 2026-08-24
- Decision owner: MAC fleet owner
- Related: ADR 0023 — one MAC source, thin per-harness adapters; that ADR delivers skills, this one delivers live I/O
- Related: ADR 0026 — the hub emits; a CLI session that never consumes is still deaf
- Related: ADR 0029 — the five coding routes this applies to
- Related: ADR 0007 — the human-facing runtime is not the coding-CLI session supervisor
Context¶
The operator asked for this on 2026-08-24: optimize the CLI sessions that run for each agent so they use the well-known hooks on Claude, Codex ("code"), Cursor, and OpenCode, and do not run those CLIs under tmux or at any other outer-supervisor layer. Every one of those CLIs now has advanced hooks that can record the session's work and inject messages into the model's context from AgentBus.
That is one decision with two jobs, and a level that has to be named because the wrong level is the one people reach for.
What exists today, verified in the tree¶
A task executor launches the coding CLI as a captured subprocess.
executor_prompt._run_captured is the seam. docs/in-flight-agent-messages.md
states the consequence in one sentence: once a task starts, nothing can
reach the coding CLI, so a correction arrives after the mistake is finished.
The shipped "fix" is a convention. executor_prompt._coordination_section
tells the model to start mac admin agentbus wait $MAC_AGENT_ID as a
background harness task, restart it with --after-cursor, and treat a
message as a correction. docs/in-flight-agent-messages.md documents that
shape and cites AgentRadio's wait_for_mention.sh as the provenance.
That is the wrong level for two independent reasons:
- It is a prompt the model may follow. Delivery of AgentBus traffic cannot depend on the model choosing to launch a watcher. A session that forgets, a harness that has no background slot, and a sandbox that forbids detached processes all become deaf.
- It already failed for registered CLI sessions. ADR 0026 recorded
two
mac.repo.update.result.v1replies addressed to a registered session on 2026-08-21, opened and closed within ~20ms, and never read.agentbus waitis a blocking inbox read. An interactive CLI has no background loop to put it in.drain/pendingwere added so a caller can poll without blocking; they still require a caller.
There is no tmux in this repository. A search of src/, deploy/,
and docs/ at e8040fec finds none. The wrong level is still real: wrap
the coding CLI in tmux, screen, dtach, or a PTY (script,
worker_debug_terminal's debug PTY) so an outer process can
capture-pane for a transcript and send-keys for an inbound message.
That fights the CLI. It is also how credentials leak into scrollback, how
a compacting session loses the injected text, and how a Cursor or Codex
TUI that is not a dumb terminal silently ignores the keystrokes.
Recording today is stdout of that captured subprocess, plus whatever the model writes as MAC evidence. Interactive host sessions have neither. The session's tool calls, files touched, and stop reason are inside the CLI; scraping a pane does not parse them.
The CLIs already have the right surface¶
The four named harnesses, plus pi (the fifth coding_agent.AGENT_PRIORITY
route), each document a hook or plugin plane that fires at turn
boundaries. Those are the points where a process MAC owns can run
inside the CLI, not around it.
| Harness | Config the CLI already reads | Inject (context the model will see) | Record (work the hub should keep) |
|---|---|---|---|
| Claude | ~/.claude/settings.json hooks (user) and .claude/settings.json (project) |
UserPromptSubmit / SessionStart JSON hookSpecificOutput.additionalContext; Stop / PreCompact for the next turn |
PreToolUse, PostToolUse, Stop, SessionEnd |
| Codex ("code") | ~/.codex/hooks.json or inline [hooks] in ~/.codex/config.toml; features.hooks |
UserPromptSubmit, SessionStart (including compact-resume); same event names as Claude |
PreToolUse, PostToolUse, Stop, SessionEnd |
| Cursor | ~/.cursor/hooks.json (user) and .cursor/hooks.json (project) |
sessionStart additional_context; postToolUse additional_context. beforeSubmitPrompt is a gate (continue), not a rewrite — do not pretend it injects |
afterFileEdit, postToolUse, stop, afterAgentResponse |
| OpenCode | plugin in opencode.json / the documented plugin array |
chat.message mutates output.parts before the LLM call; experimental.session.compacting survives compact |
tool.execute.before / tool.execute.after; event for session.* |
| Pi | its documented hook / instruction surface, when present | the same two jobs, on whatever it documents | the same two jobs |
Event names drift. The table is the kind of surface, dated 2026-08-24 against each vendor's public hook docs. An adapter that hard-codes a JSON key the vendor renamed is a bug in the adapter, not a reason to go back to tmux.
Two honesty notes that decide the Cursor and OpenCode wiring:
- Cursor's
beforeSubmitPromptcurrently accepts{ "continue": true }and does not rewrite the prompt. Injection on Cursor issessionStart/postToolUseadditional_context, not send-keys and not a prompt prefix the hook cannot apply. - OpenCode's
chat.messagecan push parts the model will see. Other OpenCode hooks (session.idle) cannot. Use the hook that injects; do not wrap OpenCode in tmux because a different hook is fire-and-forget.
Decision¶
The coding CLI is the session. MAC installs a hook adapter into that CLI. Nothing wraps the CLI in order to record it or talk to it.
1. Two jobs, one MAC program, thin per-harness wiring¶
One program MAC owns (a mac admin verb or a small console script the
hooks invoke) does both jobs:
- Inject. Non-blocking
drainof this agent's AgentBus inbox. Return the payloads as additional context in the harness's hook output shape. Empty inbox → empty context, not an error. A drain failure must not block the user's turn. - Record. On tool/file/stop/session-end events, emit a secret-free
structured record to the hub (
mac.cli_session.turn.v1or the existing evidence/action-event path). Credentials, token values, and raw pane bytes do not belong in that record.
Each harness gets a thin config snippet that points at that program with the event name the vendor documents. Adapters render; they do not author. A hook that exists on Claude and not on Codex is a bug, the same rule ADR 0023 uses for skills.
This is not ADR 0023. Skills are static obligations. This is live I/O with a running session. Same delivery pattern, different payload.
2. The wrong levels are refused, not deferred¶
These are not "later". They are how this fails:
- tmux / screen / dtach as a session supervisor, including
send-keysfor inbound AgentBus andcapture-panefor a transcript. - A PTY wrapper (
script, a debug PTY, a "headless tmux") whose job is to be the recording or injection plane.worker_debug_terminalstays a debug surface; it is not how fleet sessions hear the bus. - A prompt convention that the model start
agentbus waitin the background. Keepwait/drainas CLI verbs for operators and for harnesses that have a real background slot. Do not make them the delivery architecture. - The human-facing runtime wrapping the coding CLI (the Hermes gateway, a Slack bot that shells out). ADR 0007's boundary: conversation is not the coding session. The coding CLI's own hooks are.
- An MCP tool the model must elect to call in order to hear the
bus. Pull is optional. Injection at
UserPromptSubmit/chat.message/sessionStartis not. - Git hooks. They fire on commits, not turns. The work to record is the turn.
- Stdout scrape of
_run_capturedas the only record of an interactive session. Captured stdout remains a legitimate attempt log for a sandboxed task run. It is not a session protocol.
3. Same adapter in the sandbox as on the host¶
A fleet worker running claude -p / codex exec / cursor-agent /
opencode run inside OpenShell is still a CLI session. Install the
same hook config into the sandbox home the coding CLI will read, so a
mid-task AgentBus correction lands at the next turn boundary without
the model starting a watcher.
_run_captured stays. It is how the executor bounds the process and
keeps an attempt log. Hooks sit inside that process, which is the
level that can inject context the model will actually see.
4. Delivery is a channel; obedience is not¶
docs/in-flight-agent-messages.md already says this and it remains
true: injecting a correction does not make the agent obey. The hook
guarantees the text is in context for the next model call. Whether the
agent stops, yields a file, or ignores a peer is still a matter of
instructions (ADR 0023) and review.
5. Failure must not take down the session¶
A hook that cannot reach the hub records nothing and injects nothing for that turn. It does not block the prompt, deny a tool, or exit non-zero in a way the CLI treats as fatal. The session is the product; the bus is the announcement. Same posture as ADR 0026 §2 for emission.
Consequences¶
- Follow-up work installs the adapter and the four (five) config snippets, with a test per harness that the hook config names a binary that exists and that an empty inbox produces a valid empty hook payload. This ADR does not ship that installer.
- Once hooks are installed,
_coordination_sectionshould stop telling every executor to background-wait.docs/in-flight-agent-messages.mdremains the inbox API (wait/pending/drain); it ceases to be the delivery architecture for CLI sessions. - Interactive host sessions registered as agents become first-class bus participants without a tmux babysitter. That is the defect ADR 0026 measured and this ADR names the layer for.
- Cursor injection is weaker than Claude/Codex/OpenCode until
beforeSubmitPromptcan add context. The adapter uses the events Cursor actually honors, and the gap is recorded rather than papered over with tmux.
Not this ADR¶
- Changing
AGENT_PRIORITYor the route ladder (ADR 0029). - Making AgentBus emission complete (ADR 0026). This is consume, not emit.
- Replacing OpenShell isolation, leases, or the merge queue.
- Recording raw transcripts into Qdrant. ADR 0030 still wants an extract, not a pane dump.
Update 2026-09-07: the install step is now automatic¶
The original decision assumed an operator runs mac admin plugin
install by hand to wire a harness's hook config. That is a gap of its
own: a session that never runs the installer is deaf, silently, with
no error to notice — the exact failure shape this ADR exists to fix,
one layer up.
src/mac/cli_session.py removes the manual step. mac's main()
now calls cli_session.auto_join() on every invocation; it is a
total no-op unless a known coding-CLI harness is detected in the
environment (CLAUDECODE=1 for Claude Code — the only signal
verified against a live session so far), in which case it:
- Idempotently registers a durable
(machine, agent)identity scoped to(hostname, os-user), owned by the caller'sHumanprincipal when one resolves,visibility="private"otherwise"shared". Bothregister_machine/register_agentareINSERT ... ON CONFLICT DO UPDATEupserts, so repeated calls are free; a local cache under~/.mac/cli-session/additionally skips the network round trip withinDEFAULT_CACHE_TTL_SECONDS. - Idempotently merges a fail-open adapter for
mac admin cli-session hookinto the harness's own hook config (~/.claude/settings.jsonhooks.SessionStart/hooks.UserPromptSubmitfor Claude Code) — merges alongside whatever hooks already exist, never replaces the array. The adapter emits valid empty hook JSON if the installedmacbinary is temporarily older than the config.
mac admin cli-session hook is the adapter program this ADR
describes: a non-blocking drain_agentbus_inbox call rendered as the
harness's hook-output JSON shape (hookSpecificOutput.additionalContext
for Claude Code), empty inbox → empty context, any failure → empty
context, never a blocked prompt or a non-zero exit.
Shipped: the auto-trigger mechanism, generically; the Claude Code inject job, fully verified from inside a live Claude Code session.
Deferred, tracked as a follow-up task:
- Codex, Cursor, and OpenCode adapters. Their harness-detection env
vars and hook config shapes are named in this ADR's table but were
not verified against a live session of each, and guessing wrong
would silently install into the wrong config file — worse than not
installing at all.
- The record job (PreToolUse/PostToolUse/Stop/SessionEnd
→ mac.cli_session.turn.v1). Only inject shipped in this pass.