A Second Door, Built Differently
Codex has no peer inbox like Claude's. What it has is an App Server that speaks JSON-RPC, and for local clients that conversation runs as WebSocket messages over a Unix socket. The Codex Desktop app runs its own App Server for its own threads. Coop does not touch that one. It talks to a separate observer daemon, started with codex app-server daemon start, whose private socket it checks with the same private_socket test the Claude door uses.
Why a separate daemon, and why does that matter? Because Coop's first native probe found that the default daemon can read a Desktop thread without owning it, and that resuming the thread there would create a second writer for a chat Desktop is driving. So CodexDesktop is fenced three ways. Its request() refuses any method outside a small list (thread/queue/add, thread/items/list, thread/turns/list plus read-only discovery), and refuses any call whose threadId is not the enrolled thread. Its verify() refuses a thread Codex has archived, re-checks the socket, and refuses outright if the daemon has the thread loaded: Desktop adapter cannot substitute an independent loaded writer. Coop observes Desktop's thread; it never becomes its driver.
Queue, Then Insist on the Echo
Delivery is one call: thread/queue/add with the thread id, the prompt as a text input, and clientUserMessageId set to the outbox message id, the same id the Claude door puts in its frame's uuid. Codex answers with a queued submission. If that submission's clientUserMessageId is not the id Coop sent, the adapter refuses: Native queue accepted a different client identity. An echo that does not match is not a receipt for this message.
Two warnings came out of the first probes. A queued input becomes a native userMessage, the same role a human's typing has, not a peer envelope with provenance. So the prompt itself opens with Coop's provenance line, and the architecture forbids ever reading that native role as Dad's authorization. And the queue is not idempotent. Two immediate adds with the same clientUserMessageId returned two different queue ids; an add after consumption returned a third. A queue id is a receipt, not an exactly-once guarantee. That is why the previous lesson's rule is not optional here: the engine flips the row to uncertain and the host queue records the attempt before the call, and recovery observes by client id instead of sending again. Read the duplicate check exactly, because it is narrower than it sounds. Once a pass has recorded which turn took the input, a newer turn carrying the same client id is a Conflict (Duplicate native queue consumption), never a guess about which one counts, and so is a second match among the items searched for a steered input. But the summary walk goes newest first and stops at the first turn it finds with the id, so two copies already sitting in the history on the first read come back as one receipt, for the newer copy. The observer has no check for that case; what prevents it sits upstream, in the rule that recovery observes and never sends again.
Two Lists, Two Clocks
The receipt and the worker's answer both live in the thread's history, and Codex offers two ways to page through it. On 2026-09-30 a Haven worker lost two invitations and two reports to the difference between them.
thread/turns/list answers from the rollout plus the live turn state. thread/items/list answers from a thread store that Codex materializes from the rollout afterward. So a turn can read as completed while its final message is not yet listable as an item, and it stays that way for minutes. The first observer read each finished turn's items once, saw no final, closed the turn, and never looked again. The COOP_ACCEPT and the report were both real; they had simply not arrived in the slower list yet.
The fix was to stop asking two sources a question that only one should answer. Finals now come only from turn summaries (itemsView: summary): one response, from one source, carrying a turn's status, its first input with its clientId, and the final Codex itself selects. The item store is searched for exactly one case: an input steered into an already running turn, which is not that turn's first input and so is not in any summary. Even then the search starts from an item horizon recorded at send time, so it never rereads older history. Without a horizon, one delivery once cost 133 requests and 55 MB.
"Interrupted" Does Not Mean Over
The second trap was in the status field. The observer daemon does not run Desktop's turns, and it labels every turn it does not see running as interrupted. So a turn Desktop is in the middle of reads interrupted, with no completedAt. Every stall of 2026-09-30 (two invitations and two reports, Haven tasks #270 and #268) was a running turn taken as ended.
The rule now is structural, not lexical: a turn has ended only when it carries completedAt, or when a later turn has begun, because turns run one at a time. The status string is consulted only after that test passes. The adapter's activity reading uses the same rule, measured on office on 2026-10-08: a working Desktop thread's newest turn read interrupted with no completedAt; an idle one read completed with it.
The same history taught one more bound. A single thread measured more than 27 MB, with one item alone at 1.05 MB, over the 1 MB WebSocket frame limit the first client used. The adapter now reads ten-item pages and accepts a large single message, so one oversized item cannot blind it.
When the Daemon Is Not There
An observer that needs a daemon has a new failure: the daemon is gone. Since 2026-10-05 (commit 89420c4) the adapter recovers it, conservatively. It rediscovers only when the enrolled socket path no longer exists; a socket that exists but fails the privacy check stays a refusal. Discovery looks for exactly one private daemon socket; two or more is a Conflict that asks for operator reconciliation, because picking one would be a guess. If none exists, it starts the daemon with the idempotent start command, under an exclusive flock on a cooldown file shared by every adapter child and by discovery. Attempts, failed ones included, are spaced at least 30 seconds apart, so twenty adapters noticing the same missing daemon produce one start, not twenty. The start's PID and version are logged, and no running native work is restarted. While it is still missing, the message says exactly that: Codex observer daemon is not running. Coop tried to start it: {error}. Coop will retry shortly.