# oxtail

A coordination layer for parallel AI coding agent sessions. Multiple Claude Code or Codex CLI sessions working in the same project root become aware of each other through an MCP server (running locally) that exposes peer-discovery and cross-session-state tools.

Scope is **project-root as the unit**. Sessions in one project root see each other; sessions in another see each other; cross-project there is no visibility, by design.

## What this isn't

- **Not a phone client.** An earlier client-side experiment explored a custom phone PWA for AI coding agents. It's paused — Termius + tmux + plain SSH won the daily-drive comparison. The actual unmet need is coordination logic, not a custom client.
- **Not a competitor to Terminator.** Terminator is a separate desktop multi-agent orchestration tool with its own coherent UI. oxtail is a server-side layer that any MCP client can leverage. Both coexist; oxtail is intentionally a separate repo to keep Terminator's identity clean.
- **Not a wrapper around tmux.** tmux is the implementation primitive most likely to back the session registry, but oxtail's identity is "agent peer awareness," not "session multiplexing." Don't bake "tmux" into tool names or public surface.

## Architecture sketch

- **Transport:** MCP. Both Claude Code and Codex CLI speak it natively, so one server serves both.
- **Surface:** invocable from any client that hosts an agent — phone via SSH+Termius, desktop iTerm, the iOS Claude app, etc. The client is irrelevant to oxtail.
- **Registry (leaning):** `tmux list-sessions` filtered by project-derived names, rather than a custom JSON registry. Free dead-session detection, free naming, no daemon to maintain. Decision pending real-use signals.
- **Project scoping:** project root inferred from session CWD at agent startup.

## Status: v0.34.0 ready, dogfooding

