Harness capture matrix
Reproduced from docs/harness-capture-capability.md (the canonical source) — edit that file, not this page.
Automatic capture (spec 2026-06-16-harness-auto-capture, ADR 0009) feeds a
uniform server contract — the POST /transcript delta endpoint — through a
thin per-harness acquisition adapter. The server pipeline (buffer →
settle-sweep → extractor → curator) is built once and shared; only the small
adapter that acquires a per-turn delta differs per harness. This page is the
honest, seeded status of each harness (spec SC14), grounded in the §11.2
capability audit of the capture brainstorm and the §6 live test, and
re-grounded in Phase 2A against mem0’s shipping plugin
— which proves Codex (same hook events as Claude) and OpenCode (chat.message),
correcting the earlier “Codex blocked / OpenCode idle-bracketing” framing.
The three columns that decide whether a harness can capture at all:
- Capture mechanism — how the adapter gets a per-turn delta.
- conv_id stability — capture keys all per-conversation state by a stable
conversation id (never
$USERorcwd, spec §4.11). A harness without a stable id can’t attribute deltas safely and is blocked. - Status — whether Phase 1 ships it, and what gates the rest.
| Harness | Capture mechanism | conv_id stability | Status |
|---|---|---|---|
| Claude Code | UserPromptSubmit hook (primary) → tail the top-level transcript_path JSONL from a byte-offset cursor (subagents skipped; private turns skipped); Stop / SessionEnd kept as supplementary |
stable — session_id; concurrent sessions write distinct <session_id>.jsonl files (§6) |
Authoritative (shipped). Driven by UserPromptSubmit because Claude bug #29767 means plugin-scoped Stop hooks register but never fire; Stop / SessionEnd stay wired so capture auto-recovers when the bug is fixed. Shipped in integrations/claude/hooks/hooks.json. |
| Claude Cowork (desktop) | None new — Cowork shares Claude Code’s plugin host, so the same integrations/claude/ hooks (on-stop.mjs) are the acquisition surface |
inherits Claude’s session_id (assumed, unverified on the desktop host) |
Blocked on verification (Phase 2A). No new adapter — the same Claude plugin is the surface. Needs the Claude desktop app to confirm its plugin host fires the per-turn UserPromptSubmit / Stop / SessionEnd hooks with a usable transcript_path + session id (and whether #29767 manifests there too). GUI install (Customize → Browse plugins) is documented in the Claude README; capture is fail-soft if the desktop host diverges. |
| Codex | UserPromptSubmit hook (primary) → reuse the Claude adapter (on-stop.mjs + lib/*) on the per-turn delta; Stop / SessionEnd supplementary. Installed by merging into ~/.codex/hooks.json (owner-marker idempotent; requires codex_hooks = true in config.toml) |
assumed stable — session_id → transcript-filename basename → clean no-op; never cwd / $USER |
Ported (Phase 2A), e2e UNVERIFIED. Built optimistically from mem0’s shipping Codex hooks (install_codex_hooks.py, codex-hooks.json), which use the same hook events as Claude. The earlier “blocked: no stable id” status is resolved iff the live Codex payload carries transcript_path + a session_id — not yet confirmed (no codex CLI to spike). deriveConvId degrades gracefully and the parser is fail-soft, so a wrong payload shape no-ops rather than mis-attributing. |
| OpenCode | chat.message plugin hook (TS, @opencode-ai/plugin) → build the per-turn delta from the full ordered message list (session.messages, both roles); fires on the user message, so the assistant reply lands one turn behind (the §8.2 tolerance) |
stable — sessionID (confirmed in the v1.16.2 API) |
Ported (Phase 2A), e2e pending. Real TS plugin modeled on mem0’s opencode-mem0.ts, but keyed by sessionID (not mem0’s $USER — the collision bug we avoid). Unit + live-local-server contract verified; live OpenCode e2e pending (no opencode CLI). Corrects the earlier session.idle-bracketing guess. |
| Pi | agent_end event → completed AgentMessage[] in-payload (O(1), no cursor; prose blocks only) |
stable — ctx.sessionManager.getSessionId() |
Ported (Phase 2B), e2e pending. TS extension hook confirmed against @earendil-works/pi-coding-agent@0.75.5 types (agent_end fires once per prompt with the turn in-payload; getSessionId() stable). The agent_end (whole-prompt) vs turn_end (per-response) choice is the one optimistic assumption — fail-safe (an unexpected shape → clean no-op). Live Pi e2e pending (no pi CLI to spike). |
| Hermes | sync_turn(user, assistant, *, session_id, messages) → both halves handed in as args in-payload (O(1), no cursor) |
stable — session_id ({ts}_{shortuuid}) |
Ported (Phase 2B) — spike RUNTIME-CONFIRMED. The installed Hermes agent v0.16.0 still fires sync_turn per completed turn (turn_finalizer → MemoryManager.sync_all, confirmed live via a throwaway provider). Python adapter posts the delta with exchange-granular private skip. Best-verified of Phase 2 alongside Claude; the only deferred check is a live server socket round-trip. |
Why Claude is first
Section titled “Why Claude is first”Claude Code is the harness the owner uses daily, so Phase 1 builds and dogfoods
it. The §6 live test confirmed the data layer directly: the transcript is clean
append-only JSONL (so a byte-offset cursor is valid), each entry carries a stable
sessionId, concurrent sessions write distinct files, subagent work is isolated
in separate subagents/*.jsonl, and cwd can change within a session — which
is exactly why the buffer is keyed by conv_id, not cwd.
Capture is driven by UserPromptSubmit, not Stop. Claude bug
#29767 is that
plugin-scoped Stop hooks register but never fire (a SessionStart from the same
plugin does fire), so a Stop-only adapter would silently never run.
UserPromptSubmit fires reliably and carries the same session_id +
transcript_path, so the adapter reads the same per-turn delta — one turn behind
(it fires just before the assistant reply), which spec §8.2 already tolerates. The
Stop / SessionEnd entries stay wired as supplementary so capture
auto-recovers the moment the bug is fixed; the cursor’s advance-on-ack makes
multiple firing events idempotent.
Behavior shared by every adapter
Section titled “Behavior shared by every adapter”These are contract-level, not per-harness:
- Default-on, gated two ways (see the slash-command / private-mode
contract):
the per-machine
LIBRARIAN_AUTO_SAVE=falsekill-switch and per-turn private-mode skip ([librarian:private=on]). - Server-authoritative intake gate. Even with the client shipping, the
server buffers only when its curator intake gate (
curator.intake.enabled) is on; if off it refuses and buffers nothing (no raw text at rest for a dead pipeline). The Claude SessionStart banner surfaces both gate states. - Fail-soft. A capture/guard/extraction error never blocks the user’s turn, never leaks a stack trace into the model’s context, and errs toward not capturing on any uncertainty.
See the Phase-1 spec for the full success criteria and the Claude integration README for the shipped hooks.