git:20261015.6013eb4 to git:20261015.58f69e2

2 added, 0 removed. Audit A to A.

# thread component
Cross-session thread tools: seventeen agent-callable tools - nine that address PEER sessions (terminal, desktop, or tool-created) on every `senpi --mode rpc --multi-session` socket host the agent dir holds: the legacy endpoint plus each per-thread (`i-*`) and per-parent (`p-*`) shard, and eight relay tools that attach a session to an external chat thread (bindings, outbox, reply tokens). The host, its ensure/handshake, and the lifecycle supervisor live in senpi (`packages/coding-agent/src/modes/rpc/`); this directory owns the tool contracts, addressing, the address book, the transcript reader, durable receipts, prompt routing, and the tool-search discovery metadata.
Assembly status: assembled and live. `tools.ts` (`registerThreadTools`) builds the seventeen `thread_*` tools over a `ThreadHost` port and the gateway store; `component.ts` (`createThreadComponent`) registers them unconditionally, constructing the socket client from `live-surface.ts` unless a test injects a host; `src/extension/component-list.ts` lists the component right after `task`. There is no launch-flag gate: when no endpoint answers (with only the legacy endpoint known, when its socket is missing), each call fails as data with `host_unavailable` instead of the tools disappearing - except `thread_send`/`thread_handoff`, for which nothing live is the offline case: they answer `queued_offline`. `index.ts` re-exports every module in this directory.
## Anatomy
| Path | Purpose |
|------|---------|
| `contracts/` | TypeBox param schemas for the seventeen tools (`params.ts`, fields in `fields.ts`; relay text carries no schema length cap, because its 32 KiB cap `RELAY_TEXT_MAX_BYTES` lives in `gateway/bindings.ts` and `relay.ts` enforces it in UTF-8 bytes as `message_too_large`), shared byte limits (`THREAD_MESSAGE_MAX_BYTES` 32 KiB, read default 128 KiB / cap 1 MiB), the discriminated result unions, and `parseThreadParams` (invalid input returns `invalid_arguments` as data, never throws). |
| `errors.ts` | The 38-code failure taxonomy and `threadToolFailure`. Every failure is `{ code, message, next_action, details? }`; `next_action` names the recovery step for the model. |
| `addressing.ts` | `resolveTarget` (id-then-name ladder), `fuzzyMatch` (trigram Dice), `checkNameConflict`, `normalizeThreadName` (NFKC + trim + lowercase + whitespace collapse). Workspace scope is git-worktree equality, realpath equality outside git. |
| `address-book.ts` | `assembleAddressBook`: merges live `list_sessions` results from every reachable endpoint (hosts and terminals) with resumable sessions scanned from disk JSONL (`scanDiskSessions`). `findDiskSessions(sessionsDir, address, { all_scope, workspaceRoots })` finds the session files an address names when no endpoint lists the session: a durable id by the file name senpi gives it (`<timestamp>_<id>.jsonl`, a directory listing with no transcript read, in any workspace), any other address by the file's last `/name` (`readSessionFacts`, two bounded windows per file), read only in the session directories of the caller's workspace (`addressing.ts` `workspaceDirectories`) unless `all_scope`. Stateless per call; a dead endpoint degrades its sessions to disk truth plus an `error_note` (`live_unresponsive` when the engine judged it so) and `alive: false`. Every entry carries `endpoint` (`kind` `rpc_host`/`tui`, `socket`, `routing_id`), `surface` (`deriveSurface`: `tui`, `desktop` for `i-*`, `child` for `p-*` or worker sessions, `daemon` for the legacy host), a real `name` and a `title` (name, else the first 60 characters of the first user message, never the durable id), and `created_at`/`updated_at` from the session header and last entry when the endpoint reports none. `toGatewayAddressEntries` maps entries to the gateway engine's `routable`/`live_unresponsive`/`dead` view. |
| `endpoint-registry.ts` | Read-only mirror of senpi's endpoint registry (`<agentDir>/rpc-host-daemon/<16hex>/endpoint.json`, layout 2): `listRegistryEndpoints`, `endpointKindOf` (a `t-<16hex>.sock` name or a `tui` record is a terminal; a record without `registry_version`/`endpoint_kind` is `rpc_host`), `socketNamesDirectory` (canonical-path hash, as senpi names directories). Writes and connects nothing. |
+ | `session-facts.ts` | `parseSessionLines` + `summarizeSessionEntries`: the one set of session-file rules (header, last `/name`, first user message, newest timestamp) both readers use; `readSessionFacts` applies them to the first and last 64 KiB only, and `readDiskSession` (a dead endpoint's listing) reads through it, so only `thread_read` reads a whole transcript; `threadTitle`. |
+ | `thread-tools name conflicts` | `thread_create` checks a new name against every listed thread through `checkNameConflict` (`addressing.ts`); `thread_rename` checks only the caller's workspace unless `all_scope`, which is enough because name resolution is workspace-scoped too. A fuzzy `thread_handoff` searches the caller's workspace unless `all_scope`, and answers `caller_context_missing` (like the exact ladder) when the caller has no workspace root. |
| `session-facts.ts` | `parseSessionLines` + `summarizeSessionEntries`: the one set of session-file rules (header, last `/name`, first user message, newest timestamp) both readers use; `readSessionFacts` applies them to the first and last 64 KiB only, `readDiskSession` to the whole file; `threadTitle`. |
| `thread-link.ts` | The one thread link format, `[title](omo-thread://v1/<thread_id>)`: `threadLink`, `threadLinkUrl`, `parseThreadLinkUrl`. The thread id is the session's durable id, which is also the desktop's thread id, so the link carries no environment, path, socket or token. Every result that names a thread carries one as `link` (`thread_list` entries, `thread_create`, `thread_read`, `thread_send`, the `thread_handoff` receipt), and the family guideline tells the agent to paste it. |
| `reader.ts` | Bounded synchronous transcript reader for `thread_read`. Names its source (`live_host` vs `session_jsonl`); on the jsonl path `source_incomplete` is true exactly when no live host holds the session. An empty file is an empty transcript, never an error. |
| `gateway/` | The session gateway every `thread_send` / `thread_handoff` delivers through (senpi 2026.9.29-4 ships `pi.session.registerControlEndpoint`, `admitExternalMessage`, `wake` and `release_session`). `registration.ts` is the receiver's entry point: `sessionControlOf(pi)` feature-detects `pi.session`, and `createControlEndpointRegistrant` (stamping `runtimeInstance` from `pi.sessionContext.host_instance` on the store, so a host `release_session` settles only its own runtime's claims) persists the header, registers the session's control endpoint with the inbox drain (`gateway/inbox/<durable id>/`) and `isSessionReferenced`, and only then opens the store to record the session's incarnation (every wake's drain waits for that record; a failure to record it disposes the endpoint and fails the registration), so a session senpi answers `unsupported` for (a Windows terminal, a mode without a host) does no gateway I/O; the component calls it on `session_start` and disposes the endpoint before the store on `session_shutdown`. On an engine without `pi.session` it registers nothing. `store.ts` is an async facade over `store-worker.ts`, started as a worker thread from `gatewayStoreWorkerUrl()`: the `gateway-store-worker.mjs` sidecar the build emits beside `omo.js` when it exists, else `store-worker.ts` in source; `store-worker.ts` is the one worker thread that owns `<agentDir>/gateway/gateway.sqlite` (WAL, `synchronous=FULL`, 0600, 5 s busy timeout; one operation waits for the write lock at most `GATEWAY_LOCK_WAIT_MAX_MS` = 30 s in total - it gives up before another attempt could pass that, at 25 s with the 5 s busy timeout - then fails with a `lock_wait_exceeded` event and an error carrying `code: "gateway_lock_wait_exceeded"` (`lock-wait.ts`), so a suspended lock holder never stalls the worker's serial queue. The callers that must not give up re-arm one retry after the busy timeout, off the worker queue: the inbox drain re-runs the same wake until a pass gets through (the protocol's single busy timer), a completion write retries with its original outcome, and `thread_answer` retries releasing a question whose hand-off failed) and runs every statement (`store-ops.ts`, statement-free through `sql.ts`). Tables: `deliveries` (the single delivery authority: `queued -> admitting -> admitted -> applied`, or `refused`/`uncertain`), `receipts`, `causal_roots`/`causal_edges`, `rate_buckets`, `session_meta`, `bindings`, `outbox`, `gateway_meta`. `engine.ts` is the sender half (one `BEGIN IMMEDIATE` writes row, receipt, edge, budgets and the target's inbox marker, then a best-effort `wake`); `drain.ts` is the receiver half (barrier `reconcile`, two-phase claim, `applied` only once the runtime reports the entry emitted). `decision.ts` is the 15-cell state x mode table; `adapter.ts` is the provisional senpi contract every senpi call goes through (aligned with the senpi `feat/tui-control-endpoint` branches: `registerControlEndpoint` resolves a `{status}` union, `release_session` carries `reason: "takeover"` and refuses with `error` + `errorData`; `toSessionControlDrainResult` leaves the gateway's own refusals `not_steerable`/`expired` out of senpi's `admitted` list, because the sender reads outcomes from the row); `testing/` holds the fake runtime, the harness and the cross-process sender. The relay half (todo 13): `bindings.ts` (the closed binding record, `normalizeBindInput` defaults and validation, the snapshot cursor of `thread_bindings`, the HMAC reply-token codec, `hashArgs`), `store-relay-ops.ts` (worker-only: tool receipts, bind/unbind/rebind/list, report, completion arms, outbox read/ack, answer claim/release, session incarnation; every op one `BEGIN IMMEDIATE` transaction that first moves due bindings to `expired`), `relay.ts` (`createGatewayRelay`, the SDK the tools and the `omo thread` CLI share), `completion.ts` (`createCompletionTracker`: a completion is written at `agent_settled` with the last `agent_end`'s outcome, never at an `agent_end`). Schema v2 is additive (`session_meta.incarnation`, outbox question/answer/outcome columns, `outbox_cursors`, `completion_arms`, the realm id and token secret seeded in `gateway_meta`); v3 adds `outbox.ui_request_kind` (NULL when the session declared no kind) and `outbox.answer_state` (`in_flight` | `delivered`; NULL on rows answered before v3, which count as an in-flight claim made at their `answered_at`, so one whose claimant died is taken over after the in-flight bound; when the session refuses the taking-over answer because it no longer waits on the request, the row becomes delivered instead of pending, so it costs at most one refused frame: with the earlier answer for `question_already_resolved`, with NULL answer text for `unknown_extension_ui_request`/`unknown_request` (closed another way; `CLOSED_ELSEWHERE` wording)); v4 adds `outbox.answered_by` (the author an answer named, JSON; NULL otherwise); `migrate` takes no write lock on a current store and re-reads the version under the lock otherwise. The drain shows the queued notice through the session's own UI `notify` (`queuedNotice`: "remote message from <actor> queued (<delivery_id>)"), once per delivery, when a delivery waits behind a running turn or the user's draft; the provenance header adds `binding=<id>@<revision>` for a delivery that arrived through a binding, and `author=`/`author_id=`/`author_user_id=` (JSON-quoted, brackets escaped) when the connector named an author (`author.ts` `normalizeAuthor` validates it at the relay; a binding sender with an author has its own pair rate bucket, `binding:<id>#author:<platform user id>`). A binding message's `mode` is capped by the binding's `inbound_mode`. Every outbox insert rewrites `gateway/outbox.marker` (temp + rename) as the connector wake hint. |
| `gateway/answer-shape.ts` | `answerShape(kind, text)`: the `extension_ui_response` fields each request kind reads (`confirmed` from yes/no, `value`, or `answers` + `comment`; with no declared kind all three text fields `value` + `answers` + `comment` at once, and `confirmed` for a yes/no word), and `isBlankAnswer` (whitespace plus `\p{Cf}`). Shared by the claim check in the store worker and the wire frame. |
| `ui-answer.ts` | `answerUiRequest`: the `extension_ui_response` a relayed `thread_answer` becomes (senpi#2372: `uiRequestId` names the request, the frame keeps its own `id` for the reply), read back as delivered or the endpoint's refusal code. |
| `prompt-routing.ts` | `PromptRouter` over senpi's existing `extension_ui_request` frames: routes each prompt per policy (`answer-here`, `leave-to-own-client`, `auto-cancel`), authorizes the answerer, and cancels with a recorded reason on timeout or `close()`. |
| `tools.ts` | `registerThreadTools`: the nine peer-session handlers plus the eight relay tools from `tools/relay-tools.ts` (store-only: they take no host listing unless a session address other than the caller must be resolved, and then resolve it like a send, through `tools/internals.ts` `resolveStoredSession`: the offline view plus `sendAddressBook`, so nothing live is not `host_unavailable` and a session known only from disk resolves). Every peer call runs through one `execute` wrapper that reads the caller id, parses params, admits the call through the store's receipts, takes one host view (`tools/internals.ts` `hostView`; a send takes it with `{ offline: true }`, so an agent dir where nothing answers is a view with no live sessions, not `host_unavailable`), and maps a thrown `host_unavailable:` from the surface to the `host_unavailable` code and a thrown `unsupported:<command>` (a command a terminal endpoint does not take) to `unsupported`. `thread_send`/`thread_handoff` go through `gateway/engine.deliver`: the engine's receipt is written in the delivery's own transaction (so they skip the wrapper's receipt), a terminal target is queued for its own inbox and woken, and a session delivering to itself is refused `loop_detected`. A send resolves its target through `sendAddressBook`, which adds the session files `findDiskSessions` names when the address matches no entry, so a session no endpoint lists is still addressed by id or name and queued offline. `thread_list` rows carry `endpoint`, `surface`, `alive` and the address book's title and timestamps. Per-session calls go through `sessionPort` (the session's own endpoint). `thread_list` also lists a dead endpoint's disk sessions as `resumable` with `error_note`; `thread_read` answers those from JSONL (`source_incomplete: true`), rendering only `message` entries with role user/assistant/toolResult and skipping bookkeeping lines (header, model/thinking changes); every other non-live thread stays `not_resumable`. |
| `component.ts` | `createThreadComponent`: the `OmoSenpiComponent` named `thread`. Options exist only as test seams (`host`, `diskSessions`, `stateDirectory`, `callerSessionId`, `callerWorkspaceRoot`, `sessionControl`, `agentDir`, `store`, `controlEndpointTest`, `sessionsDirectory`, default `<agentDir>/sessions`). Opens ONE gateway store for the tools and the control endpoint (disposed on `session_shutdown`), registers the control endpoint (`gateway/registration.ts`) when the engine exposes `pi.session` (recording the session's incarnation), counts turns (`agent_start`) for the per-turn fan-out budget, remembers the delivery that caused the current run (cleared at `agent_settled`), and writes completions at `agent_settled` only for an armed session: armed through `thread_report` (`onCompletionArmed`), or found with a durable `completion_arms` row at `session_start` or on a `wake` command edge (a lock-free read, only when `gateway.sqlite` already exists; `registration.ts` `onCommandWake`), so an arm survives a restart and an arm made from the CLI (the SDK wakes the session after arming) reaches a running session. Every other settle makes no store call and creates no database. senpi waits for `agent_settled` handlers before idle, so the handler waits at most `COMPLETION_SETTLE_WAIT_MS` (250 ms) for the write, which then continues in the background; the arm is kept until its row is written, a write that hits the lock-wait bound is retried after the busy timeout with the settled run's outcome, and each failure is logged. |
| `live-surface.ts` | `createLiveThreadSurface`: the `ThreadHost` client for every supervisor-owned unix socket of the agent dir (never starts or replaces a host). Endpoints come from the engine CLI `host status --all --include-workers --json` (`parseHostStatusAll`, reused for `HOST_ENDPOINTS_CACHE_TTL_MS` = 5 s) plus the legacy socket from `resolveThreadSocket` (same env precedence as the task daemon; the operator endpoint `rpc.sock`, where `thread_create` opens sessions). An engine that cannot enumerate (pre-release usage error, no CLI) falls back to the registry on disk (`endpoint-registry.ts`). Rows keep `endpoint_kind` and the liveness verdict. A terminal (`tui`) endpoint is dialed with its `<socket>.secret` first, under a 1.5 s budget (`TUI_REQUEST_TIMEOUT_MS`), and only with `TUI_ENDPOINT_COMMANDS`; any other command throws `unsupported:<command>` before a connection opens. A terminal the engine reports not alive is not dialed, and a terminal whose socket is gone (it removes the socket when it exits) is reported `dead`. `gateway` is the engine's `GatewayEndpointPort` over the same endpoints (`wake`, host-only `release_session`, `classifyLiveness`) and never sends `prompt`. `listView` lists every endpoint live per call (`ENDPOINT_LIST_TIMEOUT_MS` = 10 s each) and tags each session with its `socket`; every `list_sessions` it sends (and the read-back after `open_session`) carries `observe: true`, so looking never resets a shard's idle window, while requests that act on a session never carry it; `endpoint(socket)` returns the per-session methods bound to that socket, because routing ids are per-host counters (`rpc-1` on every host). A dead endpoint yields an errored address-book host plus the sessions its report still claims or it last listed, read from JSONL. When no endpoint answers at all, `listView()` raises the first failure (legacy first); `listView({ offline: true })` (the send path) returns that view instead. `openSession` (`thread_create`) runs on the legacy socket. Methods: `listSessions`, `openSession`, `getMessages`, `getState`, `prompt`, `interrupt`, `setSessionName`, `setModel`, `getAvailableModels`, `setThinkingLevel`, `getAvailableThinkingLevels`. A `set_thinking_level` rejection of the form "Thinking level X is not supported by the active model." is rethrown as `thinking_level_unsupported:` so the tool can map it. |
| `sdk.ts` | `createThreadSdk({ agentDir, cwd, uid, user, engineStatusAll? })`: the script-callable surface the `omo thread` CLI, `omo daemon adopt` and connector scripts use, built to `plugin/runtime/thread-sdk/sdk.js` (entry `src/extension/thread-sdk.ts`, which points the store at the `extensions/gateway-store-worker.mjs` sidecar through `workerModuleUrl`). Every operation of the tools over the same address book, engine and relay, as principal `cli:<uid>`: `list`/`read` (receipt-free), `send` (bindingless = engine sender `{kind:"cli"}`; with `binding_id` = `relay.inbound`, the idempotency key as the event id, a named target must be the binding's session), `bind`/`unbind`/`rebind`/`bindings`/`report`/`outbox`/`ack`/`answer` (sessions named explicitly: the CLI has no calling session), `locate` (the address-book entry with endpoint, surface, liveness and session path) and `release` (senpi `release_session` with `reason:"takeover"`; a transport failure is data). A thrown surface failure maps to the tools' codes; nothing throws for a refusal. `send` resolves through the same `sendAddressBook` as the tools, over `<agentDir>/sessions`, with the offline view: a session this process never saw alive, or an agent dir with nothing running, answers `queued_offline`. `bind`, `rebind`, `report` and `bindings` with a session resolve that session the same way (`resolveStoredSession`). It never starts a host. |
| `tools/gateway-services.ts` | `createGatewayServices(options, view)`: the one store + engine + relay + `locate` composition the tools and the SDK share. |
| `tools/read-ops.ts` | `listThreads` / `readThread` (with the dead-endpoint JSONL fallback): the `thread_list`/`thread_read` bodies, shared with the SDK. |
| `metadata.ts` | Tool-search entries (`exposure: "search"`, group `threads`, verb-led labels, unique keywords) plus the single family-wide `promptGuidelines` string. No indexed field carries a negated-use sentence because BM25 indexes negated words positively. |
| `discovery.test.ts` and the other `*.test.ts` | Pure-seam unit tests; live-socket behavior is proven by the QA harnesses below instead. |
## The seventeen tools
All seventeen return discriminated unions: `{ kind: "ok", ... }` or `{ kind: "error", error: ThreadToolFailure }`. Errors are data, never exceptions.
- `thread_create` `{ name?, cwd?, fork_from?, idempotency_key? }` -> `{ thread, deduplicated }`. A normalized name collision is `name_conflict`; there is no auto-suffix anywhere in the family.
- `thread_list` `{ all_scope? }` -> `{ threads, scope: "workspace" | "all" }`.
- `thread_read` `{ thread, cursor?, max_bytes?, all_scope? }` -> `{ thread_id, items, truncated, next_cursor?, source }`. A cursor the transcript revision moved past is `cursor_stale`.
- `thread_send` `{ thread, message, delivery?, expected_turn_id?, summary?, idempotency_key?, all_scope? }` -> `{ thread_id, delivery, message_seq, deduplicated, delivery_id?, effective_mode?, endpoint? }`. Delivery outcome is one of `steered`/`started` (with `turn_id`), `queued` or, through the gateway, `queued_offline` (with `queue_position`). The gateway path adds `delivery_id`, `effective_mode` and `endpoint: { kind: "rpc_host" | "tui" } | null`, and its errors add `loop_detected`, `idempotency_uncertain` (with `delivery_id` and the row state), `scope_denied` and `unsupported`. A direct reply to the session that messaged this one under the same causal root is a cycle: answers flow through `thread_read`, `thread_report` and `thread_answer`.
- `thread_interrupt` `{ thread, turn_id?, all_scope? }` -> `{ thread_id, turn_id?, interrupted }`. Interrupting an idle thread succeeds with `interrupted: false`.
- `thread_handoff` `{ thread, match?, message, delivery?, ... }` -> `{ thread, resolved_by, delivery, message_seq, deduplicated }`. The only tool with fuzzy resolution (`match: "fuzzy"`).
- `thread_rename` `{ thread, name, all_scope?, idempotency_key? }` -> `{ thread_id, name }`. The name is trimmed; an empty result is `invalid_arguments`, and a case-insensitive match with another visible thread is `name_conflict` (no auto-suffix). Needs a live owner (`not_resumable` otherwise); delivered through `setSessionName`. The id stays the address, the name is only a label.
- `thread_set_model` `{ thread, model, provider?, all_scope?, idempotency_key? }` -> `{ thread_id, model: { provider, id } }`. Resolution ladder against the target's `getAvailableModels` catalog: exact `provider/id`, then exact id, then case-insensitive fragment of id or display name; `provider` narrows the catalog first. Zero hits is `model_not_found` (details carry up to 20 `available`), several is `model_ambiguous` (up to 10 `candidates`). Applied through `setModel`; needs a live owner.
- `thread_set_reasoning` `{ thread, level, scope?, all_scope?, idempotency_key? }` -> `{ thread_id, level, scope }`. `level` is one of `off|minimal|low|medium|high|xhigh|max`; `scope` defaults to `session` (rewrites the model's remembered level) and `turn` changes only the current session level. A level the active model can't run is `thinking_level_unsupported` with the `supported` list in details, and the thread is left unchanged. The host validates on the `turn` path, which applies to the active model right away; `session` records the model's remembered preference and is accepted even for a level that model cannot currently run (observed live against senpi 2026.9.21 with a model whose supported set is `["off"]`). Delivered through `setThinkingLevel`; needs a live owner.
- `thread_create` takes `skills` (`initial-skills.ts`): names of installed skills the new session follows from its first turn. The creator checks only each name's shape before the session exists: a path or raw text is `invalid_arguments` naming it, and nothing is opened. It does not check that the skill is installed: senpi's skill roots (`~/.agents/skills`, ancestor `.agents/skills` under trust, settings paths, package skills, omo's bundled skills) and the host's default cwd live in the session, so the new session's own registry is the one answer, and the result echoes the names passed as `thread.requested_skills`. The list rides on the `open_session` context (`initial_skills`, JSON); the session's thread component copies it into a durable `omo-initial-skills` session entry on its first start and, on every turn (the preview included), appends each skill's body looked up in the session's own registry, so scope and trust follow `/skill`. The ok result echoes `thread.skills`.
Relay tools (bindings are connector-neutral; nothing here talks to a chat platform):
- `thread_bind` `{ platform, account_id, chat_id, thread_id? ("@chat"), root_message_id?, progress_message_id?, session? (default the caller), direction?, inbound_mode? ("auto"), outbound_events? (all four), policy_id?, ttl_seconds? (604800; null = no expiry), all_scope?, idempotency_key? }` -> `{ binding, deduplicated }`. At most one `active` binding per `(platform, account_id, chat_id, thread_id)`: a thread bound elsewhere is `binding_conflict` with the holder's `binding_id`/`revision`/`session` (no implicit takeover). Same key + same arguments replays; same key + different arguments is `idempotency_conflict`.
- `thread_unbind` `{ binding_id, expected_revision, idempotency_key? }` -> `{ binding, already_closed, in_flight, deduplicated }`. CAS on the revision (`stale_revision` otherwise); detaching bumps the revision; an already closed binding replays success with `already_closed: true`; `in_flight` lists deliveries accepted through it that the session has not taken.
- `thread_rebind` `{ binding_id, expected_revision, session, all_scope?, idempotency_key? }` -> `{ binding, closed, deduplicated }`. CAS; the revision bumps, `lease_started_at` resets, `expires_at` does not move (a TTL is never extended); deliveries queued under the old revision are refused `binding_closed` (listed in `closed`), never moved. A detached or expired binding is `binding_inactive`.
- `thread_bindings` `{ session?, platform?, account_id?, chat_id?, thread_id?, status?, cursor?, limit? (50, max 200), all_scope? }` -> `{ bindings, next_cursor }`, ordered by `(created_at, binding_id)` over a snapshot fixed by the first page.
- `thread_report` `{ binding_id?, kind: milestone|report|question|completion, text, request_id?, request_kind?, idempotency_key? }` -> `{ binding_id, revision, event, cursor, reply_token, armed, deduplicated }`. Only the calling session reports, only through its own binding (`scope_denied` otherwise), only when the binding is active (`binding_inactive`), outbound and subscribed to the event (`unsupported`). Without `binding_id` it is the ORIGINATING binding: the binding of the messages the session's current answer is for (`component.ts` `RunContext.consumed`: the deliveries whose `session_control_delivery` message entered the model's context since its last final answer, from the engine's `message_start`; a queued follow-up senpi drains after that answer, before `agent_settled`, starts a new group, as does any other `user` or `custom` message: a prompt typed in the terminal, a delegated task's result, an extension's `sendUserMessage`). So a message from another thread queued behind the run never takes A's report, and the follow-up's own answer goes to its own thread. Messages from two bound threads in one answer (a steer at a tool boundary, `followUpMode: "all"`) are `invalid_arguments` naming both `binding_ids`; so is a bound message answered together with a `user` message while the session has another active outbound binding (`RunContext.local`, any `user` message in the group: a local steer into a thread's run, or the reverse; senpi gives a typed prompt and an extension's `sendUserMessage`, such as a non-blocking ask_user answer, the same shape, so both count), naming the session's active outbound `binding_ids`. With the bound message's binding as the only outbound one, the report goes to it. With no bound message (the user typed the prompt, or the CLI reports) it is the session's only active outbound binding; with several, `invalid_arguments` with their `binding_ids` (name one). Nothing is ever copied to the session's other bindings. `question` needs `request_id` (the session's pending `extension_ui_request` id) and returns a reply token; `completion` is only armed (`cursor: null`) and written once when the session settles.
- `thread_outbox` `{ binding_id, after_cursor?, limit? (100, max 500) }` -> `{ binding_id, revision, status, rows, next_cursor, acked_cursor }`. Without `after_cursor` it continues after the acknowledged cursor; an older cursor re-reads (acked rows are kept 30 days after their ack; unacked rows live as long as their binding plus 30 days). A row: `{ cursor, binding_id, revision, event, text, state, created_at, edit_message_id, provider_message_id, reply_token, question_state, outcome, answered_by, answer_state }`; `answer_state` is `in_flight` while a `thread_answer` claim is being handed over and `delivered` once the session took the answer or closed the question, so a connector settles only a delivered question.
- `thread_outbox_ack` `{ binding_id, cursor, provider_message_id? }` -> `{ binding_id, acked_cursor, changed }`. Idempotent; an older or equal cursor is a no-op; a newer cursor that names no row of this binding (past its newest row, or another binding's row) is `cursor_invalid` and acks nothing. The first `provider_message_id` acked for a milestone becomes the binding's `progress_message_id`, which later milestone rows carry as `edit_message_id`.
- `thread_answer` `{ binding_id, reply_token, answer }` -> `{ binding_id, cursor, session_durable_id }`. `binding_id` is the binding the answer ARRIVED through. A different one than the token's is `binding_mismatch` (nothing written, the question stays pending); an answered question is `already_answered`; a binding that moved (detached, expired, rebound) or a session whose incarnation changed is `stale_token`; a forged or foreign token is `invalid_arguments`. Only a match marks the question answered and hands the answer to the session's own endpoint as `extension_ui_response { id: <own correlation id>, uiRequestId: <request_id>, value, answers: {}, comment }` (`ui-answer.ts`; the text travels as `value` for a host dialog and as `comment` for a `question`, the only kind a terminal asks), matching the reply by that frame `id` (senpi#2372). The answer takes the shape of the request kind `thread_report` recorded (`request_kind`; `gateway/answer-shape.ts`): `confirm` takes yes/no (also y/n, true/false; any case, surrounding spaces trimmed) as `confirmed`, `select` a non-blank `value`, `input`/`editor` any text including empty as `value`, `question` non-blank text as `comment`; a question reported without a kind (and every row written before v3) gets non-blank text as `value` + `answers: {}` + `comment` together, so a question, select, input or editor each reads its own field, plus `confirmed` when the text is a yes/no word (the one field a confirm reads and every other kind ignores; other text leaves an undeclared confirm resolving as no); blank means only whitespace and invisible format characters (zero-width space included). An answer the kind cannot take is `invalid_arguments` before anything is claimed. While another answer is still being handed over the question is `answer_in_progress` (retry after a moment; a claim older than `ANSWER_IN_FLIGHT_MAX_MS` = 120 s is taken over), and `already_answered` means the answer reached the session. A session that cannot be located, or a reply that never comes, is `host_unavailable`; a refusal (`unknown_request`, `question_already_resolved`, ...) is `stale_token`, or `invalid_arguments` for `invalid_response`/`question_incomplete`, with the endpoint's code in `details.reason`. Both release the claim, except that an answer which took over an expired claim and is refused because the session no longer waits (`question_already_resolved`, `unknown_extension_ui_request`, `unknown_request`) marks the question delivered with the expired claim's answer (`markPriorDelivered`, bound to the caller's claim) and answers `already_answered`; a delivered answer keeps it.
- A question the session closes itself (its ask_user request answered in its own client, timed out or cancelled; senpi's `ask-user:closed` event) is closed in the store by the thread component when this runtime relayed it (`store-relay-ops.ts` `closeQuestion`). It reads like one closed elsewhere (`answered`, delivered, no answer text) and rewrites the outbox marker, so a connector stops holding the thread's later rows behind it and a late `thread_answer` is `already_answered`. Only a `pending` question changes; an answer in flight or delivered through `thread_answer` is the relay's. A question relayed by another process (`omo thread report`) or by an earlier runtime of the session is not closed this way. A release undoes only the in-flight claim that made it (its `answered_at`) and never a delivered question, so a claimant whose claim was taken over releases nothing. A confirmation records that the session took that claimant's answer: the question becomes delivered with that answer even when another answer took the claim over meanwhile (the session resolves a request once), and a question already delivered keeps its answer.
## Relay contract for connectors and the CLI
Stable surface a downstream connector consumes, through the `omo thread` CLI (`docs/reference/omo-thread.md`: JSON shapes, exit codes) or `sdk.ts` (`plugin/runtime/thread-sdk/sdk.js`), both built on: `gateway/relay.ts` `createGatewayRelay({ store, engine, endpoints, locate })` with `bind`/`unbind`/`rebind`/`bindings`/`report`/`outbox`/`ack`/`answer`/`inbound`/`settle`; results are the same data unions as the tools. A connector drives ONLY `outbox` + `ack` (read what sessions posted), `answer` (with the binding its message arrived through) and `inbound` (`{ binding_id, event_id, text }`: delivered with the binding's `inbound_mode`, idempotency key `event:<event_id>` so one platform message is admitted once; the provenance header says `source=external`, `actor=<account_id>`, `binding=<id>@<revision>`). Principals: `session:<durable id>`, `cli:<uid>`, `binding:<id>`. The store is `<agentDir>/gateway/gateway.sqlite`; the binding record is the closed schema in `gateway/bindings.ts` (`schema_version: 1`). No connector, no resident process, and no auto-broadcast to a session's bindings live in this directory, and neither does session lifecycle: the CLI and SDK never create or resume a session (a connector opens one through the host API, the operator endpoint's `open_session` that `thread_create` uses, and binds it). A completion row carries the text armed with it, not the model's reply (`thread_report` or a transcript read gives the answer). The per-author rate bucket is keyed by the author id the connector supplies: a trusted local connector, still bounded by the target's 128-message backlog.
`ThreadDeliveryMode` is an enum, not a boolean. Every send goes through the gateway (the tools, the `omo thread` CLI and the SDK alike), where `auto` never steers: it starts a turn on an idle session and otherwise queues as a follow-up; `steer` and `follow_up` behave as the "Delivery table" below says.
## Endpoint kinds
Every endpoint of the agent dir is one of two kinds (senpi `docs/rpc.md`, "Endpoint kinds: hosts and terminals"):
- `rpc_host`: a multi-session host (the legacy `rpc.sock`, a `p-*` task host, an `i-*` Desktop thread host). Sessions are addressed by routing id on that host; `wake`, `release_session` and every per-session command go to the host socket. A host session registers its control endpoint too, but on a host that binds nothing: the host socket is the endpoint.
- `tui`: one interactive terminal whose thread component registered `pi.session.registerControlEndpoint` (POSIX only; `<agentDir>/rpc/tui/t-<16hex>.sock` plus a 0600 `<socket>.secret` sent before every request). It holds exactly one session. It answers the read-mostly `TUI_ENDPOINT_COMMANDS`, plus whatever its `get_protocol_info` lists in `commands`: a terminal on senpi with terminal session controls (oh-my-openagent#9660) adds `get_available_models`, `get_available_thinking_levels`, `set_model`, `set_thinking_level` and `interrupt`, so `thread_set_model`, `thread_set_reasoning` and `thread_interrupt` work on it. `endpoint-controls.ts` maps the accepted commands to the `controls` each `thread_list` row reports; a host accepts every control. Any other command is refused as `unsupported` before a connection opens, and `prompt`, `steer`, `follow_up`, `open_session` and `release_session` are never sent to a terminal. A terminal is never a host: `omo daemon status` counts it in `aggregate.terminals`, lifecycle commands skip it, and `omo daemon adopt` refuses it.
A terminal is never joined to a host; there is no shared-runtime mode left to route around. Messages reach either kind the same way: the sender writes the gateway row and calls `wake`, and the receiver's own drain admits it through `pi.session.admitExternalMessage`.
## Delivery table
The receiver decides with `gateway/decision.ts` (`decideDelivery`), from the target's phase when its drain runs; the sender reads the outcome from the row, never from the `wake` reply:
| Target state | `auto` | `steer` (needs `expected_turn_id`) | `follow_up` |
| --- | --- | --- | --- |
| `idle` | admit followUp -> `started` | refuse `not_steerable` | admit followUp -> `started` |
| `mid_turn` | admit followUp -> `queued` | epoch matches: admit steer -> `steered`; else refuse `turn_conflict` | admit followUp -> `queued` |
| `waiting_question` | admit followUp -> `queued` | refuse `not_steerable` | admit followUp -> `queued` |
| `compacting` | admit followUp -> `queued` | refuse `not_steerable` | admit followUp -> `queued` |
| `offline` | `queued_offline` | refuse `turn_conflict` | `queued_offline` |
senpi's admission may still answer `held_draft` (the user is composing, or a submission has not reached the runtime): nothing is enqueued, the row stays claimable and the next wake retries, and the drain shows `queuedNotice` once. `already_admitted` settles a replayed claim without a second entry. A question answer never travels as a delivery: it goes through `thread_answer`.
## Budgets
None of these is a setting (`gateway/constants.ts`, `component.ts`):
| Budget | Value | Where |
| --- | --- | --- |
| Settle wait | 250 ms (`COMPLETION_SETTLE_WAIT_MS`): an `agent_settled` handler waits at most this long for a completion write, which then finishes in the background | `component.ts` |
| Write-lock wait | 5 s busy timeout per attempt; one operation gives up at about 25 s, never past `GATEWAY_LOCK_WAIT_MAX_MS` = 30 s, with `code: "gateway_lock_wait_exceeded"` | `store-worker.ts`, `lock-wait.ts` |
| Retry on `lock_wait_exceeded` | one timer at a time, after the busy timeout, until it lands or shutdown cancels it: the inbox drain (same wake), a completion write (same outcome), an answer release | `lock-wait.ts` `retryAfterLockWait` |
| Hops per causal chain | 4 (`MAX_HOPS`) | `store-ops.ts` -> `loop_detected` `hop_limit` |
| Deliveries per causal chain | 64 (`MAX_CAUSAL_DELIVERIES`) | `loop_detected` `causal_budget` |
| Causal root lifetime | 7 days (`ROOT_LIFETIME_MS`) | `loop_detected` `root_expired` |
| Sessions reached per sender turn | 16 (`MAX_FANOUT_PER_TURN`) | `overloaded` `fanout` |
| Sender -> target rate | burst 8, one token per 5 s (`PAIR_BUCKET_*`) | `overloaded` `pair_rate` + `retry_after_ms` |
| Target backlog | 128 messages / 1 MiB (`TARGET_MAX_*`) | `queue_full` |
| Delivery size | 1 MiB through the engine; tool message 32 KiB (`THREAD_MESSAGE_MAX_BYTES`); report/answer text 32 KiB (`RELAY_TEXT_MAX_BYTES`) | `message_too_large` |
| Queued delivery TTL | 24 h (`QUEUED_TTL_MS`), or the binding's expiry when sooner | row `refused` `expired` |
| Receipts | 30 days (`GATEWAY_RECEIPT_RETENTION_MS`) | `receipts` table |
| Delivered/refused deliveries | 30 days after their last change (`DELIVERY_RETENTION_MS`), never while a receipt points at one; open deliveries are never pruned | `store-retention.ts` |
| Retention sweep | at most 256 rows per table per sweep (`RETENTION_SWEEP_BATCH`), once an hour (`RETENTION_SWEEP_INTERVAL_MS`) unless the last sweep hit the bound; runs at the end of an enqueue, a relay mutation or an outbox read, never at open. Expired causal roots and their edges, rate buckets idle for a full refill, closed bindings 30 days old with no outbox row, arm or open delivery (and their `outbox_cursors`), and `session_meta` rows nothing references | `store-retention.ts` |
| `tui` request | 1.5 s (`TUI_REQUEST_TIMEOUT_MS`); host listing 10 s per endpoint; enumeration reused 5 s | `live-surface.ts` |
Self-send and a cycle back to the sender under one causal root are `loop_detected` (`self_send`, `cycle`); a caller-chosen root is refused (`forged_root`).
## Binding invariants
`gateway/bindings.ts` + `store-relay-ops.ts`; every op is one `BEGIN IMMEDIATE` transaction that first moves due bindings to `expired`.
- The record is closed: every field is present (`null` when unset), timestamps are UTC RFC 3339, `schema_version` 1. `thread_id` defaults to `"@chat"`, `direction` to both ways, `inbound_mode` to `auto`, `outbound_events` to all four, `ttl_seconds` to 604800 (`null` = no expiry).
- At most one `active` binding per `(platform, account_id, chat_id, thread_id)`; a second bind is `binding_conflict` with the holder's `binding_id`/`revision`/`session`. No implicit takeover.
- Trust model for binding mutations: `thread_unbind` and `thread_rebind` (and the CLI's `unbind`/`rebind`) share `thread_bind`'s local single-user trust model. Any session or CLI caller of this agent dir may unbind or rebind any binding; they are NOT restricted to the session the binding is attached to (unlike `thread_report`, which is `scope_denied` outside it). The `expected_revision` CAS guards against stale writes, not against another local caller.
- `revision` is a CAS token: `unbind`/`rebind` name `expected_revision` (`stale_revision` otherwise) and bump it. `rebind` resets `lease_started_at` but never moves `expires_at`, and refuses deliveries queued under the old revision `binding_closed` (they are never moved). A detached or expired binding is `binding_inactive`; unbinding it again replays success with `already_closed: true`.
- Idempotency is per `(principal, tool, key)`: the same key with the same arguments replays (`deduplicated: true`), with other arguments it is `idempotency_conflict`. An inbound connector message uses `event:<event_id>` as its key, so one platform message is admitted once.
- A delivery through a binding carries `binding=<id>@<revision>` in its provenance header, `source=external`, and the actor.
## Claimant liveness
A claim (an inbox drain claiming a delivery, an answer in flight) records its claimant as `{ pid, process_start_time, instance_id }`; `gateway/process-identity.ts` `isClaimantDead` decides whether another process may reconcile it. The start time comes from `ps -o lstart=` (C locale, UTC, recorded as `epoch:<seconds>`). On win32 there is no `ps`, so `process_start_time` is null and liveness falls back to the pid alone: a dead pid is dead, a live pid counts as live, and a reused pid is mistaken for the original claimant. That is acceptable only because `omo thread` and the gateway endpoints refuse win32 today. Windows support requires a real start-time source (for example the process creation time from the OS) before it lands.
## Outbox contract
- Only an explicit `thread_report` (or `omo thread report`) writes a row, only by the binding's own session (`scope_denied`), only through an active (`binding_inactive`), outbound binding subscribed to the event (`unsupported`). Without `binding_id` it is the ORIGINATING binding (the one whose message the session's current answer is for; two bound threads in one answer refuse, and so do a bound thread and a `user` message while another outbound binding exists), else the session's only active outbound binding; with none or several, `invalid_arguments`. Nothing is copied to other bindings.
- Cursors come from one store-wide `AUTOINCREMENT` (`outbox.cursor`, `schema.ts`), so they increase within a binding but a binding sees gaps: the missing numbers are other bindings' rows, never lost rows. Compare cursors, never count them. `thread_outbox` without `after_cursor` continues after `acked_cursor`; an older cursor re-reads. `thread_outbox_ack` is idempotent and monotonic (an older or equal cursor is a no-op, `changed: false`); a newer cursor that names no row of this binding, another binding's included, is `cursor_invalid`.
- Retention: acked rows 30 days after their ack; unacked rows live as long as their binding plus 30 days. A detached binding's outbox stays readable.
- The first `provider_message_id` acked for a milestone becomes the binding's `progress_message_id`; later milestone rows carry it as `edit_message_id`.
- `question_state` also admits `expired` and `cancelled` (schema v2 CHECK), RESERVED for question closure from the session's terminal question outcome; nothing in this release writes them.
- `event_kind` also admits `question_closed` (the `CREATE TABLE outbox` CHECK), RESERVED for the same question closure; nothing in this release writes it.
- A `question` row carries the HMAC reply token (realm id and secret seeded in `gateway_meta`); `question_state` is `pending` until `thread_answer` through the SAME binding matches it (`binding_mismatch` leaves it pending; a moved binding or a changed session incarnation is `stale_token`; an answer still being handed over makes a second one `answer_in_progress`; a delivered one makes it `already_answered`). The row records the request kind (`ui_request_kind`) the answer's shape follows. A hand-off that gets no reply releases the claim and answers `host_unavailable`; a refusal from the session releases it and answers `stale_token` (`invalid_arguments` for `invalid_response`/`question_incomplete`); a delivered answer keeps it.
## Completion arms
- Opt-in: a runtime touches the store for a completion only after an arm. `thread_report {kind:"completion"}` (or the CLI) writes a durable `completion_arms` row and answers `armed: true`, `cursor: null`; the SDK then wakes the session's endpoint. An unbound session never opens `gateway.sqlite`, and its settles make no store call.
- The durable arm is the source of truth; the in-process arm (`createCompletionTracker`) is only a cache of it and stays until the row's completion is written.
- Pickup: an arm this runtime did not make (an earlier runtime's, after a restart or crash; one made by `omo thread`) is read at `session_start` and on a `wake` command edge (`registration.ts` `onCommandWake`), lock-free and only when `gateway.sqlite` already exists.
- Write: at `agent_settled`, never at `agent_end`, with the outcome of the last `agent_end` (`completed`, `failed`, `cancelled`). The handler waits at most 250 ms; a write that hits the lock-wait bound retries after the busy timeout with the settled run's outcome; a settle while that write is outstanding starts no second write.
- Settle race: an arm that lands while a run is already settling is not written by that settle; it is written at the next run's settle, with that run's outcome (`cli-completion-arm.test.ts`).
## Addressing rules
Address entries carry both identities: the `routing_id` (the live host's `sessionId`, used to reach the session on its host) and the durable id, which is `thread_id`, the address every tool accepts. Resolution ladder in `resolveTarget`:
1. An exact durable-id match wins immediately over every entry. Under default scope, an id hit outside the caller's workspace is `scope_denied`, never `not_found`, because the id already names one thread.
2. Otherwise NFKC-normalized name match over the visible entries: one hit resolves, two or more are `ambiguous_target` with up to ten candidates, zero is `not_found`.
Scope: two paths share a workspace when they sit in the same canonical git work tree (linked worktrees of one repo count as separate workspaces); outside git, realpath equality. `all_scope: true` widens to every entry the daemon knows. A scoped call with no known caller workspace root is `caller_context_missing`.
Self-targeting: the caller's identity is read per call, from the execution context's `sessionManager.getSessionId()` (the component's `callerSessionId` option is only the fallback for hosts that don't pass one). Three EXPLICIT addresses reach the caller's own thread, and they are explicit by construction: the literal `"self"`, the caller's durable id, and the caller's exact name — the name ladder in `resolveTarget` carries no caller exclusion, so naming yourself exactly reaches you, exactly as naming any other thread reaches it. `"self"` is looked up in the address book by the caller id, and `caller_context_missing` comes back when the caller's session isn't in the book. What never happens is landing on the caller by ACCIDENT: fuzzy resolution (`thread_handoff`, `match: "fuzzy"`) filters the caller's entry out of the candidate set before scoring.
That exclusion needs an identity to exclude. When the host passes no execution context the fallback id is `UNKNOWN_CALLER` (`tools.ts`), which stands for an ABSENT identity rather than a thread: `"self"` then always answers `caller_context_missing` (it never matches an entry, even one whose durable id is literally that string), and fuzzy scoring has no caller to remove. The engine always passes the context, so the fallback is a compatibility path for older hosts, not a supported way to run the family.
Fuzzy (`thread_handoff` only): trigram Dice over name (weight 1.0), preview (0.85), and workspace basename (0.70). A leader is accepted only at score >= 0.72 with a >= 0.08 margin over the runner-up. Note the consequence of the weights: an exact workspace-basename match tops out at 0.70, BELOW the 0.72 accept floor, so a cwd-basename fuzzy match alone never auto-accepts. The basename signal can only reinforce or disambiguate; it cannot select on its own. This is conservative by design.
## Host lifecycle (senpi side)
`ensureHost()` starts a socket host on demand (one per endpoint: the legacy `rpc.sock` and each `p-*`/`i-*` session host), probes an occupying server's version and capability set (`multi_session`, `extension_events`), and REPLACES it on mismatch. Every ensured host gets the pinned installation-wide client-capability profile, independent of who ensured it first. The host runs under a lifecycle supervisor (`host-lifecycle.ts`) that byte-proxies the public socket:
- Cold start `transient` (default) idle-exits after a continuous window (default 15 minutes) with zero attached connections and zero active turns; `persistent` never idle-exits. Env overrides: `SENPI_RPC_HOST_COLD_START`, `SENPI_RPC_HOST_IDLE_EXIT_MS`.
- Orphan-proofing is kernel-level: the supervisor spawns the host with an inherited pipe on fd 3 and holds the write end without writing. When the supervisor dies for ANY reason (SIGKILL, OOM kill, crash), the kernel closes the pipe, the host reads EOF, and shuts down cleanly. Verified on darwin/arm64. On Windows the fd is not inherited and the host falls back to ppid polling at a ~2000 ms cadence (`HOST_WATCH_PPID_INTERVAL_MS`); that path is coded but untested.
- Event visibility (pinned task-2 semantics): the multi-session host broadcasts session lifecycle and agent events to ALL session clients; correlated command responses go to the requester only.
- Zero-turn resume edge: a session killed before its FIRST turn gets a NEW durable id on reopen, because the upstream engine never flushes a zero-turn session to disk. This is correct CLI behavior and user-visible.
## Delivery, receipts, prompt routing
The wrapper's tool receipts (every tool but the two sends) are at-least-once around their side effect: a replay of the same key answers the stored result instead of repeating it. Exactly-once is deliberately NOT claimed: the engine does not dedupe on the correlation id. Two routes surface honestly as `idempotency_uncertain`: a crash between native accept and receipt commit, and a side effect that THROWS inside `execute()` (the prepared receipt is transitioned to `uncertain` carrying an `error_note`, never deleted - deletion would license a silent double delivery when the throw followed a partial landing). Neither is ever auto-retried. Receipts live 30 days; orphaned `.tmp` files from interrupted atomic writes are not garbage-collected (harmless, known).
Prompt routing: the policy of an in-flight prompt is immutable; `changePolicy` to a different policy returns `prompt_route_locked`. Host loss cancels every pending prompt with a plumbed reason (`close(reason)`), never a synthesized answer. Two senpi-side constraints to respect when wiring: an inline `await ctx.ui.confirm()` inside a `session_start` handler deadlocks the bind, so detach the confirm; and multi-session inbound responses need the `sessionId` envelope so the router can authorize the answerer.
## Error taxonomy
Thread tool codes (`THREAD_ERROR_CODES`): `invalid_arguments`, `caller_context_missing`, `not_found`, `ambiguous_target`, `scope_denied`, `name_conflict`, `not_resumable`, `orphaned`, `foreign_live_owner`, `no_active_turn`, `turn_conflict`, `not_steerable`, `message_too_large`, `queue_full`, `cursor_invalid`, `cursor_stale`, `idempotency_conflict`, `idempotency_in_progress`, `idempotency_uncertain`, `approval_unavailable`, `approval_route_locked`, `partial_commit`, `unsupported`, `overloaded`, `transport_closed`, `host_unavailable`, `internal_error`, `model_not_found`, `model_ambiguous`, `thinking_level_unsupported`, `loop_detected` (gateway loop guards: cycle, self-send, hop limit, causal budget, expired root), `binding_conflict`, `binding_mismatch`, `binding_inactive`, `stale_revision`, `stale_token`, `already_answered`, `answer_in_progress` (relay tools).
Prompt-route codes (separate union in `prompt-routing.ts`): `prompt_route_locked`, `prompt_response_not_authorized`, `prompt_not_found`.
Common mappings: an oversized message is `message_too_large` (32 KiB tool cap, 1 MiB through the engine); a full per-target queue (128 messages or 1 MiB) is `queue_full`; a changed active turn under steer is `turn_conflict` with the message held undelivered; reusing an idempotency key with different arguments is `idempotency_conflict`; a concurrent in-flight operation on the same key is `idempotency_in_progress`; a delivery target with no live owner is `not_resumable`; a missing host socket is `host_unavailable` on every tool, per call, except a send, which queues offline (`queued_offline`); an unmatched or over-matched model pattern is `model_not_found` / `model_ambiguous`; a thinking level the active model rejects is `thinking_level_unsupported`.
## Desktop mirror
The mirror service (desktop side, task 17) reflects host sessions into the desktop thread list read-only, through the existing `thread.create` / `thread.meta.update` / subscribeShell pipeline. Title ownership: host-side rename TRANSITIONS propagate to the shell row; a desktop-side user rename survives host updates; a host rename that happened during mirror downtime is adopted as the new baseline on restart (the dual of the guard, resolved in the user's favor). A `project.create` failure warns once per session and drops that session from mirroring for the rest of the run.
## QA
From the repo root:
```
bun test packages/omo-senpi/src/components/thread
bun packages/omo-senpi/scripts/qa/thread-tools/run-all.mjs
bun packages/omo-senpi/scripts/qa/task-14/run-all.mjs
bun test packages/omo-native/test/thread.test.ts packages/omo-native/test/daemon-adopt.test.ts packages/omo-native/test/daemon-status.test.ts packages/omo-native/test/daemon-operations.test.ts
(cd packages/omo-senpi && bun test plugin/scripts/build-extension-runtimes.test.mjs)
```
The first run includes `gateway/` (store, engine, drain, decision table, causal guards, bindings, relay, cross-process senders) and `sdk.test.ts`. The fourth covers the `omo thread` CLI (argv, exit codes, `--json`), `omo daemon adopt` against a fake `release_session` host, and the `tui` rows of `status`/lifecycle. The fifth imports the BUILT `runtime/thread-sdk/sdk.js` under plain node from a plugin-shaped tree, proving the store-worker sidecar resolves. To exercise the documented CLI examples end to end, run each `omo thread ...` example with this checkout's launcher entry (`omo.js` in `packages/omo-native/bin`) under node, with `OMO_CODING_AGENT_DIR` pointing at a scratch agent dir whose endpoint registry (`rpc-host-daemon/layout.json` + `<16hex>/endpoint.json`) names a fake host socket; without an engine the SDK enumerates that registry.
The first runs the pure-seam unit tests. The second runs two suites sequentially, one scenario at a time on purpose (each owns its ports, sockets and pty terminals): the gateway real-surface suite below, then the legacy cross-surface scenarios (cli-surface, desktop-client, terminal-to-ui, desktop-to-cli, plugin-surface, session-control; each is reported `SKIP` with the missing path when its senpi source or desktop checkout is absent). `--suite gateway|legacy`, `--out-dir <dir>` (per-scenario evidence), `--with-mutant` (also run `loop-guard.mjs --mutant`, which must fail). The third runs the resilience injections (kill-mid-turn, version-capability, queued-resume, uncertain-operation) and writes evidence plus cleanup receipts. All three drive the components directly against a real socket host. `tools.test.ts`, `component.test.ts`, and `live-surface.test.ts` cover the assembled handlers, the registration, and the socket client through injected hosts.
### Gateway real-surface scenarios (`scripts/qa/thread-tools/`, todo 16)
Every scenario drives the product as a user runs it: the RELEASED senpi (`THREAD_QA_SENPI_VERSION`, default 2026.9.29-5, installed once into `THREAD_QA_KIT_DIR`, default `/tmp/qa-thread-tools-kit`), this checkout's `omo` launcher over a scratch COPY of the built plugin, real pty TUIs (`Bun.spawn({ terminal })`, screen kept by `@xterm/headless`), `i-*` shard hosts ensured from omo's launch spec, and the `omo` CLI. `lib/gateway.mjs` owns the kit, the sandbox (own HOME + agent dir under `/tmp/qa-thread-tools-<scenario>-*`, never `~/.omo`), a conversation-driven fake model (`QA:CALL`/`QA:SCRIPT` make tool calls, `QA:HOLD` streams until released), event-driven waits (pty output, fs events, feed records, model requests; no sleeps) and the cleanup receipt (`cleanup-receipt.json`: pids, `pgrep`, `lsof -U`/port, scratch removed; asserted as `cleanup-no-leftovers`). The copy is patched, never the checkout; each patch must match exactly one bundle site and only re-opens a test seam (the gateway is the only send path): `crash_after_admit` and `sender_kill_after_commit` expose the drain's `afterAdmit` and the store's `afterDbCommit` seams by env, `cycle_check_off` is the loop-guard mutant.
| Scenario | Proves |
| --- | --- |
| `tui-to-tui.mjs` | two TUIs `thread_send` both ways; `omo thread list` shows both with real names/timestamps; the idle target answers `started` with no keystroke; provenance header visible |
| `host-to-tui.mjs` | `i-*` host session -> TUI: a draft holds the delivery (row queued, editor unchanged, notice shown); Enter runs the draft, then the delivery; an idle TUI starts it with no input |
| `repeated-wake.mjs` | target streaming on a held model answer; one send + three `wake`s; exactly one entry per `delivery_id` |
| `offline-queue.mjs` | sender TUI -> SIGKILLed target: `queued_offline`, also from a fresh `omo thread send` that never saw the target alive (PD-2) and with no endpoint of the agent dir running (PD-1); `omo --session <path>` applies it once at start |
| `stopped-target.mjs` | SIGSTOPped target keeps the row queued (`queued_offline`, also from a fresh `omo thread send`); SIGCONT applies it once with no input |
| `sender-death.mjs` | sender SIGKILLed by `_test.afterDbCommit` right after COMMIT; the idle target applies once from its inbox watcher |
| `held-then-exit.mjs` | fresh session holds a delivery behind a typed `/exit`; the file survives exit with its id; restart applies once |
| `receiver-crash.mjs` | receiver killed after `started`, before T2 (with history, and a session with no messages); restart keeps the id, one entry on disk, no re-admission |
| `loop-guard.mjs` | A->B->C->A refused `cycle`; 17th session of one turn refused `fanout`; 9th burst refused `pair_rate`, one token back after 5 s of real wall time (the one time-under-test exception); `--mutant` must fail |
| `lost-ack.mjs` | CLI sender killed between COMMIT and reply; retry with the key is `idempotency_uncertain`; one row, one entry |
| `adopt.mjs` | `omo daemon adopt` takes an `i-*` host session into a pty TUI; same id, host no longer lists it, `session_released` entry, a later send reaches the terminal |
| `draft-preservation.mjs` | xterm.js screenshots (`draft-before.png`/`draft-after.png`) of a delivery arriving while the user types: editor identical, notice visible, released when the draft is cleared |
A `DEFECT <scenario>/<check> PD-n` line is a documented behavior the product does not hold yet; it is counted in the run-all summary (`product_defects=`) and never printed as PASS. None is open. PD-1 (a send with no endpoint of the agent dir live answered `host_unavailable`) and PD-2 (a process that never saw an offline or stopped terminal alive answered `not_found`) are fixed; their checks are hard assertions now.
## Conventions
- Every entry point returns its outcome as data; the error branch carries a taxonomy code and a `next_action` that names the recovery, so a runner hands the failure straight to the model.
- Bounded everything: read budgets, queue sizes, candidate lists, fuzzy scan caps. Nothing streams into a caller; a truncated read hands back one payload plus an opaque cursor.
- Durable state is written atomically (temp file, fsync, rename, directory fsync) with 0700/0600 modes.
- Discovery metadata keeps policy prose out of per-tool entries; the family policy lives in exactly one `promptGuidelines` string.
## Anti-patterns
- Don't throw for an expected failure anywhere in this family; construct the failure through `threadToolFailure` so the code stays taxonomy-validated.
- Don't claim exactly-once delivery, and don't add dedupe to the engine on the correlation id here; the receipt layer is the dedupe seam.
- Don't describe cwd-basename fuzzy matching as auto-accepting; at weight 0.70 it can never clear the 0.72 floor alone.
- Don't put negated-use sentences in any indexed metadata field, and don't repeat a keyword string across the family.
- Don't write a report from model output or lifecycle text: only an explicit `thread_report` call writes the outbox, and a completion only at `agent_settled`.
- Don't touch the gateway store from a lifecycle edge every session runs (`agent_end`, `agent_settled`) unless this runtime has work there, and never hold such an edge on a store call: the store's write lock is shared with other processes.
- Don't auto-cancel a relayed question the way the headless task client does; it stays pending until `thread_answer` resolves it through its own binding.
- Don't send into sessions from the mirror, and don't write desktop data back into the host.
The live thread RPC client is loaded on the first asynchronous thread/gateway operation via `#omo-thread-client-runtime` (`src/components/thread/live-surface.ts` in source, `extensions/omo-thread-client.js` in the plugin). The component still registers tools, the store and lifecycle handlers synchronously; the registrar captures the operator socket before loading. One runtime promise/client is shared per facade across concurrent operations. A failed load is not cached: it rejects as `host_unavailable:<socket>` (classified by the thread tools) and the next operation loads again. Build freshness, required installation artifacts and bundle purity include this sidecar.