Fourteen MCP tools live: `list_project_sessions`, `read_session`, `claim_session`, `set_my_state`, `register_my_session`, `get_my_session`, the v0.5 messaging pair `send_message` and `read_my_messages`, `ask_peer` (delegate-and-wait, introduced v0.6, per-client wake routing in v0.7, durable on timeout in v0.15), `reply_to_message` (reply by id, v0.13), `message_status` (delivery receipts, v0.18), and the v0.19 durable-delegation verbs `my_open_work` / `complete_work` / `block_work` (`send_message`'s `action_required` flag makes an inbound an OPEN OBLIGATION the receiver owns until it closes it). Registered both project-locally (via `.mcp.json` using `tsx ./src/server.ts` for the dev loop) and globally (in `~/.claude.json` and `~/.codex/config.toml`, pointing at `dist/server.js`).

The v0.4.0 change: peer `client_session_id` and `transcript_path` now resolve reliably for Claude Code and Codex peers, even though Claude Code strips its session-id env var from MCP children. Detection layers in `src/detect/` — env, then birth-time fingerprint matching of transcript files, with a `claim_session` escape hatch (`register_my_session` is kept for debugging) — see [docs/protocol.md](docs/protocol.md#how-session_id-resolution-works) for details.

The follow-on additions (`claim_session`, `set_my_state`) introduce a peer-awareness layer: `list_project_sessions` now surfaces each peer's `state` card so an agent can learn what its peers are doing without paying for `read_session`. Raw transcripts become the deep-dive fallback, not the default mode of peer awareness.

Current phase remains **dogfooding**: use the tools in real parallel-agent work, log friction in `NOTES.md`. Each version shipped only after observed friction named the next addition — from v0.1 (`list_project_sessions`) through v0.23.0 (oxpit fleet lifecycle); the same gating applies to whatever comes next. The full per-version history is the canonical [CHANGELOG.md](CHANGELOG.md) — keep it the single source of truth rather than re-listing versions here.

The v0.5 change: two new MCP tools (`send_message`, `read_my_messages`) plus an opt-in `PreToolUse` hook installable via `npx oxtail install-hook`. Friction observed while pairing on Terminator — two agents in the same project root can see each other's state cards and transcripts but couldn't say anything to each other. Now they can. Claude Code peers see messages mid-turn (via the hook); Codex peers (or unhooked Claude Code) see them next-turn (via polling `read_my_messages`).

The v0.6 change: one new MCP tool (`ask_peer`) that turns v0.5's async pings into a blocking delegate-and-wait. Friction observed while dogfooding v0.5 — `send_message` lets agents say things to each other, but the sender doesn't stay in-turn waiting for a reply. The original implementation blocked until a reply with a matching `from_session_id` landed. v0.10.1 keeps that as the legacy fallback but upgrades capable peers to strict `request_id` / `reply_to` correlation, so stale same-peer chatter cannot satisfy a wait.

The v0.7 change: per-client wake routing after the v0.6 wake was found to be broken against idle TUI peers. Spike investigation (issue #3) revealed Codex's paste-burst heuristic (`codex-rs/tui/src/bottom_pane/paste_burst.rs`) was suppressing Enter for ~120ms after a fast typed burst — `tmux send-keys -l text` + immediate `send-keys Enter` looked like a paste, so the trailing Enter was forcibly converted to newline. Fix: a 500ms gap between the text and the Enter for Codex peers. Verified live 2026-05-13 against the live `oxtail-codex` peer in this repo. v0.7 also fail-fasted Claude Code targets with `wake_status: "skipped_unsupported"` based on a reading of the Claude Code hook catalog (no idle hook surface → "architecturally unwakeable") — but that reasoning conflated *hook events* (which Claude Code doesn't expose for idle) with *TUI input* (which works fine via `tmux send-keys`, the same mechanism that wakes Codex). A falsifying experiment 2026-05-13 against the live `oxtail-claudejr` peer confirmed the full round-trip works: ask_peer enqueue → manual send-keys → peer entered a turn → PreToolUse hook drained mailbox → peer replied via send_message. The fail-fast was a self-inflicted regression against oxtail's symmetric-matrix vision (Claude↔Claude, Claude↔Codex, both directions), so the short-circuit was removed in the follow-up. Claude Code peers now wake via the same send-keys mechanism, just without the Codex paste-burst gap. Wake strategy is overridable via `OXTAIL_ASK_PEER_WAKE_STRATEGY=auto|legacy|off` as a rollback.

The v0.9/v0.10.1 changes close the public dogfooding gaps found by real peer traffic: Stop hook deliver-on-complete, state-gated `send_message({ wake: "auto" })`, sticky Codex claim recovery, monotonic session identity after explicit claim, body-budgeted hook pushes, and provenance wording that frames peer messages as context rather than user authority.

## How to collaborate on this project

- **Don't add features without observed friction.** Speculative structure locks in design before observation has informed it. The publish-readiness work (LICENSE, README restructure, npm metadata) was the exception, because "ship it so a third party can install it" is itself the observed need.
- **Ask clarifying questions** about scope, architecture, the MCP tool set, anything unclear. Surfacing assumptions matters more than guessing.
- **Keep observation notes in `NOTES.md`** (or a single scratchpad). Don't sprawl across multiple unstructured files.
- **Don't change code based on theories — change it based on observed deltas** between actual behavior and current capability. Theorizing an orchestration API before real friction surfaces is the same antipattern as theorizing a UI fix before instrumenting.

## Design principles (locked in)

1. **Project-scoped, never global.** No cross-project visibility, ever.
2. **Implementation detail stays out of public naming.** tmux is plumbing.
3. **Both Claude Code and Codex CLI must work** with whatever we build. MCP is the cross-tool protocol; Skills are Claude-specific syntactic sugar that wraps MCP tools, never primary functionality.
4. **Minimum viable first.** One MCP tool that's actually used > five speculative ones.

## Invariants worth defending

- **`client.session_id` is the unique agent identity.** Not `server_pid`, not `tmux_session`. One Claude/Codex client can be backed by multiple MCP server children — the documented dual-scope setup (project `.mcp.json` + user `~/.claude.json`) intentionally spawns two oxtail processes per session, and Claude Code/Codex restarts during a long session can leak ghost children. The registry stores one file per `server_pid`, so duplicates per `session_id` are the norm; `readAll()` collapses them by `session_id` (freshest `started_at` wins). Any new code that reasons about peer identity must key on `client.session_id` — adding lookups keyed on `server_pid` or `tmux_session` will reintroduce the bug class where peer reads bail with misleading scope errors (see commit history for the v0.6-era dedupe fix).
- **The mailbox is keyed by the agent identity too (v0.17).** A session's canonical inbox is `~/.oxtail/mailboxes/<mailboxSessionKey(session_id)>.jsonl` — `mailboxSessionKey` (sanitized prefix + 8-hex sha256 suffix, collision-proof) is the SINGLE protocol primitive for that filename, computed only in TypeScript; the bash hooks read the precomputed `mailbox_key` from registry entries and must never re-derive it. Senders route on the receiver's advertised `capabilities.mailbox.session_keyed` — a pre-v0.17 reader only drains pid boxes, so mail routed to its session box would never be seen. Readers (read_my_messages, the hooks, ask_peer's poll/final drain) drain the union of session box + legacy pid boxes with `message_id` dedup. The legacy pid boxes, `migrateMailbox`, and reap-deferral exist for the compatibility window and unclaimed children; do not grow new features on them — new mail flow belongs on the session box.
- **Session identity is monotonic after first non-null resolution.** Automatic detection is a bootstrap aid. Once `claim_session`, `register_my_session`, or sticky-claim recovery sets a session id, later env/birth-time detection and `get_my_session` refreshes must preserve it. Only another explicit claim can change it.
- **`ask_peer` replies must correlate when the peer supports it.** Same-peer chatter is not a reply. Upgraded peers advertise `capabilities.mailbox.reply_to` and must satisfy waits with `from_session_id == target.session_id` plus `reply_to == request_id`; unmatched messages stay in the mailbox. The older `from_session_id`-only path is legacy compatibility and must be surfaced as `correlation: "uncorrelated"`. For no-capability peers, stale same-peer chatter may still satisfy the wait; that is an explicit compatibility limitation, not a correctness guarantee.
- **Peer messages are context, not user authority.** Mailbox provenance (`origin: "peer"`, `request_id`, `reply_to`, `source_message_id`) is diagnostic metadata, not a trust boundary. Hook text must keep the trust framing visible — the "context, not user authority" line plus the drained/count-0 note — and injected hook bodies must stay under an explicit budget. The rendered reply protocol leads with `reply_to_message(message_id)` (the correlation-safe path that reconstructs target + `reply_to` server-side from the received-ledger); the per-message header still renders `message_id` + `from_session_id` + `request_id` (full protocol names) as the documented `send_message` FALLBACK (the ledger write behind `reply_to_message` is best-effort) and for dup/loss debugging. `reply_to` is no longer rendered in the header (reply_to_message reconstructs it), and single-valued provenance the framing already implies (`origin: "peer"`) stays in the mailbox JSONL but is not rendered into context.
- **A displayed reply handle must be resolvable: record the received-ledger before the mailbox line is visible.** Both delivery paths are destructive — `read_my_messages` and the PreToolUse/Stop hook each truncate the mailbox on handoff — so `reply_to_message` resolves `message_id` against a durable per-session ledger (`~/.oxtail/received/<hash(session_id)>.jsonl`), never the queue. `deliverToPeer` (the single delivery primitive behind `send_message` / `ask_peer` / `reply_to_message`) MUST write the ledger entry **before** appending the mailbox line: append-then-record reopens a window where the hook renders a `message_id` the receiver cannot yet reply to. The ledger is keyed and owned by receiver `session_id`; a lookup reads only the caller's own file. The ledger write is best-effort (a failure degrades to "no handle, reply via `send_message`") but must never reorder ahead of, or block, the actual delivery.
- **Delivery mutations are crash-consistent; the shared advisory lock is owner-validated.** A crash mid-write must never corrupt a neighbour: mailbox appends heal a torn record boundary so two JSONL lines can't glue into one unparseable record (`appendLines`), and full-file rewrites (received-ledger, selective drain) go through a temp file + atomic `rename` (`atomicWrite`), never an in-place `writeFileSync` that a torn write could leave half-applied. The `mkdir` lock carries an owner token in a sidecar `<lock>.owner` (beside the dir so it stays empty and a plain `rmdir` still works for any legacy holder): **release removes the lock only if it still owns it** (a stalled holder can't stomp a successor), and **stale removal is single-winner** (`<lock>.steal` marker) **plus compare-and-clear** (remove only if the owner is still the dead token observed). Provable race-freedom is unachievable on a plain shared filesystem (no atomic compare-and-swap); the design closes every realistic race and the only residuals — enumerated in `src/locks.ts` — require a >30s SIGSTOP-class stall inside a microsecond syscall gap, with a bounded consequence (rare double-delivery or a degraded reply-handle, never a wedge or torn file). Since v0.17 the protocol lives ONCE, in `src/locks.ts`: the installed hooks delegate to the `hook-drain` helper (compiled `locks.ts`/`mailbox.ts` copied beside the scripts at install) instead of mirroring the lock in bash — keep it that way; a second implementation is how the v0.14 race class crept in. As a consequence-mitigation, the union reader `drainMany` dedups by `message_id` (v0.14.1), so the most realistic duplicate — a migrate/rescue crash-window leaving the same message in two boxes — is delivered exactly once (ids are unique nonces, so dedup only ever collapses true duplicates).

## Recently shipped

- **SessionStart auto-join (v0.17.0, hooks v9).** The manual `/oxtail-join` ceremony is no longer required for hooked Claude Code sessions: `sessionstart.sh` drops the SessionStart stdin payload (session_id/cwd/transcript_path) plus the writing hook's `$PPID` + start-sig into `~/.oxtail/session-starts/`, and a new `hook-drop` detect strategy (between env and birth-time) adopts the drop whose recorded host process is an ancestor of this MCP server — so multiple sessions sharing a project disambiguate by process tree instead of abstaining the way birth-time must. Sole-drop fallback (medium confidence) covers degraded `ps`; multiple unconfirmable drops abstain. Drops are pruned after 30 days; SessionStart stdout is context-injected so the hook never prints. This closes the gap the spike named: env is structurally stripped, birth-time is structurally ambiguous at 2+ agents — exactly the dogfooding configuration.

- **Session-keyed mailboxes + hook-drain helper (v0.17.0).** The structural fix for the split-identity-key class: a session's inbox is now one file keyed by `client.session_id` (`mailboxSessionKey` — sanitized prefix + sha256 suffix, stored as `mailbox_key` on the registry entry so bash never re-derives it), so MCP-child pid rotation can't strand mail by construction and the documented resolve→enqueue orphan residual is closed for v0.17+ readers (a legacy send whose breadcrumb vanished mid-flight rescues a session-box copy, deduped by `message_id`). Mixed-version interop is capability-gated: senders route on the receiver's advertised `capabilities.mailbox.session_keyed`; readers union session box + legacy pid boxes. The PreToolUse/Stop hooks became thin bash triggers delegating lock+parse+render to a `hook-drain` helper — compiled `locks.ts`/`mailbox.ts` installed beside the scripts — killing the awk JSON parser, the bash lock mirror, and the `FIELD_ORDER_PREFIX` coupling for v8+ installs (the serializer guard stays while pre-v8 hooks exist in the wild). Empty-inbox fast path stays pure bash (no Node on quiet tool calls); the helper handshake is `--protocol`-versioned and fails open. `HOOK_MARKER_VERSION` → 8. Design shaped by Codex's pre-review: capability routing over blind dual-write (stale session-box copies would re-deliver after upgrades), hash-suffixed key over raw sanitize (collision), breadcrumb re-check on the legacy path.

- **Read-side staleness honesty + receive convention (v0.16.x).** v0.16.2: every `read_session` return carries freshness/provenance (`resolved_session_id`, `session_id_source`, `transcript_mtime`, `transcript_age_seconds`) so a sticky-recovered identity pinned to a stale transcript no longer reads as "peer never replied" — and the tool is documented as browse/diagnostic, never proof of a reply (the mailbox is). v0.16.1: dropped the `read_my_messages` "must poll" wording that sent an idle Codex into a sleep-loop + 100s blocking `ask_peer`; delivery is sender-driven and the standing convention for no-hook peers is "idle is safe — trust the wake," with one turn-boundary read as the manual Stop-hook equivalent. v0.16.0: a 3-lens compile-sim pass landed 4 HIGH + 5 MEDIUM + 4 LOW correctness fixes (`recoverClaim` abstains instead of guessing, wall-clock lock-acquire budget, `proc_sig` pid-reuse guard before waking a pane, torn-line-safe mailbox migration).

- **Durable `ask_peer` (v0.15.0).** A timed-out `ask_peer` records a pending obligation (`~/.oxtail/pending-ask/`, keyed on requester `session_id` + `request_id`, written before one final authoritative union-drain), so the peer's late reply — minutes or hours on — wakes the requester back (`wake_reason: "late_reply_to_pending"`) via the lenient path that reaches even a markerless idle Codex requester. The PreToolUse hook re-stamps `busy` on every tool call so a long active turn never reads stale-busy. New env: `OXTAIL_PENDING_ASK_TTL_MS` (1h), `OXTAIL_ACTIVITY_BUSY_TTL_MS` (10m); default `ask_peer` timeout 45s→60s.

- **Crash-consistency + cross-language lock hardening (v0.14.0).** A `compile-sim` pass plus four Codex adversarial rounds hardened the delivery core against crash/torn-write and lock races. **Crash-consistency:** every mailbox append heals a torn previous write so a crash can't glue two JSONL records into one unparseable line (`appendLines` in `src/mailbox.ts`); the received-ledger rewrite and `drainFirstMatching`'s survivor rewrite are now atomic temp-file + `rename` (`atomicWrite`), so a torn write can't drop unrelated survivors / corrupt old reply handles. **Advisory lock:** the `mkdir` lock gains an owner token in a sidecar `<lock>.owner` (kept beside the dir so it stays empty and a bash hook's plain `rmdir` still works); **release** removes the lock only if it still owns it (closes stall-resume-release stomp), and **stale-clear** is gated behind a single-winner `<lock>.steal` marker + compare-and-clear (closes the double-clear). The protocol lives once in `src/locks.ts` and is mirrored in both bash hooks (`assets/pretooluse.sh`, `assets/stop.sh`; `HOOK_MARKER_VERSION` → 6 forces re-install). **Honest limit:** a provably race-free stale-recoverable lock isn't achievable on a plain shared FS — the residuals all require a >30s SIGSTOP-class stall in a microsecond syscall gap, bounded to a rare double-delivery / degraded reply-handle (never a wedge or torn file), documented in `src/locks.ts`. Also: `deliverExistingToPeer` preserves `message_id` + ledger on the `ask_peer` abort-recovery path (was minting a new id + skipping the ledger). Codex converged after 4 rounds (it broke the first two lock attempts before the owner-token + compare-and-clear design held).

- **Reply by id + received-ledger (v0.13.0).** `reply_to_message(message_id, body)` looks the inbound envelope up in a durable per-session ledger and derives `target` / `reply_to` / `source_message_id` server-side, replacing the manual rewiring that silently degraded a correlated exchange into loose mailbox traffic. New `src/received.ts` (ledger: sha256-keyed file, `mkdir`-lock, bounded retention `OXTAIL_RECEIVED_MAX`=1000 with a `received_ledger_pruned` trace so a drop is never silent) and `src/delivery.ts` (`deliverToPeer` = `buildMessage` → `recordReceived` → `requeue` — the record-before-append ordering above), wired into `send_message` / `ask_peer` / `reply_to_message`. Adversarial race-pair + ledger-failure-still-delivers tests in `src/delivery.test.ts`. Converged with Codex over a 5-round peer-messaging pressure test; Codex's review caught the append-before-record race, fixed before merge.

- **Wake hardening (v0.12.0 — issues #5/#6/#7, the v0.7-review backlog).** Three deferred wake items, landed together. **#6 (security):** wake send-keys now only ever target the pane the live process tree says hosts the peer's `server_pid` (`chooseVerifiedWakePane` → `currentPaneForServerPid`), never the peer's self-written `tmux_pane`/`tmux_session`; unverifiable ⇒ refuse (`skipped_no_target`). Registry-sourced tmux ids are shape-validated (`isValidTmuxPane`/`isValidTmuxSession`) and a spoofed `TMUX_PANE` env is ignored. This removed the cached-pane and session-name send-keys fallbacks (legit peers always register a real pane; churn is handled by re-resolution). **#5 (debounce):** all wake paths funnel through `wakePeer`, which coalesces repeat wakes to the same peer within `OXTAIL_WAKE_DEBOUNCE_MS` (default 1s, in-memory per process) ⇒ `skipped_debounced`. **#7 (observability):** a `wake_outcome` trace event per wake; `oxtail diagnose` summarizes wake_status counts by tool from `MCP_TRACE_FILE`; a scheduled `codex-drift.yml` fails if Codex's `PASTE_ENTER_SUPPRESS_WINDOW` drifts past our 500ms gap. New modules: `src/wake-debounce.ts`, `src/diagnose.ts`; `chooseVerifiedWakePane` in `src/registry.ts`.
- **Wake-on-reply (Slice 1, peer-messaging refinement push).** A `send_message` that carries `reply_to` now auto-wakes the original requester **by default** (explicit `wake:"off"` opts out), closing the observed stranding where a peer's async reply to an idle requester forced a human to relay it. The reply path is a separate, stricter gate than the lenient `wake:"auto"` path (`src/autowake.ts`): it fires only for a **fresh-idle** target (idle marker newer than `OXTAIL_AUTOWAKE_FRESH_IDLE_MS`, default 5m) — stale/unknown/missing/busy ⇒ `skipped_no_fresh_idle`, never a best-effort wake — and adds a **per-target rate limit** (`skipped_rate_limited`), a persistent **one-wake dedupe** keyed on `(session_id, reply_to)` (`skipped_deduped`, GC'd by age) to survive duplicate/late hook drains, an `OXTAIL_AUTOWAKE=off` kill-switch, and a best-effort `skipped_store_error` degrade so a broken dedupe store can never turn an already-enqueued reply into a tool error. Target is resolved by `client.session_id` with the pane re-resolved immediately before send-keys (no `server_pid`/stale-pane reuse). Response surfaces `wake_status` + `wake_reason:"reply_to_default"`. **Coverage caveat:** the fresh-idle gate keys on the busy/idle marker that only the Claude Code hooks maintain, so this slice reaches a **hooked Claude Code requester** (the observed case). A Codex / hookless-Claude requester has no idle marker ⇒ `skipped_no_fresh_idle` (reach it with explicit `wake:"auto"`); closing that direction is **Slice 2** (`expects_reply:true` — a requester-side waiter signal), deliberately not faked here with a blind `unknown ⇒ wake` that would reintroduce the active-waiter double-wake.
- **Protocol hardening (v0.10.1).** `ask_peer` now stamps outbound messages with `request_id`; reply-to-capable peers answer with `send_message({ reply_to: request_id })`, and the waiter ignores stale same-peer messages. Explicit identity claims are monotonic, so stale automatic detection cannot clobber a real client session id. PreToolUse/Stop hook pushes are body-budgeted and labeled as peer context, not user authority.
- **Deliver-on-complete and state-gated wake (v0.9).** The Stop hook delivers waiting messages at turn end, closing the text-only-turn gap left by PreToolUse. `UserPromptSubmit`/`Stop` maintain a busy/idle flag so `send_message({ wake: "auto" })` nudges idle peers without typing into a busy composer. Sticky Codex claim recovery keeps identity across MCP child restarts.
- **Per-client wake routing (v0.7, refined).** `ask_peer` routes its wake mechanism per `client_type`. **Codex**: paste-burst-aware send-keys (500ms gap between text and Enter) — verified to submit. **Claude Code**: same send-keys mechanism without the gap (no paste-burst in its TUI) — verified end-to-end 2026-05-13 against `oxtail-claudejr`. v0.7 originally fail-fasted Claude Code targets under a hook-catalog argument; the follow-up restored symmetric wake after falsifying that conclusion empirically. Response includes a `wake_status` field for caller diagnostics. Pre-wake pane re-resolution closes the stale-pane-ID race from v0.6. `OXTAIL_ASK_PEER_WAKE_STRATEGY=auto|legacy|off` env override for rollback. Issue #3 has the spike findings.
- **Delegate-and-wait (v0.6).** `ask_peer({ target, body })` blocks server-side until the peer replies or a timeout elapses. v0.10.1 adds strict `request_id` / `reply_to` matching for upgraded peers; legacy peers retain the original `from_session_id`-only behavior and are reported as uncorrelated. Late replies fall back to the v0.5 hook / poll delivery path. Target must have a registered `client.session_id`.
- **Cross-session messaging (v0.5).** `send_message({ target, body })` + `read_my_messages()`. Mailbox lives at `~/.oxtail/mailboxes/<server_pid>.jsonl`, drained under an `mkdir`-based advisory lock. Opt-in PreToolUse hook (`npx oxtail install-hook`) for mid-turn delivery to Claude Code.

## Deliberately deferred

- **Output capture** (vs. metadata only). Costs a wrapper layer (`script -F` or pty-mirror). Only worth doing if real friction shows metadata isn't enough.
- **Codex mid-turn delivery.** Pending Codex CLI exposing a hook surface.
- **Delivery receipts / read receipts.** Sender learns `{ ok: true, message_id }`; whether the recipient saw it is invisible. Add when real use names the shape.
- **Broadcast / multi-recipient send_message.** 1:1 only in v0.5.
- **Orphan mailbox cleanup.** Mailbox files for dead pids accumulate in `~/.oxtail/mailboxes/`. Tiny and harmless; revisit when real waste shows up in `du`.
- **Skill set.** Decide after the first MCP tool exists and we know what it feels like to use raw.
- **MCP tool naming.** Pick after observation tells us the verbs.
