# grove.core.agents — tool-agnostic agent introspection (AgentAdapter + impls)

> ↑ [grove.core](../CLAUDE.md) · [root](../../../../CLAUDE.md)

This package reads an agent tool's native sessions and normalizes them to the `AgentActivity` / `AgentSession` model (frozen dataclasses in `model.py`); clients and the `ActivityService` consume only that, never a raw transcript. The `AgentAdapter` Protocol (`base.py`) is the seam; impls are `ClaudeCodeAdapter` (filesystem transcripts), `CodexAdapter` (filesystem rollout JSONL), `GenericAdapter` (no-op) and `MewboAdapter` (REST, all HTTP through `grove.core.mewbo.MewboClient`, reading `GroveConfig.mewbo`: `base_url`, `api_key_env` — an env-var NAME, never a secret — and `timeout_seconds`).

**`remote = True` (mewbo alone) means the work runs on a backend and the local tmux pane says nothing about it**, so the blend must not demote the adapter's reported WORKING on a quiet pane — gating on tmux read every busy remote agent as IDLE.

**The session-read surface is keyed by `(cwd, session_id)`, never by paths** — that is what lets a remote adapter fit the seam without faking filesystem `Path`s. `locate_transcripts` is the one deliberately filesystem-shaped method; remote adapters return `[]` and `SessionSummary.transcript_path` is `Optional` for the same reason.

**An adapter normalizes *shape*, not *semantics*** (the provider-boundary rule, [root](../../../../CLAUDE.md)): code exists only for parameter/protocol differences, never to correct or second-guess what a model *does*. A new tool is one adapter module plus one `registry.get_adapter` entry.

## Structured payloads — one recipe, four instances (and two deliberate breaks)

`AgentQuestion`, `FileEdit`, `TodoList` and `TaskBoard` share one recipe: a `*_TOOL_NAMES` frozenset plus `recognizes(name)` as the **single** definition of "is this that kind of tool call", and a `from_tool_call(...)` normalizer every adapter calls from one place in its dispatch. Each rides the transcript as its own `DigestEntry` role, so an old client degrades to a quiet note. A recognized-but-unparseable payload falls through to the generic tool digest — never an empty entry, never a raise.

### `ToolCall` — the payload that is about the INVOCATION, not the tool

**`ToolCall` breaks the recipe deliberately, and the break is the design: it recognizes no tool names and rides EVERY `DigestEntry` a `tool_use` block produced, structured cards included.** Request, response, duration and still-running are facts about the call; the other four describe what one particular tool's arguments *mean*. Keeping them in one field would have forced a client to ask "is this a diff card or a tool call" before it could draw a spinner, when the honest answer is *both*. The generic `tool` role therefore gained a payload rather than the tree gaining a ninth role.

