git:20260908.bbb0fcb to git:20260917.fdcbb8c

3 added, 1 removed. Audit A to A.

# 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.
## Queue delivery identity
A cross-session enqueue may carry `hop-chain` on its opening envelope while its removal and delivered attachment omit it. Pending-queue matching ignores only that transport attribute, preserving sender fields and body bytes. A named remove followed by its delivery witness consumes one queued occurrence, not two identical messages. Never infer undelivered work from raw string inequality before checking the protocol's transport metadata.
## Profile read isolation
A pinned transcript profile is task-local identity, not a temporary process
setting. `transcript_scope` stores explicit roots in a ContextVar; manager scopes
never mutate `os.environ`. Filesystem adapters read the override first and search
only that profile, while unscoped discovery retains its ordinary root cascade.
This isolates nested and concurrent workspace reads and prevents equal session
ids in different profiles from being merged. Discovery scope and transcript
ownership are separate: the usage projector derives ownership from each actual
transcript path before deduplicating.
## 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 `plan_approval` (the plan is the prompt, and Grove supplies the dialog's own rows as options — see the plan bullet below). `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 no longer provider-shaped, and the keystroke grammar it used to need is DELETED.** Grove dismisses the on-screen widget with Escape and restates the whole batch as one Grove-fenced message down the ordinary steering path (`core/instructions.py`, engine side in [grove.core](../CLAUDE.md)). The two things that make that provider-neutral: every agent Grove can steer accepts Escape and a line of text, and an answer expressed as prose needs no knowledge of how a picker paints. What remains Claude-only is the *pending-question lookup* — `answer_question` still resolves the batch from the hook sidecar and gates on the minted `agent_session_id`, and **codex has neither**. That is the one thing a per-adapter write seam would have to fix, and it is a lookup, not a grammar. **Do not re-derive a picker grammar for any provider**; the reason the old one is gone is below.
### 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.
- **Mailbox metadata must survive normalization, not be recovered from digest prose.** `MailboxMessage` retains known peer envelope sender/recipient/subject/body before the human-readable notification summary strips them. `cross-session-message` joins teammate and queued peer envelopes as machine traffic, not a human turn. The full mailbox rides the fetched turn projection only, not the short activity digest. Task ids are identifiers, not invented agent names; absent endpoints remain absent.
- **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` only when both counts are measured and the subset is valid; a missing cache subset cannot establish fresh input. 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.
**`available_models` and `AgentActivity.model` ARE DIFFERENT NAMESPACES, and nothing in the types says so.** The catalog is the agent's `--model` vocabulary — Claude Code's tier aliases (`opus`, `sonnet`) or a deployment's gateway ids (`anthropic-opus-5[1m]`) — while `parse_activity(...).model` is read off the transcript and is the API model the provider **reported** (`claude-opus-5`, `gpt-5.6-sol`, `qwen3.6-flash`). Measured over ~10,900 real assistant messages on the reference host, **9** carried a name the catalog also contained. So `SessionControlsView.current_model` must never be equality-tested against a `models` entry: the webapp's model chips did exactly that, and no chip had ever rendered as selected on any workspace, gateway or stock — which reads as a dead control rather than as a mismatch. There is no honest mapping between the two (it is the provider's own aliasing), so a client states the reported model as its own fact instead of marking a chip.
**`switch_model` is DISPATCH, and an agent refusing the id is the agent's business — but "whose bug is it" is worth chasing all the way, because the answer moved twice.** Symptom: `/model anthropic-sonnet-5[1m]` answers `Unable to validate model: undefined is not an object (evaluating 'vn.usage.input_tokens')`, while `/model sonnet` and the interactive picker both work. It reproduces in a bare tmux pane with no Grove involved, which rules Grove out — and that is where the first investigation **stopped, and filed it as a Claude Code bug. That was wrong.**
Read from the 2.1.263 bundle, `validateModel(name, opts)` short-circuits on a tier alias, on a model already in `providerCache.validatedModels`, and on one present in `picker.options`; anything else costs a real request — `max_tokens: 1`, `querySource: "model_validation"`, `cache_control: ephemeral` — after which it reads `usage.input_tokens`. Its catch has typed arms for auth, network and `not_found_error`; `Unable to validate model:` is the **fallback** arm, so reaching it means the throw was **not an API error at all** — a plain TypeError while reading the response.
Replaying that exact request against the gateway names the cause outright:
| request | gateway responds |
|---|---|
| `"stream": false` explicitly | `Content-Type: application/json`, a message object with top-level `usage` |
| `stream` **omitted** | `Content-Type: text/event-stream`, SSE frames |
**The Anthropic API contracts `stream` as defaulting to FALSE; this LiteLLM gateway defaults it to TRUE.** The probe omits `stream`, so it gets SSE where it expects JSON, `usage` is `undefined`, and the TypeError surfaces as a message that names neither streaming nor the gateway. Everything else fits: ordinary turns work because Claude Code streams them explicitly, aliases and the picker work because they never reach the probe, and *every* full id fails because the inline argument is the only path that does. **The fix is the gateway honouring the default; there is no client-side workaround — opening the picker first was measured and does NOT populate the short-circuit for these ids.**
Two durable lessons. **An error string that quotes a minified expression is a TypeError wearing an error message — treat it as "a shape was wrong", not as "the operation was rejected", and go find which shape.** And **"it reproduces without our code" proves only that it is not ours; it does not identify whose it is** — three layers were candidates here and only replaying the exact request separated them.
Grove's position is unchanged regardless: the engine forwards the id verbatim (the provider boundary), and rewriting a chosen id into an alias would be correcting what the tool does. The SURFACE says a switch is *delivered* rather than confirmed and points at the terminal where the agent answers. **Re-probe per Claude Code release**, the same discipline the two-switch OTel rule above needs.
**`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.**
**Claude Code's native `sessions/<pid>.json` registry is corroborating poll evidence, never a replacement for either source.** It is pid-keyed and survives process exit, so readers must require `/proc/<pid>` liveness (and match `procStart` to Linux start ticks when supplied; a zombie is dead too) before accepting its native `busy`/`idle` status or `tmux` pane target. The scan follows `_ClaudeHome.config_dirs()` and memoizes parsed JSON on `(device, inode, size, mtime_ns)`, but liveness is checked each pass because the process can exit without rewriting its file. It has no `blocked`: native busy/idle refines only the tmux-activity dimension, while a hook `BLOCKED` override still wins and absence degrades exactly to the existing transcript/tmux blend.
**Hook registration is deliberately narrower than the vendor enum.** `PostToolUseFailure`, `PreCompact`/`PostCompact`, and `StopFailure` prove main-thread execution; `PermissionRequest` and `Elicitation` are native BLOCKED prompts; their resolution counterparts restore WORKING. `SubagentStart`/`SubagentStop` remain registration-only (`state_for` must return `None`), because their events refresh fleet detail but cannot move the main thread's axis. Do not register configuration/file/worktree/instruction events: they are setup or filesystem noise on the fleet's hottest process path, not evidence of an interactive turn.
- `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 BRIEF'S CENTRAL CLAIM WAS UNFALSIFIABLE, WHICH IS WHY IT WAS DISBELIEVED.** "You are the coding agent in a Grove workspace" is checkable from a worktree under `.worktrees/` and checkable from nothing at all under ROOT placement — where the agent stands in an ordinary repository checkout, on an ordinary branch, with no artifact in sight — so the reasonable reading there is that the note is boilerplate about somebody else. An agent that reaches that conclusion never loads the skill, and then reports nothing, which reads as an agent ignoring instructions rather than one correctly declining to trust an unsupported claim. The engine now wraps the brief with the workspace's own specification (`WorkspaceManager._brief_facts` → `GroveInstruction.workspace`), and the copy tells the agent to READ the placement line rather than infer placement from where it is standing. **A durable instruction that an agent cannot corroborate is one it is right to discount; give it the evidence in the same breath.**
- **The brief now says two things it used to only imply, and both are there because a POINTER cannot fix a miss whose cause is that the pointer was not followed.** The todo paragraph was the first (documented above). The second is the standing instruction to replace a title or description that has stopped describing the work — which is different from `NAMING_TEXT`, and the split is what each half can know: the engine can see an EMPTY description and says so loudly, while "this name no longer fits" is a judgement only the agent working the task can make, so it is stated once for every workspace. **Any further addition here has to clear the same bar: name the delivery failure that makes pointing insufficient.**
- **A word budget, not a word limit.** The text is spent on the first turn of every session on the host, so the test asserts a ceiling and the docstring says which paragraphs earn their place. The facts underneath cost nothing extra — every one is already on the persisted record.
- **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
**Question presence is not resolution.** Native Claude writes an unanswered `AskUserQuestion` into its transcript immediately; interactive flush timing can differ. Capture comes from the hook or owned protocol, and a matching `tool_result` is the transcript evidence of answer/cancellation. Subagent activity advancing freshness must never dismiss the parent's standing question.
- **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: a matching `tool_result` in `read_messages`, including empty/error outcomes, resolves the capture through `tool_outcomes`. A question entry alone resolves nothing. 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.
- **A PLAN APPROVAL IS THE ONE ANSWER THAT IS NOT A PAYLOAD, AND IT IS THE ONE EXCEPTION TO THE RULE BELOW.** `ExitPlanMode` normalizes to `kind="plan_approval"` carrying the dialog's three real rows (`PLAN_APPROVAL_OPTIONS`), and `AgentQuestion.selects_a_mode` is the single discriminator every layer reads. Answering it sends `Down`×index + `Enter` (`WorkspaceManager._answer_by_selection`), never Escape and never text. **Measured 2026-09-13 on Claude Code 2.1.270, and every leg was verified by execution:**
- **Escape is this dialog's REJECT.** It records `User rejected Claude's plan` / `toolUseResult: "User rejected tool use"`. So the dismiss-and-restate path — correct for every other question — *actively rejected the plan it claimed to approve*, then delivered the prose as a brand-new plan-mode task, while the daemon answered 204. **The failure was invisible from every artifact except the transcript.**
- **No hook can approve a plan, though the documentation says one should.** A `PreToolUse` **or** `PermissionRequest` hook returning `allow` fires with the right payload, validates, and is then IGNORED — the dialog stays up and the session stays in plan mode. `deny` on either works and suppresses the dialog. The asymmetry is explained by the bundle: the mode switch runs *inside the tool body* (`O4({from:"plan", to:prePlanMode, trigger:"exit_plan_mode"})`), so its destination is the row, and a permission verdict has no way to name one. **This is the most expensive shape of negative result — the event catalog, the matcher, the payload and the JSON all agree with the wrong answer, and only the file-on-disk check disproves it. Do not re-open it from the docs.**
- **Why this is not the deleted grammar coming back.** That grammar mapped *N answers onto N questions* across a tab strip by reading what was painted; its worst failure was answering the wrong questions while reporting success. This is one dialog, one fixed list, no batch, and **position only** — Grove never reads the pane to find a row. Labels are Grove's own words precisely because rendered text is not a contract: option 1 read `Yes, and switch to BYPASS PERMISSIONS…` on one build and `Yes, and use auto mode` on 2.1.270, one version apart. A relabelled row still selects correctly; only Grove's *description* of it can age.
- **The residual cost is stated rather than hidden:** losing the terminal race lands the keys on whatever replaced the dialog. That is inherent to driving a widget, which is why the path is confined to the one question with no other channel. `plan_mismatch` refuses anything but exactly one index, so "no row" is a 422 instead of a bare Enter approving whatever the cursor was over.
- **`tool_input` also carries `planFilePath`** (the plan is written to `~/.claude/plans/<slug>.md` regardless), and an approval's `tool_result` begins `User has approved your plan.` — a clean resolution signal that needs no screen reading.
- **ANSWERING NO LONGER DRIVES THE WIDGET, AND THE DELETED GRAMMAR IS THE LESSON — do not rebuild it.** Grove used to type into the provider's own picker: digits for options, a synthetic "Type something." row for free text, Tab for multi-select, a final Enter for the review screen, plus a rendered-screen witness per step. Every clause of that was measured and correct, and the design was still wrong, because it made *what a human may answer* a function of *how one build of one tool paints a dialog*. The costs it imposed are the census of what the widget was deciding: free text only on a single-select, no note beside a choice, `confirm` and optionless `free_text` unanswerable at all, and multi-select refused outright — rendered as answerable by the web UI and 422'd on submit for as long as it existed. And when the verification was absent, the failure mode was the worst available: four questions answered with the multiSelect skipped and the rest collapsed onto option 1, reported as success.
- **What replaced it is Escape plus one message, and it needed no provider knowledge at all.** `AgentQuestion.plan_mismatch` keeps the only two rules the widget was not the author of (one item per captured question; an index that names a real option); `GroveInstruction.answers` renders the batch; the manager dismisses and steers. **The generalizable form: when a driver's rules turn out to be a rendering's rules, the fix is to stop driving the rendering — not to verify it harder.**
- **`SendKey` is terminal vocabulary, not an adapter grammar.** The Send Keys surface delivers a human-chosen named key without interpreting the screen; it does not revive the deleted picker driver. Key meaning belongs to the application, so Ctrl+C is not advertised as a provider-neutral cancel action. The dispatch order still matters — a `StrEnum` IS a `str`, so `send_keys` checks the enum first or a named key would be typed as text.
- **Dispatch semantics, like steering:** delivered is not answered. The message lands, the agent's response arrives later on the transcript, and losing the terminal race is now benign — an Escape with nothing to cancel plus a duplicate answer, rather than a wrong one.
## 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.
+ - **Published normalized messages are immutable snapshots, including split-block continuations.** The Claude folder replaces a changed record rather than mutating the published message; a late compaction summary replaces only its boundary. Folder-owned projections run under `TranscriptCache.project`'s lock. The legacy parser mutator still exists for direct parsing, so never share raw lines across independent fold states or refold already-folded objects. Reset reparses disk.
+ - **Incremental JSON decoding is not incremental projection.** Retain normalized message identity and fold derived facts over changed records; a new stat signature must not mean reconstruct every historical content block. Whole tuple publication still copies O(message count) references, and retroactive replacement/reordering may require a rebuild. Complexity tests count conversions and processed payloads rather than confusing a shallow list copy with a duplicate object graph.
+ - **Retention budgets are object estimates, not RSS guarantees.** The global `transcript_cache` policy is applied once by daemon construction to both filesystem adapters. Fold and memo budgets are separate; oversized results remain complete but are not retained. Charge newly retained roots and container-capacity changes, never all historical payloads per append. Provider-only rendered attachments and unused structured tool-output fields are discarded before retention, while projected prompt/tool-result bodies remain complete. A budget below an active session's working set causes repeat cold parses; measure that case before claiming a smaller cap saves compute.
- **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.**
## A transcript's FOLDER is not a durable fact about its session
**Claude Code RE-HOMES a transcript when the session enters one of its native `.claude/worktrees/` checkouts, so the cwd-encoded folder stops predicting where the file is — and there is no repaired encoding that recovers it.** The folder then names the worktree the session entered, while every record's own `cwd` keeps naming the directory that record was written in. Neither end of the file agrees with the folder: measured over the whole reference host, **3 of 293 transcripts** have a folder that disagrees with their recorded cwd, all three from this feature, and in **2 of the 3 the session's LAST cwd is a SUBDIRECTORY of the folder's path** (`…/worktrees/mailbox-message-ui/webapp`). So "encode the last cwd instead" is wrong too — the folder is derived from a directory the session may never record at all.
- **The consequence is a class of bug, not one bug: a cwd scan cannot find a known session, while the id that names it stays perfectly valid.** `discover_paths`/`list_sessions` list one forward-encoded folder, so a relocated session vanishes from every cwd-scoped surface at once; `locate`/`locate_transcripts` glob the UUID across every folder and confirm by the in-line `cwd`, so they never lost it. Grove shipped both, and the gap between them was invisible until a session actually moved.
- **`session_summary(cwd, session_id)` is the identity-keyed read that closes it**, and it is on the `AgentAdapter` protocol rather than in one caller because the question — *where is THIS session* — is an adapter's to answer. It resolves through `locate_transcripts` and must never walk the store: one glob per config dir, on request paths only. Codex implements it for uniformity and not for relocation (date-partitioned rollouts are never re-homed); generic/mewbo answer `None`.
- **Do NOT route `list_sessions` through it, and do not widen `discover_paths`.** The cwd scan is what answers *which sessions live here* for sessions whose ids nobody knows yet, and it is on the ~1 Hz poll path — the asymmetry above is the standing reason that scan stays one directory. The two seams answer different questions and both are load-bearing.
- **General form, and this tree has now paid for it twice: a path is a lossy, mutable projection of an identity.** The `cwd`-is-not-an-identity rule in [grove.core](../CLAUDE.md) says a directory cannot tell you *whose* a session is; this says a directory cannot reliably tell you *where* one is either. **When a provider files an artifact under a derived path, resolve by the identity it also publishes.**
## 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 — the app server is now partly consumed, so read the section below before re-deriving any of its verbs. **Codex ships native OTel** (per-turn tokens, `ttft.duration_ms`, OTLP http/grpc).
### The owned native session (`native_claude.py` / `native_codex.py` / `native_owner.py`)
**Idle Claude resumes need a control handshake, not a greeting.** With explicit `--resume <UUID>` and no initial user prompt, send native `initialize` and require its ACK before using the pinned identity. An unsolicited `system/init` may arrive only when a turn runs; waiting for it deadlocks idle recovery. A startup failure must settle/cancel its init future before closing the reader.
**Provider NDJSON records are not bounded by asyncio's stream-buffer limit.** Read chunks and assemble complete lines with `NativeStream`; a large tool result must not kill `readline()` at 64 KiB or be truncated to fit it. `wait_closed()` exposes reader EOF/failure to the worker without cancellation of a watcher cancelling the reader. Cleanup must reap the provider even when its reader already failed; pending requests must settle in `finally`.
**A Claude Code or Codex agent is a Grove-OWNED native session by default (`AgentSpec.native`, formerly the opt-in `mailbox`), and the interactive TUI is the explicit alternative — the built-in `claude-terminal` / `codex-terminal` entries.** The inversion follows from the measurement two sections down: neither provider lets a second client observe or control a session a TUI holds, so the only process that can interrupt, switch the model or answer a question is the one that launched the agent. `NativeOwner` is that process's seam; `AgentSpec.owns_native_session` (flag AND kind) is the single predicate that decides a create, and the decision is PERSISTED as `WorkspaceState.native` so a roster edit never re-decides for a workspace that exists. Launching a native session never makes a second writer on an existing TUI.
- **Operator controls are provider VERBS, not prose, and both providers answer them differently.** Claude: a `control_request` per op, resolved by the `request_id` Grove minted on its `control_response` (measured 2.1.270: `interrupt` acks `{"still_queued": []}` and the running `Bash` ends with its tool_result rejected; `set_model` validates the id against the API first, so a gateway-unknown id comes back as an `error` frame carrying the 400). Codex: `turn/interrupt` REQUIRES the `turnId` (schema-required), so the owner tracks the active turn and answers `False` with none to stop; `thread/settings/update {model}` applies to every later turn and the server acks BEFORE validating the id — the rollout's next `turn_context.model` is the proof it took (measured 0.154.0: `turn_aborted` on the interrupted turn, the next turn on the new model). A refusal is `False` and the provider's reason lands on the pane, never a raise: the daemon already answered 204 on dispatch, the same delivered-not-confirmed contract typing into a pane has.
- **The worker↔daemon frame carries an `op`, and only `message` has a receipt.** `steer` (operator text), `interrupt` and `set_model` are relayed to the owner and never acknowledged through `/mailboxes/ack`; peer `message` keeps its `NativeSubmission` receipt. The coordinator's `control()` refuses with `not_registered` when no owner is connected and `backpressure` when it is not draining — both map to the pane-shaped 409, because a respawn is the remedy either way.
- **A native session's QUESTION never fires a hook, so the owner is the only witness — and it publishes the ask through the hook SPOOL rather than a second reader.** Claude routes a headless `AskUserQuestion` to the owning client as a `can_use_tool` control request, and only when the session was launched with `--permission-prompt-tool stdio` (without it the model hunts for a tool it cannot call — measured 2.1.270; under `--dangerously-skip-permissions` ordinary tool calls never arrive, only the question does, which is why holding it inserts no permission gate). Codex has no hooks at all: its asks are JSON-RPC SERVER requests (`item/tool/requestUserInput`, `item/commandExecution|fileChange/requestApproval`) that block the turn until answered. `AskRecorder` drops each ask as `*.ask.json` into `paths.agent_hook_spool_dir()` — the same bind-mounted directory a containerized hook spools into, so one string resolves in both namespaces and the worker needs neither the sidecar dir nor the daemon — and `ClaudeHook.drain` folds it into the session's sidecar as the `PendingQuestion` + BLOCKED every question surface already reads. **The answer goes back as STRUCTURE, never prose**: `answer_question` on a native workspace sends the `answer` op carrying the validated plan, and the owner renders the provider's frame (Claude `control_response … updatedInput.answers` keyed by prompt text; Codex `{answers: {id: {answers: [labels…]}}}` or `{decision: accept|decline}`), because the tool's result IS the human's choice and restating it as a message leaves the ask standing. Two measured gates on 0.154.0 govern what a Codex workspace actually asks: `--yolo` on the argv does NOT move the app server's thread policy (stays `on-request` — only `-c approval_policy=…` does), so approvals are the common ask; and `request_user_input` exists only under the `plan` collaboration mode. An approval is normalized as a two-option question (Approve/Decline) so the existing card renders it; `acceptForSession`, `cancel` and `permissions/requestApproval` are deliberately not offered.
- **The owned stream's FACTS (cost, TTFT, turn duration, a shell's exit code) ride the same spool as the ask (`*.facts.json`) and merge FIELD-WISE into the sidecar (`HookRecord.native` → `AgentActivity.native` → `AgentActivityView.native`).** Each frame states some of them — Claude's terminal `result` the cost/TTFT/duration (`total_cost_usd` is CUMULATIVE per session, measured 2.1.270, so it is carried whole and never summed), Codex's `item/completed` for a `commandExecution` the exit code — so `record_native_facts` replaces only the fields a drop carries and every hook-shaped write carries the block forward, exactly like the window. `None` per field means unstated, and the whole block is `None` for a terminal session. **The Codex exit code lands only for the `shell` tool**: the `exec` (unified_exec / JS REPL) tool the current models prefer runs commands inside a script and emits no `commandExecution` item at all — its exit status lives in the tool's output prose, which the provider-boundary rule forbids reading — so a native Codex session on that tool honestly shows no exit code. `experimentalRawEvents` on `thread/start` re-exposes the rollout's `custom_tool_call` records on the live stream, which changes nothing about that.
- **The owned `result` frame already carried the CONTEXT WINDOW and the SUB-AGENT census, and both were parsed and dropped.** Claude Code publishes its window only on the statusLine channel, which is SILENT under `-p` — so for a native session that terminal frame is the only source, and it has one: `modelUsage[<model>].contextWindow` plus the same four token classes `_context_from_statusline` sums. It rides the existing `*.facts.json` drop and folds into the record's own `context` field, so every meter reads it unchanged — one drop, two fields. `subagent_stats` is the census: it lands on `NativeFacts` as a SESSION TOTAL, deliberately distinct from `AgentActivity.active_subagents` (how many run right now), because a session that spawned ten and finished them reads 10 and 0 and both are correct. **Grove's status hooks are not registered on an owned launch**, which is why the hook-sourced `FleetSummary` is empty there and this frame is the only census a native session has. Before adding a reader for a native fact, check what `parse_result_frame` already returns and throws away.
- **`native_launch.py` mints credentials through the auth store, which is why a test that creates a built-in agent reaches `auth.json`.** The suite therefore pins the TERMINAL roster centrally (`_terminal_agents_by_default`, a field-default patch plus `model_rebuild`, because a Pydantic default is baked at class build) and `native_roster` opts a test back in — with `tmp_state_dir`, since that is the one host path a terminal launch never touches. **A flipped default silently re-arms every test that never pinned it**, and the second door is a test that spawns a real daemon, which no fixture reaches: pin `native: false` in the config it reads.
Mailbox opt-in launches a new protocol-owned session, never a second writer on an existing TUI. Claude stream replay UUIDs witness native input, not model processing; a busy session can expose the input only after the send deadline, so `unknown` is legitimate even when a later conversational reply arrives. Codex's top-level app-server owner submits idle input or active-turn CAS and never supplies turn permission/model overrides or answers approval requests. Native tool availability and tool permission are separate: the Claude init tool census gates MCP footer guidance, while explicit operator permission rules still govern calls. The scoped MCP server must never connect an ordinary owner client just to read skills.
### The Codex app server — a control plane for sessions Grove LAUNCHES, never a reader of ones it did not
**`codex app-server` is a real JSON-RPC surface and Claude Code has none** — 0 occurrences of `app-server` in the 219 MB 2.1.269 binary, against 163 client methods at codex-cli 0.154.0. Claude's equivalent is the stream-json control protocol (`-p --input-format stream-json`), which **replaces the Ink TUI**. **The two are not symmetric, and any plan claiming parity is wrong.** Both are consumed by exactly one thing: the owned native worker (`mailbox_claude.py` / `mailbox_codex.py`), which launches the session and therefore holds its control channel.
**Two sibling surfaces look like the answer and are not — both closed by execution, so do not re-open either.** `claude mcp serve` is a REAL server and the exact inverse of what a supervisor needs: `StdioServerTransport` only, advertising Claude Code's *own* tools for another client to call, with no session, status, transcript or interrupt tool. And **Direct Connect is dead-coded** — a literal `if (false && …)` guard — so the `cc://` scaffolding is not a nearly-live feature.
- **NEITHER PROTOCOL LETS A SECOND CLIENT OBSERVE OR CONTROL A SESSION AN INTERACTIVE TUI HOLDS, and a read path was shipped and retired on the wrong belief.** Measured 2026-09-14 on codex-cli 0.154.0: `thread/resume` on a thread whose TUI is open answers `-32600 "already has an active writer"`. `thread/read`, `thread/turns/list` and `thread/items/list` (implemented at 0.154.0; `-32601` at 0.147.0) all answer from the rollout FILE, so they carry nothing the file reader does not already have. Claude Code has no thread list at all. **So the transcript file is the observation floor for every workspace, and the app server / stream-json is the control plane only for a workspace Grove launched headless.** The retired `read_thread_facts` + `GET /workspaces/{id}/runtime-facts` spawned a subprocess per request to fetch the context window and failed on every live session — the shape to recognise is *"a fact the existing reader cannot obtain"* asserted without grepping the artifacts the reader already parses.
- **"Absent from every transcript at any version" was FALSE for the context window, and the census that would have caught it costs one grep.** `event_msg/token_count.info` carries `model_context_window`, `cached_input_tokens` and `reasoning_output_tokens` — 203 of 217 rollouts on the reference host, every codex-cli ≥ 0.98, including the 0.147.0 thread the claim was measured on. `_RolloutLine.context_window` reads it off the same record `usage_tokens` already reads, the LATEST report wins (a mid-session model switch moves the window), and `used` is the inclusive `last_token_usage.input_tokens` because cached tokens occupy the window whether or not they are billed. **Before asserting a provider does not record something, `grep -l` the real store for the key.**
- **Claude Code states its window ONLY on the statusLine channel, and that channel is silent in `-p` mode.** The `statusLine` command receives `context_window.{context_window_size, current_usage, used_percentage}` on stdin after every turn — `current_usage` is `null` until the first request completes (three payloads measured before the first real number), and `context_window_size` is present from the first invocation. `ClaudeHook.statusline_command` is the `--statusline` arm of the same `grove-agent-hook` entry point: it folds the window into the sidecar's `context` field, prints NOTHING (whatever it prints becomes the terminal's status row), and never pushes. **`statusLine` is single-valued, so registering it REPLACES the user's own for that launch** — scoped to the pane Grove opened via `--settings`, never to `settings.json`, and the container variant keeps the decor script instead. Every hook event CARRIES the window forward (no event carries one; a write that dropped it would blank the meter on every tool call), and the statusline arm replaces ONLY the window — state and question stay the hook events' own.
- **The measured 2.1.270 verbs, for S2+:** `control_request` `interrupt` (stops a running `Bash` mid-call, answers `still_queued: []`), `set_model` (switches mid-session and re-emits `system/init` with the new model), `initialize` (returns the slash-command census and re-delivers `pending_permission_requests` on reconnect), and `can_use_tool` for `AskUserQuestion` answered by `control_response … updatedInput.answers`. Capabilities `[interrupt_receipt_v1, interrupt_cancel_queued_v1, msg_lifecycle_v1]`. Headless writes the SAME `~/.claude/projects` transcript as the TUI. Codex on an owned thread: `turn/steer` (with `expectedTurnId` CAS — keep ours over the reference implementation's `turn/start` auto-steer), `turn/interrupt`, `thread/settings/update {model, effort, approvalPolicy}`, and server requests `item/commandExecution|fileChange|permissions/requestApproval`.
- **`codex app-server generate-json-schema --out DIR --experimental` IS the census** and the unknown-variant error message is the live verb list; never hand-maintain either. **The schema is a SUPERSET of what is implemented and the first error you get is the wrong one** (`requires experimentalApi capability` before `not supported yet`), so every verb is confirmed by execution and a client degrades per verb rather than tearing down the connection.
- **Transport: a Grove-owned child on stdio.** `ws://IP:PORT` works (`/readyz` + `/healthz`) and is the remote story; `unix://PATH` failed here with `Operation not permitted`; `app-server daemon start` demands a standalone-installer layout a normal install lacks. Two `initialize` capabilities matter: `experimentalApi`, and `optOutNotificationMethods` to mute `fs/*` chatter at the source.
- **`thread/tokenUsage/updated` has no reference implementation anywhere** — the one other adopter on this host never consumes it — so its semantics are ours to measure; `item/completed` (`exitCode`, `durationMs`) arrives ONLY on the live stream of a turn the owner started, never on a replay.
- **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 no *cost*, and that is a property of the signal split rather than a misconfiguration to hunt. The content half of that claim was true on 2.1.226 and is **false on 2.1.241** — see the span bullet.
- **Spans carry content on 2.1.241, which REVERSES the rule recorded for 2.1.226 ("no content on any span, under any configuration").** Measured 2026-08-23 against a disposable OTLP receiver: `claude_code.interaction` carries the full `user_prompt` (plus `user_prompt_length`), and the tool spans carry `full_command` with a `tool.output` event whose attributes include `bash_command` and `output`. Span names observed: `claude_code.interaction`, `claude_code.llm_request`, `claude_code.tool`, `claude_code.tool.execution`, and **`claude_code.tool.blocked_on_user`** — the last being a direct measurement of how long a human blocked a tool call, which nothing else in the system reports. `ttft_ms` is on `claude_code.llm_request` as documented (1465 ms observed). Assistant responses and raw API bodies were NOT seen on spans; those remain log-only. **Cost is still absent from spans.**
- **This makes `owns_content(kind)`'s trade materially worse than the line below admits.** Forcing `OTEL_TRACES_EXPORTER=none` when Grove owns the content no longer costs only TTFT — it now also discards prompt text, tool commands and command output on the trace, and the `blocked_on_user` timing outright. Re-cost that decision before defending it; the honest alternative is still ingesting the stream through `telemetry/receiver.py`.
- **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.
- **The two-switch rule for spans held on 2.1.226 and NO LONGER HOLDS on 2.1.241 — re-probe it per release rather than trusting either version of this line.** It was: `OTEL_TRACES_EXPORTER=otlp` alone emits nothing, `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1` also required. Measured 2026-08-23 on 2.1.241 against a disposable local OTLP receiver, `CLAUDE_CODE_ENABLE_TELEMETRY=1` alone delivered POSTs to **all three** of `/v1/traces`, `/v1/logs`, `/v1/metrics`, with the beta variable OMITTED. So a span-shaped feature genuinely does move between releases — which is the durable half of the original warning — and the *direction* it moved was toward being on by default. **Do not read the reconstruction's `feature-flags.json` as the answer here:** it reports `ENHANCED_TELEMETRY_BETA=False` for the neighbouring build while traces export fine, which is the same manifest-versus-runtime gap that says `DIRECT_CONNECT=true` for a `cc://` handler that provably does nothing. A compiled flag bit is a hypothesis; a receiver is the evidence.
- **`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 after a request. Contiguous response fragments share one normalized assistant message until that explicit usage boundary, and the usage claim is consumed once.** Otherwise reasoning/text/tool fragments become phantom unmeasured API requests that invalidate the session's cost. A user turn, tool response or model context keeps separate requests separate; absent usage remains unknown rather than zero. 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.
- **"Which tool call IS a shell command, and what did it ask for" is ONE seam (`agents/shell.py`), because two consumers ask it and neither owns it.** The usage audit bills a call's duration to its leading executable and the telemetry export publishes a canonical shell observation; before this each kept its own copy of the tool-name set and of the command-key table, so a harness added to one stayed invisible to the other. It lives here rather than in either consumer because it is a fact about PROVIDER SHAPES, which is this package's question. Four names, all census-backed: `Bash`, `exec_command`, `shell`, `local_shell`. `BashOutput`/`KillShell`/`write_stdin` are deliberately absent — each addresses a session some earlier call created and carries no command of its own, so admitting them would publish an observation with nothing to grade. **Classification is by exact NAME**: a tool whose arguments happen to carry a `command` key is not a shell tool, and a name that merely contains one is not either.
- **Argv is preserved rather than flattened, and the rendering is `shlex.join`.** A harness handing over `["bash", "-lc", "echo hi"]` has already decided the word boundaries, which is strictly more than any line carries; a bare space join turns `["sh", "-c", "a b"]` into a line that means something else. The old usage-side join was that space form. **It needed no `SCHEMA_VERSION` bump** and the reason is worth keeping: `target` is the LEADING word, and an argv's first element is that word under either rendering, so no stored value can move (pinned by test).
- **The native OTLP spells the same call a third way, and the capture proves both keys.** `claude_code.tool` carries `full_command` on the span and `bash_command` on its `tool.output` event, with the same value in both (`tests/core/data/otlp_claude_code/`). The span's own key is preferred; the event's is the fallback. That is why the key table lives beside the tool-name set rather than in the transcript adapters.
- **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.