- Python 98.8%
- Shell 1.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
ci / check (push) Successful in 39s
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> |
||
| .forgejo/workflows | ||
| libexec | ||
| tests | ||
| .gitignore | ||
| CHANGELOG.md | ||
| install.sh | ||
| LICENSE | ||
| oo-claude | ||
| README.md | ||
| VERSION | ||
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
claudeexecutable, whichever comes first; the session id is the owner's registry row (it follows/clearand/resumeinside the app), elseCLAUDE_CODE_SESSION_ID; an explicit--sessionmust 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, elseCLAUDE_CODE_MESSAGING_SOCKETonly when the owner itself serves it (Claude Code's<pid>.sockname, else a bare connect +SO_PEERCRED). Every delivery checks withSO_PEERCREDthat the listener is the owner (measured: the listener pid is the session'sclaudeprocess; anything else ispeer-mismatch, nothing is written, the line stays pending). An inherited socket variable of a grand-parent session — or of aclaude mcp listhealth check spawned from inside another session — therefore never carries a line into the wrong session; such a doorman stays insocket-not-mine,no-door,no-session-idornot-descendantand 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 instandby— 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_iduuid4,from= the sender tag or the structuredfrom, sanitised, elseoo-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 classrefusedonce (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 toolclaude_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 withserver/discoverand falls back toinitializeon 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-32603and the server keeps running;claude_inbox_statusreportsthread_alivefor 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; classrefused(+reason) for a line the doorman cannot deliver. - heartbeat:
state(following|stopped| …),inode,owner_pid,peer_pid(the listener's pid fromSO_PEERCRED),socket,mode(mcp|cli),version,pending_bytes,last_error,stopped_at. <inbox>.doorman.lock(flock; one deliverer per inbox).peer_statusfollows the registry row'sstatusat 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 withstop_reasonend_turn/stop_sequenceand notool_use); otherwisequiet/finishedwithstate_reasonfinal-textorstale. 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.jsonnext to its transcript (the parent'sAgentcall description).last_step/last_textare 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 --resumea session that runs (a second driver) —oo-claude resumerefuses it asheld-by-session; reach a running session withoo-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 startinjects; 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--sessionat another session than the one the doorman descends from (session-mismatch, orsession-unverifiablewhen 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:
resumeandstartdrop everyCLAUDE*variable except the 18 provider/auth names listed underresume;--systemdpasses 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
startsessions report their final turn only.claude -p --output-format jsonprints one result, for the session's final turn. A line the doorman delivers can start a follow-up turn; thenresult,subtype,is_errorandnum_turnsdescribe the reply to that line, and the first turn's answer or error is not in them —startrecordsdoorman_deliveredand anotethen; read the transcript (oo-claude read <uuid>).total_cost_usdis the whole process. Measured 2026-10-30 (evidencer1-measure/):--max-turnsapplies per turn (a steer-started turn ran aftermax_turns_reached),--max-budget-usdcaps the whole process (the follow-up turn started with the first turn's spend asused) — steer lines can extend a worker past--max-turns, never past--budgetor--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, matchingmsg_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 itsmsg_id. -
delivered-steermeans 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_statusfollows the registry'sstatus, which a starting-psession reports asidlebefore its first turn (→delivered-startalthough 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.lockand.doorman.jsonin the inbox dir — remove them by hand when the sessions are gone (oo-claude sessionslists sleeping inboxes that still hold lines). -
resumeof a session the desktop app owns: its transcript carriesentrypoint: 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.