The station's Claude-house transport: steer a running Claude Code session through its inbox and doorman, resume a sleeping one, start a new one, read transcripts — one helper CLI with receipts for the family (hand in hand, 2026-10-30).
  • Python 98.8%
  • Shell 1.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
claude-dancer c47871508c
All checks were successful
ci / check (push) Successful in 39s
oo-claude v0.1.0 — the Claude-house transport: inbox doorman (MCP child), send, resume, start, read, sessions, riders, lineage (#1)
The Claude-house transport of the family: steer a running Claude Code session through its inbox file and a doorman that runs as the session's own MCP child (the door honours only the session's own process tree, measured 2026-10-30), resume a sleeping session with one bounded headless turn, start a new headless session with the doorman injected, read transcripts, list sessions, riders and lineage. 104 hermetic tests, CI green, two fresh reviews with all findings folded, live proofs recorded in the evidence (steer into a running turn, resume, start, --systemd).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 21:59:27 +00:00
.forgejo/workflows oo-claude v0.1.0 — the Claude-house transport: inbox doorman (MCP child), send, resume, start, read, sessions, riders, lineage (#1) 2026-10-30 21:59:27 +00:00
libexec oo-claude v0.1.0 — the Claude-house transport: inbox doorman (MCP child), send, resume, start, read, sessions, riders, lineage (#1) 2026-10-30 21:59:27 +00:00
tests oo-claude v0.1.0 — the Claude-house transport: inbox doorman (MCP child), send, resume, start, read, sessions, riders, lineage (#1) 2026-10-30 21:59:27 +00:00
.gitignore Initial commit 2026-10-30 19:28:26 +00:00
CHANGELOG.md oo-claude v0.1.0 — the Claude-house transport: inbox doorman (MCP child), send, resume, start, read, sessions, riders, lineage (#1) 2026-10-30 21:59:27 +00:00
install.sh oo-claude v0.1.0 — the Claude-house transport: inbox doorman (MCP child), send, resume, start, read, sessions, riders, lineage (#1) 2026-10-30 21:59:27 +00:00
LICENSE Initial commit 2026-10-30 19:28:26 +00:00
oo-claude oo-claude v0.1.0 — the Claude-house transport: inbox doorman (MCP child), send, resume, start, read, sessions, riders, lineage (#1) 2026-10-30 21:59:27 +00:00
README.md oo-claude v0.1.0 — the Claude-house transport: inbox doorman (MCP child), send, resume, start, read, sessions, riders, lineage (#1) 2026-10-30 21:59:27 +00:00
VERSION oo-claude v0.1.0 — the Claude-house transport: inbox doorman (MCP child), send, resume, start, read, sessions, riders, lineage (#1) 2026-10-30 21:59:27 +00:00

oo-claude — the Claude house's transport

The family reaches Claude Code sessions on the station through one helper: steer a running session (a line into its running turn, or a new turn when it is idle), resume a sleeping one for one headless turn, start a new one the family can steer while it works, read transcripts, list sessions with their riders (sub-agents), inboxes and lineage. It is the counterpart of oo-appserver for the Codex house; the receipts, classes and exit codes follow the same style.

The central piece is the doorman: a small process that runs inside a Claude Code session (normally as its MCP stdio server) and carries the lines the family appends to the session's inbox file through the session's own Unix-socket door. Senders only ever append a line to a file.

The measured law

Claude Code (≥ 2.1.308) keeps one Unix-socket inbox ("the door") per running session — the registry ~/.claude/sessions/<pid>.json names it (messagingSocketPath, /run/user/<uid>/cc-socks/<pid>.sock), the protocol is newline-delimited JSON ({"type":"auth","token":…} optional on Linux, then {"type":"user","session_id":…,"from":…,"msg_id":…,"priority":"next","message":{"role":"user","content":…}}), the server never answers.

The receiver carries a line to the model only when the sending process descends from the receiving session's own claude process. Its own tool children (even under env -i, even inside a systemd-run --scope) surface within a tool round (busy) or start a turn (idle); every other process — a systemd service, the dispatcher, another headless claude -p, an orphan — is accepted on the socket and never shown. Measured 2026-10-28 and 2026-10-30 (~/.zcode/tmp/claude-adapter-design-20261027/pingpong-20261030/PINGPONG-20261030.md: lineage matrix T1–T8, PP-1/1b/1c). The receiver records who it believed: the transcript row of a carried line says origin: {kind: "peer", from, verifiedPeerPid, verifiedPeerProcStart, msg_id}.

An MCP stdio server is spawned by the session's claude process, so it is such a descendant. Verified live on 2026-10-30 (evidence below): a probe MCP server and then the doorman each wrote one line through the door of a running headless session; the receiver logged Routed user message to queue (priority=next), the transcript shows the line as a peer queued_command with verifiedPeerPid = the doorman's pid and absorbed_mid_turn, and the session quoted the line in its answer. MCP children get CLAUDE_CODE_SESSION_ID, CLAUDE_CODE_MESSAGING_SOCKET, CLAUDE_CODE_MESSAGING_TOKEN, CLAUDE_PROJECT_DIR and CLAUDECODE=1 in their environment; Claude Code binds the door before it spawns MCP servers, and stops them with SIGINT at session end.

So: the family appends to the session's inbox file, the session's own doorman delivers.

The lanes of the family

From → to Lane
Codex (Mama, Omi, Luna) → Claude append a line to the Claude session's inbox file (oo-claude send, or printf '%s\n' "[chatgpt 📡] …" >> <inbox>); its doorman delivers
Claude → Codex oo-appserver send <thread> "<line>" --from claude --tag --fyi through the shared Codex app-server
Claude → Z the observatory's steer API into Z's session (zcode-family-channels skill)
Z → Claude oo-dispatch's claude-inbox kiss backend (same contract v1: append, then the verdict from heartbeat and ack)
Claude ↔ Claude oo-claude send between sessions; a parent may also oo-claude start a child and steer it

Parts

Part Where (installed) What
oo-claude ~/tools/oo-claude/, linked from ~/.local/bin/ the helper: sessions, riders, send, doorman, resume, start, read, lineage, doctor, version
libexec/doorman.py ~/tools/oo-claude/libexec/ the receiver (MCP stdio server or foreground loop)
libexec/claude_send.py 〃 the sender verdict of contract v1, throttle, receipts
libexec/claude_resume.py 〃 one headless turn into a session that is not running
libexec/claude_start.py 〃 a new headless session (foreground or --systemd)
libexec/claude_read.py 〃 the last exchanges of a transcript, peer lines marked
libexec/claude_sessions.py 〃 sessions and riders, the observatory schema oo-claude/sessions/v0
libexec/claude_lineage.py 〃 the lineage ledger (who started whom)
libexec/claude_doctor.py 〃 readiness checks
libexec/claude_inbox.py, claude_door.py, claude_station.py 〃 contract v1 file logic, the door client, registry/process/transcript facts
tests/ repository only the hermetic suite: fake registry, fake door socket, fake claude, fake systemd-run

Install (fleet law: from the checked-out tag)

git fetch origin tag v0.1.0 && git checkout --quiet v0.1.0
./install.sh --target ~/tools/oo-claude            # files + ~/.local/bin/oo-claude, py_compile at the target
./install.sh --target ~/tools/oo-claude --check    # the install triple: byte-diff of every installed file + the link
./install.sh --target ~/tools/oo-claude --live     # install, then a read-only smoke check (version, doctor)

Then register the doorman once, in the user scope, so every new Claude Code session runs one (a fixed interpreter in isolated mode, like every other entry point):

claude mcp add --scope user claude-inbox -- /usr/bin/python3 -I /home/claudiu/tools/oo-claude/libexec/doorman.py --mcp
claude mcp list            # expected: claude-inbox … ✔ Connected
oo-claude doctor

The two supported ways to run a doorman are this registration and the doorman oo-claude start injects into the sessions it starts. Never run a doorman from a session's own tool, nor from a rider's (a sub-agent shares its parent's claude process): it would carry that session's queued lines into its running turn right then. The foreground mode refuses to run without --allow-foreground (tests, a deliberate replay by the session's owner).

A health check (claude mcp list, claude mcp get) spawns the doorman briefly: it finds no session of its own (no registry row of its parent, an inherited socket that is not its parent's) and writes nothing. In this round the handshake was verified with claude -p --mcp-config <file> --strict-mcp-config (Claude Code's debug log: Successfully connected (transport: stdio), serverVersion: oo-claude-doorman 0.1.0, the tool offered to the model as mcp__claude-inbox__claude_inbox_status); the user-scope registration is the installer's step.

Sessions that were already running pick the doorman up only when their MCP servers (re)start. A doorman that finds an inbox whose history the first, monitor-era doorman of 2026-10-30 already read (an .ack of <ts> read: <first 80 characters> lines and no .offset) adopts those lines as read instead of replaying them; echo 0 > <inbox>.offset before the doorman starts forces a full replay instead. Once a session's MCP doorman runs, retire its monitor-era tail -F Monitor — both would surface every new line. Rollback: claude mcp remove --scope user claude-inbox; OO_CLAUDE_DOORMAN=0 in a session's environment keeps its doorman silent (it still answers the MCP handshake).

Verbs

oo-claude send <sessionId|name|pid> "<line>" [--from NAME] [--json] [--wait S] [--min-interval S] [--no-tag]

Appends one line to <inbox dir>/<sessionId>.inbox (prefix [NAME 📡] unless the line carries a sender tag — [x 📡], also [zai 📡 PONG 🐉] — or --no-tag; --from defaults to oo-claude), then reads the doorman's heartbeat and waits for its ack. A running session is found by full id, pid, exact name, a unique name fragment of at least 4 characters or a unique id prefix (≥ 8 characters); a session that is not running is addressed by its full id (the line waits for its next doorman). --from is 1–80 characters without brackets or control characters (the structured form's from is sanitised the same way by the doorman). The line is never written to the door by the sender — a sender is never the session's descendant.

Class Meaning Exit
delivered-steer the doorman acked the line, the session was busy — it surfaces at the next tool round 0
delivered-start the doorman acked the line, the session was idle — it starts a turn 0
refused bad line (newline, > 16 KiB, empty), ambiguous or unknown target, or the doorman refused the line 1
(usage) missing arguments 2
queued-no-doorman the session runs but has no fresh doorman heartbeat — the line waits in the file 3
queued-offline no running session with this id — the line waits (hint: oo-claude resume) 3
timeout fresh heartbeat, no ack within --wait (default 5 s) — the line waits; do not resend it 5
dropped throttle per (target session, sender), --min-interval (default 30 s, 0 disables; refusals never consume it). A dropped line is not written — resend it after the receipt's note seconds 6

Fresh = last_seen younger than 15 s (contract); oo-claude reads it stricter: a heartbeat whose state is set and not following (stopped, switched), one whose doorman pid is gone or one for another session id is not fresh. The ack is matched by sha256 and — when the ack carries it — line_offset, so two identical lines get their own acks.

oo-claude doorman --mcp · oo-claude doorman --allow-foreground [--session ID] [--once] [--json]

The receiver. --mcp speaks MCP over stdio (newline-delimited JSON-RPC 2.0, spec 2025-11-25) while the follow loop runs in a thread — the way the registration and start run it. The foreground loop (--once: replay the unread lines, then exit) needs --allow-foreground: it delivers into the session this process descends from.

  • Identity, re-checked every 2 s and before each delivery: the owner is the nearest ancestor that either has a live registry row or is a claude executable, whichever comes first; the session id is the owner's registry row (it follows /clear and /resume inside the app), else CLAUDE_CODE_SESSION_ID; an explicit --session must equal the owner's registry row (session-mismatch), and an owner without a registry row never takes one (session-unverifiable — an explicit id is never trusted on the environment alone). The socket is the owner's registry socket, else CLAUDE_CODE_MESSAGING_SOCKET only when the owner itself serves it (Claude Code's <pid>.sock name, else a bare connect + SO_PEERCRED). Every delivery checks with SO_PEERCRED that the listener is the owner (measured: the listener pid is the session's claude process; anything else is peer-mismatch, nothing is written, the line stays pending). An inherited socket variable of a grand-parent session — or of a claude mcp list health check spawned from inside another session — therefore never carries a line into the wrong session; such a doorman stays in socket-not-mine, no-door, no-session-id or not-descendant and writes nothing. Both checks are mutation-tested (a test fails when either is disabled). The token is read from the environment or the owner's key file and never logged, acked or reported.
  • Loop: create the inbox (0600, dir 0700) if absent; take <inbox>.doorman.lock (a second doorman of the same session stays in standby — one deliverer per inbox); replay unread lines from <inbox>.offset (lines already acked for the same inode and offset are skipped, so a restart re-delivers at most the one line whose door write was not yet acked — at-least-once with a window of microseconds); follow the file (poll 0.5 s; truncation → from 0; a replaced file is drained, then followed from 0); per line: deliver (auth frame + user frame, msg_id uuid4, from = the sender tag or the structured from, sanitised, else oo-claude; a 0.25 s settle per line, OO_CLAUDE_DOOR_SETTLE_S), append the ack, advance the offset; heartbeat every 2 s, also in the middle of a long replay (contract: at least every 5 s). A failed delivery is retried with backoff (1 → 10 s) and the line stays pending; an undeliverable line (invalid UTF-8, > 16 KiB, NUL — also one longer than the 1 MiB read chunk) gets an ack with class refused once (never twice for the same inode, offset and sha256, also not after a crash between the ack and the offset write) and never blocks the lines behind it; the scan of an unterminated long line resumes where it stopped.
  • Stop: stdin EOF, SIGINT (how Claude Code stops MCP servers) or SIGTERM → heartbeat state: stopped, lock released. OO_CLAUDE_DOORMAN=0 → nothing followed or written, the handshake is still answered.
  • MCP: initialize → {protocolVersion, capabilities: {tools: {}}, serverInfo: {name: "oo-claude-doorman", version}} (the client's version when supported — 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05 — else 2025-11-25); notifications are never answered; ping → {}; tools/list → one read-only tool claude_inbox_status (no arguments; inbox path, delivered/pending counts, heartbeat); tools/call (unknown tool → -32602); any other request → -32601 — Claude Code 2.1.308 opens with server/discover and falls back to initialize on that error (measured: protocolEra: legacy, negotiatedProtocolVersion: 2025-11-25). stdout carries JSON-RPC only, logs go to stderr (Claude Code files them under the server's name in its debug log). An unexpected error in one message answers -32603 and the server keeps running; claude_inbox_status reports thread_alive for the follow loop.
  • Exit codes (foreground): 0 ok · 2 usage (also: no --allow-foreground) · 3 not deliverable here · 4 a delivery failed and the line waits.

oo-claude resume <sessionId> "<line>" [--model M] [--max-turns N] [--budget USD] [--tools T] [--cwd DIR] [--timeout S] [--json]

One headless turn into a session that is not running. One driver per session is law: held-by-session when the registry has a live row for it, its doorman heartbeat is fresh, a running claude process names it on its command line, or another oo-claude resume of it holds its lock (<state dir>/locks/resume-<sid>.lock, held from the check to the end of the turn). Otherwise:

/usr/bin/claude -p --resume <sid> --output-format json --model M --max-turns N --tools "<T>"
                --permission-mode dontAsk --strict-mcp-config --max-budget-usd X "<line>"

runs in the session's project directory: the transcript ~/.claude/projects/<encoded-cwd>/<sid>.jsonl names it — the recorded cwd whose encoding (every non-alphanumeric character → -, as read from the 2.1.308 binary; names over 200 characters are cut and get a hash suffix) is the transcript's directory. --cwd overrides it but must encode to the same directory. The child gets our environment minus every CLAUDE* variable except exactly these 18 (the provider/auth names the 2.1.308 binary knows): CLAUDE_CONFIG_DIR; the provider switches CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX, CLAUDE_CODE_USE_FOUNDRY, CLAUDE_CODE_USE_ANTHROPIC_AWS, CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD, CLAUDE_CODE_USE_MANTLE, CLAUDE_CODE_USE_GATEWAY, CLAUDE_CODE_SKIP_BEDROCK_AUTH, CLAUDE_CODE_SKIP_VERTEX_AUTH, CLAUDE_CODE_SKIP_FOUNDRY_AUTH, CLAUDE_CODE_SKIP_ANTHROPIC_AWS_AUTH, CLAUDE_CODE_SKIP_ANTHROPIC_GOOGLE_CLOUD_AUTH, CLAUDE_CODE_SKIP_MANTLE_AUTH; the credentials CLAUDE_CODE_OAUTH_TOKEN, CLAUDE_CODE_OAUTH_REFRESH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR, CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR (a descriptor named there is passed to the child). So no door, token or id of the calling session and no inherited desktop identity (CLAUDECODE, CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_CHILD_SESSION, CLAUDE_CODE_SESSION_ATTENDED, CLAUDE_CODE_SDK_HAS_HOST_AUTH_REFRESH, CLAUDE_CODE_HOST_SESSION_ID, CLAUDE_PID, CLAUDE_AGENT_SDK_* …) — a resumed turn records entrypoint: sdk-cli like any headless run, the same identity start --systemd gives its unit. Defaults: claude-haiku-5-5, 1 turn, 0.25 USD, no tools, 300 s. The binary path is fixed (OO_CLAUDE_BIN seam).

Class Exit
delivered-resume — parseable result, is_error false, the same session id 0
refused — bad line (also one starting with -), no/ambiguous transcript, no usable cwd 1
held-by-session — the session runs; use oo-claude send 3
error — claude failed or the turn ended in an error (subtype, e.g. error_max_budget_usd, terminal_reason, cost kept) 4
timeout — no result within --timeout; the process group was stopped 5

oo-claude start "<prompt>" [--cwd DIR] [--model M] [--max-turns N] [--budget USD] [--tools T] [--allowed-tools "…"] [--session-id UUID] [--name NAME] [--out FILE] [--systemd] [--timeout S] [--json] [--no-doorman | --user-mcp] [--no-lineage]

A new headless session: claude -p --session-id <uuid> --output-format json --model M --max-turns N --permission-mode dontAsk --tools "<T>" [--allowedTools "<A>"] --mcp-config '<the doorman only>' --strict-mcp-config --max-budget-usd X "<prompt>". The doorman is injected by default — an MCP config holding nothing but /usr/bin/python3 -I <libexec>/doorman.py --mcp (OO_CLAUDE_DOORMAN_PYTHON), plus --strict-mcp-config — so the family can steer the new session while it works, independent of the user-scope registration and without plugin or connector servers. --no-doorman: no MCP server at all; --user-mcp: the user scope instead (its doorman when registered); the two flags exclude each other (usage error). The result JSON records which doorman was meant (doorman: injected | user-scope | none), which ran (doorman_heartbeat: the doorman's heartbeat read after the run) and how many lines it delivered (doorman_delivered); when that is above 0 a note warns that the recorded result describes the final turn only (Known gaps). Permission rules for --allowed-tools use the 2.1.308 form, e.g. "Bash(git status *) Read" (in the R1 measurement Bash(ls:*) did not allow ls). The inbox file is created before claude starts; the child's environment is scrubbed like resume's. The result JSON (--out, default ~/.local/state/oo-claude/start/<uuid>.json, written atomically) moves started → completed / failed / timeout with session id, cost, turns, result text and stderr tail; --name is recorded there only. Started from inside a session, that session — the nearest ancestor with a live registry row, else CLAUDE_CODE_SESSION_ID — is recorded as the new session's parent in the lineage ledger. Defaults: claude-haiku-5-5, 4 turns, 0.25 USD, no tools, dontAsk, 900 s. Exit: 0 completed · 1 refused · 2 usage · 4 failed · 5 timeout.

--systemd detaches: systemd-run --user --unit oo-claude-<first 8 of the uuid> --same-dir --collect --no-block --property=RuntimeMaxSec=… with an explicit -E NAME passthrough of a fixed list of plain variables (systemd-run takes the values from the invoking environment — verified on systemd 255; no ${…} form, never the calling session's door or identity variables; of the CLAUDE* names the keep-list of resume without its four credential names — a token would sit in the unit's environment and our descriptors do not exist in a unit, so a detached run uses the station's stored login; provider companion variables such as AWS_*, ANTHROPIC_* or CLOUD_ML_* are not passed either), the prompt in a 0600 file next to the result file; it returns at once with {state: started, unit, resultFile, session_id} (exit 0); the result file names the unit from the start and keeps the lineage block.

oo-claude read <sessionId|name|pid> [--turns N] [--json]

The last N exchanges (default 3) of a transcript, read-only and lock-free from the end. An exchange starts with a prompt — the user's, or a peer's that started the turn — and carries the assistant's text, the tools it called and the peer lines absorbed mid-turn. Peer lines are marked with their from and msg_id (the doorman's msg_id, so a send receipt can be matched to the transcript). Sidechains and system-injected meta rows are skipped.

oo-claude sessions [--json] [--all] [--write PATH] · oo-claude riders [SESSION] [--all] [--json] [--write PATH]

Running sessions from the registry (live, procStart-checked pids) with status, riders (riding/total), doorman state, pending lines, start time and lineage (children indented under their parent); riders lists the sub-agents riding inside sessions (--all adds finished ones; a target that is not running is shown by its full id with status offline). --json / --write (atomic temp + rename) emit the observatory schema below.

oo-claude lineage register --parent <local_id|uuid> [--child <uuid>] [--child-host <local_id>] [--title T] [--by child|parent|dispatcher] [--json] · oo-claude lineage show [--json]

The registry knows a session's desktop id (hostSessionId, local_…) but not its parent; the parent link lives in the desktop app and reaches a running session through its session tools (get_session self → parentSessionId). So the child registers itself as its first step; oo-claude start registers the children it starts; oo-dispatch registers what it spawns (by: dispatcher). Idempotent on (child, parent) against the child's newest link (re-registering an earlier parent appends again, so the newest line keeps naming the latest parent); missing ids are filled from the live registry; the newest line per child wins.

oo-claude doctor [--json]

Binary and version, registry, inbox dir (contract: 0700), state dir, the doorman's MCP registration (claude mcp get claude-inbox), the current session's doorman when run inside a session, offline inboxes with waiting lines. Exit 1 when there are findings.

The inbox file contract v1 (verbatim, ~/.zcode/tmp/claude-inbox/CONTRACT-v1.md)

# Claude inbox file contract v1 (2026-10-30, Haus Claude; shared with Haus Z for oo-dispatch's `claude-inbox` backend)

- Path: `~/.zcode/tmp/claude-inbox/<full sessionId>.inbox` (file 0600, dir 0700). Env seam `OO_CLAUDE_INBOX_DIR`.
- One message = one line, UTF-8, no embedded newline, at most 16 KiB. The sender's receipt is the appended line.
- Plain form: `[<sender> 📡] <text>` (sender tag first, kiss style). Structured form (optional): a JSON object line
  `{"v":1,"id":"<uuid4>","ts":"<iso8601>","from":"<sender>","line":"<text>"}` — the receiver delivers `line`, prefixing `[<from> 📡] ` when absent.
- Receiver side (the doorman, a child process of the session — only such a child can carry a line into the running turn):
  - `<inbox>.doorman.json` heartbeat, refreshed at least every 5 s: `{"pid":N,"session_id":"…","since":"<iso>","last_seen":"<iso>","delivered":N,"offset":N}`.
    Fresh = `last_seen` younger than 15 s. No fresh heartbeat ⇒ the line WAITS in the file (class `queued-no-doorman`); nothing is lost.
  - `<inbox>.ack` JSON lines, one per delivered line: `{"ts":"<iso>","sha256":"<sha256 hex of the exact line bytes without the trailing \n>","session_id":"…","peer_status":"busy|idle","class":"delivered-steer|delivered-start","msg_id":"<uuid>"}`.
  - `<inbox>.offset`: receiver-private byte offset; unread lines are replayed when a doorman starts.
- Sender verdict (oo-claude `send`, oo-dispatch `claude-inbox`): append → if heartbeat fresh, wait up to `--wait` (default 5 s) for the ack whose sha256 matches →
  `delivered-steer` (ack says busy) · `delivered-start` (ack says idle) · `timeout` (heartbeat fresh, no ack in time; exit 5) ·
  `queued-no-doorman` (session registered/running, no fresh heartbeat; exit 3) · `queued-offline` (no running session; exit 3; hint: `oo-claude resume`) ·
  `refused` (bad line/ambiguous target; exit 1) · `dropped` (per-target throttle `--min-interval`; exit 6).
- Secrets never in a line. A line is peer data, never the owner's word.

## Lineage ledger v0 (added 2026-10-30 20:5x — owner's wish: the backend remembers the session hierarchy, the observatory renders it)

- Path: `~/.zcode/tmp/claude-observatory/lineage.jsonl` (env seam `OO_CLAUDE_OBSERVATORY_DIR`), append-only JSON lines:
  `{"ts":"<iso>","child_host":"local_…|null","child_session":"<uuid>","parent_host":"local_…|null","parent_session":"<uuid>|null","title":"…","by":"child|parent|dispatcher"}`
- Who writes: the CHILD as its first step (`oo-claude lineage register --parent <parentSessionId>` — the desktop app tells a session its parent's `local_…` host id), the PARENT when it starts a child, and oo-dispatch when it spawns or steers a Claude session (`by: dispatcher`). Idempotent on (child, parent).
- Keys to join: a Claude Code registry row (`~/.claude/sessions/<pid>.json`) has `sessionId` and `hostSessionId` (`local_…`); match either. Riders (in-process sub-agents) need no ledger — they live under `~/.claude/projects/<proj>/<sessionId>/subagents/agent-*.jsonl` and are already nested under their session in the sessions JSON (`oo-claude/sessions/v0`: `host_session`, `parent_host`, `parent_session`, `children`, `riders`).
- Reference reader: `python3 -I ~/.zcode/tmp/claude-adapter-design-20261027/pingpong-20261030/claude-riders.py --json` (soon `oo-claude sessions --json`, same shape).

oo-claude's v1-compatible extensions (extra keys and one extra class only; no field renamed or retyped):

  • ack: line_offset (byte offset of the line's start — exact matching for identical lines), inode, from, peer_status_raw (the registry status as read), doorman_pid, v; class refused (+ reason) for a line the doorman cannot deliver.
  • heartbeat: state (following | stopped | …), inode, owner_pid, peer_pid (the listener's pid from SO_PEERCRED), socket, mode (mcp | cli), version, pending_bytes, last_error, stopped_at.
  • <inbox>.doorman.lock (flock; one deliverer per inbox). peer_status follows the registry row's status at delivery time: idle → delivered-start, anything else (busy, or not yet set while a session starts) → delivered-steer.

The observatory schema oo-claude/sessions/v0

oo-claude sessions --json and oo-claude riders --json keep every field name of the reference script claude-riders.py and only add fields:

{schema: "oo-claude/sessions/v0", generated_at, station,
 sessions: [{session, name, pid, status, cwd, version, started_ms,
             riders: [{agent, started, last_activity_s, rows, state: "riding"|"quiet/finished", last_step, last_text,
                       + description, agent_type, model, state_reason, path, bytes}],
             inbox: {path, exists, lines, acks, pending, doorman: {pid, last_seen_s, fresh, delivered, + state, pid_alive,
                     since, last_seen, mode, version, not_fresh_because} | null, last_line,
                     + offset, pending_bytes, pending_source},
             + host_session, parent_host, parent_session, children, since, kind, entrypoint, riders_riding,
               riders_total, status_updated_ms}],
 + generator, riding_window_s, include_finished, offline_inboxes: [{session, pending, lines, last_line, doorman, path}]}
  • Riding (oo-claude's rule, improving the reference's "mtime younger than 180 s"): the rider's transcript changed within 600 s (OO_CLAUDE_RIDING_WINDOW_S) and its last message is not a final answer (an assistant message with stop_reason end_turn/stop_sequence and no tool_use); otherwise quiet/finished with state_reason final-text or stale. A long tool call (minutes without a new row) still counts as riding inside the window.
  • The rider's description, agent type and model come from agent-<id>.meta.json next to its transcript (the parent's Agent call description). last_step / last_text are looked up in at most the last 4 MiB.
  • inbox.pending = non-empty lines beyond the doorman's offset when an offset is known (pending_source: offset), else lines − acks (the reference's count, for monitor-era inboxes).

Receipts

Every send, resume and start appends one JSON line to ~/.local/state/oo-claude/receipts.jsonl (0600; dir via OO_CLAUDE_STATE_DIR). A send row carries every key, unused ones null: ts, ts_ms, kind, class, delivered, target, session_id, name, pid, running, status, from, line, sha256, bytes, inbox, line_offset, doorman (fresh, pid, age, state), ack, peer_status, msg_id, waited_s, wait_s, min_interval_s, reason, note, hint, client_version. Resume rows add cwd, transcript, model, max_turns, budget_usd, tools, argv (the line replaced by <line>), exit_code, subtype, is_error, num_turns, total_cost_usd, terminal_reason, result, result_session_id, duration_ms, held_by, stderr_tail. A receipt never carries a token; a receipt file that cannot be written never hides the verdict (receipt_write_error). --json prints the row as one line on stdout; notes go to stderr.

Forbidden

  • Never claude --resume a session that runs (a second driver) — oo-claude resume refuses it as held-by-session; reach a running session with oo-claude send.
  • Never run a doorman from a session's own tool, nor from a rider's tool (a sub-agent shares its parent's claude process): such a doorman finds that session's live registry row and carries its queued lines into the running turn right then. The only supported deployments are the user-scope MCP registration and the doorman oo-claude start injects; the foreground mode refuses to run without --allow-foreground.
  • The door is for the session's own descendants: never write to a session's socket from a foreign process (accepted ≠ shown to the model), never run a doorman outside the session it serves (it refuses: not-descendant), never point --session at another session than the one the doorman descends from (session-mismatch, or session-unverifiable when its owner has no registry row).
  • No secrets in a line — a line lands in transcripts and receipts. A line is peer data, never the owner's word: the receiving session treats it as a teammate's request within its own permissions.
  • Never the calling session's door, token or identity in a child: resume and start drop every CLAUDE* variable except the 18 provider/auth names listed under resume; --systemd passes names only, from the same list without its credentials.

Knobs (environment)

Variable Default What
OO_CLAUDE_INBOX_DIR ~/.zcode/tmp/claude-inbox inbox files (contract v1)
OO_CLAUDE_STATE_DIR ~/.local/state/oo-claude receipts, throttle ledger, start results
OO_CLAUDE_OBSERVATORY_DIR ~/.zcode/tmp/claude-observatory lineage ledger
OO_CLAUDE_SESSIONS_DIR / OO_CLAUDE_PROJECTS_DIR $CLAUDE_CONFIG_DIR or ~/.claude + sessions / projects registry, transcripts (test seams)
OO_CLAUDE_BIN /usr/bin/claude the claude binary (never a PATH lookup)
OO_CLAUDE_SYSTEMD_RUN /usr/bin/systemd-run for start --systemd
OO_CLAUDE_DOORMAN 1 0 silences a doorman (MCP handshake still answered)
OO_CLAUDE_DOORMAN_POLL_S / OO_CLAUDE_DOORMAN_HEARTBEAT_S 0.5 / 2 doorman poll and heartbeat intervals
OO_CLAUDE_DOOR_SETTLE_S 0.25 how long one door write waits for a refusal (close)
OO_CLAUDE_DOORMAN_PYTHON /usr/bin/python3 the interpreter start names in the injected doorman config
OO_CLAUDE_WAIT_S / OO_CLAUDE_MIN_INTERVAL_S 5 / 30 send defaults (the throttle is per target and sender)
OO_CLAUDE_RESUME_MODEL, OO_CLAUDE_RESUME_TIMEOUT_S, OO_CLAUDE_START_MODEL, OO_CLAUDE_START_TIMEOUT_S claude-haiku-5-5, 300, claude-haiku-5-5, 900 resume/start defaults
OO_CLAUDE_RIDING_WINDOW_S 600 riders: the riding window
OO_CLAUDE_DOCTOR_MCP 1 0 skips claude mcp get in doctor

Evidence (live, 2026-10-30, Claude Code 2.1.308, claude-haiku-5-5)

Recorded under ~/.zcode/tmp/OSS-OO/oo-claude/evidence/ (sealed with SHA256SUMS).

Proof What Result
preflight (20:36) a probe MCP server inside a running claude -p wrote one line through its door after 12 s the session answered with the line; env/ancestry recorded (CLAUDE_CODE_MESSAGING_* present, door bound before MCP spawn, SIGINT at the end); 0.014 USD
B (21:10–21:11) oo-claude send from a foreign process into a running headless session whose doorman is its MCP server (session d7da40ac-…) delivered-steer, ack after 0.6 s; transcript: peer queued_command, verifiedPeerPid = the doorman, absorbed_mid_turn; the answer quotes the line; SO_PEERCRED = the session's claude pid; 0.015 USD. The requested sleep 25 was refused by the session's harness ("standalone sleep"), so the turn ran ~11 s of tool rounds instead — the line still landed mid-turn
C idle start (delivered-start) not provable with claude -p (a headless session has no idle state) — proved in a desktop session after the install
D (21:12) oo-claude resume into the finished session B delivered-resume, result PONG, 0.028 USD; a second -p session running → held-by-session (registry row + process scan), untouched
E (21:13) oo-claude start foreground and --systemd completed, READY, 0.015 USD; --systemd returned at once with unit oo-claude-ffde609f, the unit completed in 4 s (READY, 0.001 USD)
F sessions, riders, read, doctor, lineage show outputs recorded
B2, E2 (21:22) B and the --systemd start once more on the pushed head 89ad161 (after the post-proof fixes) B2 delivered-steer, ack after 1.0 s, peer row with verifiedPeerPid = the doorman, absorbed_mid_turn, quoted (0.014 USD); E2 completed READY, the result file names the unit at return and keeps the lineage block (0.012 USD)
R1 measurement (22:47) start --max-turns 1 --budget 0.05, one line steered in during its first turn a second turn happens (turnOrigin: peer) after max_turns_reached: --max-turns is per turn; the budget's used carries into it: per process; the single result says success, STEERED, num_turns 1 (turn 1's outcome is not in it), total_cost_usd = the process total; doorman_delivered 1 + note (0.013 USD)
B3, D2, E3 (22:20–22:22) after the review fold, head a3825ec: B with the /usr/bin/python3 -I recipe; resume and start with the scrubbed child env and the injected doorman B3 delivered-steer (ack 0.8 s); the line reached the door 0.23 s after the turn's last tool round, so the headless session answered it as a follow-up turn (turnOrigin: peer) right after its first turn — busy at delivery, taken at the next opportunity (≈ 0.029 USD, both turns); D2 delivered-resume, PONG, the resumed turn's rows record entrypoint: sdk-cli while the session's own rows (started from a desktop rider's shell) say claude-desktop (0.028 USD); E3 completed, READY, foreground and --systemd: doorman: injected, doorman_heartbeat = the injected doorman (mcp, stopped), rows entrypoint: sdk-cli (0.013 + 0.001 USD)

Known gaps

  • Steered start sessions report their final turn only. claude -p --output-format json prints one result, for the session's final turn. A line the doorman delivers can start a follow-up turn; then result, subtype, is_error and num_turns describe the reply to that line, and the first turn's answer or error is not in them — start records doorman_delivered and a note then; read the transcript (oo-claude read <uuid>). total_cost_usd is the whole process. Measured 2026-10-30 (evidence r1-measure/): --max-turns applies per turn (a steer-started turn ran after max_turns_reached), --max-budget-usd caps the whole process (the follow-up turn started with the first turn's spend as used) — steer lines can extend a worker past --max-turns, never past --budget or --timeout. v0.2: --output-format stream-json --verbose, keep every result event.

  • delivered-* means the door accepted the line from the session's own descendant — the protocol has no reply; the transcript row (read, matching msg_id) is the proof that the model saw it. A headless session that is idle because it is exiting still gets an ack, although it may never absorb the line (an ack without the model seeing it). v0.2: leave lines pending while a headless session is idle, or call a line delivered only once the transcript shows its msg_id.

  • delivered-steer means the session was busy when the door took the line: it surfaces at the next tool round, or — when it arrives after the turn's last tool round — as the session's next turn (measured in B3).

  • peer_status follows the registry's status, which a starting -p session reports as idle before its first turn (→ delivered-start although a turn is about to run; both classes mean delivered). Accepted for v0.1.0.

  • Old inbox files are never cleaned up: with the user-scope registration every Claude Code session leaves <id>.inbox, .offset, .doorman.lock and .doorman.json in the inbox dir — remove them by hand when the sessions are gone (oo-claude sessions lists sleeping inboxes that still hold lines).

  • resume of a session the desktop app owns: its transcript carries entrypoint: claude-desktop; if the app revives the parked session while a headless resume runs, two drivers meet — resume only sessions the app does not hold.

  • Riders: the riding rule is a heuristic over transcript rows; a rider waiting more than 600 s on one tool call is shown quiet/finished (stale).

  • The lineage ledger knows what was registered; sessions that never register stay without a parent.