# Adapters: per-harness mapping

> This document is part of the sessionpipe protocol and is licensed under [CC BY 4.0](https://sessionpipe.org/legal/#spec-license). The words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are used as in RFC 2119. Status: **Draft** (see [VERSIONING.md](https://sessionpipe.org/protocol/versioning/)).

An adapter is the only vendor-specific code in sessionpipe: one file under
`packages/core/src/adapters/<name>.ts` plus its fixtures under
`conformance/harness/<name>/`. This document is the registry of harness names and,
per harness, where the hooks are written, which native event maps to which protocol
event, which ids the payload carries, where the transcript is read for tier 2, and the
quirks the code must honour. Every mapping here is proven by a fixture recorded from
a real payload; a row without fixtures is marked *(recording pending)* and is not
yet normative.

**What every adapter reads, and nothing more.** A documented hooks or plugin API,
and files the person's own harness wrote on the person's own machine: the transcript
named in the hook payload, a session registry, the config file's account id (or the
API endpoint its settings name, for a home with no login). No vendor API is called,
no credential is read, no token is sent anywhere. The one exception is the account
pool ([PRIVACY.md §3](https://sessionpipe.org/protocol/privacy/#3-what-a-sender-never-reads)), which exists only after
the person runs `sessionpipe pool on` or `pool add`, holds only the accounts they added,
and talks only to that vendor's own endpoints for their own requests; the harness's
*Pool* row says exactly what it reads, writes and calls. The
documentation URL per harness is in its row.

**The one exception, the person's own to make.** A harness whose own service names a
session without telling the machine may be asked for that name, and only when the
person turned it on (`sessionpipe titles on`, config.json `remote_titles`; off by
default). The adapter then reads the harness's sign-in on this machine, sends it to
that service alone, for that one read, and keeps it nowhere: never in a file, an event
or a log. Only Claude Code has one (claude.ai, below).

## Registry

| `harness.name` | Product | Docs | Session end? | Stop can block? | Milestone |
|------------------|---------|------|:---:|:---:|:---:|
| `claude-code` | Claude Code (Anthropic) | code.claude.com/docs/en/hooks | yes | yes | M2 |
| `codex` | Codex CLI (OpenAI) | learn.chatgpt.com/docs/hooks | yes | yes | M2 |
| `gemini-cli` | Gemini CLI (Google) | geminicli.com/docs/hooks/reference | yes | yes (AfterAgent) | M2 |
| `antigravity` | Antigravity (Google) | antigravity.google/docs/hooks | **no** | no | M2 |
| `devin` | Devin CLI (Cognition) | docs.devin.ai/cli/extensibility/hooks/overview | yes | yes (exit 2) | M2 |
| `grok-build` | Grok Build (xAI) | the user guide the CLI installs (`~/.grok/docs/user-guide/10-hooks.md`) | yes | yes | M2 |
| `cursor` | Cursor | cursor.com/docs/hooks | yes | yes (`followup_message`) | M7 |
| `copilot-cli` | GitHub Copilot CLI | docs.github.com/en/copilot/reference/hooks-reference | yes | yes | M7 |
| `droid` | Droid (Factory) | docs.factory.ai/reference/hooks-reference | yes | yes | M7 |
| `kiro` | Kiro (Amazon) | kiro.dev/docs/hooks | **no** | yes | M7 |
| `opencode` | OpenCode (Anomaly) | opencode.ai/docs/plugins | **no** (`session.deleted` only) | n/a (input API) | M7 |

A receiver infers "done" for a harness without a session end from silence, and
presents that as its own reading.

## Common conventions

- **Config written by `sessionpipe install`** is one entry per event, running
  `"<absolute node>" "<absolute dist/hook.js>" <harness> <event>` with quoted absolute
  paths. Never `#!/usr/bin/env node`, never an npx path.
- **stdout**: nothing, except where the harness parses it (Antigravity: `{}`).
- **`session.seq`** is assigned by the sender's outbox, not by the adapter; adapter
  fixtures wildcard it.
- **Facts** (title, url, model, account_id, repo, branch, attended) are read by the worker from
  the harness's files after the hook exits; the hook event itself carries only what
  stdin has. A fixture's `expect` lists what stdin alone yields; `files` in a fixture
  supplies transcripts for fact tests.
- **Ids**: `turn_id` = the harness's prompt/turn id when it has one; `call_id` = its
  tool-use id; `attention_id` = the tool-use id of the permission request when the
  payload has one, else an id the sender mints, unique within the session. Claude
  Code's `PermissionRequest` carries no tool-use id, so its adapter mints
  `perm-<16 hex of sha256(prompt id | tool | input)>`: the same request always names the
  same attention, which is what a `permission.answer` has to name (CONTROL.md §6).
  Notifications without an id are `notif-<epoch ms>`.
- **Who started a turn** (`turn.started.submitted_by`, `turn.started.injected`): only
  `claude-code` fills these today, and only when its payload or the prompt's shape says so.
  `codex`, `gemini-cli` and `devin` leave `submitted_by` absent — no recorded payload of
  theirs names who submitted the prompt — and leave `injected` absent for want of a
  recorded wrapper of their own to key on, not because it is unknowable: all three carry
  the prompt, and Codex's reader already has `codexInjected`. `antigravity`'s `turn.started`
  has no prompt at all. An absent field is unknown, not "the person" (PROTOCOL.md §4).
- **Control** (CONTROL.md §6, §9): only on a machine with control paired, the Stop hook
  asks the daemon's socket for a queued, verified message and may answer a block with
  it, and the PermissionRequest hook may wait there for a verified answer. These two
  are the only hooks that ever print for a harness that doesn't parse stdout, and they
  are exempt from the 150 ms budget.

## Claude Code (`claude-code`) — M2

| | |
|---|---|
| Config | `settings.json` in **every** config dir: `~/.claude`, `$CLAUDE_CONFIG_DIR`, and any `~/.claude-*` holding `projects/` or `settings.json` (one per account; hooks written to one never fire for the others). Timeouts in seconds. |
| Hook shape | `{"hooks":{"<Event>":[{"matcher":"","hooks":[{"type":"command","command":"…","timeout":5}]}]}}`; the matcher is only present on tool and permission events. |
| Mapping | `SessionStart` → `session.started` (`source` ← `source` / `trigger`) · `UserPromptSubmit` → `turn.started` (`turn_id` ← `prompt_id`, `prompt_chars`, `submitted_by` ← the payload's `source` forwarded verbatim when it has one, `injected: true` when the prompt opens with one of Claude Code's own wrappers) · `PreToolUse` → `tool.started` · `PostToolUse` → `tool.ended` ok · `PostToolUseFailure` → `tool.ended` !ok · `PermissionRequest` → `attention.needed` permission · `Notification` → `attention.needed` (both carry `agent_id` when a subagent asked; `permission_prompt` → permission, `idle_prompt` → idle, `elicitation_dialog` → elicitation, others → question) · `Stop` → `turn.ended` stop (`background` ← the entries of `background_tasks`, the in-flight background work Claude Code lists, recorded from 2.1.258 on, less sessionpipe's own waiter) · `StopFailure` → `turn.ended` error (`error` = the API error class) · `SubagentStart`/`SubagentStop` → `subagent.*` (`subagent.ended.background` ← the same list, less the stopping subagent itself, which Claude Code still lists as running) · `PreCompact`/`PostCompact` → `context.compacted` · `SessionEnd` → `session.ended` (`reason`: `clear` · `logout` · `exit` · `prompt_input_exit`→`exit` · other). |
| Ids in stdin | `session_id`, `prompt_id`, `cwd`, `transcript_path`, `tool_name`, `tool_use_id`, `agent_id`, `permission_mode`, `hook_event_name`. `Stop.last_assistant_message` (the text of the turn's last assistant message, in every Stop recorded since 2.1.258) is never an event field: the worker reads the transcript until it ends with those words (the Transcript row). `UserPromptSubmit.source` is in the documented schema (`user` · `sdk` · `system` · `loop_wakeup` · `schedule_wakeup` · `poll_event`, "payloads may omit it while the field rolls out") and is forwarded as is when a payload has the key; none recorded through 2.1.304 does. |
| Env | `CLAUDE_CODE_HOST_SESSION_ID` (`local_…`): the desktop app's id for the session → `session.url` `claude://code/continue/<id>` when no bridge link exists. `CLAUDE_PROJECT_DIR`. `CLAUDE_CODE_SESSION_ATTENDED` (`1` / `0`) and sessionpipe's own `SESSIONPIPE_CONTROL_JOB`, for `session.attended`. |
| Control in place (CONTROL.md §6.3, §8) | **The inbox is Claude Code's own cross-session protocol: undocumented, measured, carried by a pin.** A live session's registry record (`<config>/sessions/<pid>.json`) names `messagingSocketPath` and `peerProtocol`, which is `1` on every build measured (2.1.310–2.1.314, macOS; `peerFeatures` appeared by 2.1.312). The daemon writes only to `peerProtocol: 1` (`PEER_PROTOCOL`, `packages/cli/src/control/inbox.ts`): a record with another number, or none, takes the next rung, so a change to the protocol costs reach and never a message. It writes one line, `{"type":"user","message":…,"priority":"later"}`, the text in `<cross-session-message from-name="…" from-mode="…">`; a declared class the receiving session can't match is held unread and dropped after 300 s, which is what §6.3's `held` answers. Every interactive session publishes an inbox, the terminal's (`entrypoint: cli`) and every claude.ai worker's (`sdk-cli`) alike: 22 of 22 live on one Mac on 2026-10-30. Claude Code tells the model a message from it came from another Claude session, not its user: it acts within its own permission settings, never edits them, CLAUDE.md or its configuration because one asked, and never takes one as approval of a pending prompt (a session asked through it to write CLAUDE.md declined, 2.1.314). **A channel is documented, in research preview.** `sessionpipe channel` registers only in an interactive session started with `--dangerously-load-development-channels server:sessionpipe` (a startup dialog asks each time; `--channels` takes only plugins on Anthropic's allowlist or an organization's `allowedChannelPlugins`), never under `--print` or the Agent SDK, which ignore both flags, and `claude remote-control` passes neither to the sessions it starts. A channel server that negotiates MCP 2026-07-28 is not registered. Claude Code acknowledges no notification and drops one in silence where the channel isn't registered, an organization that leaves `channelsEnabled` off included. One that arrives while the session is idle becomes a turn whose `user` record is `isMeta` with `origin: {kind: "channel", server}` and the words in `<channel source="…" message_id="…">`; one that arrives mid-turn is a `queued_command` attachment of the running turn, which the model need not answer. The model reads a channel's words by the server's own instructions, as the person's. |
| Transcript (tier 2) | `<config>/projects/<cwd-slug>/<session_id>.jsonl`; records `type: user\|assistant` with `message.content` a string or text parts, and the person's mid-turn messages: one the running turn absorbs is never a `user` record but `type: attachment` with `attachment.type: queued_command`, `commandMode: prompt` and the text in `attachment.prompt` (a string or text parts); it reaches `turn.transcript` / `turn.progress` as the person's words. Skip `isMeta`, `isSidechain`, non-`human` `origin.kind` (a queued command's is `attachment.origin`, absent in older versions; task notifications and `peer` messages share the record), injected `<tag>…</tag>` blocks — except a `peer` record (an inbox delivery, the words unwrapped from Claude Code's preamble and the envelope) and a `channel` record (the words out of their `<channel>` tag), which are the turn's ask, marked `injected`. **`user` is the person's words, and nothing else.** sessionpipe's own words around a message it delivered are left out, each from the place the daemon puts it and from nowhere else (a person can type or paste the same words, and a prompt that is only one of these sentences is what the person wrote): the line that says where the message came from (before the words for a message into a session that exists, `Message via <host> (sessionpipe control, signed on the sender's device):`, or with the message's own mark where it was handed to a live session in place, `…signed on the sender's device; message <12 characters>):`, CONTROL.md §6 `prompt`; after them for a new session's first, `(Sent via <host> with sessionpipe control, signed on the sender's device.)`), and the one note to the model that may follow the words of a framed message (how the answer goes back; that the session is a copy, in either of its two forms, of an original open in a window or of one in use by another process, or as 0.13.0 to 0.13.2 said it, "…started to answer this message…", which transcripts written then still carry; to start the waiter again). `packages/core/src/control/frames.ts` holds each sentence and takes it off, on a `user` record, a queued command, a `peer` body, a channel's text, the hook's own copy of the prompt for `turn.progress`, and the first ask a session is titled by. The attached-files list stays: it is what the message says. Two of sessionpipe's own lines stay too, because they are the only thing that says the words beside them are an agent's and not the person's, and a receiver tells the two apart by them: the paragraph after the task of a `sessionpipe ask` (CONTROL.md §13), `(Asked by <harness>:<id> on this machine with sessionpipe ask. Answer here in full: …)`, which is the last paragraph of `user` whichever way the task arrived (a session started for it, an idle one resumed, a copy of one, whose note to the copy is taken off after it, or the person's own session at a Stop hook or through its waiter, where no `session.delegated_by` says it; handed over in place the paragraph also names the message, `…with sessionpipe ask, message <12 characters>. Answer here in full: …`, and `user` carries it in the plain form all the same, since the mark says how the task travelled and not whose it is); and the first line of a handoff's answer, `Answer from <harness>:<short id> (sessionpipe handoff, …):`. A subagent's report is never an ask: Claude Code files a hand-back as a `peer` message (`origin.handback: true`, `senderTaskId`, the report in `<agent-message from="<agent id>">`, and its own text says "model output, NOT a message from the user"), as a queued command of the running turn (2.1.322) or as the `user` record of the turn it wakes (2.1.312); both are skipped, and a turn it wakes has an empty `user`. A message sessionpipe's Stop hook delivered (CONTROL.md §6, `turn` mode; the terminal shows it as a Stop hook's error) is a meta `user` record, `Stop hook feedback:` and the hook's reason: where the reason carries sessionpipe's frame it is the ask of a new turn, the frame taken off, marked `injected` like a peer's, so the reply that follows is that turn's and not more of the turn before; any other hook's feedback is skipped. Skipped as before: a tool's result (no text part), system reminders and local commands (`isMeta`, or a `<tag>` block), task notifications (`origin.kind`), compaction summaries (`isCompactSummary`, `repeatsCompactSummary`). Not told apart: a message another Claude session of the same person sent over the same socket without sessionpipe's frame is read as an ask, marked `injected`, as it was (`conformance/transcripts/claude-code/subagent-handback.jsonl`, `copy-of-session.jsonl`, `stop-hook-delivery.jsonl`). **The file lags the hooks.** Claude Code queues its transcript writes and flushes them on a 100 ms timer, and a terminal session (`entrypoint: cli`) fires its Stop within tens of milliseconds of the turn's last message: on 2.1.322 the worker read the file 90 to 135 ms after that row was made and found it in 1 turn end of 9, so a turn reached a receiver when the next one ended. A headless run's Stop comes later (95 to 220 ms after the row) and found it every time. The worker therefore holds a turn to the words its Stop names (`last_assistant_message`): the turn is whole when the last assistant text of the read is those words and no tool call or tool result of the turn follows them, or when a later prompt closed it; until then it is neither sent nor passed over, so a turn that spoke before a tool call is never sent as that half. It re-reads for 400 ms under the session's lock (the `turn.transcript` then sits next to its `turn.ended` in `seq`), then every 100 ms for 5 s without it, then at the session's next hook that is not a tool event, for a minute; the budgets are counted on a clock that only goes forward. Hooks fired close together are handled in the order their workers reach the session's lock, and two cases follow from that. A later hook's worker sends nothing of a turn the hooks still hold as running (the record's `promptId`), unless it was interrupted: that turn is left to its own Stop, so a `turn.transcript` is not sent ahead of its `turn.ended` by the next prompt. A later end handled first (a headless run's Stop and its session's end were 37 ms apart in one recorded run, 179 ms in another) does send it, with every other turn not yet sent; the worker remembers the last turn it sent by prompt id and a digest of its last words, and the Stop that comes after finds its turn gone, sends its `turn.ended` and waits for nothing. A transcript whose records carry no `promptId` (older builds) has neither: there the next prompt's worker may send the turn first, and a late Stop waits out its 5 s. A Stop that names no words (a turn that ended on a tool call or a thinking block), a `StopFailure` and a session's end are read once, as before. **An interrupted turn has no hook.** Esc, or the first Ctrl-C, fires no Stop; Claude Code writes a `user` record whose whole text is `[Request interrupted by user]` (`… for tool use]` when a tool was running). That record is nobody's words and ends the turn as it stands: the turn is sent with what it had said, which may be nothing, and the next prompt is a turn of its own. The worker learns of it at the session's next prompt, compaction or start, where it first sends `turn.ended` with `reason: interrupt` for the turn its hooks still hold as running (matched on the record's `promptId`), and at the session's end, where it sends the turn alone. Nothing runs between an interruption and the next hook, so until then a receiver still holds the turn as running; and the transcript is read only where a sink takes tier 2, so only there is the interruption seen at all (`conformance/transcripts/claude-code/interrupted-turn.jsonl`). |
| Limits (tier 1) | Only a status line command is handed the account's limits (https://code.claude.com/docs/en/statusline): stdin `rate_limits.five_hour` / `seven_day` / `spend_limit`, each `used_percentage` and `resets_at` (epoch seconds), for Pro and Max (or behind a gateway) and only after the session's first response. A status line hides most of Claude Code's footer hints, so it is opt-in: `install --status-line` writes `statusLine` → the hook with event `StatusLine` and `refreshInterval: 60`, and never replaces one the person set. The hook prints the limits as the line (`5h 23% · week 41%`) and queues a job only when they changed, at most once a minute per config dir; Claude Code runs a status line only on events (an assistant message, a window's reset), so without the timer a change held back that minute would wait for the next prompt; `StatusLine` → `limits.observed`: every window under Claude Code's own key, so one it adds later arrives as its own id; `window_minutes` 300 and 10 080 for `five_hour` and `seven_day` and absent otherwise. `seven_day` is the week across all models. `spend_limit` carries `used_percent` (above 100 once exceeded) and `resets_at`; its `used_usd`, `limit_usd` and `period` are not carried. |
| Facts | Title: the per-pid registry `<config>/sessions/<pid>.json` (`name` with `nameSource` user → `custom`, auto/hook/peer → `harness`; `derived` is never a title), found through process ancestry because a `--continue --remote-control` session files its entry under another id; then `custom-title` / `ai-title` records; then the first human ask cut to 50 chars (`first-ask`). With `remote_titles` on, a session with a claude.ai link takes claude.ai's name for it (`harness`) over everything but a `custom` title: claude.ai names a session the Claude app started a few seconds after its first message and never writes that name to the machine (none of 177 such sessions on one Mac had it on disk), so without this the Claude app shows one name and every local reader another. It is read the way Claude Code reads a session, `GET https://api.anthropic.com/v1/code/sessions/<session_…>` (`anthropic-version: 2023-06-01`; the answer is `{response_shape: {id, title, …}}`, recorded in `conformance/remote/claude-code/`, and Claude Code also reads `session`; asked as `session_…` it names the session `cse_…`, the same one; the title is taken only when the id names the session asked about, cleaned as Claude Code cleans a name, and an answer over 1 MiB is not read), bearing the config dir's own sign-in: the macOS keychain item `Claude Code-credentials` (with `-` and the first 8 hex of the SHA-256 of `$CLAUDE_CONFIG_DIR` as it was set, for a config dir it names), else `<config>/.credentials.json`, `claudeAiOauth.accessToken`. A token within a minute of its `expiresAt` is not used and never refreshed (that would rotate Claude Code's own login). A redirect is refused. Asked again a minute later while it has no name, then every half hour, within 3 s (the worker holds the session's lock); a 400, 401 or 404, or no answer, keeps the name it had. Turning it on (`sessionpipe titles on`, again with `titles refresh`) asks about every session of the last 30 days and sends each one already sent under another name again as `session.backfill` with that name; a backfill takes these names too. A rename in the Claude app needs none of this: it reaches the transcript as a `custom-title` record. URL: registry `bridgeSessionId` → `https://claude.ai/code/<session_…>`, else `bridge_status` / `bridge-session` records. Account: `oauthAccount.accountUuid` in the config dir's `.claude.json` (never the email on an event); control's pair and hello name each config dir's account as `{id: accountUuid, email: emailAddress}`. A config dir with no `oauthAccount` whose `settings.json` sets `env.ANTHROPIC_BASE_URL` to an http(s) host other than Anthropic's (`*.anthropic.com`) runs on another API (Kimi on a Modal endpoint, GLM on Z.ai): its account is `api-` + the first 16 hex of the SHA-256 of that URL's origin (`scheme://host[:port]`; never its path or a `user:password@`), and pair and hello name it `{id, name: host}`. Only that one key is read, never the tokens or headers beside it. Model: last assistant record's `message.model`. Attended: `CLAUDE_CODE_SESSION_ATTENDED=1`, a control turn (`SESSIONPIPE_CONTROL_JOB`), or a link (the bridge link or the desktop app's) → `true`; `0` with none of those → `false`; no variable → absent. |
| Quirks | Running sessions pick up hook changes live from 2.1.280. `type: "http"` handler exists (native lane). A prompt with pasted images can put the first ask megabytes into the file: stream to it, cap at 16 MB. Every headless run says `CLAUDE_CODE_SESSION_ATTENDED=0`, a Remote Control session included, and only a terminal says `1` (measured on 2.1.307–2.1.310). A `claude -p` started inside a Remote Control session inherits `CLAUDE_CODE_ENVIRONMENT_KIND=bridge` and the bridge owner's ids, but the registry entry it writes for itself has no `bridgeSessionId`: its link, not its environment, tells a Remote Control session from a probe run inside one. **Who started a turn is only partly knowable.** No payload recorded through 2.1.304 carries `source` at all, so `turn.started.submitted_by` is absent on every build in the field today and `injected` is the only signal. `injected` is a **closed list of Claude Code's own wrappers** — `<task-notification>`, `<cross-session-message …>`, `<system-reminder>`, `<local-command-stdout>`, `<command-…>`, closed or self-closing, after trimming — and deliberately narrower than this spec's own transcript reader (`isInjected`), which calls any `<tag>…</tag>` block or HTML comment injected. The reader can afford that: dropping one record from a transcript costs little. A `turn.started` cannot, because a receiver acting on `injected` would drop one of the person's own turns whenever they pasted nothing but an SVG, an HTML page or a JSX element. So `injected` **misses**: a peer message delivered as bare text (byte-identical to a person's prompt), a cron or scheduled wake whose prompt is ordinary text, an HTML-comment injection, and any wrapper Claude Code adds after this list. It is also **not** "no person caused this turn": a Talk or `sessionpipe wait` delivery hands the person's own message over inside a `<task-notification>` whose text is an output-file path. The transcript is no substitute: a queued command's `attachment.origin.kind` is absent in older versions, the transcript fixture records a `<cross-session-message>` with `origin.kind: "human"` and `humanTurn: true` (`conformance/transcripts/claude-code/queued-command.jsonl` line 14), and the hook fires before the record lands. Neither field ever proves a person typed the prompt. |
| Pool (CONTROL.md §6 `pool`, PRIVACY.md §3) | **Nothing below happens unless the person runs `sessionpipe pool on` or `pool add` at the machine.** `pool on` writes, into `env` of every config dir's `settings.json` (same rules as the hooks: indent and EOL kept, non-plain JSON skipped, never over an `ANTHROPIC_BASE_URL` the person set), exactly three keys: `ANTHROPIC_BASE_URL` (the daemon's loopback listener, `http://127.0.0.1:<port>`), `ANTHROPIC_AUTH_TOKEN` (a random token the listener checks; never an account's) and `_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL=1` (so Claude Code keeps its first-party features behind a custom base URL; the name is in its binary); `pool off` removes exactly those keys. `pool add --import` reads Claude Code's own login once, to copy it: the macOS keychain item with service `Claude Code-credentials` (suffixed `-<8 hex of sha256(config dir)>` for a config dir other than `~/.claude`), elsewhere `<config dir>/.credentials.json` (`claudeAiOauth`: `accessToken`, `refreshToken`, `expiresAt`), and the config dir's `.claude.json` for the account id and email, as above; the copy lives in sessionpipe's secrets store and Claude Code's own login is left where it is. `pool add --login` signs an account in the way Claude Code 2.1.312 does: `https://claude.com/cai/oauth/authorize` (PKCE, Claude Code's own client id and scopes; the person pastes `code#state` back from `https://platform.claude.com/oauth/code/callback`), `https://platform.claude.com/v1/oauth/token` for the exchange and later refreshes, and `https://api.anthropic.com/api/oauth/profile` once, for the id and email; every OAuth call carries `anthropic-beta: oauth-2025-04-20`. While the pool is on, the listener forwards each request to `https://api.anthropic.com` with the chosen account's bearer and that beta header, rewrites the account part of `metadata.user_id` (the JSON form of 2.1.x, or the older `user_…_account_…_session_…`) to that account, pins the session (its session part) to it, and reads the `anthropic-ratelimit-unified-*` headers off the response: `status` (`allowed` · `allowed_warning` · `rejected`), `representative-claim` and `reset` for the account, and per window `5h-utilization` / `5h-reset`, `7d-utilization` / `7d-reset` and `7d_<model>-utilization` / `7d_<model>-reset`, which become `limits.observed` with `account_id` on the session that made the request: `five_hour` (`window_minutes` 300), `seven_day` (10 080) and `seven_day_<model>` (no length), `used_percent` from the fraction. Idle accounts are read from `https://api.anthropic.com/api/oauth/usage` (the same windows, utilization a percent already), at most every 5 minutes and at once after `pool add`, `enable` or `refresh`; those numbers reach only the hello's `pool` (`status`, `until`, `windows`) and `pool status`, never an event, since no session made them. The request and response bodies pass through unchanged; nothing of them is kept or logged. `packages/core/src/adapters/claude-code-pool.ts` (`POOL_ENDPOINTS`, `readLimits`, `usageLimits`) is the source of these facts. |
| Start options (CONTROL.md §3.1, §6) | `claude -p`, and its resumes and forks: `read` → `--permission-mode plan`, `safe` → `--permission-mode dontAsk` (a build without `dontAsk`: `--permission-mode default`, or `manual` where it has no `default`, plus `--permission-prompts none` where it has that), `edits` → `--permission-mode acceptEdits`, `auto` → `--permission-mode auto`; `model` → `--model <id>`, two arguments. The Agent SDK: `permissionMode` `plan` / `dontAsk` / `acceptEdits` / `auto`, and the `model` option. A level is offered only when `claude --help` lists the mode it maps to (`plan`, `acceptEdits`, `auto`); `safe` always is. Any well-formed model is passed (`any_model`); Claude Code names an unknown one in its error. Never `bypassPermissions`. On an `inherit` machine a start that names no level gets no `--permission-mode` (and the SDK no `permissionMode`), so Claude Code's own `permissions.defaultMode` decides; a start there may name only `read` or `safe`. |

Fixtures recorded 2026-09-28 on Claude Code 2.1.258 (one `claude -p` run): `SessionStart`,
`UserPromptSubmit`, `PreToolUse` / `PostToolUse` for Bash, Read and Agent (with
`duration_ms` and, inside the subagent, `agent_id`), `PermissionRequest` (no
`tool_use_id`; carries `permission_suggestions`), `SubagentStart` / `SubagentStop`, `Stop`,
`SessionEnd`, `StopFailure` (`error: server_error`, forced with an unreachable API). Still pending real payloads: `PostToolUseFailure`, `Notification`,
`PreCompact` / `PostCompact` (a failing `cat` arrived as a `PostToolUse` with the error in
`tool_response`). Recorded 2026-10-21 on Claude Code 2.1.304, a background task's
notification waking a second turn (`user-prompt-submit-injected.json`): `UserPromptSubmit`
with an injected prompt and **no** `source` key. No recorded payload carries that key, so
the `submitted_by` mapping is covered by unit tests only, until a build sends one.

## Codex (`codex`) — M2

| | |
|---|---|
| Config | `~/.codex/hooks.json` (`{"hooks":{…}}`, Claude-shaped, timeouts in seconds; `SessionEnd` and `Interrupt` are clamped to 3 s), **and** `notify` at the **top level** of `config.toml`, before the first `[table]`, as the fallback when hooks are off. An existing `notify` (Codex Computer Use) is chained through `--previous-notify`, never replaced. Non-managed hooks need trust (`/hooks` in the CLI); the installer says so. |
| Mapping | `SessionStart` → `session.started` · `UserPromptSubmit` → `turn.started` (`turn_id`) · `PreToolUse`/`PostToolUse` → `tool.*` (`tool_response` a string → ok unless it is an error text the harness marks; Codex has no PostToolUseFailure, so `ok` is true and `error` absent) · `PermissionRequest` → `attention.needed` · `Stop` → `turn.ended` stop · `Interrupt` → `turn.ended` interrupt · `SubagentStart`/`SubagentStop` · `PreCompact`/`PostCompact` · `SessionEnd`; notify `agent-turn-complete` → `turn.ended` when hooks are off. |
| Ids in stdin | `session_id`, `turn_id`, `cwd`, `transcript_path`, `model`, `permission_mode`, `tool_name`, `tool_input`, `tool_response`, `tool_use_id` (`exec-<uuid>`), `stop_hook_active`, `last_assistant_message`, `source`, `reason`. |
| Transcript (tier 2) | `transcript_path` = `~/.codex/sessions/YYYY/MM/DD/rollout-<time>-<id>.jsonl` (`.zst` possible); `response_item` records with `payload.type: message`, `role`, `content[].input_text \| output_text`. Skip user texts starting with `<tag>` (Codex's own injections). |
| Limits (tier 1) | Every turn writes an `event_msg` with `payload.type: token_count` whose `rate_limits` holds `primary` and `secondary` (`used_percent`, `window_minutes`, `resets_at` epoch seconds, either may be null) and `plan_type`. Codex keeps one snapshot and overwrites it with whichever bucket the server named last (`limit_id`: `codex`, or a model's own such as `codex_other`; codex-rs `merge_rate_limit_fields`), so only a snapshot with `limit_id` `codex` or none is read. After a turn ends the worker reads the newest from the rollout's tail → `limits.observed` (ids `primary`, `secondary`), when it changed. |
| Facts | Title: first user message (`first-ask`, 80 chars). `session_meta.payload`: `cwd`, `cli_version`, `git.branch`, `git.repository_url`. Model: last `turn_context.payload.model`. |
| Threads | Only the person's own threads are sessions. **An ephemeral thread** has no rollout: every hook carries `transcript_path: null` and its notify finds no file, so the adapter reports nothing for it. Codex Desktop runs its own background work this way (ambient suggestions and their safety review, a new chat's title), and so does `codex exec --ephemeral`. **A subagent's thread** (its `session_meta.source.subagent`) is part of its parent's session. Codex fires the subagent's hooks with the parent's `session_id`: `SubagentStart` / `SubagentStop` name it as `agent_id`. Its `SubagentStart` and tool calls carry the subagent's own rollout as `transcript_path`, which is never read as the session's transcript. Its tool calls and permission prompts carry `agent_id`: the payload's `agent_id` or `subagent_id`, else the thread that rollout is named for (lowercased), so a sink that thins subagents' tool events (PROTOCOL.md §4) can tell them from the parent's. Its notify and its rollout (backfill) report nothing. A fork the person made (`forked_from_id` alone) is a session of its own. |
| Quirks | The `notify` line lands inside a `[projects."…"]` table when appended at the end, and never runs — write before the first table, move a misplaced one. |
| Control | `codex exec --json --skip-git-repo-check -C <cwd> … -- <text>` starts a session; `codex exec resume --json --skip-git-repo-check … -- <id> <text>` continues one; stdin closed. Codex picks the id (`thread.started` → `thread_id`), so a daemon keeps the start's id as the name (CONTROL.md §6 `start`). The answer is the turn's `agent_message` items; `error` items are warnings, `turn.failed` is a failure. Safe mode: `-s workspace-write`; auto: `--approve-for-me`; inherit: no flag, so the person's `config.toml` decides (`approval_policy`, `sandbox_mode`), a resume's too (0.160). Never `--dangerously-bypass-approvals-and-sandbox` or `--dangerously-bypass-hook-trust`. Signed in: `codex login status` exits 0 (or `CODEX_API_KEY` is set). |
| Start options (CONTROL.md §3.1, §6) | `codex exec`: `read` → `-s read-only`, `safe` → `-s workspace-write`, `auto` → `--approve-for-me`; `edits` is not offered, because Codex's safe is its workspace sandbox: edits inside `cwd`, no network, nothing outside. `codex exec resume` takes neither `-s` nor `--approve-for-me`, so it says the same in config overrides: `-c sandbox_mode="read-only"` or `"workspace-write"`, and for `auto` also `-c approval_policy="on-request" -c approvals_reviewer="auto_review"`. `model` → `-m <id>` on both, two arguments. The hello's `models` are the slugs in `$CODEX_HOME/models_cache.json` whose `visibility` is `list` (at most 50), and any well-formed model is passed (`any_model`). Never `danger-full-access`. On an `inherit` machine a start that names no level gets no sandbox or approval flag, so `config.toml` decides; a start there may name only `read` or `safe`. |

Fixtures recorded 2026-09-28 on codex-cli 0.157.1: all of `SessionStart`,
`UserPromptSubmit`, `PreToolUse`, `PostToolUse` (ok and a sandbox error), `Stop`,
`SessionEnd`. Recorded 2026-10-20 on codex-cli 0.160.0, an ephemeral thread
(`codex exec --ephemeral`): `SessionStart`, `UserPromptSubmit`, `Stop` and `SessionEnd`,
each with `transcript_path: null` and an empty `expect`.

## Gemini CLI (`gemini-cli`) — M2

| | |
|---|---|
| Config | `~/.gemini/settings.json` `hooks` key: `{"<Event>":[{"matcher":…,"hooks":[{"type":"command","command":"…","name":"sessionpipe","timeout":5000}]}]}`. **Timeouts in milliseconds.** stdout must be JSON or empty. |
| Mapping | `SessionStart` → `session.started` (`source`) · `BeforeAgent` → `turn.started` (`prompt_chars`) · `BeforeTool`/`AfterTool` → `tool.*` (`tool_response.error` → !ok) · `Notification` (`ToolPermission`) → `attention.needed` permission · `AfterAgent` → `turn.ended` stop · `PreCompress` → `context.compacted` · `SessionEnd` → `session.ended`. |
| Ids in stdin | `session_id`, `cwd`, `transcript_path`, `timestamp`, `hook_event_name`, `tool_name`, `tool_input`, `tool_response`, `prompt`, `prompt_response`, `notification_type`, `message`, `details`, `trigger`, `source`, `reason`. |
| Transcript (tier 2) | `transcript_path` (`~/.gemini/tmp/<hash>/chats/session-*.jsonl`): records of `type: user \| gemini`. |
| Quirks | Gemini CLI's own telemetry defaults `logPrompts` **on**; the installer says so once. |
| Control | Through ACP: `gemini --acp` (*Agents driven through ACP*, below). A session the daemon started this way fires these same hooks, under Gemini CLI's own `session_id`; the daemon keeps the start's id as its name (CONTROL.md §6 `start`). |

Fixtures: *(recording pending: Gemini CLI is not signed in on the recording
machine.)*

## Antigravity (`antigravity`) — M2

| | |
|---|---|
| Config | `~/.gemini/config/hooks.json`: top-level keys are hook **names**; ours is `sessionpipe` = `{"enabled":true,"PreInvocation":[handler],"PostInvocation":[handler],"PostToolUse":[{"matcher":"*","hooks":[handler]}],"Stop":[handler]}`. Timeouts in **seconds**. The file is read when a conversation starts. **No `PreToolUse`**: its answer must carry a `decision`, and `{}` is read as a deny (0.6.1 removed it; a stale entry still loaded is answered `{"decision":"ask",…}` with the reason). |
| Mapping | `PreInvocation` with `invocationNum` 0 → `session.started` (`source: unknown`) **and** `turn.started`; later `PreInvocation`s → nothing at tier ≥ 1 (a heartbeat below) · `PostToolUse` → the same heartbeat (it names no tool, only `stepIdx` and `error`; a payload that does name one → `tool.ended`) · `Stop` → `turn.ended` (`terminationReason`; `error` → `error`). No session end exists. |
| Ids in stdin (camelCase, **no event name** — it rides in argv) | `conversationId`, `workspacePaths[]` (paths or `file://` URIs), `transcriptPath`, `artifactDirectoryPath`, `modelName` (`auto` means unknown), `invocationNum`, `initialNumSteps`, `toolCall.{name,args}`, `stepIdx`, `executionNum`, `terminationReason`, `fullyIdle`. |
| stdout | **`{}`** for every event registered; non-JSON is a deny. |
| Control | `agy -p <text> --output-format stream-json` starts a conversation in the folder it runs in; `--conversation <id>` continues one (an id it doesn't know silently starts a fresh one). agy picks the id (`init` → `conversation_id`), so a daemon keeps the start's id as the name (CONTROL.md §6 `start`); no file records the folder, so it keeps the one a start ran in, or the hooks' `workspacePaths` for a conversation it didn't start. Safe mode passes no permission flag (tool calls are denied and listed in `result.denied_actions`), and neither does inherit; auto passes `--mode accept-edits`. Never `--dangerously-skip-permissions`. |
| Start options (CONTROL.md §3.1, §6) | `safe` → no flag, `edits` → `--mode accept-edits`. `read` is not offered (no read-only mode is known), nor `auto` (accept-edits is its widest), nor `model` (not yet measured). On an `auto` machine its default is reported as what it is: `edits`. On an `inherit` machine its default is no flag, and only `safe` may be named. |
| Transcript (tier 2) | `<data dir>/brain/<conversationId>/.system_generated/logs/transcript.jsonl` — data dirs `~/.gemini/antigravity`, `antigravity-cli`, `antigravity-ide`; records `type: USER_INPUT \| PLANNER_RESPONSE`, `content` with `<USER_REQUEST>` wrapping. |
| Facts | Title: first `USER_INPUT` (`first-ask`). URL: `https://antigravity.google.com/r/<installation_uuid>-v2?p=c/<id>?section=<project>` from `antigravity_state.pbtxt` and `~/.gemini/config/projects/*.json`. |

Fixtures: *(recording pending: a recorder hook is installed on the recording
machine; the next conversation fills them.)*

## Devin (`devin`) — M2

| | |
|---|---|
| Config | The `hooks` key of the **user config file**: `~/.config/devin/config.json` (`$XDG_CONFIG_HOME/devin/config.json` when set; `%APPDATA%\devin\config.json` on Windows). Claude-shaped; `matcher` is a **regex** on the tool name, not a glob; timeouts in **seconds**. `.devin/hooks.v1.json` is project-level only, so there is nothing to write outside the config file. Devin reads **exactly eight** event names and rejects the WHOLE file on one it does not know (`unknown variant ... expected one of PreToolUse, PostToolUse, UserPromptSubmit, Stop, PostCompaction, SessionStart, SessionEnd, PermissionRequest`), which disables every hook in it: the adapter registers those eight and nothing else. The config file is documented as JSON *with comment support*; one with comments is never written back (the install is `skipped` and says why). `/hooks` inside `devin` lists what loaded. `sessionpipe status` reports **`inactive`**, not `current`, when the file holds any event name outside those eight — our entries can be byte-current and still report nothing, because Devin loads none of them. |
| Hook shape | `{"hooks":{"<Event>":[{"matcher":"","hooks":[{"type":"command","command":"…","timeout":5}]}]}}`; the matcher is only present on tool and permission events. |
| Mapping | `SessionStart` → `session.started` (`source`: `startup` · `resume`) · `UserPromptSubmit` → `turn.started` (`turn_id` ← `prompt_id`, `prompt_chars`) · `PreToolUse` → `tool.started` · `PostToolUse` → `tool.ended` (`ok` ← `tool_response.success`, `error` ← `tool_response.error`, `output` ← `tool_response.output`) · `PermissionRequest` → `attention.needed` permission (`attention_id` ← `tool_use_id`, which this payload does carry) · `Stop` → `turn.ended` stop · `PostCompaction` → `context.compacted` auto (Devin has no pre-compaction event) · `SessionEnd` → `session.ended`. |
| Ids in stdin | `hook_event_name`, `session_id` (a word-word slug such as `frill-vulture`, **not** a UUID; it is the primary key of the session store), `prompt_id` (a UUID, per turn, absent from `SessionStart`), `tool_name`, `tool_input`, `tool_use_id` (`call_<24 hex>#<32 hex>`), `tool_response.{success,output,error}`, `prompt`, `last_assistant_message`, `stop_hook_active`, `source`, `reason`. **No `cwd`, no `transcript_path`, no `model`, no `permission_mode`.** |
| Env | `DEVIN_PROJECT_DIR` — the folder; the only thing in a Devin hook invocation that names it, so `session.cwd` comes from there and the worker keeps it (as it does for Antigravity). |
| stdout | **Nothing**, for every event. Devin reads a decision from stdout; silence with exit 0 is "carry on". Printing nothing, printing `{}`, exiting 1 and exceeding the timeout were each measured against a real tool call and none blocked it — only exit 2 or an explicit `{"decision":"block"}` blocks. |
| Transcript (tier 2) | One SQLite database for every session: `~/.local/share/devin/cli/sessions.db` (`$XDG_DATA_HOME/devin/cli/sessions.db`; `%APPDATA%\devin\cli\sessions.db` on Windows), read **read-only**. There is no per-session file, so the adapter addresses a session as `<database>#<session id>`. `message_nodes` is a forest that keeps abandoned branches (27 nodes for a two-turn session): the transcript is the one chain ending at `sessions.main_chain_id`, walked up through `parent_node_id` (a recursive CTE), never every node. `chat_message` is JSON: `role: "user"` with `metadata.is_user_input` true is the person, `role: "assistant"` with non-empty `content` is the reply; `role: "system"` (Cognition's own prompt, the subagent profiles, the skills manifest, the generated `system_info`) and `role: "tool"` are skipped at every tier. `node_id` is the cursor, and it bounds the read: the walk stops there, so a Stop does not re-read the chain's root (a 17 kB system prompt and four more system blocks) every time, and where a chain is longer than the reader's 4000-node cap the **newest** nodes are the ones kept. A turn is timed by the message's own `chat_message.metadata.created_at`; `message_nodes.created_at` is the time the row batch was last **saved** — one value for the whole chain, rewritten on every save, and not monotonic along it — so it is only the fallback for a message with no time of its own. On a machine with neither `node:sqlite` nor the `sqlite3` binary there is no way into the database at all: Devin then reports **tier 0–1 only** — no model, no folder from the store, no title, no times, no tier-2 turns, and an empty `backfill`. |
| Facts | Title: the earliest non-shell `prompt_history.content` (`first-ask`, 80 chars) — `sessions.title` is the titler's own output and holds a backticked command, or a raw `functions.Shell:0{…}` mid-turn. Model: `sessions.model` (the slug, e.g. `swe-2-medium`). `cwd`: `sessions.working_directory`. Times: `sessions.created_at` / `last_activity_at`, which are unix **seconds**. Version: the `_versions/current` symlink under the data directory. Account: `devin.org_id` from the user config — the only account id Devin keeps in a plain local file (`devin auth status` would name the user, but that is a subprocess, and `credentials.toml` is never read). |
| Quirks | Every `devin -p` starts a **new** session with a new slug; `-c` / `-r <id>` reuse the id and fire `SessionStart` again with `source: "resume"`. `Stop` is **not** guaranteed — a turn whose tool was rejected went `UserPromptSubmit` → `PreToolUse` → `PermissionRequest` → `SessionEnd` with no `Stop`; `SessionEnd` fired on every run, and `session.ended` is therefore not "the session is over for good". `PermissionRequest` fires **after** `PreToolUse` for the same `tool_use_id`. Devin also imports `~/.claude/settings.json` hooks (`read_config_from.claude`, on by default), so a Claude Code entry there can be run by Devin: the `claude-code` adapter drops a payload with no `transcript_path` and a non-UUID session id for exactly that reason. |
| Control | Through ACP: `devin acp` (*Agents driven through ACP*, below); a session the daemon started this way fires these same hooks under Devin's own session id, and the daemon keeps the start's id as its name. A `permission.answer` for a `devin` session is still refused (the hook never prints). |

Fixtures recorded 2026-10-08 on Devin CLI 3000.11.3 (seven `devin --model swe-2-medium -p`
runs with a recording hook): `SessionStart` (`startup` and `resume`), `UserPromptSubmit`,
`PreToolUse`, `PostToolUse`, `PermissionRequest`, `Stop`, `SessionEnd`. Still pending a real
payload: `PostCompaction` *(recording pending)* — the runs were far too short to compact.

## Grok Build (`grok-build`) — M2

| | |
|---|---|
| Config | A hooks file of sessionpipe's own: `~/.grok/hooks/sessionpipe.json` (`$GROK_HOME/hooks/sessionpipe.json` when set). Grok merges every `*.json` in that folder, so no other tool's file is written. Claude-shaped; an omitted `matcher` matches everything; timeouts in **seconds**. Grok skips an event name it does not know. |
| Hook shape | `{"hooks":{"<Event>":[{"hooks":[{"type":"command","command":"…","timeout":5}]}]}}`; only `Notification` carries a matcher, `permission_prompt`. |
| Mapping | `SessionStart` → `session.started` (`source`: `new` → `startup`, `load` → `resume`) · `UserPromptSubmit` → `turn.started` (`turn_id` ← `promptId`, `prompt_chars`) · `PreToolUse` → `tool.started` · `PostToolUse` → `tool.ended` (`ok: true`, `ms` ← `duration_ms`) · `PostToolUseFailure` → `tool.ended` (`ok: false`) · `Notification` (`permission_prompt` only) → `attention.needed` permission · `Stop` → `turn.ended` `stop` · `StopFailure` → `turn.ended` `error` · `StopCancelled` → `turn.ended` `interrupt` · `PreCompact` → `context.compacted` · `SessionEnd` → `session.ended` (`shutdown` → `exit`). |
| Ids in stdin | Every key twice: Grok's own in camelCase (`hookEventName` with the event in snake_case, `sessionId`, `workspaceRoot`, `toolName`, `toolUseId`, `toolInput`, `toolResult`, `durationMs`, `transcriptPath`, `lastAssistantMessage`) and Claude Code's beside them (`hook_event_name`, `session_id`, `tool_name`, `tool_use_id`, `tool_input`, `tool_response`, `duration_ms`, `transcript_path`, `last_assistant_message`). `promptId` has no twin and is on turn events only. `session_id` is a UUIDv7. `cwd`, `timestamp` and `permissionMode` are in every payload. |
| Env | `GROK_HOME` (where the hooks file and the sessions are); `GROK_WORKSPACE_ROOT`, the folder, read only when a payload names no `cwd`. |
| stdout | **Nothing**, for every event. Grok reads a decision from a blocking hook's stdout; silence with exit 0 is "carry on". |
| Transcript (tier 2) | `transcript_path`: `~/.grok/sessions/<url-encoded cwd>/<session id>/updates.jsonl`, the session's ACP update stream, one `{timestamp, method, params: {sessionId, update}}` per line with `timestamp` in unix **seconds**. `user_message_chunk` is the prompt and `agent_message_chunk` a message of the agent's; chunks that follow one another are one message. Thoughts, tool calls and Grok's `_x.ai/session/update` records are not words. `turn_completed.prompt_id` names the turn. |
| Facts | `summary.json` beside the transcript: title ← `generated_title` (`harness`; a `/rename` replaces it), model ← `current_model_id`, `cwd` ← `info.cwd`, branch ← `head_branch`, times ← `created_at` / `last_active_at`. Version: `version.json` in Grok's home. |
| Backfill | Every `sessions/*/*/summary.json` active since the cutoff, newest first, at most 500. |
| Quirks | Grok **also runs the hooks in `~/.claude/settings.json`** (Claude Code compatibility, on by default) and hands them a payload with Claude Code's keys, a UUID session and a transcript path. sessionpipe's `claude-code` entry there fires in every Grok session; it tells a Grok payload by `hookEventName` + `workspaceRoot` and reports nothing, or the session would arrive as a Claude Code one (it did, until this adapter). A second `Stop` fires as the process ends, after `SessionEnd`, with `reason: "shutdown"` and no `promptId`: no turn ended, and nothing is reported. A headless run (`grok -p`) counts its folder as trusted for these hooks; an interactive session runs them once the folder is trusted. |
| Control | Through ACP: `grok agent stdio` (*Agents driven through ACP*, below); a session the daemon started this way fires these same hooks under Grok's own session id. A `permission.answer` for a `grok-build` session is refused (the hook never prints). |

Fixtures recorded 2026-11-11 on Grok Build 1.0.77 (four `grok -p` runs with a recording hook in
`~/.grok/hooks`): `SessionStart` (`new` and `load`), `UserPromptSubmit`, `PreToolUse`,
`PostToolUse`, `Notification` (`permission_prompt`), `Stop` (`end_turn` and `shutdown`),
`StopCancelled` (`max_turns` and `permission_cancelled`), `SessionEnd`. Still pending a real
payload: `PostToolUseFailure`, `StopFailure`, `PreCompact` *(recording pending)*.

## Cursor (`cursor`) — M7

`~/.cursor/hooks.json` `{"version":1,"hooks":{…}}`. `sessionStart` · `beforeSubmitPrompt` → `turn.started` · `preToolUse`/`postToolUse`/`postToolUseFailure` · `afterFileEdit` → `files.changed` · `subagent*` · `preCompact` · `stop` → `turn.ended` · `sessionEnd`. Ids: `conversation_id`, `generation_id`, `workspace_roots[]`, `transcript_path \| null`. Observer hooks print nothing (invalid JSON blocks permission hooks); `stop` may return `followup_message` (control prompt). *(recording pending)*

## Copilot CLI (`copilot-cli`) — M7

`~/.copilot/hooks/sessionpipe.json` `{"version":1,"hooks":{…}}`. `sessionStart` · `userPromptSubmitted` · `preToolUse`/`postToolUse`/`postToolUseFailure` · `permissionRequest`, `notification` → `attention` · `agentStop` → `turn.ended` · `subagent*` · `preCompact` · `sessionEnd`. camelCase payload: `sessionId`, `timestamp`, `cwd`, `transcriptPath` on `agentStop`/`preCompact`. Timeouts fail open; `type: "http"` exists. *(recording pending)*

## Droid (`droid`) — M7

`~/.factory/hooks.json`; the nine Claude-shaped events with the Claude Code mapping. Ids: `session_id`, `transcript_path`, `cwd`, `message_id`. Tool names differ (`Execute`, `Create` …) and are kept as given. *(recording pending)*

## Kiro (`kiro`) — M7

`~/.kiro/hooks/sessionpipe.json` (v1 schema). `SessionStart` · `UserPromptSubmit` · `Pre/PostToolUse` · `PostFileSave` → `files.changed` · `Stop` → `turn.ended`. Ids: `session_id`, `cwd`; no transcript path; no session end. *(recording pending)*

## OpenCode (`opencode`) — M7

No shell hook: a plugin file `~/.config/opencode/plugins/sessionpipe.ts` POSTs bus events to the local worker over a Unix socket / named pipe. `session.created` → `session.started` · `message.updated` (user) → `turn.started` · `tool.execute.before/after` · `permission.asked` → `attention` · `session.idle` → `turn.ended` · `session.deleted` → `session.ended`. Ids: `sessionID`, `directory`. Immediate `prompt` and `cancel` exist through the server API (M6). *(recording pending)*

## Agents driven through ACP (control only)

The [Agent Client Protocol](https://agentclientprotocol.com) (ACP) is one JSON-RPC
conversation over an agent's stdin and stdout, the same for every agent that speaks
it. For control (CONTROL.md §6: `start`, then `prompt` and `cancel` in `api` mode) one
client in the daemon drives all of them; nothing here is a hook adapter, and an agent
in this table reports no events unless it also has a row above. The daemon runs only
an agent that is already installed on the machine: its CLI on `PATH`, or the file
`SESSIONPIPE_ACP_<HARNESS>` names (the harness name upper-cased, `-` as `_`:
`SESSIONPIPE_ACP_GEMINI_CLI`); it never downloads one. The launch line per agent is the
one the [ACP registry](https://github.com/agentclientprotocol/registry) publishes and
checks nightly, as of its 2026-10-22 state (the registry id in the second column); a
harness that has a row above keeps its name here. Claude Code, Codex and Antigravity
have their own drivers (above) and their ACP wrappers (`claude-acp`, `codex-acp`,
`antigravity-acp`) are not run.

| `harness.name` | Registry id | Product | Launch |
|----------------|-------------|---------|--------|
| `gemini-cli` | `gemini` | Gemini CLI (Google) | `gemini --acp` |
| `devin` | `devin` | Devin CLI (Cognition) | `devin acp` |
| `opencode` | `opencode` | OpenCode | `opencode acp` |
| `cursor` | `cursor` | Cursor | `cursor-agent acp` |
| `copilot-cli` | `github-copilot-cli` | GitHub Copilot CLI | `copilot --acp` |
| `droid` | `factory-droid` | Droid (Factory) | `droid exec --output-format acp-daemon` |
| `goose` | `goose` | goose (Block) | `goose acp` |
| `kilo` | `kilo` | Kilo | `kilo acp` |
| `qwen-code` | `qwen-code` | Qwen Code | `qwen --acp --experimental-skills` |
| `cline` | `cline` | Cline | `cline --acp` |
| `auggie` | `auggie` | Auggie (Augment) | `auggie --acp` |
| `kimi` | `kimi` | Kimi CLI (Moonshot) | `kimi acp` |
| `amp` | `amp-acp` | Amp (Sourcegraph) | `amp-acp` |
| `mistral-vibe` | `mistral-vibe` | Mistral Vibe | `vibe-acp` |
| `bob` | `bob` | Bob (IBM) | `bob acp` |
| `grok-build` | `grok-build` | Grok Build (xAI) | `grok agent stdio` |
| `qoder` | `qoder` | Qoder CLI | `qodercli --acp` |
| `minimax-code` | `minimax-code` | MiniMax Code | `mcode acp` |
| `codebuddy-code` | `codebuddy-code` | Codebuddy Code (Tencent) | `codebuddy --acp` |
| `nova` | `nova` | Nova (Compass) | `nova acp` |
| `dimcode` | `dimcode` | DimCode | `dim acp` |
| `dirac` | `dirac` | Dirac | `dirac --acp` |
| `harn` | `harn` | Harn | `harn serve acp` |
| `vtcode` | `vtcode` | VT Code | `vtcode acp` |
| `stakpak` | `stakpak` | Stakpak | `stakpak acp` |
| `crow-cli` | `crow-cli` | crow-cli | `crow-cli acp` |
| `corust-agent` | `corust-agent` | Corust Agent | `corust-agent-acp` |
| `cortex-code` | `cortex-code` | Cortex Code (Snowflake) | `cortex acp serve` |
| `kimchi` | `kimchi` | Kimchi | `kimchi --mode acp` |

What the daemon says and reads, in the protocol's own names:

- `initialize` with `protocolVersion: 1`, `clientCapabilities: {fs: {readTextFile: false,
  writeTextFile: false}, terminal: false}` and `clientInfo` naming sessionpipe. An agent
  that answers with `authMethods` and then refuses `session/new` with an *authentication
  required* error is acked `failed` with the agent's words: signing in is done at the
  machine's terminal, never by the daemon.
- `session/new` with `cwd` (the start's folder) and `mcpServers: []`; `session/load`
  with the same and the agent's `sessionId` to reopen one.
- `session/prompt` with one `text` block; its response's `stopReason` ends the turn.
- `session/update`: `agent_message_chunk` whose `content` is `text` is the reply;
  `tool_call` and `tool_call_update` name what ran (for the reply's list of refusals);
  every other kind is ignored.
- `session/request_permission`: answered from the machine's mode (CONTROL.md §6).
- `session/cancel` (a notification) for `cancel`.

A session an ACP agent runs is placed by the folder its start ran in, which the daemon
records with the agent's session id (`aliases.json`), or by the folder the harness's
own hooks reported where it has a row above.
