Skip to content

harness-adapter

Provenance

  • Source: .spec/spexcode/spec-cli/sessions/harness-adapter/spec.md
  • Source SHA-256: 7efbc102d0ffdd3114fb38ea3ec7ec8cbd8bc298b10ad799505e8ec6561f3c10

harness-adapter

raw source

SpexCode integrates with whatever coding-agent harness the user runs — today Claude Code, Claude headless, Codex, Codex headless, OpenCode, pi ([[pi-harness]]), pi headless ([[pi-headless]]), and z-code ([[zcode-harness]]), tomorrow others. Their differences are real and many. The rule (the project's own platform-boundary principle): platform differences live at an adapter boundary; product semantics never know which harness is in play. So there is ONE Harness interface, ONE implementation per harness, and an if (codex) / if (claude) branch ANYWHERE in product code (materialize, dispatch, sessions, board, slash) is forbidden — that branching belongs to the harness detector and the adapter only.

acceptance — the live-behavior matrix

An adapter is accepted by LIVE BEHAVIOR, never by artifact inspection: pi's stop-gate bridge shipped with every mechanical proof green (shim written, manifest compiled, unit tests passing) while a real session silently dropped every stop-gate rejection and hung active forever. So a new or reworked adapter with a resident or controller-backed runtime merges only with per-behavior eval readings, each measured through a REAL dispatched session of that harness, covering eight lifecycle behaviors: (1) undeclared stop — the gate's rejection reaches the session and the record flows out of active; (2) PreToolUse block — a blocking hook genuinely stops the tool and the handler's own reason reaches the agent; (3) askspex session ask --note flips the record to asking with the note on the board; (4) deliver + steer — an idle send lands exactly once (exit 0) and a mid-turn send reaches the live turn; (5) resume — stop → resume continues the SAME conversation; (6) liveness — a killed agent reads offline within seconds (even with a stale socket file on disk) and a relaunch reads online; (7) commit gate — a dirty-tree merge proposal is rejected at settle with the reason delivered into the session; (8) close — zero residue (tmux window, process tree, worktree/branch, sockets, session record). The matrix is a parameterized test asset in [[live-matrix]], while each harness node's eval.md owns the scenario declarations. The test file drives a real dispatched session of any registered launcher through the declared behaviors and files per-scenario readings with evidence transcripts; it never mutates the CLI or rewrites the declarations. A new harness is covered by its launcher + scenario data, with no new runner route. A harness whose evidence is only artifacts has not been measured. The shared matrix applies where the behavior has the shared process-resident meaning; a deliberate semantic difference is measured by a replacement scenario rather than forced into a false common shape. [[claude-headless]] replaces the matrix's stop/resume and kill/offline rows with its own idle-resume and record-liveness rows, and adds hard-interrupt readings. [[codex-headless]] replaces the matrix's process-resident stop/resume and kill/offline rows with its no-TUI idle-turn and record-liveness readings, while delivery remains the shared app-server turn/start/turn/steer path. [[pi-headless]] replaces the process-resident liveness and idle-resume rows with record-backed liveness plus pi's text-mode rendezvous-steer/cold-resume readings. [[zcode-harness]] is a deliberate one-shot exception: its --prompt launcher has no reusable control channel, so its replacement scenario measures launch prompt receipt, hook gates, declaration, and process liveness. deliver and resume explicitly reject rather than impersonating a control transport; no false combination cell is filed for an operation that harness does not offer.

Prompt delivery also carries a dense, rerunnable COMBINATION campaign across every registered adapter that declares a delivery path (currently four interactive and four controller-backed headless adapters, including [[codex-headless]]): harness form x prompt origin (launch's first prompt, the terminal-free input route with replyVia:"note", and plain spex session send) x delivery timing (idle wake and in-turn steer/queue). Each runnable cell uses only those real product surfaces and proves four facts together: native delivery confirmed, the answer is readable at the requested/available user surface (replyVia:"note" and every headless default land in a timeline declaration note; an interactive plain launch/send lands in its pane), liveness stays truthful, and the authored declaration lands. A pane reading includes its real tmux scrollback: stop-gate guidance may scroll a valid answer above the current viewport, which is still user-readable pane output, not a missing response. Declaration landing is proven by the live board's observed active -> settled transition; it does not require a matching history row because the debounced timeline observer can legitimately fold a fast turn that returns to the same status between samples. That board proof never substitutes for a required timeline ANSWER: a note-routed cell still waits for the marker in /timeline. The launch prompt has no second in-turn invocation, so launch x in-turn is an explicit BLOCKED cell rather than a fabricated send path. The note insert treats the declaration command as reply TRANSPORT, not as part of the requested work: even a raw prompt that says "use no tools" or "only print the answer" must still finish by placing the complete reply in the truthful declaration's --note. Normal final output is invisible on this route, and the stop-gate's generic auto-declaration is lifecycle recovery, never an answer substitute. BLOCKED is reserved for that structural non-cell: a runnable cell whose turn cannot start, exits without a reply/declaration, or leaves a stale lifecycle is a FAIL (with any matching issue referenced), and the runner still invokes later cells through the real adapter instead of converting one failure into skipped coverage. Every cell files its own transcript-backed reading on the most specific adapter node available; the aggregate table files on this node. The campaign reuses one session per launcher to keep model spend bounded while still preserving real note-to-terminal channel transitions, and gives pi-family turns a wider first-token wall.

Claude interactive delivery has one measured transport handoff: a session moved to a Claude background job is a fork. When the successor's hook has persisted its exact Claude session id as moved, the adapter resolves the roster worker by that exact worker.sessionId; without a readable matching stamp, it falls back to the roster's dispatch.launch.mode=resume, dispatch.launch.fork=true, and source transcript path. In either case the roster supplies the live rendezvous socket and current rvAuth, so the adapter sends the role/auth handshake before the ordinary reply frame. A roster entry whose socket cannot accept the handoff is stale transport, not an override forever: retry the source session's stamped launch-time socket before leaving the durable message owed. The roster root is the launched source process's own CLAUDE_CONFIG_DIR field when that still-live process exposes it, then the backend environment/default fallback: a backend must not silently assume its own Claude home is the launcher's. It reads no other process environment fields. The adapter never guesses a token, prints one, or changes the pane/raw-key transport. A missing or unreadable fork entry preserves the normal launch-time socket path; the durable queue remains the acceptance boundary in either case.

expanded spec

The harness is resolved ONCE into the matching adapter; everything downstream calls the adapter. DETECTION is not payload-sniffing: each adapter OWNS its shim, and the shim bakes the harness id as the dispatcher's first argument (dispatch.sh <id> <Event>), so dispatch.sh exports SPEXCODE_HARNESS and a hook subprocess learns its harness from the shim that wired it — deterministically, never by guessing the payload shape. There is a third baked id beyond the native two: plugin, written by the [[plugin-harness]] bundle's hooks.json. It has no Harness adapter of its own (it is a DELIVERY form, not a runtime) — dispatch.sh accepts it and harness.sh routes it through the claude family (a plugin host like adopter-a/Claude shares Claude's payload shape) via the default case, so the shell side needs no separate plugin) arm. On the TS side the harness is derived from the selected launcher or the materialized tree's explicit harness set. Product code loops adapters and their placement facts; it never branches on a harness id. The Adapter owns exactly these divergence points — its whole surface:

  • slashCommands() — the / menu, computed the way THAT harness computes its own (Claude: a captured built-in set + .claude/commands/** + skills; Codex: its built-ins + ~/.codex/prompts/** + plugin commands). Decoupled from execution — see slash-commands.ts (today Claude-only; becomes the Claude impl).
  • executionTrace(thread, currentTurn) — the one read-only transcript seam. The four base adapters locate and incrementally parse their current native thread behind this shared selector, returning only the last displayable assistant working prose plus the small typed tool-step projection after it. It never returns raw envelopes, arguments, outputs, reasoning, or another message history. The selector comes fresh from the durable human timeline and a reader uses it only to compare native user boundaries; it never stores one. Session and HTTP code consume only that normalized result and never branch on a harness id. The transcript is an ephemeral adapter observation, never a second SpexCode session record: [[message-stream]] owns the one conversation entry and its REST/SSE transport.
  • events / shim — which lifecycle events to bind, and the per-harness hook shim that points each at the dispatcher (.claude/settings.json vs .codex/hooks.json vs pi's generated .pi/extensions/spexcode.ts — the shim's content is whatever FILE that harness discovers, not necessarily a hooks JSON; pi has no external hook binding at all, so its shim is an extension synthesizing claude-shaped payloads — [[pi-harness]]). Every GENERATIVE shim (pi's extension, opencode's plugin) composes the ONE shared shim runtime ([[shim-runtime]], embedded verbatim): the generator declares only its event-name mapping and host API bindings, while the payload synthesis, the single block-verdict contract (exit 2 + stdout decision:block JSON), and the multi-connection rendezvous server live in that one source — never rewritten per harness. The shim's LOCATION is a divergence point too: Claude reads .claude/settings.json from the worktree, but Codex discovers a LINKED worktree's PROJECT hooks from the ROOT CHECKOUT — codex-rs rewrites the hooks-config folder of any linked worktree to <repo_root>/<rel-from-checkout-root>/.codex (root_checkout_hooks_folder_for_dir), so a thread whose cwd is the worktree root reads <mainCheckout>/.codex/hooks.json, NEVER the worktree's own. So the Codex shim + its trust materialize at the MAIN checkout (one shared .codex/hooks.json for the main checkout and every worktree — a per-PROJECT artifact, mirroring the per-project runtime tier); dispatch.sh resolves its proj from the thread cwd, so the one shared shim still gates each worktree correctly. But that rewrite has a LAYER-ANCHOR precondition: codex-rs builds a project config layer only for a dir (in cwd→project-root) that itself contains a .codex/ directory, THEN rewrites that layer's hooks-folder to the root checkout. A linked worktree whose root has NO .codex/ anchors NO layer, so the rewritten root hooks are never discovered and ZERO hooks fire — silently (this bit a FRESH-INIT project with no skill nodes: the dogfood only worked by accident, its materialized .codex/skills incidentally supplying the anchor). So the Codex adapter ALSO writes its shim into the worktree's own .codex/hooks.json — a pure ANCHOR (the rewrite ignores its content, reading the root's; worktreeHookAnchor), null for claude (its shim already lives in the worktree) and for the main checkout (shimFile wrote it there). Codex lacks Notification + StopFailure: codex's canonical hook event set (its HookEventName enum, codex 0.142.3) is preToolUse/permissionRequest/postToolUse/ preCompact/postCompact/sessionStart/userPromptSubmit/subagentStart/subagentStop/stop — there is no idle/ attention "notification" event and no failed-stop event, so those two claude-only events are genuinely absent, not unimplemented. Failure detection therefore does not fabricate another hook: the Codex adapter's optional observeTurnFailures capability subscribes to the app-server's native turn/completed notifications and reports only structured failed outcomes to the shared session layer.
  • contract file(s) — where the surface: system block is materialized ([[harness-delivery]]): Claude ./CLAUDE.md or ./.claude/CLAUDE.md; Codex ONLY the repo-root ./AGENTS.md.
  • artifact dirs — the auto-discovered dirs the on-demand surfaces materialize into, or null when the harness lacks that primitive: skillDir for surface: skill (SKILL.mds — claude .claude/skills/, codex .codex/skills/) and agentDir for surface: agent (sub-agent <name>.mds — claude .claude/agents/; Codex has no file-discovered agent-definition primitive → null, so materialize skips it). Each is ONE adapter line; a null dir is the whole "this harness can't" branch, never an if (codex) in materialize.
  • trust — make an agent run our hooks with zero prompts. This is codex's HARDEST divergence, because --dangerously-bypass-hook-trust covers only ONE of THREE independent codex tiers a dispatched worker must satisfy — the other two the adapter establishes explicitly (bypass alone leaves a fresh-init codex worker firing ZERO hooks, session.json frozen, no Session trailer):
  • (a) layer BUILT — the worktree needs a .codex/ anchor (the events/shim point above); without it codex builds no project layer and the hooks are never even seen.
  • (b) layer ENABLED — codex-rs drops a DISABLED (untrusted) project layer BEFORE hook discovery runs (get_layers(include_disabled=false)), and bypass_hook_trust is read only AFTER, per-handler — so it can never ENABLE a layer. An untrusted project's WHOLE layer is disabled (disabled_reason_for_decision). The dispatched-worker app-server does NOT auto-trust (only the interactive TUI / codex exec approval flow does — the "auto-trust confound" that made a standalone .codex appear to work). So the adapter writes PROJECT trust ([projects."<mainCheckout>"] trust_level = "trusted") UNCONDITIONALLY — the main-checkout key covers every worktree via codex's repo-root trust fallback. That write must be DUPLICATE-SAFE: codex refuses to load a config.toml with a duplicate key, and codex AUTO-writes a bare [projects."<proj>"] the moment it trusts a folder — so the writer STRIPS every prior definition of this project's trust (our sentinel block in any past format, a bare table, and its [hooks.state] entries) before appending, self-healing a config that already carries one instead of appending a second key that takes codex fully offline.
  • (c) hooks REVIEWED — even trusted+enabled, an unhashed hook is "new or changed", and codex FORCES the startup hook-review prompt on a PERSISTENT RESUME regardless of the bypass flag (bypass_hook_trust_for_startup_review = config.bypass_hook_trust && !is_persistent_resume, tui/src/lib.rs). Our visible TUI attaches via codex … resume <tid> (a persistent resume), so an unhashed hook WEDGES the worker at an interactive "Hooks need review" menu. So the adapter ALSO writes the reverse-engineered per-hook trusted_hash blocks (codexHookHash) UNCONDITIONALLY — matching hashes make review_needed_count == 0 and codex skips the prompt. (The old belief that a flag-capable binary could SKIP the hash was wrong: the flag does not suppress the resume review. The version-brittleness the bypass was meant to avoid is inherent — codex offers no config to disable the review — so we accept it and keep bypass only as DEFENCE.)

A trust writer returns the path it asserted (or no paths for a harness whose trust mechanism writes nothing), making the materialization receipt and user-facing init report derive from the adapter's real side effect instead of a parallel capability claim. bypass_hook_trust still rides on BOTH thread paths as that defence (so the app-server thread runs the hooks even if a version bump makes a hash mismatch): (1) the BACKEND-owned thread/start (codex-launch) carries config.bypass_hook_trust — codex applies it per thread from the request's config override map, NOT from the shared app-server's own --dangerously-bypass-hook-trust CLI flag (INERT for a thread); (2) the visible --remote … resume TUI carries the flag. The capability probe (<binary> --help) MUST probe the SAME codex the session runs, so the launch script EXPORTS SPEXCODE_CODEX_CMD for the codex-launch child (a fallback bare codex picks the WRONG install on a multi-codex box and mis-decides). SPEXCODE_CODEX_BYPASS_HOOK_TRUST forces the switch. Claude relies on folder-trust (often nothing). - clean / removeTrust — the materialize INVERSE: clean(proj, arts, preserveProject) surgically removes ONLY this harness's tree-local artifacts — the managed contract block (sentinels), generated local shim, and the arts-named skill/agent files. Project-scoped shim/trust is installation transport: ordinary re-materialize preserves it and the tree's final dispatch allowlist makes it inert when unselected; project-wide dematerialize/uninstall passes the destructive mode and calls removeTrust. Every step is gated on a SpexCode identity stamp (the managed-block sentinels, the shim's own dispatch.sh command line, the trust sentinels, the name-scoped on-demand paths), so it never touches a user's CLAUDE.md/AGENTS.md prose, a hand-made settings.json, a sibling skill the user added, or any .spec data. [[harness-delivery]] calls it for every adapter, so dropping a harness from harnesses prunes its local products without deleting project transport. Adding a harness adds an adapter (with its clean), never a prune branch in materialize. - payload accessors — read session_id, the edited-file path (Claude tool_input.file_path vs Codex apply_patch command — Codex has NO file_path), and notification type, from a hook's stdin. - acting identity — which id a hook acts on. The payload's session_id is the acting thread, so it is preferred; the launched SPEXCODE_SESSION_ID in the hook's env is the fallback. That preference is RESOLUTION-AWARE, never blind: a payload id wins only when a record answers to it (directly, or through the harness_session_id alias a backend captured at thread start). A harness may re-mint its conversation id mid-session — a claude compaction/continuation does — while the record keeps the launched one, and a blindly preferred payload id then names no record at all. Every record-dependent hook (the lifecycle gates, the freshness stamp, the failure path) would silently no-op and the session would read working forever, with no error on any surface. So an unresolvable payload id falls back to the launched id, and an unresolvable divergence is the only case that costs a store read: when the two ids agree, nothing is read. - launch / sessionId — the launch command and id model: Claude claude --session-id <uuid> [--worktree] (caller chooses the id); Codex codex under the launcher's configured approval/sandbox policy (id is codex-assigned — the backend owns it via thread/start at launch and resumes by it). Because that backend-owned thread exists BEFORE the visible remote TUI, the adapter translates Codex's documented launcher autonomy flags (--yolo / --dangerously-bypass-approvals-and-sandbox, -a / --ask-for-approval, and -s / --sandbox) into the typed thread/start approval/sandbox fields; a flag present only on the later --remote resume command is not policy delivery. The agent-typed CLI resolves its own id via the harness's env (CLAUDE_CODE_SESSION_ID / …). Codex's app-server is a per-PROJECT daemon shared across every worktree's threads, so it is started in the STABLE per-project runtime dir — never a caller's transient worktree: a daemon that inherited a worktree cwd is bricked when that worktree is later removed (its cwd goes (deleted) and codex then fails EVERY new thread's config load with No such file or directory). For the SAME reason it inherits no session IDENTITY: the spawn strips SPEXCODE_SESSION_ID and every adapter's sessionEnvVar (the list is adapter-derived, so a new harness needs no edit), because a project-scoped daemon started by whichever session launched first, serving every later thread, would otherwise hand that one session's id to every thread's tool shell — a stale lie for everyone else, and for nobody at all once that session closes and its record is swept (measured: daemons here still running for days under a long-gone session's id). The id a thread actually needs — its OWN — codex injects per command, so stripping the inherited ones removes a wrong answer without removing a right one. launchEnv(id) owns the transport bootstrap variables too: a rendezvous adapter returns its daemon mode + per-session socket, while a transport that needs neither returns no adapter env; the session launcher only composes those values with the governed session id and configured home variables. A shared runtime also declares its PID/isolation artifacts and a live control-plane probe through the adapter. The probe reports the runtime's loaded-thread set and whether each reference is active; active is a state of one loaded reference, not another reference. Record-only and queued sessions cannot invent a reference, while a loaded thread with no matching record stays in the set as unowned. Ownership joins only governed records belonging to adapters that declare that same shared-runtime descriptor; a coincidentally equal id from another adapter or a non-governed record is not a reference owner. An unhealthy/unknown probe returns an unknown refcount rather than a record-derived fallback; product mutation treats that uncertainty as a separate fail-closed blocker. The adapter exposes full projection and mutation proof as separate capabilities: resource reporting may read every loaded reference to describe turn presence, while lifecycle mutation uses the paginated loaded-ID set, both exact target descendant collections, and the whole-collection census — whose rows already carry each thread's live turn state, so presence for every member is answered by the reads the proof performs anyway. A gate asks whether a turn is in flight at the tip, so its cost must track how many threads exist, never how much history any one of them holds; a per-thread transcript read makes a long-lived session unmutatable against any fixed budget, and raising the budget only moves the threshold. Turn IDENTITY is the separate question: only interrupt needs to name the turn it interrupts, so only interrupt pays a transcript read, against a target that is by definition active. Presence the app-server did not report — including two native sources contradicting each other — is unknown and fails closed; it is never derived from something cheaper. The periodic report keeps its short bounded probe budget; a lifecycle mutation's explicit target census has its own longer bounded budget so a busy shared app-server does not turn a safe target proof into a false refusal. A transport-local census refusal is retried a small bounded number of times with the same generation fence; semantic ownership refusals return immediately. Ordinary stop reads the target and refuses descendants. Cold archive treats the adapter's native ancestorThreadId result as an ownership closure (all depths, excluding the ancestor), verifies every member's direct-parent chain against the active/archived collections, establishes every loaded member's turn presence from those same collections, and archives the initially-active closure deepest-first with the ancestor last; already-archived members are proof, not mutation. Unreadable-record quarantine is a separate, narrower adapter operation because it has no record-shaped ownership claim to pass into cold archive. It receives one exact native thread id plus the exact unreadable record id excluded from the owner census; that exclusion leaves the incident record opaque without blindfolding the census, so any other unreadable governed record remains an unknown-control refusal. Before archiving, the adapter proves the stable generation, zero other governed owners, an exact one-thread closure, no descendants, and an idle known turn. It may accept an already-archived target only after proving that exact target is unloaded. Otherwise it archives only that target, then re-censuses the same generation and target while preserving every loaded sibling reference. It returns public audit facts and an in-memory compensation closure; if the outer opaque-byte move does not commit, compensation can restore only the thread it just archived and only on the original generation. Live, active, owned, ambiguous, descendant-bearing, changed-generation, or unknown native state always refuses before the record layer moves bytes. The mutation proof fences the shared PID/start/detached-receipt/ socket generation across those reads; an unrelated slow sibling remains a protective loaded ID but cannot block an isolated target subtree. Post-mutation it re-censuses the identical closure, requires the whole subtree unloaded and uniquely archived, and keeps unrelated loaded siblings intact. Duplicate active/archived membership, a member absent from both collections, changed ancestry, or a late replacement fails closed; compensation restores only originally-active members and only on the unchanged generation. That generation exists only for one verifier-owned version-4 detached launch receipt whose live PID/start and process group agree, whose Linux /proc session also agrees when running on Linux, and whose socket inode is unchanged. Darwin never consumes ps sess as evidence. During the one-way v3 receipt migration, a mutation guard only may atomically promote the retired detached-v3 PID start PGID SID scope to that v4 receipt, and only when every stored field equals the exact live Linux identity; reporting and all other reads never mint or repair a receipt. Missing, malformed, or mismatched legacy evidence remains an unproven generation and refuses the mutation before any session teardown. Unknown/active subtree state, ambiguous ancestry, or a generation change fails closed, and compensating mutation is permitted only on the unchanged original generation. The adapter receipt also owns post-cold compensation outside the native RPC boundary: until the product commits the archive record and final offline proof, a failure returns the same receipt to restoreRuntime, which restores all and only its originally-active subtree members. A receipt-free resume remains the normal parent-only restore. The Codex app-server is spawned as a detached child in its own operating-system process group and session, not merely wrapped in nohup (nohup did not survive the real Codex Node launcher resetting signal behavior). The process adapter writes a private receipt only after proving PID/start and PGID == PID, plus Linux SID == PID; every later consumer re-verifies it through that adapter, while Darwin deliberately asks no ps sess question. A receipt alone never proves the live boundary. Killing the pane that happened to launch the daemon therefore cannot HUP unrelated turns. Launch acceptance and launch readiness are separate adapter facts. The optional launchReady seam returns an adapter-owned readiness fence, not a boolean: its immutable proof names the runtime/reference facts that made the launched session addressable, and its validator re-proves those same facts after product code crosses a durable internal pending boundary. Public readers project the exact pre-resume stopped/offline record throughout that validation; only a successful recheck clears pending and publishes stopped:false. Adapters without it retain the existing bounded liveness proof and recheck. A missing, timed-out, or invalidated fence is a launch failure, never a successful handoff. Codex-headless readiness freezes one exact live detached shared-root receipt/socket generation, the loaded target thread, and its unique governed record owner. Ownership is joined from every governed record whose adapter declares that shared-runtime descriptor; exactly one record may claim the target thread and it must be the session being resumed. The loaded-ID set establishes reference state but cannot establish record ownership. The post-pending validator repeats the full generation, loaded-reference, and owner join: an unload, restart, owner collision, or reassignment retains/restores the original stopped/offline projection without a false transition. This launch fence does not replace steady-state headless liveness: once committed, a sleeping headless conversation remains record-backed. - worktree — Claude has a native --worktree + WorktreeCreate/WorktreeRemove hooks; Codex has none (SpexCode manages the worktree itself). The adapter exposes whether the harness owns worktrees. - pane-title semantics (paneTitleIsSelfSummary) — whether the harness's tmux pane title IS the agent's own live task self-summary, so the board headline may derive from it. Claude continuously writes a one-line task summary into its OSC title → true; Codex sets the title to a spinner glyph + the cwd FOLDER name (not a summary) → false, so its headline falls through to the launch-prompt preview rather than showing the folder. Consumed by [[session-activity]]'s headline resolver — this capability field is the ONLY harness branch in that path (no if (codex)). - headless — whether the adapter launches without an interactive TUI. [[launcher-visibility]] consumes this capability to keep headless profiles out of the dashboard picker by default without learning an adapter id; the complete launcher registry and explicit CLI selection remain unchanged. Claude, Codex, OpenCode, and pi each declare false; an actually non-interactive adapter declares true on its own row. A one-shot headless adapter may also declare launchOneShot, which tells the generic launcher not to treat its intentional fast exit as a failed boot worth replaying. - runtime: liveness + delivery + interrupt + cleanup — the RUNTIME transport, lifted onto the adapter so product code honours ownsRendezvous instead of hard-wiring the claude rendezvous socket. liveness(rec, tmuxAlive, runtimeDir, pane, socketLive) answers "is this session's agent ready?" — from the caller's ONE runtime snapshot, which carries the window presence, a per-pane probe (the pane's root pid + one whole-box process table from a single ps), AND socketLive (whether a CONNECT to this session's rendezvous socket found a live listener, probed once for the whole list). claude = the tmux window is up AND a live LISTENER is on its reclaude rendezvous socket (socketLive) — a listener the OS accepts, not the mere existence of the socket FILE. This matters because a crashed/killed claude does not unlink its unix-socket path, so the old existsSync(rvSock) read a DEAD pane as online for as long as that stale file lingered — the incident's "dead pane stuck working for 30+ minutes". A connect() is the honest test: a live claude accepts it, a stale file refuses it (ECONNREFUSED, instant), an absent file ENOENTs (instant) — so a dead claude reads offline within seconds. The rendezvous pathname is a launch-time fact, stamped beside the session record so future derivations cannot strand an existing worker. New paths live in a short, per-uid 0700 directory under the literal /tmp spelling, not the platform's expanded TMPDIR: macOS commonly expands that variable under /var/folders/..., and adding the runtime hash plus session UUID can exceed its ~104-byte sun_path cap. That failure is particularly deceptive — the socket inode can exist while every connect() fails EINVAL, falsely reading every otherwise healthy Claude session as unknown. The short path is therefore unconditional, like Codex's short app-server path; platform limits belong at this transport boundary, never in lifecycle semantics. (The pane command is always the wrapper/shell while claude runs as its child, so claude still IGNORES the pane probe.) codex = the tmux window is up AND a codex process is live in the pane's DESCENDANT process tree. The pane's FOREGROUND name is NOT the signal: a healthy, rendering codex TUI's pane_current_command is bash (the launch wrapper) for its whole life — the codex processes live BELOW the pane pid (bash launch.shbash -lcnode (the codex CLI) → the vendored codex binary) — so the earlier foreground==codex probe FALSE-read every live codex as offline (field-confirmed), the strictly worse direction: the board showed working codex sessions as dead and a supervisor could wrongly reopen/kill them. Nor is the app-server socket the signal: it is per-PROJECT and SHARED by every worktree's thread, so it stays bound even when THIS session's visible codex --remote … resume <tid> TUI FAILED and its launch pane, after the bounded resume retries, dropped back to the shell prompt — sock-presence read a dead launch as online (the first field-confirmed false-positive). The honest per-session discriminator is the pane's process TREE: HEALTHY = a codex-ish process (matched by basename codex* or node* — the CLI runs as node before/alongside the vendored binary) exists among the pane pid's descendants; FAILED = the retries exhausted, everything under the pane exited, the pane sits at a bare idle shell with no codex/node anywhere below it. A probe tmux/ps couldn't report is not-live. The 'starting' boot grace stays in the CALLER (sessions.ts liveness), so a still-booting codex pane — whose tree may not yet contain codex while bash bootstraps the shared app-server — reads 'starting', not 'offline', for the legitimate startup window. The app-server socket is still the DELIVERY channel (per project, keyed on runtimeRoot(), ONE app-server shared by every worktree's thread), just not the liveness gate. The session's thread id is NOT discovered at all — the BACKEND OWNS it: at launch it thread/start { cwd: <this worktree> }s on the shared server (codex resolves that worktree's per-cwd context — AGENTS.md + skills + project config — by walking the thread cwd, so one project-scoped server behaves analogously to a per-worktree claude launch; its PROJECT HOOKS are the one exception, read from the root checkout per the events/shim point above) and stores the returned thread.id on the governed record as harness_session_id — no capture hook, no rollout-file scan, no cwd guess. The app-server --listen unix://<sock> endpoint is a WebSocket at path /rpc (the same upgrade the --remote TUI performs); delivery speaks WebSocket JSON-RPC over that Unix socket directly — NOT codex app-server proxy (a dumb byte relay that performs no HTTP upgrade, which the server rejects). deliver(rec, text) is a best-effort immediate poke, never a second delivery decision. The log append already made the message durable ([[dispatch]]), so every adapter returns only whether this attempt reached its native input channel; failure leaves the same mid OWED, for the delivery queue to retry. claude writes one {type:reply,text,mid} line and retries the write a small fixed number of times. Its single-connection daemon may still lose a poke when another connection replaces it, but that cannot lose the message; no repaint, receipt, kick classification, or transport outcome state remains. Claude's deliveryBlockedBy(paneText) predicate recognizes the sessions panel ("← for agents"), which swallows injected replies. It merely suppresses that known-useless poke: the line is already delivered and the reader shows it at the next boundary. Codex has no such predicate (its poke is app-server JSON-RPC; pane state is irrelevant). codex reaches its same-turn poke through the per-PROJECT Codex app-server JSON-RPC control plane the visible TUI uses, addressing the owned thread id (the one stored at launch). The handshake is initialize → initialized → thread/loaded/list (PROVE our thread is loaded) → thread/read{includeTurns}. That read decides the inject: if a turn is in progress (the thread has an inProgress turn), turn/steer injects the message INTO that live turn — the model reacts mid-turn ("inserted right after the running tool call completes"), it is NOT queued for after the turn ends; if the thread is idle, turn/start opens a new turn. turn/steer REQUIRES the active turn id as its expectedTurnId precondition (read from the thread, never from SpexCode's possibly-stale session status); a turn that ends in the read→steer window fails that precondition and is retried as a turn/start. Either way the app-server response confirms it landed. There is NO tmux prompt typing fallback for Codex: typed keys can truncate and can only prove tmux accepted input, not that Codex accepted a turn. Its hard interrupt follows the same exact-native rule: read the newest inProgress turn through the owned generation, send turn/interrupt {threadId, turnId}, then re-read until it has settled; an idle thread is already interrupted, while a generation change, unreadable turn, or still-active turn refuses loudly. The adapter uses one independent thread/resume connection to atomically subscribe to that owned thread's outcome notifications. A live turn/completed with status failed carries the native error message and completedAt; completed and interrupted are controls and produce no lifecycle write. When a backend replacement joins a thread already in systemError, the same resume response's initialTurnsPage supplies the latest turn id and completion time. A concurrent native turn/started cancels that historical projection, so an old failure cannot overwrite the new turn's active lifecycle. resumeArg(rec) is the relaunch tail reopen() hands launch(), but the two harnesses consume that tail differently and the codex side MUST honour that: claude --resume <id> is appended straight to the claude command (the SAME conversation, the id we pinned). codex has no bare codex to append to — its launchCmd is a bootstrap script that feeds the tail ("$@") to spex internal codex-launch, which mints a NEW thread and fires the tail AS the first-turn prompt. So the codex resume tail is a --resume <thread-id> marker the script branches on: it resumes the owned thread DIRECTLY (skip codex-launch, no new thread, no prompt turn — tid=<thread-id>), then its final codex … resume "$tid" performs codex's own resume on the owned id — its rollout persists on disk, the SAME conversation. Empty marker (no captured id) → a fresh thread on the same worktree/record. The discriminator is sound because a new launch's tail is always ONE single-quoted prompt arg, never the literal --resume — so a resume can never be mistaken for a prompt and fed to codex-launch (which would mint a NEW thread whose first message is the marker text). The adapter also declares its own settled launch failures — the patterns of ITS output for a launch that running again cannot fix (claude: a --resume id it has no conversation for, a rejected credential; codex: a thread id with no rollout on disk). That declaration is the ONLY place a harness's error wording is ever matched: the launch transport asks the adapter and consumes the verdict, so a settled failure is spent once with the harness's own line left visible instead of retried into silence ([[launch]]), and product code never learns a harness's English. A harness that declares none simply keeps the plain bounded retry. sessions.ts's liveness()/isOccupying()/sendKeys()/ reopen()/waitForReady() all route through these adapter methods — there is no socket hard-wire and no if (codex) left in the runtime path; the rendezvous-socket path + its replyViaSocket optimistic write MOVED into harness.ts as the claude adapter's deliver/liveness implementation, while Codex's app-server launch and JSON-RPC turn delivery live in the Codex adapter. [[claude-headless]] composes the materialize half from claudeHarness but replaces this whole runtime half: its intact, non-stopped record is online, active delivery writes a native stream-json user event into the resident turn child, idle delivery spawns a claude -p --resume turn, and hard interrupt writes Claude's native control_request/interrupt. Every complete native stdout event is forwarded unchanged through the controller's stdout; it is not persisted as a second SpexCode conversation record. Launch also registers the interactive agent process in agent.pid; adapters may use that per-session signal alongside their native transport proof. OpenCode prefers its rendezvous listener and falls back to the registered pid, so a plugin-load failure still reads honestly. Claude/pi use their live listener, while Codex uses the visible pane's descendant process tree. cleanupRuntime(rec) is the inverse owned by the same transport: rendezvous adapters unlink their socket, claude-headless unlinks its controller socket even when tmux killed the controller before its signal handler ran, and Codex leaves its shared project app-server intact. Their socket — and the only honest test of "theirs" is that the agent this teardown just killed is GONE, so removal waits for a PROVEN-dead listener (the same tri-state probe liveness uses) and a path still answering is left in place, loudly. The asymmetry is deliberate: a dead-but-unlinked file is harmless residue the next teardown reaps, a wrong unlink strands a working agent forever — still bound to a path nothing can reach, undeliverable, and reading as a corpse to every prober. The ordinary teardown still leaves zero socket residue, because its agent really is dead — and that is the product's job to GUARANTEE before it asks an adapter to sweep: the pane is the agent's home, not its leash, so a teardown that finds its own registered pid outliving the pane escalates (SIGTERM, then SIGKILL, identity-guarded against a recycled pid) rather than leaving an orphan whose still-live listener the adapter would then, correctly, refuse to remove.

That proof is the second of two defences, and the first is the socket's NAME. A session id alone does not identify a session on a box: SPEXCODE_HOME scopes the store and SPEXCODE_TMUX scopes the tmux server, so two worlds can hold one id (a fixture, a migration, a record copied for diagnosis) — and a path derived from the id alone made them share the one resource neither scoping covered. An isolated instance's kill-session then missed while its unlink landed, and delivery would have crossed the same way. So the path is derived from the runtime the session belongs to (runtimeRoot(), the identity that already scopes its store) and is a LAUNCH-TIME FACT: launch stamps it beside the record like agent.pid, and every later reader — launch env, liveness probe, delivery, teardown — reads the path the agent actually bound instead of re-deriving one. A session launched before the stamp existed keeps the unscoped path it really bound, so nothing running is disturbed and the fallback retires as sessions turn over.

Headless liveness describes a durable conversation that can accept another delivery; it does not erase the outcome of the last ephemeral turn. An intact record normally remains online between ephemeral turns because the adapter can accept another delivery without a resident turn process. The one explicit boundary is the human stop: after the runtime has been torn down, the retained record carries stopped and every headless adapter's shared record-backed liveness reads it offline. resume clears that marker as it relaunches the same conversation; close needs no marker handling because it removes the whole record. Turn outcomes enter the session layer through each harness's native signal: Claude's StopFailure hook, a process-backed headless adapter's non-zero child exit, or the Codex app-server observer inherited by its interactive and headless forms. Every source reaches the same active-only markTurnFailure compare-and-set, changing a live undeclared active lifecycle to error; a zero process exit, native completed or interrupted turn, declaration, or explicit stop that landed first changes nothing. Process notes name the harness plus exit code or signal; Codex notes retain the native error message and native completedAt. online may remain true when the adapter's controller, pane home, or shared server can still accept the next delivery; the orthogonal error lifecycle is the honest signal that the previous turn failed.

The runtime's behavior-identical mechanics are shared once across adapter rows: shell arguments use one POSIX single-quote encoder; resident headless controllers use one newline-delimited JSON socket client and timeout; socket-backed headless delivery uses one live / unproven / absent gate before its adapter-specific cold wake; listener-backed liveness and record-backed liveness are named predicates; and per-session socket cleanup uses one unlink helper. Adapter rows retain only the real differences: request payloads, timeout/error labels, cold-wake spawners, listener-or-pid fallback, delivery refusal text, and the sockets each runtime owns.

The adapter-neutral identity face is one ordered HarnessIdentity registry: each harness id and its sessionEnvVar appears once there, and every full adapter projects its identity row from that registry. sessionIdentityEnvVars() and layout's environment lookup consume the same rows. Thus a new adapter cannot leave a stale session-id env list behind, and a consumer that only needs identity data never loads launcher, transport, or materialization machinery.

Most of this was consolidation: the event/snake maps, the Codex trust writer, and the shim writers were scattered in [[harness-delivery]]'s materialize; CLAUDE_CMD in [[sessions-core]]; the Claude / menu in slash-commands.ts. They now live in harness.ts (eight adapters gathered in HARNESSES), which materialize loops over and sessions resolves by the selected launcher's harness — there is no if (codex) left in product code. The genuinely NEW Codex pieces: the Codex / menu (taken from the pinned codex-rs source the same discovered-not-guessed way), and the tool mapping that closes the inert-on-codex gap.

Because the hook handlers are pure shell, they cannot import harness.ts; hooks/harness.sh is its shell mirror (sourced by every handler, exported by dispatch.sh). It owns the harness-divergent payload parse. Codex has NO file_path; the touched file lives inside tool_input.command, and the tool that carries it differs by operation: an edit is its own first-class tool tool_name:"apply_patch" whose command is the bare patch envelope (*** Update File: <path> lines, with NO literal apply_patch token), while a read/ shell is tool_name:"Bash" + tool_input.command. So hp_code_path accepts BOTH tools and _hp_codex_cmd_path detects a mutation by the *** … File: markers themselves (not by an apply_patch token), else takes the last path-like token (sed -n 1p f.tsf.ts). A patch can bundle SEVERAL *** … File: markers (a multi-file edit), so hp_code_path emits ALL touched paths — one per line — and every consuming hook iterates them. Its operation mode is the semantic matcher shared by every harness: read accepts only read-shaped payloads, mutate only edits, and access their union. The native shims still bind the common PreToolUse event broadly; a non-matching payload simply resolves to no path. [[inject-spec-first]] uses read, then advances only if the spec graph resolves a real governor; [[inject-spec-of-file]] uses mutate. Neither hook branches on a harness or on special filenames. The shared hp_field reads a top-level JSON string value as a real JSON string: the close quote is the first UNESCAPED ", so a command carrying a quoted literal (sed -n "1,5p" f.ts) is captured whole, not truncated at the inner quote. hp_is_ask maps Codex's request_user_input (and Claude's AskUserQuestion) onto the question capture. hp_is_subagent reads the acting-agent discriminator: a Claude IN-PROCESS subagent (Task tool) fires the parent's hooks with the PARENT's session_id/transcript_path but a top-level agent_id (+ agent_type) stamp the parent's own calls never carry (measured live, claude 2.1.207 — the payload-id rule above cannot separate them, this stamp can). The scan is structural: only the pre-tool_input payload prefix is searched for the "agent_id": key shape — every string value's quotes arrive JSON-escaped and an agent_id-NAMED tool parameter sits inside tool_input, past the truncation — so the answer is deterministic, never a content heuristic. Codex payloads carry no such field (its verified field set below), so the probe never matches there; mark-active consumes it to keep a supervising parent's declared state out of its subagents' reach (the stop-gate race). So [[inject-spec-first]], [[inject-spec-of-file]], and mark-active fire on Codex, not just Claude — the shared shim lives at the main checkout, but its commands run dispatch.sh with the thread cwd as proj, so each worktree gates against its own tree even though one project-scoped server (and one shared shim) drives them all. The session-id + global-store resolution every handler repeated is folded into the same helper (hp_session_id, hp_store_dir). There is NO codex thread-id capture hook: the backend OWNS the thread id (it thread/starts the thread at launch and stores the id as harness_session_id — see above), so no dispatcher or lifecycle hook branches on Codex and Claude needs nothing here either (its pinned id already is the record id). But design C's hooks fire from the SHARED per-project app-server process, whose env can inherit the FIRST session's baked SPEXCODE_SESSION_ID, so a governed codex hook must NOT trust that env var. On codex, hp_session_id resolves from the hook payload's session_id — the acting codex THREAD id — and id→record resolution carries an ALIAS step: when no record sits at the id directly, find the one record that captured this id as harness_session_id (a grep over the few session.json files on the shell hot path — no jq; the typed TS read mirrors it in readAliasedRawRecord). This is what lets the pure-shell mark-active re-flip and the ask-capture, plus every shell hook lifecycle write, reach the right record from a thread id even when the app-server env is contaminated. The alias needs no cleanup artifact — it lives in the record's own harness_session_id, swept with the record on close. Claude is unaffected on this path: its exported CLAUDE_CODE_SESSION_ID equals both its payload id and the record key, so the direct hit always wins and the alias step never runs.

Identity is INJECTED where it is known, never inferred later. SPEXCODE_SESSION_ID names the governed record of the context it sits in, and it earns that meaning from ONE invariant: every process we create is given its own identity, and a process that belongs to no single session is given none.

  • A session launch bakes SPEXCODE_SESSION_ID=<record id> into the agent — after STRIPPING every session-identity variable it inherited (sessionIdentityEnvVars(), adapter-derived: the launch-injected id plus each adapter's sessionEnvVar). The strip is not decoration: a session's pane inherits the tmux SERVER's environment, so without it whichever session started that server rides along into every later worker.
  • A codex thread cannot be handed identity that way — its tool shells are children of the SHARED app-server, not of its own agent — so the backend injects the same variable per THREAD, through codex's own shell_environment_policy.set in thread/start's config override map (codexStartThreadParams), and the visible --remote … resume TUI re-establishes it with the same -c override, because that client is the other entry point creating a context for this session. Verified live: the thread's own tool shell reports exactly the injected record id and nothing of the launcher's environment.
  • The shared app-server — and any other process we own that serves every session rather than one — is spawned with those variables stripped. This is the same invariant read from the other side, and it is where github#76 came from: a daemon started by one session outlived it and kept handing that session's id to every later thread's git commit, so commits carried a stranger's session and, once it closed and its record was swept, an id that named nothing.

So identity is not something later code re-derives, checks, or guesses at. prepare-commit-msg READS SPEXCODE_SESSION_ID and stamps it: no store lookup, no per-harness ladder, no ancestry test, and nothing taken from the current directory — where a process stands says nothing about who it is, and a trailer written from a guess is worse than an absent one. No id → no trailer. The same invariant is what lets envSessionId ([[portable-layout]]) and hp_session_id keep the alias step as a NARROW concern (a hook payload carries the acting codex THREAD id, which is a harness identifier rather than a record key) instead of a defence against a contaminated environment.

A missing id is the ORDINARY case — most repos on the box are nobody's session — so the hook no-ops cleanly under set -euo pipefail rather than aborting the hook and the commit with it; the fail-loud stance is reserved for genuine errors past that point. The stamp lands via git interpret-trailers, never a raw append: git parses only the LAST paragraph as trailers, so an appended Session: paragraph would silently demote any trailer block the message already carries (e.g. spex ack's Spec-OK:) to body prose; interpret-trailers joins the existing block instead.

verified codex facts (live round-trip, real codex 0.142.3)

The Codex impl of the adapter must encode these (measured against a real self-launched codex): - payload fields: session_id(uuid), turn_id, transcript_path, cwd, hook_event_name (CamelCase, e.g. PreToolUse), model, permission_mode, tool_name, tool_input, tool_use_id, prompt. No file_path. - .codex/hooks.json event keys are CamelCase (codex fired all 5: SessionStart/UserPromptSubmit/PreToolUse/ PostToolUse/Stop) — the shim is correct as-is; snake_case is ONLY the trust-hash key format. - codex tool model (corrected against a LIVE apply_patch round-trip — the earlier "everything is Bash" reading was wrong for edits): a read/shell is tool_name:"Bash" + tool_input.command (e.g. sed -n 1p f); an edit is a distinct tool tool_name:"apply_patch" whose tool_input.command is the bare patch envelope*** Begin Patch / *** Update File: <path> / … — carrying NO literal apply_patch token and NO file_path. So the adapter keys the mutation off the *** … File: markers (NOT an apply_patch token) and accepts both apply_patch and Bash as code-touch tools; otherwise [[inject-spec-of-file]] and an edit-first [[inject-spec-first]] are INERT on codex (the first cut had both bugs — proven live, then fixed). The store/dispatch layer itself is sound (mark-active flip, declare/commit gate, silent non-governed Stop all work once hooks fire) — but that was first "proven" on a STANDALONE .codex in the cwd, which the interactive/exec flow AUTO-TRUSTS, masking the dispatched-worker gap: a linked-worktree thread on the shared app-server needs the layer BUILT + ENABLED + hooks HASHED (the trust point above) before dispatch.sh ever runs. Verified on a real FRESH-INIT dispatched codex worker: with the anchor + project trust + per-hook hashes in place, SessionStart… Stop fire through dispatch.sh, session.json advances past launch, and the commit carries the Session trailer. - session-id model (codex-rs source-verified): codex MINTS its own thread id internally (Uuid::new_v4/ ThreadId::new) — there is NO flag/env to pin a NEW session's id (CODEX_THREAD_ID is an OUTPUT codex injects, not an input; resume takes an existing rollout id). So a dashboard-launched codex session can't have its governed record keyed by the harness id the way claude's --session-id allows. The adapter's resolution: the launcher keys the record by a SpexCode id, stores the codex thread id on it as harness_session_id, and a codex hook resolves from the payload THREAD id first because the shared app-server env may carry another session's SPEXCODE_SESSION_ID. id→record resolution then ALIASES that thread id onto the record carrying it as harness_session_id. Claude needs neither step: its exported id equals its payload id equals the record key, so the direct hit always wins. - no rendezvous (ownsRendezvous:false): codex has no reclaude control socket, so SpexCode uses Codex's own app-server. Each SpexCode project has ONE project-scoped codex app-server --listen unix://<project sock> (started once, reused). The app-server and the visible codex --remote unix://<sock> resume <tid> TUI share that one socket, so they MUST be the SAME codex install — a version split across the socket breaks the thread/start→resume handoff (the app-server on one version creates a thread a differently-versioned resume can't find, and an old-enough app-server can't serve --remote unix:// at all). So the app-server command is DERIVED from the in-effect launcher codexCmd's binary (its first shell token, dropping args like --yolo): <bin> app-server runs the exact install <bin> --remote … resume will. It is NOT a bare codex off PATH — on a multi-install host (e.g. a homebrew codex shadowing an nvm codex) a bare codex resolves to a DIFFERENT binary than the launcher's, which was the macOS-only version-skew failure. SPEXCODE_CODEX_SERVER_CMD stays the explicit escape hatch (highest precedence, overriding the derivation); a codexCmd whose first token is a wrapper script forwards app-server through the wrapper. That socket lives on a short, sun_path-safe, per-project-unique path<socketBase>/spexcode-cx-<hash>.sock, where <hash> is a stable digest of the project identity (the runtime dir) and <socketBase> is an owned per-uid subdirectory of the platform tmpdir (spexcode-cx-<uid>, created 0700 by the derivation itself; the SPEXCODE_CODEX_SOCKET_DIR env override still replaces it) — NEVER bare tmpdir, and NOT nested under the project runtime dir. Bare /tmp is not merely untidy, it is BROKEN out of the box: on a normally-hardened Linux host (fs.protected_regular=2, root-owned sticky /tmp — stock Ubuntu) codex refuses to bind a unix socket directly in the shared sticky /tmp (EPERM), so the server never comes up, the client connect ENOENTs, and every codex-launcher session dies through launch.sh's retries while claude launchers work — yet the same codex binds fine in any owned subdirectory (github#30). Per-uid, not one shared dir, so a second user on the box never lands in the first user's 0700 dir; the launch script re-mkdir -p -m 700s the base at run time in case a tmp cleaner wiped it after the bake. The path MUST also stay short because a Unix socket path is capped at sun_path (~104 bytes on macOS, 108 on Linux) and runtimeRoot() flattens the entire project path into one long dash-segment (encodeProject), so the naive <runtimeRoot>/codex-app-server.sock overran the cap on a deep macOS project (path must be shorter than SUN_LEN + connect EINVAL — the app-server never bound; Linux's larger limit + shorter /root paths happened to fit). The hash is derived from the SAME project identity the launch, liveness, and delivery seams all pass, so they compute the IDENTICAL sock with no coordination — the one-app-server-per-project invariant. The short-path derivation is unconditional on every platform (no darwin branch — a platform difference handled at the path seam, not a product if). The .pid/.log/.lock sidecar files carry no sun_path limit and stay under the project runtime dir. The check-and-start of that shared server is serialized by a POSIX-portable lock — an atomic mkdir mutex with a bounded wait, NOT util-linux flock (absent on macOS, where the flock path failed the whole bootstrap and left the pane at the shell). The lock is held only across the check-and-start and released immediately; a stale dir left by a dead launcher is cleared after a bounded wait so it can never deadlock a launch. Because a mkdir lock has no inherited-fd hazard (unlike flock, held until every fd on its open file description closes), the long-lived daemon can't pin it — no fd-inheritance guard on the spawn. Each worktree session = ONE thread on that server, created by the BACKEND: the launch script runs spex codex-launch <sock> <worktree-cwd> <prompt>, which thread/start { cwd }s (codex loads that worktree's per-cwd context — AGENTS.md, skills, project config — from the thread cwd; PROJECT HOOKS are the exception, read from the main checkout's .codex — VERIFIED both by codex-rs source and a live round-trip: with the shim at <mainCheckout>/.codex/hooks.json all five events fire for a worktree thread, and removing that file while the worktree's own .codex/hooks.json stays in place makes EVERY hook go silent — so a per-project server behaves like a per-worktree launch for everything except the hooks, which are genuinely per-project), stores the returned thread.id on the governed record (harness_session_id, keyed by SPEXCODE_SESSION_ID), then fires the prompt as the FIRST turn — materializing the thread's rollout on disk, which the visible codex --remote unix://<sock> resume <tid> TUI then renders natively (VERIFIED: the TUI resumes a backend-created thread once it has ≥1 turn, and a later turn/steer/turn/start also renders live in the pane). That resume reads the thread's ROLLOUT FILE (<CODEX_HOME>/sessions/YYYY/MM/DD/rollout-<ts>-<tid>.jsonl), so a resumable thread is exactly one whose rollout exists — and that file has a WARM-UP RACE the launch must wait out (VERIFIED live, codex 0.142.5): thread/start ALONE writes no rollout (only a fired turn does), and a freshly-spawned app-server acks thread/start+turn but persists the rollout ~2-4s LATE — the SAME thread's file lands a few seconds after, it is not lost. A launch that hands the id to resume immediately dies with "no rollout found for thread id", and the launch retry loop then misreads that fast failure as a daemon race, sprays fresh threads, and stores the last (non-resumable) id — wedging every future reopen. The guard is ONE waypoint: codex-launch fires the first turn then WAITS (waitForCodexRollout, 20s) for the rollout to land BEFORE it stores harness_session_id or prints the id — so the id it returns is always resume-ready, and a genuine miss FAILS LOUD (non-zero, stores nothing; launch.sh aborts rather than resume ""). The 20s budget deliberately exceeds launch.sh's fast-fail threshold, so a real failure exits PAST it and the retry loop treats it as a true end, never a duplicate-prompt respray — turning a silent permanent wedge into an honest, non-duplicating retry. The rollout scan walks day-dirs newest-first but EXHAUSTIVELY — never capped at "the newest few" — because future-dated junk under sessions/ (a test once planted 2099/12/* in the real CODEX_HOME) sorts above every real day-dir, and a cap let three such dirs mask ALL real rollouts: every launch then died "persisted no rollout" with the rollout sitting on disk. No cold-branch pre-warm is needed: the wait absorbs the warm-up on the first launch after a server boot (a few extra seconds in starting). Follow-up delivery opens a WebSocket to the same socket's /rpc and turn/steer/turn/starts the OWNED thread id. The app-server is a shared control plane, not a session identity; session routing is solely the owned Codex thread id, so several spexcode serve processes never cross-send. Delivery falls back to reading the one loaded thread (thread/loaded/list) only for a pre-existing session whose id was never stored. Explicit --remote is the default because it deterministically binds the pane and backend control to the project app-server.