- **`running` is a VALUE, never the absence of `result`** — the whole point of the field. A settled call that returned nothing also has `result=None`, so a UI keying on nullness draws a check on an in-flight tool. `DigestEntry.tool is None` is the third, separate fact ("this entry did not come from a tool call, or the provider surfaces no per-call detail" — mewbo, whose turns come from an event fold rather than the spine, is deliberately left at `None`).
- **The in-flight signal generalizes for free because both harnesses flush the same way.** Claude writes the assistant message carrying the `tool_use` *before* the result arrives (which is also why a trailing `tool_result` advances the status tail); Codex writes its rollout record-by-record. So *an unresolved call* means "in flight" in both files, and `tool_outcomes` membership is the one test — no provider branch exists or is needed. Measured 2026-08-11: 3 running of 7868 Claude calls across 25 sessions (one of them a live session's own `Bash`, one a standing `AskUserQuestion`) and 21 of 31390 Codex calls across 194 rollouts.
- **`tool_outcomes(messages)` replaced three hand-rolled copies of the same `dict[str, str | None]` pre-scan** (both turn renderers plus `latest_todo_from_messages`), and the reason it had to become a value type is that the map could not carry the two facts the payload needs: `is_error` and the RESULT MESSAGE's timestamp. `TaskBoard.apply` takes the same map, so a `TaskCreate`'s server-assigned id still comes out of the one scan. **Membership, not truthiness, is the resolution test** — a tool that returned nothing is resolved.
- **Duration is the projector's pairing rule, moved to where both can reach it.** Call start = the `tool_use` message's timestamp, end = the resolving result message's, `max(0, …)` — the identical clamp `usage/_intervals.ActiveIntervals.of` applies, for the identical reason (two clocks, so an inverted pair is an artefact rather than negative work). `usage/projector.py::_event_rows` and `_derived_intervals` still carry their own copy of the loop; **they should adopt `tool_outcomes` next time they are touched** — same rule, two implementations is exactly the drift `_intervals` was created to stop.
- **Parallel calls in one assistant message share a start and nothing else.** Correlation is per `tool_use_id`, so interleaved returns never blur; the shared start is honest, they were issued together. Real Claude pairs measured returning 3782 ms and 4246 ms from one message. The N entries a *batch* fans out to (a `MultiEdit`, a question group) share ONE `ToolCall` object, because the fan-out is a rendering of one invocation.
- **A long duration is usually a human, and that is not a bug to correct.** The measured max over 25 real Claude sessions is 15.5 h — a permission-gated `Bash` whose result was written when the human came back. The transcript says the call took that long, and it did.
- **Codex's `exec_command_end` carries the harness's OWN measured duration + exit status, and it is CLI-VERSION-GATED almost to the point of not existing.** It is an `event_msg` sibling of `token_count`/`task_started` (status/tokens half of the dual-record rule), correlated to its `exec_command` `function_call` by the SAME `call_id`, `duration` as a Rust `Duration` struct (`{"secs": int, "nanos": int}`, converted to ms) plus a plain-int `exit_code` — both present together on 707/707 real on-host records, 100% of the `exec_command` calls in the rollouts that carry it at all. **Measured 2026-08-11 across every rollout on the reference host: present ONLY on codex-cli 0.122.0 (461/464 calls) and 0.125.0 (246/246) — ABSENT on 0.93.0 through 0.121.x (no completion-with-duration event of any kind) AND absent on the currently installed 0.147.0**, which replaced it with `event_msg` `item_completed` wrapping `item.type == "CommandExecution"` (identical `duration`/`exit_code` shape, plus top-level `started_at_ms`/`completed_at_ms`) keyed by the item's own id — no correlating field back to the owning tool call (`custom_tool_call` name `"exec"`) was found on this host, so that shape is UNIMPLEMENTED; re-open only after finding a real id join, not a turn_id/timing heuristic. When present, `ToolCall.duration_ms` prefers it over the message-timestamp diff (a real fix: derived duration measures message round-trip latency too, not just the process — one real pair read 1030 ms derived against 963 ms native), and the new `ToolCall.exit_code` carries the exit status. **`exit_code` deliberately does NOT flip `ToolCall.status`** — see the no-structural-error-flag paragraph below; it is new, additive information only, reachable via `ContentBlock`/`ToolOutcome`/`ToolCall`, all Optional and `None` on every version that never wrote the record. **The "Bash commands by cost" ranking premise was partly wrong**: `usage/projector.py::_event_rows` already derives a `duration_ms` for every provider from message timestamps directly off `ContentBlock`, independent of `ToolCall` — so Codex calls were never literally absent from that ranking, only measured less precisely. Wiring the native fields onto `ContentBlock` (not just `ToolCall`) is what leaves the projector a real, minimal follow-up: prefer `block.duration_ms`/`block.exit_code` when set, exactly the same precedence `ToolCall.from_block` now applies.

**Codex has NO structural tool-error flag ON `function_call_output`, and the absence there is a census rather than an oversight.** Over 9016 real `function_call_output` records the payload keys are exactly `{call_id, output, type}` (plus an `id`/metadata variant); 0 of 31390 calls could report a failure. The failure lives in the output prose (`Process exited with code 1`), so reading it would be interpreting the tool's semantics — the provider-boundary line. A failed Codex tool therefore reads `ok` with its error text in `result`, pinned by test so nobody "fixes" it with a regex. Claude fills `is_error` natively (200 of 7868 real calls). **This asymmetry is now DECLARED rather than left for each consumer to rediscover: `AgentAdapter.reports_tool_errors` (the `reports_queue` shape — `True` for claude_code, `False` for codex, and `False` again for generic/mewbo, which have no message spine and so no tool result to flag).** It exists because a consumer that merely *counts* `is_error` publishes a rate whose denominator includes calls that could never have contributed to it — the usage audit's "Bash commands by cost" shipped exactly that, deflating every mixed row by its own invisible Codex share (see [usage](../usage/CLAUDE.md)). **A capability that only the adapter can know must be declared here, not inferred downstream, or the inference becomes a provider-name list in whatever layer needs it.** **This claim is scoped to `function_call_output` specifically** — the sibling `exec_command_end` record (above) DOES carry a real `exit_code`, and using it to flip `status` would not be "fixing this with a regex" (it is a genuine structural field on a different record, not prose-sniffing) — it is simply a bigger, deliberately UNMADE decision: `status` has downstream consumers (turn/digest rendering, any future notifier) that currently assume Codex calls never read `"error"`, and flipping it needs its own scoped change plus updating `test_codex_never_reports_a_structural_tool_error`.

**Codex writes `output` in TWO shapes and the second is a fifth of all traffic — 7064 bare strings against 1952 content-block lists of `{"type":"input_text","text":…}` over 9016 real records.** Coercing the list to `""` (which the adapter did until 2026-08-11) shipped ~22 % of every Codex tool response to the wire EMPTY while the record held it the whole time — invisible, because a tool result was never rendered. `_Line._output_text` mirrors `claude_code._Record._result_text` rather than inventing a rule, and matches on a string `text` rather than on the `type` tag: an image part legitimately has none, and keying on the tag re-breaks on a rename. **A field nothing rendered was never forced to be right.**

### Questions (`AgentQuestion`)

- `QUESTION_TOOL_NAMES` = `{"AskUserQuestion", "ExitPlanMode"}`, and the Claude BLOCKED status path reuses `recognizes` so "is this a question" cannot drift between rendering and status. A batch yields N entries sharing a `group_id`, inline in turn order.
- **A `tool_result` is a forward reference — resolution needs a pre-scan, never an inline lookup.** The answer lands *after* the question's call, possibly turns later, so both parsers build a `group_id → answer_text` map across all records first. Resolution is **group-level** (one tool-call id, one blob); a per-question split would be guessing model output.
- **Claude:** `AskUserQuestion` carries a *batch* (`input.questions[]`, mixed kinds, `multiSelect`/`options`/`header`); `ExitPlanMode` is a `confirm` (the plan is the prompt, no options). `digest()` emits the bare tool name — it strips payloads by design.
- **Codex DOES persist a native question, and it is the same shape as Claude's — statement of record, codex-cli 0.147.0.** The tool is **`request_user_input`**, a first-party Codex tool (not MCP-bridged; it never appears under `mcp_tool_call_end`), recorded as an ordinary `response_item/function_call` whose JSON-**string** `arguments` carry `questions[]` of `{id, header, question, options[{label, description}]}` — Claude's `AskUserQuestion` payload plus a per-question `id`. The answer is the matching `function_call_output`, whose `output` is `{"answers": {<question id>: {"answers": [<label>, …]}}}`. So `QUESTION_TOOL_NAMES` gains **one entry** and the existing batch branch of `from_tool_call` normalizes both providers. `tool_search_call` already carries a dict and has no `call_id`, so extraction stays gated to `function_call` only.
  - **Evidence, pinned:** 33 real calls across 13 rollouts in `$CODEX_HOME/sessions/**` spanning cli_version 0.94.0 → 0.122.0, plus the installed 0.147.0 binary carrying `core/src/tools/handlers/request_user_input.rs`, `tui/src/bottom_pane/request_user_input/`, the `experimental_request_user_input` config key and the tool's own schema prose ("Provide 2-3 mutually exclusive choices… the client will add a free-form 'Other' option automatically", "Stable identifier for mapping answers (snake_case)"). The census over the whole store enumerates *every* `(type, payload.type)` pair — there is no approval/elicitation/question record class besides this one, so the negative half is a real enumeration rather than a grep that found nothing.
  - **Grove keeps the positional `<call_id>#<i>` id and drops Codex's own question `id`**, because resolution is group-level by contract; adopting it would buy a per-question split of an answer blob nothing consumes. **Kind is always `single_select`** — Codex emits no `multiSelect` and the CLI *rejects* a question with no options ("request_user_input requires non-empty options for every question"), offering free text as a client-side extra choice instead of a schema variant. A real answer therefore sometimes reads `["None of the above","user_note: …"]`.
- **BLOCKED is transcript-visible for Codex and impossible for Claude, and the reason is flush timing, not record shape.** Claude flushes nothing while a question is on screen; Codex writes its rollout record-by-record as the turn runs — **measured live on 0.147.0**: the file sat at a fixed size for ~30 s with an unanswered `function_call` as its last record while the tool was in flight. So an open question call (no `function_call_output` for its `call_id`) IS a question standing on screen. **It must OUTRANK task pairing**: the ask sits inside an unfinished turn, so `task_started` is unmatched and `_EventState.state` legitimately says WORKING — hence `activity()` decides BLOCKED and `state()` documents that it cannot. The output record is written for *every* outcome observed on-host (an answers object, `aborted by user after Ns`, `request_user_input is unavailable in Code mode`), which is what makes "no output yet" mean outstanding rather than merely un-normalizable.
- **`AgentActivity.questions` therefore has two sources, one per provider, and the service prefers the sidecar and falls through to the parser.** Claude fills it from the ask-time hook (the parser leaves it empty); the Codex parser fills it directly (there is no hook mechanism to fill). `ActivityService._pending_questions` returning `transcript.questions` with no capture is byte-identical to returning `()` for Claude. **Only UNANSWERED questions ride it** — the group empties itself the moment the output lands, so nothing lingers on the stream and the notifier's `unanswered()` edge detection works unchanged.
- **Codex *approval* prompts remain interactive and unpersisted.** "Codex has no BLOCKED" was true of approvals and was wrongly generalized to questions; keep the two apart.
- **Answering back is still Claude-only, and the blocker is not the keystroke grammar.** `WorkspaceManager.answer_question` gates on `request.session_id == state.agent_session_id` (**codex mints none**), resolves the pending batch from the **hook sidecar** (codex has no hook), and calls `ClaudeCodeAdapter.build_answer_keys` by name. Two of those three are provider assumptions rather than a missing adapter method, so a per-adapter write seam needs the *pending-question lookup* to become adapter-dispatched (transcript for codex, sidecar for claude) before a keystroke builder is worth writing. Codex's own picker grammar is unverified — do not guess it.

### File edits (`FileEdit`)

- **No disk-read fallback, ever — by design.** `old_text` is empty whenever the call carried no "before" (a full-file `Write`); by the time any parser runs the file already reflects the edit, so **an honest all-additions diff beats a fabricated or stale "before"**. Only real-time capture at invocation time (a `PreToolUse` hook sidecar, Claude-only) could carry it — deliberately not built. `MultiEdit`-shaped batches yield N `FileEdit`s from one call, hence the tuple return.
- A unified-diff **patch body** (Codex `apply_patch`) reconstructs both sides by line class, recovering the path from a `*** Update File:` / `+++ b/` / `--- a/` marker when the call carried none.

### Todo lists (`TodoList`)

- **Real on-host shapes, pinned — NOT a guessed fixture.** `TODO_TOOL_NAMES` = `{"TodoWrite", "update_plan"}`. Claude `TodoWrite` = `input.todos[]` with `content`/`status`/`activeForm`; Codex `update_plan` is a **`function_call`** whose `arguments` JSON-string carries **`plan[]` with `step`/`status`** — NO `content`, NO `activeForm`. `from_tool_call` reads `todos` else `plan`, item text from `content` else `step`, and returns a 0-or-1-tuple (one call is ONE list).
- **Status coerces to `"pending"`** for anything outside the `pending`/`in_progress`/`completed` triple both providers agree on — never guessing intent, never raising.

### The Task system — TodoWrite's successor on builds that ship it

**Claude builds that ship "Tasks" replace `TodoWrite` with `TaskCreate` (one per item) + `TaskUpdate` (one per change); `TaskList`/`TaskGet` are read-only queries.** `TASK_TOOL_NAMES` = `{"TaskCreate", "TaskUpdate"}` is deliberately **not** unioned into `TODO_TOOL_NAMES` — the reconstruction shape differs; `TaskList`/`TaskGet` stay unrecognized and render as bystander tool calls. **`TaskOutput`/`TaskStop` are an UNRELATED concept** (background process output and cancellation) — never route them here despite the name family. A host shipping Tasks emits no `TodoWrite` at all, so an absent `TodoWrite` is not evidence that a profile suppresses it.

- **The board is a DIRECTORY on disk, and the transcript is only one participant's mutations of it — so `latest_todo` reads `<config>/tasks/<board>/<id>.json`, not the fold.** Measured on a real 5000-line session: the live board held 58 tasks, the fold reported 84. Three losses, none of them fixable inside a fold, all in the same direction (too many): **a deletion emits no tool call whatsoever** (the file is removed — clearing a stale item or a whole planning epoch is invisible), **ids restart when the board does** (a re-issued id silently overwrote an earlier task, merging two generations), and **a shared board has several writers** (a teammate's `TaskUpdate` lands in that teammate's own transcript — 21 of the 58 read `pending` in the fold while the board had them completed). The stored record carries the same field names the tool payload does (`subject`/`status`/`activeForm`), which is why `_ClaudeTasks.read` folds it through `TaskBoard.create` + `update` rather than a second mapping. **Before reconstructing state from an event log, check whether the tool persists the state itself — and ask which mutations that log cannot contain.**
  - **Resolving the board name: `teamName` from a sub-agent sidecar first, `session-<first 8 of the session id>` only as the fallback.** A session id rotates in place (`/clear`, a fork) while the board keeps the name the LEAD session started with — the reference session's own id resolved to no directory at all. **NOT compaction, despite what this file and two docstrings asserted until 2026-08-11: over 36 real compacted transcripts every one carried exactly ONE `sessionId`, so a compaction keeps both the id and the file, history included.** The belief was never measured, and it survived because the fallback it justifies is right for the causes that DO rotate; `/clear` was not re-tested, so its half stands unverified either way — say exactly that rather than inheriting the whole claim. **An empty board directory is not an empty board:** Claude Code creates one per session eagerly, so "the directory exists" would claim an authoritative empty list for every session on the host; only a directory holding at least one task file wins, and everything else falls back to the fold. The fold therefore stays the answer for `TodoWrite` sessions and for any board this process cannot reach, and its residual over-count there is accepted rather than patched.
  - **`teamName` is stamped into a sub-agent's sidecar only at SPAWN time, so a session that JUST rotated and has not yet spawned its own first teammate carries no team on its own paths — reproduced live: 88 total/34 done reported for a 62-item/58-done real board, exactly the fold's signature.** `_ClaudeHome.resolve_team(cwd, session_id)` widens `team_name` to the session's siblings recorded for the same `cwd` (bounded by `_TEAM_SIBLING_SCAN_LIMIT`, filesystem listings only, no transcript parse) so a rotated — or even fully dead-pointer, zero-transcript — id still resolves the team an earlier sibling already established. `latest_todo` calls this instead of `team_name` directly. Self-limiting: once the current session spawns its own teammate, `team_name` on its own paths answers again and the fallback never runs.
- **Why not a `TodoList.from_tool_call` variant:** Tasks correlate a late `TaskUpdate` against a server-assigned id **the call itself never carries** (it rides back in the `TaskCreate`'s own `tool_result` text), so materializing the list needs a running fold, not a pure per-call function. `TaskBoard` is that fold, reusing the SAME `tool_use_id → tool_result` map question resolution builds (no second pre-scan), and `snapshot()` returns a genuine `TodoList` — **zero new wire contract, zero client changes**. A `create`/`update` returning `False` (unresolvable id, untracked id, unreadable payload) falls the call back to the generic tool entry.
- **An unrecognized status on UPDATE is ignored, not coerced** — the opposite of create-time coercion, because coercing to `"pending"` would silently regress a completed task backwards on a provider quirk.
- **Fields the board does not model** (`owner`/`addBlocks`/`addBlockedBy`) ride the real `TaskUpdate` wire but have no `TodoItem` equivalent — dropped deliberately, not a parsing gap.

### Compaction boundaries (`CompactionBoundary`) — the second break, and it breaks the OTHER half

**`ToolCall` keeps the recipe's derivation and drops its role; a compaction keeps a role and drops the derivation.** It is the first structured payload that comes from a native harness RECORD rather than a tool call, so there is nothing to normalize from a `tool_use` block and the payload has to be *carried*: it rides a new `compaction` `MessageRole` whose message is deliberately **CONTENTLESS**. That emptiness is what made putting a new event on the shared spine free — every consumer that reads a message for prose (the trace exporter's `if parts:`, `final_result_from_messages`, the usage projector's `role == "user"` count) skips it without knowing it exists, so only the two renderers that opt in ever see it. **When adding a role to a shared spine, ask what the existing readers do with an empty one; if the answer is "nothing", the addition costs nothing.**

- **The trigger asymmetry is the whole reason the field is nullable, and it must never be defaulted.** Claude Code records `compactMetadata.trigger` natively and authoritatively (exactly `manual`/`auto`, 46/9 over 55 real boundaries). Codex records NO trigger anywhere, in any version measured from 0.93.0 to 0.147.0 — so manual-vs-automatic is genuinely unknowable there. `None` therefore means *this harness records none*, and a plausible default would be indistinguishable on the wire from Claude's measured value. Same rule one field over: Codex has no token accounting and encrypts its `replacement_history`, so `payload.message` is empty on 132/132 records and `summary` is `""` as a fact about the format, not a parse failure. The field is still READ rather than hard-coded, which costs nothing and is what would surface a future Codex that fills it.
- **`cumulativeDroppedTokens` is a SESSION-RUNNING TOTAL and reads exactly like a per-event count.** Publishing it raw inflates every boundary after the first by the whole session's history — and a session compacts repeatedly (up to 5 observed in one Claude file, 20 in one Codex rollout), so the error compounds silently. The delta is `total - previous total within the same file`, with the previous starting at **0** rather than at "unknown": verified against the same record's independent `preTokens - postTokens` on 55/55, which is what proves the counter starts at zero per file rather than continuing something older. A total that went BACKWARDS yields `None` — the counter restarted under us, and that is not a negative count.
- **The summary is written AFTER its boundary and stamped BEFORE it, so file order and clock order disagree — join by id, never by position.** The replacement summary is a separate `type:"user"` record with `isCompactSummary`; its timestamp is earlier than the boundary's on 55/55 (by up to 1.6 s), so once `_read` has time-sorted the records the summary sits *before* its boundary in 47 of 55 cases. A forward scan is correct against the raw file and **finds nothing in the stream the parser actually sees** — it silently resolved half the corpus before the real files disproved it. `parentUuid` equals the boundary's `uuid` on 55/55, so the id join is both exact and order-free. This is the same clock-versus-order conflict `_stamp_deliveries` exists for: **when a provider's own ordering and its own timestamps disagree, look for an id linking the two records before reaching for either order.**
- **Codex's key is the TOP-LEVEL `type:"compacted"` record, and the `event_msg`/`context_compacted` mirror is the one place the dual-record rule's `event_msg` half is not merely redundant but WRONG.** The mirror is content-free and **absent entirely in 0.147.0** (present in every older version on-host), so keying on it goes blind on the current release while looking perfectly correct against the archive.
- **`type:"summary"` is NOT a compaction** — it is an unrelated session-title record, with **0 occurrences** in the compacted corpus. Keying on the obvious name finds nothing and means nothing.
- **The digest carries the marker and drops the payload.** A real summary measured 13.9–55.3 KB, the largest single thing a transcript holds, so it rides the fetched turn view only; `digest()` keeps its "strips payloads by design" contract.

## Claude transcript parsing

Each fact is a trap if forgotten; all are pinned against real on-host JSONL.

- **A `type:"user"` line is usually NOT a human turn.** `tool_result` blocks carry `role:"user"` (one real session: 4683 user lines, 80 real turns). The real-turn filter: not sidechain/meta, no `tool_result` block, text free of `<command-*>` / `<bash-*>` / `Caveat:` / compaction markers.
- **Locate transcripts by globbing the session UUID, never by decoding the project folder name.** The cwd encoding (**every non-alphanumeric char → `-`**, not just `/` `.` `_`) is lossy and irreversible. Confirm the match via each line's own `cwd`.
- **Status comes from the tail assistant `stop_reason`** (`tool_use` → working, `end_turn`/`stop_sequence` → waiting) — **and a trailing `tool_result` line advances the tail too.** Mid-tool the physical last line is a `tool_result` carrier; without advancing on it the status reads the *previous* assistant's `stop_reason` — one whole turn of lag, a busy session reporting WAITING.
- **One JSONL line is NOT one logical record — Claude Code writes one line PER CONTENT BLOCK.** All lines of one API response share `(message.id, requestId)` with *distinct* `uuid`s and *identical* `usage`/`stop_reason` (123/123 multi-line messages on-host; block order `thinking → text → tool_use*`). Hence two identity layers in `_read`: `uuid` = line identity (a repeated uuid is a resume/fork replaying history → drop); `dedup_key` = logical identity (a new line under a seen key is a split-block sibling → **merge its blocks** via `absorb_continuation`, count usage once). A first-line-wins drop keeps only the leading `thinking` block, so every text/tool follow-up vanishes from turns, digests and tool counts **while fixture-based tests stay green** — fixtures pack blocks into one line.
- **A delivered `<task-notification>` is a plain `type:"user"` line** (no `isMeta`, no `tool_result` block); without its marker in the non-human filter it renders as a raw-XML "user prompt" in every client. It is classified `is_task_notification` → the `notification` digest role and advances the tail to WORKING. The same envelope also appears in `queue-operation`/`attachment` records — queue plumbing, skipped. **`<teammate-message>` is its sibling** (`is_teammate_message` mirrors it exactly); untreated it inflates `human_turns` and renders as raw XML.
- **Every async spawn's first `tool_result` is a launch ACK, not the return — and the modern shapes carry no `run_in_background` at all.** Three spawn flavors, three close rules, all in `_SubagentFleet`: (1) a spawn carrying `input.run_in_background` closes on the `<task-notification>` carrying its `<tool-use-id>`; (2) an in-process TEAMMATE (`Agent` with a `name`) acks `toolUseResult.status:"teammate_spawned"` and closes only on a `<teammate-message>` **idle_notification matched by NAME** (bare and `name@team` both observed — compare bare halves; no tool-use id exists to match), and interim relays are `<teammate-message>` lines too and must NOT close; (3) a `Workflow` run acks `"async_launched"` with its own async `taskId` (the `TaskOutput` id space, NOT the Task board's) and closes on a later `TaskOutput` poll for that id, else stays open with blend staleness as the honest fallback (a 465 s/16-agent run left the main JSONL silent for 220 s+ stretches). **Closing on any ack reads every modern fleet as 0.** `Task` is the older name of `Agent`; treat both.
- **The fleet-active promotion lives at the PARSE seam, not the blend.** `_TranscriptParser.activity()` promotes a tail-derived WAITING to WORKING when `fleet.active > 0`; BLOCKED/ERROR still outrank. Dead-fleet safety net: sidechain appends feed `last_event_at`, so a dead fleet goes stale and the blend's stale+quiet demotion ends the WORKING.
- **Sub-agent transcripts glob RECURSIVELY: `subagents/**/agent-*.jsonl`.** A Workflow worker nests at `subagents/workflows/wf_<runId>/agent-*.jsonl`; a top-level-only glob makes every Workflow run invisible to freshness, fleet and turns. Teammate metas are rich (`name`/`color`/`model`/`agentType`/`taskKind`/`teamName`) — fleet identity prefers meta `name` over agentType/description/first-prompt. On-disk linkage is `sourceToolAssistantUUID` only; no `parent_tool_use_id` field exists on disk.
- **`subagent_turns(cwd, session_id, thread_id)` is thread-filtered `read_messages` through the SAME turn builder `turns()` uses** — never a second parser; unknown thread → `()`. The team plane (roster, task board, inboxes) persists OUTSIDE `projects/` and is deliberately unconsumed.
- **An assistant tail holding an unanswered `AskUserQuestion`/`ExitPlanMode` is BLOCKED, not WORKING** (the answer's `tool_result` advances the tail back). Permission prompts never reach the JSONL — **and a SUBAGENT-originated permission prompt never fires the parent's `Notification` hook either** (live-verified: a 2+ minute hard block with the sidecar stuck at `working`/PreToolUse), and multiple subagents' prompts QUEUE in the parent pane. **The sidecar BLOCKED signal is structurally blind to fleet permission prompts**; the reliable seam is the `--permission-prompt-tool` library plus daemon wiring.
- **`/clear` rotates the session id in-process, and a `SessionEnd` sidecar or a pane `/resume` are the same dead-pointer shape**: the minted `--session-id` becomes a dead pointer while the live conversation continues under a *different* id in the same cwd. Adapter-side that means "a minted id with no file is not necessarily young" and "a resumed session's transcript is born *before* the workspace"; the recovery policy lives in [grove.core](../CLAUDE.md)'s `sessions_for`.
- `_parse_timestamp` always returns an **aware** datetime (tz-less strings assumed UTC) — freshness math subtracts from `utcnow` and a naive value raises mid-poll.
- **Defensive parsing:** per-line `try/except`, tolerate `FileNotFoundError`, and **coerce booleans that arrive as strings** (`isSidechain:"false"`) — never `bool(raw)`.
- `transcript_digest()` strips `tool_result` payloads by design.

## Codex transcript parsing

**Verified against real on-host rollouts (codex-cli 0.125.0, question facts re-pinned at 0.147.0); pin against real JSONL, never a fixture — same law as Claude.**

- **Path is date-partitioned, NOT cwd-encoded** (the opposite of Claude): `$CODEX_HOME`-or-`~/.codex` / `sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`. Session id = the `uuid` (= `session_meta.payload.id`); locate globs the uuid suffix and confirms via the in-file id, never a folder name.
- **cwd lives ONLY in `session_meta` (line 1) and `turn_context`, not on every line.** The Claude reflex of reading any line's `cwd` returns nothing. Discovery reads a bounded head (≤8 lines) for it, and that one head read backs both `discover` and `list_sessions`.
- **Codex DUAL-RECORDS — the central trap, its analogue of Claude's split-block dedup.** Every message is written twice: as a `response_item` (structured transcript) AND as an `event_msg` mirror. The rule: **conversation/turns/replies/tools ← `response_item` ONLY; status/tokens ← `event_msg` ONLY.** Counting from both doubles every message — green against packed fixtures, wrong against real files.
- **Status from explicit turn boundaries, not a stop_reason.** `event_msg/task_started` + `task_complete` share a `turn_id`; an unmatched `task_started` → WORKING, all matched → WAITING. Fallback with no task events: a trailing assistant `message` → WAITING, a trailing human/tool → WORKING. **BLOCKED overrides both** when a `request_user_input` call has no output yet (see Questions above); *approval* prompts are still interactive and never persisted.
- **`token_count.info` carries BOTH a cumulative and a per-turn figure, and mixing them multiplies the session.** `total_token_usage` is CUMULATIVE (`input_tokens` strictly grows) — take the LAST populated report for `AgentActivity`, never sum. `last_token_usage` is the request that just finished — the ONLY one that may land on an `AgentMessage`. `info` is `null` early in a session, so tokens legitimately read 0 until the first usage report.
- **Codex counts cached tokens INSIDE `input_tokens`, where Claude reports input NET of them** (`total_tokens == input_tokens + output_tokens`, verified on-host). `TokenUsage.input` is contracted as the FRESH input the price book charges at the full rate, so the Codex adapter subtracts `cached_input_tokens`; left un-netted every cached token is billed twice, once as input and once as cache read. `reasoning_output_tokens` → `TokenUsage.reasoning`, which is informational only (it is already inside `output_tokens`, and the price book deliberately does not charge it as a fifth class).
- **`launch_decoration` is empty — Codex cannot be launched with a chosen id** (the thread id is internal; `--session-id` is resume-only). So `_mint_agent_session_id` returns `None` and every Codex session reaches Grove through `discover_sessions`.
- **Reasoning is a black box:** `response_item/reasoning` is almost always opaque `encrypted_content` with an empty `summary`. Surface a marker only when `summary` carries readable `summary_text`; never read or decrypt `encrypted_content`.
- **A real human turn = `response_item message role:"user"` minus the injected preamble** (text holding `<environment_context>` / `<user_instructions>` / `# AGENTS.md` / `<permissions instructions>`); `developer`-role messages are system injections, never turns.
- **`session_meta.payload.git` carries `{branch, commit_hash, repository_url}`** when the cwd is a repo — the `git_branch` source, and the only record that has it.
- **`apply_patch` is a `custom_tool_call`, NOT a `function_call`**, and its `input` is the raw patch body as a plain string, unlike `function_call.arguments` which is JSON. Until `custom_tool_call` was in `is_tool_call`'s payload-type set, **every `apply_patch` edit was invisible** — uncounted, absent from turns and digest, silently skipped. Any *other* future Codex custom tool rides the same widened classification and renders as a generic tool entry unless it also matches `FileEdit.recognizes`.

## Deterministic session correlation

**Grove mints the session id and launches with it; it never scans to guess** — so the transcript path is known by construction. The minted id is a canonical dashed UUID (Claude requires RFC-4122, distinct from the bare-hex workspace id) and reaches the pane through the adapter's `launch_decoration`, shell-quoted by `tmux.build_workspace_layout`.

- **No launch flag is ever hard-coded in `tmux.py`** — it comes from the adapter. `AgentKind` (`config.py`) is `Literal["claude_code", "codex", "generic", "mewbo"]`; the built-in `claude` ships `kind="claude_code"`.
- **Each adapter declares `resumable` and `_RESUMABLE_KINDS` DERIVES from it** (`frozenset(a.kind for a in all_adapters() if a.resumable)`) rather than being hand-listed, so a future resumable adapter cannot be missed by a stale constant. Today: claude_code and codex. Which verb mints and which continues is engine policy ([grove.core](../CLAUDE.md)).
- **`launch_decoration(session_id, *, resume=False)` is the mint-vs-resume seam** (the manager gates resume to `_RESUMABLE_KINDS` before calling). claude_code `--session-id <id>` → `--resume <id>` — **plain `--resume` KEEPS the same session id and file** (rotating needs `--fork-session`), so pinning `agent_session_id` to the resumed id is correct. codex `[]` → `["resume", <id>]`, riding *after* the configured command with no injection machinery, because codex's grammar is `codex [OPTIONS] <COMMAND> [ARGS]` — `codex --full-auto` + `resume <id>` parses, and `resume` also accepts `-m/--model` so an appended `--model` still lands. On a codex resume the chosen uuid IS persisted, even though codex otherwise mints nothing.
- **The CLI-vs-Agent-SDK resume cwd trap** (matters only if an adapter swaps its launch mechanism): the `claude` **CLI** `--resume <id>` is worktree-aware — it resumes a session recorded in the same *repo* from a different worktree dir. The **Agent SDK**'s resume is strictly cwd-keyed, so a resume launched from a different dir than the session was born in would miss. Grove launches the CLI; anyone porting onto the SDK must anchor the cwd to the session's original.
- **`adapter.model_decoration(model)`:** claude_code / codex → `["--model", <id>]`; mewbo / generic → `[]` — **mewbo's model is still honored**, forwarded at REST session-create instead, best-effort. `_compose_launch` appends it **independently of the session id** (codex mints none but still honors `--model`) and **before** the trailing `initial_prompt` positional. **Grove never validates or interprets a model id, the tool does.** `CreateWorkspaceRequest.model` is create-only, never persisted, never re-applied on resume/respawn.
- **The create-time `initial_prompt` rides the LAUNCH, never post-boot pane typing** — claude_code appends it as a trailing POSITIONAL, so the agent boots already working on it; typing into the pane after boot races the agent (the swallowed-Enter trap below). A remote adapter has an empty `launch_decoration`, so its prompt re-engages through message dispatch after the workspace is persisted, best-effort: a delivery failure never rolls back the create. generic/shell drops it.
- `manager.primary_transcript(id)` returns a `tuple` — the snapshot convention, and it dodges the `list`-method-shadows-`list[]`-builtin mypy trap inside `WorkspaceManager`.

## Model catalog & discovery — the ≤10 picker seam

**`adapter.available_models(command) -> tuple[str, ...]` OFFERS a list for a create-form picker, never a validated allowlist** — create forwards any id verbatim, so a model absent from the catalog works fine. `command` (the configured `AgentSpec.command`) supplies the binary, so no tool name is hard-coded.

- **`codex` — the real auto-refresh:** `codex debug models` returns `{"models":[{slug, visibility, priority, …}]}`; keep `visibility == "list"`, order by `priority`. **Trap: the output is ~190 KB** because every model embeds a giant `base_instructions` prompt — parse ONLY those three keys. It is the one subprocess in the adapter (`_probe_codex_models`, best-effort, never raises) and the seam the autouse conftest fixture patches so the suite never shells out.
- **`claude_code` — tier aliases `("sonnet", "opus", "haiku")`, because there is NO CLI enumeration:** models are exposed only through the interactive `/model` picker, and `--help`/`--list-models` list nothing. The aliases re-point each release, so offering them never goes stale the way a dated id would; full ids and gateway models still work, and `AgentSpec.models` overrides.
- **`mewbo` / `generic` — `()`;** a deployment pins its ids via `AgentSpec.models`.

**`registry.resolve_models(*, kind, command, configured)` is the SINGLE composer, so every surface shows the same list.** `configured` wins WHOLESALE when non-empty (a pin/curate/reorder seam), else `available_models`; then de-dup and cap at `MODEL_CATALOG_CAP = 10` — display only, never a restriction on create. The daemon `GET /agents` route resolves it into `AgentSummaryView.models` **in the executor** (codex discovery is a subprocess). **The MCP and CLI layers never import adapters** — they get the resolved list over the wire.

## Mewbo — the remote adapter

**Pure logic over `MewboClient` payloads; all HTTP stays in `grove.core.mewbo`.**

- **The client is built lazily on the first introspection call, from the ONE global config** (`load_config(repo_root=None)` — the `ActivityService` rule), so **importing the registry never reads config or opens sockets**. `MewboAdapter(client=...)` is the test DI seam.
- **`GET /events` is authoritative** — top-level `status` / `done_reason` / `title` come from the server's own summary; never reconstruct state from the timeline tail. Status map: `running` → WORKING; settled (`completed`/`done`/`finished`/`interrupted`/`idle`/`waiting`) → WAITING; `waiting_user`/`needs_input`/`blocked` → BLOCKED; `failed`/`error` → ERROR; unrecognized or missing → UNKNOWN. An error-ish `done_reason` promotes any terminal status to ERROR.
- **Peak vs billed tokens — never mix.** `GET /agents` `total_input_tokens` is PEAK semantics (root peak plus per-sub-agent peaks, the context-pressure number); the cumulative billed sum is the separate `total_input_tokens_billed` on `/usage`. `tokens_in` uses the peak.
- **Mewbo `user` events ARE real human turns** (unlike Claude transcripts), so turn buckets split on them; `agent_message` + `completion` are the assistant replies. Result events carry `tool_id` too, so **count only the call side or every step doubles** — `is_tool_result` is the exact mirror of `is_tool_call`, so richer rendering never moves metrics, tokens or status. `tool_input` is polymorphic per tool (dict → `k=v` pairs, bare string passthrough).
- **The tool NAME for recognition purposes is `operation`, not `tool_id`** (`tool_id` is a per-step identifier reused across the call+result pair). `tool_label()` is a *display* string — never recognize off it.
- **No local surfaces by design:** `launch_decoration`/`locate_transcripts`/`discover_sessions` return empty — no CLI to decorate, no transcript files, and a remote session cannot be "hand-started in a cwd" invisibly to Grove. `list_sessions(cwd)` filters rows by their recorded `context.cwd` and skips rows without one.

## Push status — the hook sidecar

**A per-session sidecar that a Grove-managed Claude Code hook writes; it overrides the polled blend, which cannot separate waiting from done and is blind to a permission prompt.**

- `hook.py::ClaudeHook` maps each event name to an `AgentActivityState`: `Notification` → BLOCKED (the signal polling cannot see), `Stop` → WAITING, `SessionStart` and tool events → WORKING, `SubagentStop` → `None` (a finishing sub-agent never flips the main thread). It writes `<state-dir>/agent-sidecars/<session_id>.json`. The dedicated `grove-agent-hook` entry point reads hook JSON on stdin (plus `$TMUX_PANE`) and **always exits 0** — a hook must never fail the agent.
- **Sidecar staleness is transcript-supersession, not wall-clock age** (`HookRecord.supersedes_poll`): a push loses the instant the transcript outruns it (`transcript.last_event_at > record.ts` — the steer-after-Stop case, where age-only trust pinned WAITING on a re-engaged agent for minutes). **Only WORKING also ages out** (`DEFAULT_SIDECAR_MAX_AGE_SECONDS`, 300 s — the dead-agent guard); settled states never expire, and expiring BLOCKED into a polled guess re-creates the invisible-permission-prompt bug the hook exists to fix. `ClaudeHook.read` is mechanism only; the supersession judgment needs the transcript's clock, which only the blend site has.
- **Install is additive:** `cfg.hooks.enabled` (default `True`) writes a Grove-owned hook-only settings file and appends `--settings <that file>` — the user's `.claude/settings.json` is never edited. **Blast radius of hooks-on-by-default:** any suite touching `manager.create()` for a claude_code agent writes real hook settings, so `tests/conftest.py` carries an **autouse** fixture redirecting `paths.agent_sidecar_dir`/`agent_hooks_settings_path` to tmp — those read raw `platformdirs`, NOT the `user_config_path` the opt-in state fixture redirects.
- **Handler topology = ONE `command` handler per event; the entry point makes the daemon push** (sidecar write, then a POST to `HOOK_INGEST_ROUTE` — one constant imported by both `hook.py` and the daemon — which awaits a scoped `ActivityService.poll_once()`, collapsing poll-tick lag to an immediate SSE refresh with no new frame types). **A registered `http` handler cannot ask whether the address it names is reachable**, which is what killed the earlier dual `command` + `http` shape: inside a container `127.0.0.1:7421` is the container's own loopback, so the agent reported `connect ECONNREFUSED` in its own UI on every event — as did a host with the daemon stopped. **The URL reaches the entry point as an argv (`--daemon-url`), never a config read** — this is the hottest process in the system, and `urllib` is used over `httpx` for the same reason.
- **A containerized agent has NO `grove-agent-hook`** — it is a console script of a package the project's image never installed — **so the command arm spools and the host folds.** `ClaudeHook.spool_script` writes the payload verbatim into a host directory bind-mounted at its OWN absolute path (one rendered command, one string, both namespaces); `ClaudeHook.drain` folds it through the same `record_event` a host hook calls, ordered and timestamped by the spool file's mtime. **The drain hangs off `ClaudeHook.read`, the one seam all four sidecar consumers already call.** Mount and blast-radius reasoning lives in [grove.core](../CLAUDE.md).
- **Auth for the ingest route is a same-host secret file, NOT the pairing bearer** (`ClaudeHook.ensure_ingest_token()` → `hook-ingest.token`, 0600). The human-approval-gated pairing bearer is wrong for an automated caller firing unattended on every tool call — **reuse the same-host-secret pattern for that shape of caller.**

## The first-turn brief (`brief.py`)

**`UserPromptSubmit` is the ONE event whose stdout Claude Code injects into the model's context** — every other event's is ignored — so it is the only channel that can tell an agent anything, and `AgentBrief` is that one short text: where it is, that what it reports is published onto attached tickets, and the name of the `working-in-grove` skill. **It points and never restates**, because the skill is also published as a plugin: a duplicated rule costs context in every session and drifts the day either copy is edited.

- **The env var IS the per-workspace switch.** The hook settings file is HOST-GLOBAL (`paths.agent_hooks_settings_path`), shared by every workspace, so a per-workspace choice cannot be expressed in it; `_launch_env` is per workspace and per launch and the hook process inherits the agent's env — the exact `GROVE_PHASE_FILE` precedent. The rendered brief is host-global too (one identical text), so only the *variable's presence* varies.
- **Once per session, claimed with an exclusive create, marker under the sidecar dir** — never in the worktree, where it would show up in the user's own `git status`. `UserPromptSubmit` fires on every prompt, so claiming BEFORE returning the text is load-bearing: claim-first can at worst lose a brief nobody saw, return-first repeats it forever whenever the claim fails.
- **The hook must never exit non-zero — exit 2 BLOCKS the user's prompt.** So every failure (no var, no file, unwritable marker) prints nothing and still returns 0, and the emit sits last in `run_hook_from_stdin` so nothing else can put a byte on stdout ahead of it. No config load, no cwd→workspace resolution: this is still the hottest process in the system.
- **No "is this a Grove session" discriminator exists or is needed** — Grove's hooks live only in the file passed via `claude --settings`, so a hand-run `claude` in the same worktree runs no hook at all.
- **A kind with no such hook is briefed through the create-time prompt instead** (engine side, [grove.core](../CLAUDE.md)) — codex has NO hook mechanism whatsoever, and a containerized claude's hook command takes its spool fallback and prints nothing, so both fail identically from the injection side.

## Live question capture + answering

**Claude Code flushes NOTHING to the transcript while an `AskUserQuestion` or `ExitPlanMode` is on screen** — the assistant message holding the `tool_use` block is written only *after* the human answers or cancels, so no transcript reader can see a *pending* question and **the ask-time `PreToolUse` hook is the only signal.**

- **Capture keys off `QUESTION_TOOL_NAMES`, never a hard-coded name**, storing the raw `tool_name` + `tool_input` (+ `tool_use_id`, `asked_at`) on the same sidecar the status push uses and normalizing lazily through the shared `from_tool_call` seam — no second question shape, one file per poll carrying both.
- **The pending question is a tiny state machine over that one file, and its clear rules differ from the state map's.** A question-tool `PreToolUse` *captures*; any other `PreToolUse`, or `PostToolUse` / `Stop` / `UserPromptSubmit` / `SessionEnd`, *clears*; **`Notification` and `SessionStart` CARRY FORWARD** (read-modify-write the prior sidecar). **The carry-forward is load-bearing:** the permission `Notification` fires ~6 s *after* the ask while the question is still up, so treating it like every other event would erase a standing question after 6 seconds. (`Notification` → BLOCKED is unchanged; only the question half carries.)
- **Two independent resolution signals, because hooks can be missed.** The sidecar clears on the answer's events, AND the activity service cross-checks the transcript: once the `tool_use` and its resolving `tool_result` flush together (post-answer/cancel, `is_error` included), the group is present in `read_turns` → not pending. The cross-check is gated on `transcript.last_event_at > asked_at` so it only re-parses once the transcript has moved — while genuinely pending nothing flushes, so it is free. Surfaced as the **whole batch** (one `AskUserQuestion` carries up to four questions answered atomically) and added to the poll fingerprint, so appear and resolve both stream at once.
- **Answering drives the TUI by keystroke — the one place a select-question needs more than `send_text`.** `tmux.send_keys` is the narrow primitive (a named key ∈ {Tab, Enter, Escape} or a literal `-l --` text run; a `StrEnum` IS a `str`, so the dispatch checks the enum first). `ClaudeCodeAdapter.build_answer_keys` is the pure, verified grammar: single-select → digit `i+1`; free-text (single-select only) → digit `len+1` + text + Enter; multiSelect → a digit per choice then Tab; a final Enter iff a review step exists (>1 question OR any multiSelect). It **branches on the normalized `kind`, not the tool name**, and **rejects what was not verified** (`confirm`, optionless `free_text`, multiSelect+text) rather than guessing keystrokes — the manager maps that `ValueError` to 422 and only matches the captured `tool_use_id` (409 if stale or absent, re-checked right before the send). **Dispatch semantics, like steering:** keys-sent is not confirmed-answered — the residual race leaves harmless literal text in the reset composer, and resolution arrives later on the stream.

## Session exploration (list / turns / dump)

`SessionSummary` mirrors the official Agent SDK's field names (`first_prompt`, `git_branch`, `cwd`, `created_at`), but Grove keeps its own reader rather than the dependency because the SDK handles none of: the multi-config-dir cascade, string-boolean coercion, the preamble cwd scan.

- **`discover_paths` is the one scan; `discover` (ids), `discover_births` (id + BIRTH) and `list_sessions` (summaries) are projections of it** — do not add a third scan variant, extend the tuple. **`discover_births` is the dashboard's cheap adoption pre-filter:** `birth` (the first timestamped record) rides out of the SAME bounded head read that confirms the cwd, so the adoption gate rejects a historical transcript on cheap metadata and pays a full parse only for candidates that pass. Returns `(id, birth, mtime)` newest-first by mtime; `[]` for generic/remote.
- **`list_sessions` builds each summary and its point-in-time `AgentActivity` from ONE parse of the main transcript** (sub-agent files are sidechain detail, excluded). "A full parse per tick is fine; revisit only if it measurably lags" came due — it pegged the daemon on a multi-GB transcript tree, and every full-file read now flows through the incremental cache below.
- **`last-prompt` records come in two shapes:** with `lastPrompt` text, and leafUuid-only pointers. `last_prompt_text()` skips the pointer form — treating it as an empty prompt loses the real one.
- **`read_turns` groups assistant entries (text + tool calls, in block order) under each human turn.** Assistant records preceding any human turn (a resumed or compacted head) collect under a leading turn with empty `user_text` — never dropped.
- **Watch-item: forked sessions store a parent *pointer* and hydrate on read.** No pointer records observed on-host yet — if fork-heavy transcripts start showing truncated history, this is why.
- **Cross-worktree aggregation is NOT adapter business** (it lives in `core/sessions.py`). **Adapters only ever answer for one `cwd`.**

## Incremental transcript cache (`transcript_cache.py`)

**Every full-file transcript read in both filesystem adapters flows through one mechanism:** `TranscriptCache` (per path-tuple fold state, advanced by parsing only bytes appended since the last read) plus `ResultMemo` (a stat-signature memo over the derived products). Before it, the 2 s poll re-parsed every session's full JSONL twice per workspace: ~95 % of daemon samples in `json.loads`, two pegged executor threads, RSS past 1 GB on a ~2 GB tree. Transcripts are append-only in the steady state, so an unchanged file costs one `stat` and an active one only its delta.

- **The fold policy is the adapter's, injected as a `RecordFolder`** — Claude's carries the uuid-replay drop and the split-block `absorb_continuation` merge; Codex's is a plain appender. **The cache owns cursors, resets, byte budget and thread-safety; it knows nothing about providers.**
- **`absorb_continuation` mutates the kept record's raw dict, so parsed dicts must be PRIVATE to one fold state.** Never share raw lines across fold states or refold previously-folded objects — a second absorb of the same sibling duplicates content blocks silently. A state reset therefore always re-parses from disk; that is why there is no shared per-file line cache underneath.
- **Reset (full re-read) triggers on: inode change, any non-growth size change, or a vanished file that had content.** The cursor only ever advances past the last `\n`, so a writer-in-progress partial trailing line is deferred, not skipped (UTF-8 cannot straddle that boundary — `\n` is a single byte).
- **Memo values must be immutable** (frozen dataclasses / tuples); they are shared across the poll thread and request executor threads. The key carries method + cwd + session id (+ `last` for turns), and the stat signature covers the path *set*, so a new sub-agent file invalidates naturally.
- **Both caches are module-level singletons** (they must outlive the throwaway adapter instances). Tests reset them via the adapters' public `clear_caches()` in an autouse fixture — **never by patching privates.** Within one test, beware same-size same-`mtime_ns` rewrites: the cache honestly cannot see them, and real transcripts only append.
- **Sort-order nuance:** a record's tiebreak `index` reflects *fold* order, not whole-file parse order, after incremental appends — only observable for no-timestamp records, which live in the preamble and fold first anyway.

## Host-wide discovery (`discover_all`)

**`discover_all()` is deliberately a DIFFERENT scan from `discover_paths(cwd)` for Claude, but the SAME scan for Codex — an asymmetry that is easy to get backwards.** Claude's `discover_paths` forward-encodes a cwd to ONE folder and lists it (cheap enough for the activity poll) where `discover_all` walks every folder in the projects cascade, so **routing `discover_paths` through `discover_all` would turn the poll's one-folder cost into a 100+-folder walk per tick, reproducing the daemon-CPU blowup through a refactor that looks like a DRY win.** Codex has no such cheap form to protect: its rollouts are date-partitioned and carry no cwd, so `discover_paths(cwd)` was ALREADY a full store walk plus a filter. **Two methods that both "find transcripts" can have opposite cost profiles.**

- **`git_branch` rides the same head-read record as `cwd` for both providers, so `discover_all` costs no extra I/O to add it.** Verified over 374 real Claude transcripts: `gitBranch` and `cwd` land on the identical JSONL line 374/374 times. Codex gets it from `session_meta.payload.git.branch` (151/168 rollouts on the reference host).
- **Measured store shape on the reference host:** 376 top-level Claude transcripts (374 yield a `cwd`; a bounded 200-line head read walks the whole store in ~0.07 s), 168 Codex rollouts (168/168, ~0.02 s). **373 real transcripts sit behind only 92 distinct cwds** — so any cwd→something resolution must memoize per distinct cwd or pay ~4× the real work.
- **A `SessionRef` with `cwd=None` is a real, honest output — never dropped.** The ~2 % of Claude transcripts whose head read never reveals a cwd still enumerate, rendered as "unknown location".
- **`discover_all` is a discovery *mechanism*; the scope contract that governs it lives in [grove.core](../CLAUDE.md).** Read that before extending — **the most likely "improvement" to an enumerator is a richer row, and richer here means a full parse, which the scope contract forbids.**

## Out-of-band discovery mechanics

**`discover_sessions(cwd, exclude_id)` finds sessions Grove did not mint.** It is a read-only fs glob, so it **always runs**: adapter-gated only (generic/shell ⇒ `[]`), **NOT** gated by `cfg.hooks.enabled`, which gates only sidecar install — **read-only discovery needs no opt-in.** The service calls it once per cwd in the workspace's `scan_cwds` union, because a session hand-started at the worktree ROOT of a nested project records its cwd there, not at `agent_cwd`, and the adapters exact-match the recorded cwd. Adoption *policy* lives in [grove.core](../CLAUDE.md)'s `sessions_for`.

- **`discover_sessions` returns ids NEWEST-FIRST by transcript mtime, not alphabetical.** A plain `sorted()` is an arbitrary, often-dead pick, and the `[:1]` rescue depends on newest-first to land the live session.
- **`_first_cwd` scans PAST the cwd-less preamble** (`mode` / `file-history-snapshot` / `summary` lines) to the first record carrying a `cwd`. Real transcripts open with two or three preamble lines; keying off line 0 alone returns `None` and rejects **every** real transcript. Test fixtures hid this by putting `cwd` on line 0 — **verify discovery against a real on-host transcript, never a hand-built one.**
- **Config-dir cascade:** `CLAUDE_CONFIG_DIR` (CSV) → `~/.config/claude` → `~/.claude`. `projects_dirs` always also appends the latter two.

**Two operational corollaries** that bite even with the code correct:

1. **A custom-named Claude agent MUST declare `kind: "claude_code"`.** Merge-by-name does not inherit the built-in `claude`'s kind, so a custom-named entry defaults to `generic`, mints no id, and relies entirely on discovery.
2. **Pin or neutralize a profile with `agents[].env` / `agents[].env_unset`, never the daemon's own env.** A pane inherits the tmux server env (which inherited the daemon's), so a `CLAUDE_CONFIG_DIR` on the *daemon* leaks daemon → server → pane → agent, overriding the selected profile and skewing the scan. The launch boundary clears whatever `env_unset` names and exports whatever `env` names (unset-before-export) — **no var name is hard-coded in Grove.** A *legitimate* pin has the mirror-image problem (the reader's ambient env differs from the agent's launch env by design), closed on the write side by `TranscriptContext.for_launch` (see [grove.core](../CLAUDE.md)).

## Native instrumentation & control — provider research facts

> Distilled from official docs and vendor source. These anchor the instrumentation tiers — re-verify only on a major tool release, never re-research from scratch.

- **Claude's JSONL transcript is an UNVERSIONED internal format** (Anthropic: direct parsers "can break on any release"). Keep file-parsing best-effort and pinned to real on-host JSONL; the durable feeds are stream-json, hooks and OTel.
- **Native keep-alive + input:** `claude -p --input-format stream-json --output-format stream-json --verbose` with stdin held open accepts NDJSON user turns across turns — the native replacement for pane typing. `assistant` events carry per-turn `usage`; the terminal `result` carries `total_cost_usd`/`duration_ms`/per-model breakdown; `--include-partial-messages` adds live token deltas. **TTFT is NOT in stream-json** — only the beta OTel `llm_request` span carries `ttft_ms` (`CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1` + `OTEL_TRACES_EXPORTER=otlp`). Nothing is written to the transcript mid-generation either, so live counters come only from partial-message deltas, OTel metrics, or the wire.
- **Sub-agents — two linkage layers, do not conflate.** On DISK: separate files at `{sessionId}/subagents/agent-{agentId}.jsonl` (`isSidechain: true`), threaded by `parentUuid` with `sourceToolAssistantUUID` pointing at the spawning `tool_use` assistant message. On the live STREAM: every sub-agent message carries `parent_tool_use_id` (nesting ~5 deep). The sidecar `{transcript}.meta.json` is fire-and-forget and may be missing — degrade. Agent name/color ride the MAIN transcript as `agent-name`/`agent-color` entries; lifecycle edges come via `SubagentStart`/`SubagentStop` hooks. **This lineage is all that exists — reconstruct from it or not at all.**
- **The sidecar carries THREE spawn facts and only one of them is universal — census over 2590 real sidecars, 2026-08-10.** `spawnDepth` is on **every** file; `toolUseId` on 741; `parentAgentId` on 309; and **270 threads at depth ≥ 1 carry neither correlation field**, so ~26% of non-root sub-agents can never be attached to a spawn point by any means. Nesting is real and not rare: 41 threads at depth 2 and 6 at depth 3, all of which do carry `toolUseId`. **The split is by `taskKind`:** a `task` sub-agent is spawned by a Task tool call and has `toolUseId`; an `in_process_teammate` (1579 files, 61%) is spawned without one and has **no `toolUseId` at all**, so a correlator that reads only that field is blind to the majority of sub-agents. Prefer the reported `spawnDepth` over any depth you compute by walking spawn edges — the two legitimately disagree, since a teammate is `spawnDepth: 0` despite being reachable from the main thread, and recomputing a number the provider publishes is second-guessing it.
- **Permission/question interception, headless-answerable:** precedence is hooks → deny → ask → permission mode → allow → SDK `can_use_tool`. `--permission-prompt-tool <mcp_tool>` answers prompts with allow/deny JSON; `PreToolUse` can emit `permissionDecision` AND rewrite tool input (`hookSpecificOutput.updatedInput`); "agent needs input" is the `Notification` hook (`idle_prompt`/`agent_needs_input`/`permission_prompt` matchers). Hooks support **`"type":"http"` with a `headers` map** (Claude POSTs the event JSON and reads the decision JSON from the 2xx body; default timeout 600 s, so a blocking `Stop` hook is a potential long-poll delivery slot). Model-facing injection rides `hookSpecificOutput.additionalContext` on `UserPromptSubmit`/`PreToolUse`/`PostToolUse` — **`Stop` + `decision:"block"` + additionalContext is documented to continue the turn but MUST be empirically verified on the installed build before anything relies on it**, and `reason` goes to the USER, never the model. All hook output caps at 10k chars.
- **Mid-session controls:** the stream-json `system/init` event enumerates `slash_commands`/`tools`/`mcp_servers`/`plugins`/`model`; `/skill-name` and `/config key=value` work headless; a mid-run model switch needs the SDK control channel (source-verified `control_request` subtypes: `initialize`, `interrupt`, `set_permission_mode`, `set_model`, `rewind_files`, `mcp_reconnect`, `mcp_toggle`; CLI→SDK callbacks `can_use_tool`, `hook_callback`, `mcp_message`). **Auth is the only irreducible interactive need.** The Agent SDK subprocesses the same `claude` binary, so it is capability-identical to stream-json; SDK framing is API-key auth, while driving the CLI inherits the user's login.
- **Codex analogs (the seam holds):** headless is `codex exec --json` (thread→turn→item NDJSON; `turn.completed` carries usage including cached/reasoning; **event names drift across versions — pin against the installed CLI**). Sub-agents are real forked threads. Network-off is `--sandbox workspace-write` + `sandbox_workspace_write.network_access=false`; **Claude has NO sandbox flag**, so tools-off is `--disallowedTools WebFetch,WebSearch` — the asymmetry `offline_decoration` exists for. Live steering is app-server JSON-RPC `turn/steer`/`turn/interrupt`, with no bare-CLI Claude equivalent. **Codex ships native OTel** (per-turn tokens, `ttft.duration_ms`, OTLP http/grpc).
- **Wire-truth seam:** Claude honors `ANTHROPIC_BASE_URL`; Codex has `model_providers.<name>.base_url` + `env_key` — both reachable through the existing `agents[].env` launch boundary, so a passthrough proxy needs zero tool-side cooperation and sees true TTFT and live token flow.
- **LangFuse ingestion:** OTLP at `{host}/api/public/otel/v1/traces`, HTTP Basic `b64(public:secret)`. **Build historical spans with the raw opentelemetry-sdk** — the LangFuse Python SDK's live start API cannot assign past timestamps. LangFuse adopts W3C ids but v4 does **not** promise deduplication: export complete immutable turn traces, reconcile observation ids through `GET /api/public/traces/{trace_id}`, and checkpoint outside the rebuildable usage cache. Never re-emit a growing session root, context span, or late-closing tool span. Push `langfuse.observation.usage_details`/`cost_details` explicitly; TTFT stays absent when the transcript did not measure it; observation types `agent`/`tool`/`generation` map the fleet tree 1:1.
- **Four LangFuse ingestion behaviours, measured against the live instance 2026-08-10 by exporting a synthetic trace and reading it back.** (1) `langfuse.observation.type: agent` **is accepted** and lands as `type=AGENT`, despite docs listing only `span`/`generation`/`event` — the docs are behind the product. (2) **Unmapped attributes are retained**, under `metadata.attributes`, so the whole `grove.*` vocabulary stays filterable rather than being dropped; only attributes LangFuse maps to a first-class field (input/output/model/usage) are consumed out of it. (3) **Values are stringified on ingest** — an int `2` reads back `"2"`, so anything a dashboard aggregates must expect a string. (4) **TOOL observations are renamed on display** to `gen_ai.tool.name`, so a span sent as `execute_tool Read` renders as `Read` while `invoke_agent`/`chat` names survive intact — the wire stays convention-correct and the UI simply differs. Do not "fix" the span name to match the UI.
- **Historical turn completion is proved from the normalized spine, not a provider status side channel.** Codex's `event_msg/task_complete` is intentionally excluded by the dual-record rule; if the spine still tails on a tool result, withhold that turn until a final assistant message or the next human prompt closes it. Treating the event as permission to export would either add a second parser or freeze a trace before the post-tool reply arrives.
- **There is NO uninvited input channel into a RUNNING interactive TUI session** — no IPC or control socket, no queue file or FIFO, no local attach; interactive input comes exclusively from pty raw-mode stdin. **Native channels exist only when composed at LAUNCH:** the stdio MCP channel subprocess (`--channels`), stream-json stdin / the SDK control protocol (headless), and hook injection. **Send-keys Enter-swallow root cause:** bracketed paste — a `\r` inside the paste-accumulation window is a literal newline, never a submit; only a lone `return` keypress submits, so send text and Enter as separate writes with the Enter delayed past the window. **The bridge is session mobility:** conversation state lives in the session file, not the process, so `claude -p --resume <id> --input-format stream-json` continues the SAME session on a native channel, and vice versa.
- **The portable control plane is the native session protocol** (stream-json over stdio, hook events, OTel/proxy spans); only the *transport* varies — local pipe, `docker exec -i` stdio, a shim over HTTP. **tmux is a process plane and a presentation mode, never the control plane.**

### Claude Code's OTel surface — which SIGNAL carries what (2.1.226)

The one distinction that governs every design here: **the three signals carry different things, and choosing a backend chooses what you can see.** A traces-only consumer (LangFuse) sees structure and timing and *neither* content nor cost, and that is a property of the signal split rather than a misconfiguration to hunt.

- **Spans** carry structure, timing and tokens — and **no content and no cost**, on any span, under any configuration. Verified against a live trace: `input`/`output` arrive null.
- **Log records** carry the content: `claude_code.user_prompt.prompt`, `claude_code.assistant_response.response`, and `api_request_body`/`api_response_body` (the whole Messages API request and response). Gated by `OTEL_LOG_USER_PROMPTS`, **`OTEL_LOG_ASSISTANT_RESPONSES`** (falls back to the prompts flag when unset), `OTEL_LOG_TOOL_DETAILS`, `OTEL_LOG_TOOL_CONTENT`, **`OTEL_LOG_RAW_API_BODIES`** — all default OFF.
- **Cost is real and native but lives only on metrics and logs**: the `claude_code.cost.usage` metric (unit USD) and `cost_usd`/`cost_usd_micros` on the `claude_code.api_request` log event. It is itself a *client-side estimate* from a bundled price table that degrades silently on an unrecognised model id, which is why Grove's own estimate is the more honest number rather than a duplicate of it.
- **Transcripts carry NO cost at all** — measured across 26k assistant messages, zero keys matching cost/price/billing. Anything cost-shaped must come from OTel or be computed.
- **Spans need TWO switches.** `OTEL_TRACES_EXPORTER=otlp` alone emits nothing; `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1` is also required, and the failure is a silent empty stream indistinguishable from a dead endpoint. Metrics and logs are stable; **everything span-shaped is beta** and may move between releases.
- **`OTEL_RESOURCE_ATTRIBUTES` is honoured** (verified end to end: Grove's `grove.*` identity reaches a real trace). On *metrics* it is additionally gated by `OTEL_METRICS_INCLUDE_RESOURCE_ATTRIBUTES`, so an operator can silently strip it from one signal while the others keep it.
- **`TRACEPARENT`/`TRACESTATE` are read INBOUND and adopted as parent context.** So "the agent mints its own trace id" holds only while nothing hands it a parent — a true parent-child tree is available, at the cost of Grove owning a span for the workspace's whole lifetime.
- **`settings.json` offers no telemetry block and no session-metadata key.** Its `env` map is the only way it configures telemetry, so environment variables remain the mechanism whatever the surface looks like. The one exception is **`otelHeadersHelper`**, a command whose JSON output becomes the export headers on a refresh timer — the native seam for a rotating credential. Note that *managed* settings can pin an endpoint and drop a lower-scope override entirely.
- **`usage` facets beyond the four token counts:** `service_tier` (`standard`/`priority`/`batch`) is a **billing-rate multiplier**, so any cost estimate that ignores it is wrong for non-standard traffic. `server_tool_use` counts server-side web search/fetch requests, billed *on top of* tokens. `iterations` is a per-round decomposition of the totals already present — **summing it double-counts**; same for `output_tokens_details.thinking_tokens`, which is inside `output_tokens`. `inference_geo` is almost always `not_available` on this host, so do not build a dimension on it. `speed` uses a different vocabulary in the transcript (`standard`) than on OTel (`normal`/`fast`) — normalize before comparing.
- **`Stop` is REWRITTEN to `SubagentStop` inside a subagent**, which is why a `Stop`-only hook sees main sessions exclusively. It is not an omission to work around; register both. Subagent hook payloads carry `agent_id`/`agent_type` matching the same attributes on `claude_code.subagent.spawn`, so hook-side and OTel-side subagent records join.
- **Cross-surface join keys already exist — do not invent correlation.** `message.uuid` on an OTel record equals the transcript entry's `uuid`; `prompt.id` equals the hook payload's `prompt_id`.

### Codex's OTel surface — the split is the opposite way round (0.147.0)

**Codex reads standard OTel env vars PARTIALLY, and the halves divide exactly against intuition: the ENABLEMENT vars are dead, the IDENTITY vars work.** So a launcher cannot switch Codex's export on from the environment, but once config has switched it on the launcher stamps per-workspace identity from the environment with no file rewrite — verified live, with `grove.*` attributes arriving on real traces.

- **Dead**: `OTEL_TRACES_EXPORTER` / `_LOGS_` / `_METRICS_` (never read anywhere), and `OTEL_EXPORTER_OTLP_ENDPOINT` / `_TRACES_ENDPOINT` (config's endpoint is applied programmatically and short-circuits the env lookup). Setting them produces configuration that reads as wired and is inert.
- **Live**: `OTEL_RESOURCE_ATTRIBUTES` (the SDK's env detector seeds the resource before Codex overwrites only `service.name`/`service.version`/`env`), `TRACEPARENT`/`TRACESTATE` (explicitly read and adopted as parent context), `OTEL_EXPORTER_OTLP_HEADERS` and its signal variants (merged over config headers, env winning a key collision — which is what keeps credentials out of the committed TOML).
- **`[otel]` has exactly seven keys and is `deny_unknown_fields`**, so a typo is a hard config error rather than a silent no-op — a rare and welcome property. Three traps: `endpoint` must be the **full signal URL** (nothing is appended; a bare host:port posts to the root and drops everything), `protocol` is **required** for `otlp-http`, and the exporter variant names are **kebab-case** (`otlp-http`) while the seven top-level keys are snake_case.
- **`metrics_exporter` defaults to `statsig`**, shipping product metrics to the model vendor with a hardcoded client key, in **release builds only** — so a debug build shows nothing and invites the wrong conclusion. `metrics_exporter = "none"` disables it; `[analytics] enabled = false` is the stronger switch, forcing it off regardless. The same exporter resolution applies to the log and trace exporters, so `"statsig"` there would ship spans to the vendor.
- **`otel.exporter` is the LOGS exporter and `otel.trace_exporter` is spans** — the names do not say so. They are fully independent and may point at different endpoints.
- **No Codex trace can carry content, structurally.** Content fields live syntactically inside a log-only macro whose target is filtered out of the trace pipeline; `log_user_prompt` only swaps the text for `[REDACTED]`. No flag promotes content onto a span. Tokens *are* on traces, in standard `gen_ai.usage.*` naming plus `codex.usage.reasoning_output_tokens`.
- **No cost in currency anywhere** — not telemetry, not `TokenUsage`, not the rollout files. Unlike Claude Code, which at least has it on metrics and logs.
- **There is no `otel.service_name`.** `service.name` is the originator (`codex_exec` for `codex exec`), and the only override is a dual-purpose internal variable that also rewrites the HTTP originator header — use `OTEL_RESOURCE_ATTRIBUTES` for identity instead.

## The agentic-loop spine (`model.py`) — one parse, many projections

**`AgentMessage`/`ContentBlock`/`TokenUsage` (frozen slotted dataclasses) are the lineage-preserving event layer the render digest is DERIVED from — never a second parser.** `read_turns`/`transcript_digest` are projections of `messages()`, pinned byte-identical by the adapter suites; `parse_activity` deliberately stays on its record path, because it needs the `stop_reason` + fleet + token-folding the provider-neutral spine omits. Public seam: `read_messages(cwd, session_id)` — **promoted onto the `AgentAdapter` Protocol** once the trace forwarder became the polymorphic consumer (it resolves an adapter by kind and replays whatever spine it returns). **Generic/mewbo answer `()`, and that is an ANSWER, not a debt:** a bare shell records no conversation and a remote session's history is the backend's timeline, so "no content here to replay" is exactly what a content consumer must act on — synthesizing a spine for mewbo would be a second parser over a shape it does not fit. The promotion also deleted the consumer's runtime `hasattr` probe, which is the tell that a capability check was standing in for a missing contract.

- **Two `AgentMessage` fields do not mean what their names suggest:** a message-level `tool_use_id` is set ONLY on a `notification` (naming the tool it reports on), and `parent_tool_use_id` is the transcript's `sourceToolAssistantUUID`. The `tool` role is a result-carrier, not a call.
- **`TokenUsage` field = `None` means the provider did not report it; a reported 0 is a real 0** — never fabricate. Claude sets input/output/cache_creation/cache_read (reasoning always `None`); **Codex reports usage on its OWN `token_count` line, written after the request it reports on, so `messages()` stamps `last_token_usage` onto the newest assistant message before it and CONSUMES the claim** — a second report can never re-stamp an already-attributed message, and a request with no report leaves `usage` unset rather than zeroed. Codex messages have no `message_id`.
- **Codex can normalize several opaque reasoning records to byte-identical `AgentMessage`s at the same millisecond.** A content hash is therefore not always a unique observation id. The trace projector keeps the first deterministic id unchanged and adds a per-trace collision suffix only when that id is already used; this preserves every existing non-colliding id and avoids exposing encrypted reasoning merely to distinguish records.
- **The spine omits raw `stop_reason`, so a sub-agent's status is read off its tail content-block SHAPE** (an assistant reply ending in a `tool_use` block = working) — the analogue of the main-thread `stop_reason` rule. `fleet_activity` covers the in-session sidechain fleet only; a background CLI session is a separate top-level session, and identity degrades to a truncated first-task prompt rather than being fabricated.
- **`latest_todo(cwd, session_id)` IS on the `AgentAdapter` Protocol**, because the manager and daemon call it polymorphically. Claude answers it from the Task system's own on-disk board where one exists (see the Task section above) and projects the spine only as the fallback. **The load-bearing invariant a "last N turns" tail read gets wrong:** the split-call Task system correlates a late `TaskUpdate` against a `TaskCreate` id that can sit arbitrarily far back in the SAME session, so **the fold must run from session start, never a bounded tail window** — a fixture pinning exactly that (a `TaskUpdate` ~40 turns after its `TaskCreate`) is the regression test. mewbo (no message spine) projects over its own turn fold. The manager seam `WorkspaceManager.latest_todo(workspace_id)` **RAISES `AgentSessionNotFound` for a sessionless workspace** rather than degrading to empty, because a caller needs to tell "no session" (404) from "session exists, no todo tool called yet" (`None` → an empty view).
- **`offline_decoration()` — tools-off:** claude_code → `--disallowedTools WebFetch,WebSearch`; codex → sandbox network-off flags; generic/mewbo → `[]`. Gated on `AgentSpec.tools_offline` and forwarded verbatim — **Grove never decides which tools are "network".**
- **`telemetry_env()` — the tool's OWN exporter switch**, distinct from `TelemetryConfig.derive_env`'s generic OTLP endpoint/headers: claude_code → `{CLAUDE_CODE_ENABLE_TELEMETRY:"1", OTEL_METRICS_EXPORTER:"otlp", OTEL_LOGS_EXPORTER:"otlp"}` (spans and TTFT stay a beta opt-in via the agent's own `env`); **Codex → `{}` permanently, a decision rather than a gap**; generic/mewbo → `{}`. Injected **only when the OTLP endpoint actually resolved**, so a tool never enables an exporter pointed at nowhere.
- **Codex's native OTel can never carry content to LangFuse, so Grove must not write its TOML.** Codex emits every event to two targets: prompts, tool arguments and outputs go to the OTLP **logs** stream, traces get only `*_length` / counts — and LangFuse ingests traces with no logs endpoint, so a Codex trace configured every correct way is structurally, permanently BLANK. That is the whole argument for the transcript replay being Codex's content path. Three further reasons not to write `config.toml [otel]` from the adapter: it is the user's own GLOBAL Codex config (a launch-time write changes runs Grove never started, breaking "unaffected without Grove"); adapters are read-only over the filesystem by contract, with `onboarding.py` the one writer and only when a human asks; and `metrics_exporter` defaults to **`statsig`**, so enabling the block ships product metrics to OpenAI unless the same write also pins it off. A per-launch `-c otel.*` override dodges only the global-config objection.

## Session controls + the input-affordance matrix

**`session_controls(cwd, session_id) -> SessionControls` enumerates a session's controls by the CHEAPEST tier — a filesystem scan, no running session needed.** Claude: `.claude/commands/**.md` (namespaced `dir:cmd`), `.claude/skills/*/SKILL.md`, MCP from `.mcp.json` + `~/.claude.json`, model from `resolve_models`. Codex: `$CODEX_HOME/prompts/*.md` and `config.toml [mcp_servers.*]` via `tomllib`. generic/mewbo → empty (honest). `invoke_control`/`switch_model` deliver a prompt-composed `/name` or `/model <id>` through the EXISTING steering path — no new dispatch, `CapabilityUnavailable` for a kind that cannot take one.

**`ClaudeCodeAdapter.project_mcp_servers(cwd)` is the same scan's bare-names projection, deliberately NOT on the `AgentAdapter` Protocol.** The container trust stamp (engine side in [grove.core](../CLAUDE.md)) pre-approves exactly the project-scoped servers it returns — **so the two lists must come from one parser or they drift into a container that trusts the folder and still blocks on an unapproved server.** It answers PROJECT scope only: the user-scoped registry names host commands no container can run.

**Affordance matrix — verify per tool release.** **The Codex arm of `session_controls` does not enumerate skills yet** (only `prompts/*.md`) though both tools ship the same `SKILL.md` + YAML-frontmatter shape — if it grows one, reuse `_CodexHome.base_dir()` the way `onboarding.py` does. MCP servers are enumerable on both but toggling is SDK-control-only (Claude) or launch-only (Codex), so Grove surfaces them **read-only**, as it does permission mode (Codex approvals are interactive-only). Model switch is best-effort: Claude `/model <id>` is pane-verified, Codex `/model` may open a picker (inline-arg unverified). Auth (`/login`), the `/resume` picker and Ctrl+O are interactive-only presentation affordances — **not Grove controls.**

## Onboarding external tools (`onboarding.py`)

**`onboarding.py` is a side-effect module like `git.py`/`tmux.py` — it writes to Claude/Codex's OWN config and never reads Grove workspace state.** Grove never reimplements a tool's config format: `grove mcp install` shells out to the tool's *own* `mcp add` rather than hand-writing `.mcp.json`/`config.toml`, and path resolution reuses `_ClaudeHome.config_dirs()` / `_CodexHome.base_dir()` so the config-dir cascade keeps one definition.

- **User-scope skill install is gated on "has this tool been used on this host" (`user_skills_dir() -> Path | None`), never unconditional.** Claude: the first EXISTING base in the cascade, `None` if none exist — **never falls back to creating `~/.claude` from scratch, since that would be lying about detection.** Codex: `$CODEX_HOME/skills` iff the base dir already exists. Project scope is unconditional, because the user explicitly chose that repo.
- **`claude mcp add` supports `--scope user|project|local` (Grove exposes the first two); `codex mcp add` has NO project-scope flag at all** — it always writes `$CODEX_HOME/config.toml`. So `CodexTool.mcp_add_argv(target="project", …)` returns `None`, surfaced as an `"unsupported"` outcome rather than a hand-rolled TOML write: Grove never reimplements a tool's config format.
- **`register_mcp` shells out via `subprocess.run` referenced through `grove.core.agents.onboarding.subprocess` — a patch-target trap.** `subprocess` is one shared module object process-wide, so patching `onboarding.subprocess.run` mutates the SAME attribute every other module's `import subprocess` resolves to — including `grove.core.git`'s — for the rest of that test. A CLI-level test that also calls `detect_root` needs its fake to pass non-`claude`/`codex` argvs through to the real `subprocess.run`.
- **`cli_onboarding.py` calls `onboarding.install_skill`/`onboarding.register_mcp` via a module-qualified import, not a name import**, so a whole-function `monkeypatch.setattr` on the module reaches the CLI layer — **a name import copies the reference at import time.**
- **The bundled skills ship as package data under `src/grove/skills/<name>/SKILL.md`, read via `importlib.resources`** — no `__init__.py` under `skills/` (it is data, not an importable subpackage). **Never duplicate one into this repo's own `.claude/skills/`** — the packaged copy IS the single source of truth, installed into *other* projects, not dogfooded into Grove's own tree.

## Session lessons

- **`AgentActivity.started_at` is the session's BIRTH (first-record timestamp), populated at zero extra parse cost** (both filesystem parsers already computed it for `SessionSummary.created_at`). It is the seam `WorkspaceState.adopts_session` (see [grove.core](../CLAUDE.md)) compares against a workspace's own `created_at` to decide whether a *discovered* session belongs to it, rather than merely being the newest-touched file in the cwd. Mewbo and generic leave it `None` — a session that never discovery-adopts by construction.
- **Every parsing fact that was hard to get right was hard because the real transcript disagrees with the obvious mental model** (preamble-before-cwd, user-lines-aren't-turns, one-line-per-block). Pin against on-host JSONL, never a fixture.
