# 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. **The fix for that gap was to widen `team_name` to the siblings recorded under the same `cwd`, and it has been REMOVED, because a cwd is not an identity** — the same rule `phase.py` already states one layer up, arriving here through a different door. Every ROOT-placement workspace in a repo scans the shared repo root, as do hand-started agents, so the scan returned the newest sibling that named ANY team and served *its* board as this session's checklist: **6 of 8 live workspaces on the reference host were reading a stranger's list, two of them the same 27-item board, and a workspace whose agent had never called a Task tool showed 15 completed items belonging to another project entirely.** `latest_todo` passes `team_name(paths)` — this session's own sidecars — and nothing else. **The two failure modes are not symmetrical, which is what decides it:** the rotation gap degrades to the transcript fold, which over-counts a session's OWN work (a deletion emits no tool call), while the widening mis-attributed somebody else's — a wrong magnitude against a wrong owner. **Do not re-open this with another path-shaped heuristic**, and note there is no identity signal on disk to re-open it with: over 60 real `~/.claude/teams/<board>/config.json` files only **5** name a `leadSessionId` that is a transcript on this host, and **19 of 34** non-empty boards match no session id at all — so `session-<first 8>` is a rule the harness only sometimes follows, and a board Grove cannot name is a board it must not guess at.
- **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 **scoped to ONE FILE** (a new line under a seen key *in the same file* 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.
  - **The file scoping is the load-bearing half, and a `fork` sub-agent is what proves it.** A fork's own file opens with a `fork-context-ref` record and then RE-RECORDS the parent's spawning message — same `message.id`, a **fresh `uuid`**, `isSidechain: true` — so it slips the line-identity guard (which already means "drop a cross-file replay") and lands on the merge, handing the parent's record the same `tool_use` block **twice**. The duplicate `tool_use_id` reaches the wire and throws assistant-ui's keyed resource registry, which takes the entire transcript surface down: a workspace page that renders for a second and then dies. Scope by SOURCE FILE, never by sniffing `agentId`/`isSidechain` — "one logical API response is written to exactly one file" is structural, where a payload field is provider semantics. The replay then survives as its own sidechain record and the projections that already filter sidechain content drop it for free, so no discard rule is needed. **Only `fork` does this**; an `in_process_teammate` writes no inherited head record, which is why 12 teammates in one session were harmless and the single fork was fatal.
  - **The crash was the loud half; the silent half was cost accounting, and it is the reason to care about a duplicated block generally.** The absorbed copy rode the PARENT's record — `is_sidechain: False` — and both spine consumers partition on exactly that (`usage/projector.py` separates sub-agent usage from the root, `trace.py` builds `main_thread` from the non-sidechain messages). So the root thread credited one tool call twice: measured on the real transcript, root `tool_use` blocks for that id went **2 → 1** (458 → 457 overall) across the fix. Every fork inflated the session's tool count, its duration pairing and its share of any per-tool cost ranking, permanently, in a cache rebuilt only on a schema bump — and the numbers stayed entirely plausible. **A duplicated content block is a data-integrity bug in the audit before it is a render bug, and only the browser happening to THROW on a duplicate key surfaced it at all.**
  - **`requestId` is GONE from modern transcripts — 0 of 755 assistant lines (CC 2.1.233, 2026-08-15)** — so `dedup_key` degrades to `message.id` alone and the file scope is the only disambiguator left. Any future fix here that leans on `requestId` is leaning on a field that is no longer written.
- **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, because there is NO CLI enumeration:** models are exposed only through the interactive `/model` picker, and `--list-models` lists 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. **But the TUPLE goes stale the day the vendor adds a TIER, and that failure is silent in both directions** — `fable` shipped and Grove offered `sonnet`/`opus`/`haiku` for releases afterwards, because a short list of *valid* aliases is indistinguishable from a complete one: every id in it still launched, every test still passed, and the only symptom was a picker missing a row nobody could prove should be there. **`claude --help`'s own `--model` prose is the census** (it names the current aliases outright, which is the one enumeration this tool does publish); re-read it on a major release. Never write the member list into a second file — the docstring on `_MODEL_ALIASES` and this line are already two, and the config-reference prose generated from `AgentSpec.models` is a third that publishes to the docs site.
- **`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 — display only, never a restriction on create.

**`MODEL_CATALOG_CAP = 10` applies to DISCOVERY ONLY, and the asymmetry is load-bearing.** Discovery answers with whatever a tool publishes and nobody chose it, so trimming is a kindness; a list in `AgentSpec.models` was typed by a person, in order, to say *these are the ones I use*. Capping that discards the back half of an explicit answer with nothing said — the truncation reads as a complete list, so the operator concludes Grove ignores its own config, which is the exact failure mode the `_MODEL_ALIASES` note one paragraph up describes from the other direction. Measured on the reference host: a gateway published **22** models under one prefix, and the cap was silently serving 10 of them. 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 used to be host-global as well** (one identical text, so only the variable's *presence* varied); it is now per workspace, because `brief.instructions` cascades per repository and the naming nudge is per workspace, and a variable can say yes or no but not *which text*.
- **`compose()` is pure and the caller supplies the facts, which is what keeps the two delivery roads honest.** `AgentBrief` learns nothing about workspaces, config or the cascade — it takes `appended` and `unnamed` and orders the paragraphs. Grove's own text leads, always: an operator's addition is read *against* what a Grove workspace is, not instead of it. Blank `appended` is byte-identical to no append, the same absence-renders-as-nothing rule the clients follow.
- **The todo paragraph in `TEXT` is a deliberate exception to "point, never restate", and the exception is about DELIVERY, not importance.** The skill carries the rules and is the right home for them — but a skill is loaded on demand, and the observed failure was agents running whole tasks with no list at all, which means they never loaded it. **A pointer cannot fix a miss whose cause is that the pointer was not followed.** Two sentences in the brief; the rules stay in the skill. Any future addition here has to clear the same bar: name the delivery failure that makes pointing insufficient, or put it in the skill.
- **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.** `add(raw, source)` passes the file each line came from precisely to keep that split: the cache supplies a structural fact it already holds, and only the folder decides whether it means anything (Claude scopes its merge key by it; Codex ignores it, because an appender has no collision to resolve).
- **`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.
