The problem in one picture
On this machine, every Claude Code session can send a text message to any other Claude Code session. They find each other through a shared folder and talk over Unix sockets. We want Codex sessions to join that conversation as equals: a Claude session should be able to send a brief to a Codex reviewer called Sol and get its answer back, exactly as it would from another Claude session.
Codex does not speak that socket protocol. So one small program has to sit next to each Codex session and translate. That program is the bridge. Everything in this note is about making that program as small as the job allows.
%%{init: {"theme": "neutral"}}%%
flowchart LR
subgraph CS["Claude side"]
A[Claude session
Multi-model]
B[Claude session
Bridge v3]
end
R[("Registry folder
~/.claude/sessions/*.json")]
subgraph XS["Codex side"]
W[Wrapper for Sol
one python process]
T[Codex TUI in the pane
child of the wrapper]
end
D[("Codex app-server daemon
shared, already running")]
A -- "reads who exists" --> R
W -- "writes its own row" --> R
A -- "message over a socket" --> W
W -- "JSON-RPC over one WebSocket" --> D
T -- "attached to the same thread" --> D
W -- "reply over a socket" --> A
The two rules this design follows
Copy what Claude already does. The Claude messaging protocol works today across every session on this machine. Wherever we had a choice, we chose what Claude does.
Add nothing the protocol does not have. Every earlier version added safety machinery on top: a spool on disk, a supervisor, a fallback path, locks, version gates, timeouts. Each addition was where the next bug lived. v4 removes them and explains, item by item, what happens instead.
How Claude sessions already talk
There is no server in the middle. Every session is its own tiny server, and every session is a client when it wants to speak. Four facts describe the whole protocol.
- Every session listens on its own socket. The file is
/run/user/1000/cc-socks/<pid>.sock, readable and writable only by our user. - Discovery is a folder of files. Each session writes
~/.claude/sessions/<pid>.jsonwith its name, its process id, when that process started, its socket path, its status (busy or idle) and its version. Listing agents means reading this folder. Nothing watches it. - Liveness is judged by the reader. Before trusting a row, a reader checks that the process is alive and that its start time still matches. Stale rows simply sit there until someone cleans up. They harm nothing.
- Sending is two lines of JSON on a fresh connection, then hang up. The sender reads the target's key file, which only our user can read, and writes an auth line followed by the message. A reply is not a response on the same connection. It is a brand new message sent back to the sender's socket the same way.
{"type":"auth","token":"<32 hex characters from ~/.claude/sessions/<pid>.<sha256 of socket path>.key>"}
{"msgV":1,"msg_id":"<uuid>","type":"user",
"message":{"role":"user","content":"<the text>"},
"priority":"next",
"from":"uds:/run/user/1000/cc-socks/<sender pid>.sock"}
%%{init: {"theme": "neutral"}}%%
sequenceDiagram
participant S as Sender session (pid 100)
participant F as Registry folder
participant K as Key file of pid 200
participant R as Receiver session (pid 200)
S->>F: read 200.json (name, socket path, procStart)
S->>S: check pid 200 is alive and procStart matches
S->>K: read peerToken
S->>R: connect 200.sock, write auth line + message line, close
R->>R: verify token, verify the connecting pid with SO_PEERCRED
R->>R: queue the message, deliver it at the next tool boundary
Note over R: minutes later, when it has an answer
R->>F: read 100.json, check pid 100 still alive
R->>S: connect 100.sock, write auth line + reply line, close
What the protocol deliberately does not have
- No supervisor. Nobody starts, restarts or monitors sessions. You open a window, a session exists. You close it, the session is gone and its row goes stale.
- No durability. A session queues incoming messages in memory. If it dies, they die with it. The sender's next send fails at connect, which is how it learns.
- No reply timeout. A sender gets three things instead: a receipt that the message arrived, the receiver's busy or idle status in the agent list, and an optional one-shot notice when the receiver next goes idle. A reply comes whenever it comes.
- No version gate. The row carries the version as a label so a reader can see it. Nothing checks it.
One thing it does have, measured 27 September on Claude Code 2.1.280: a receiver drops a replayed frame. The same sender sending the same msg_id twice on two connections is enqueued once; the same text under two different ids arrives twice. The check is in memory and keyed on the id, which is why v4 keeps a small in-memory set of recently seen sender and id pairs and nothing more.
Why this matters
Each of those four absences is a place where v2 and v3 had machinery. That machinery is where every serious bug lived. v4 matches the protocol's absences on purpose.
The one extra piece: the wrapper
Codex cannot listen on a socket or write a registry row, so a wrapper does it for each Codex session. The wrapper is a single python process that lives inside the tmux window you open for that peer. It talks to Codex over one WebSocket connection to the app-server daemon that Codex already runs for every user, and, for a visible peer, it has one child: the Codex TUI that you see in the pane, attached to the same thread on that daemon. A headless peer has no child process at all. The agent core runs once, in the daemon, however many peers are open. This is the shape Codex gives its own desktop app and IDE extension, and a spike on 27 September ran three turns through it on a throwaway thread, with a pane attached, exactly this way.
What the wrapper does on open
- Reads the installed Codex version from the package manifest, and asks the daemon for its own with
codex app-server daemon version. Both are labels only. They can differ, and the log line says so. - Opens one WebSocket to the daemon's socket at
~/.codex/app-server-control/app-server-control.sock, the stable path Codex itself reports, and speaks JSON-RPC over it. It stays connected and subscribed for as long as the peer is open, because the daemon unloads a thread sixty seconds after its last subscriber leaves. If the connection is refused, the wrapper runs Codex's owncodex app-server daemon startonce and tries again, then gives up loudly. - Resumes the thread recorded for this name in
~/.claude/codex-peer-threads/<Name>, or starts a new thread and records it. This file is the only persistent state the bridge has. It is the equivalent of resuming a Claude session. - Sends the thread its configuration: model, reasoning effort, read-only sandbox, approval policy
never, and the MCP tool that lets the peer message Claude sessions. This happens on every open and every reconnect, because a thread does not keep it across a daemon restart. - Writes its registry row and key file, and binds its socket. From this moment other sessions can see and message it.
- For a pane peer, runs the TUI in the foreground of the pane, attached to the same thread on the daemon with
--remote unix://and that socket path. Turns that other sessions start show up in the pane like your own.
Pane peer
You open it, you see it. The TUI in the pane is attached to the peer's thread on the daemon, and the pane appears in tmux and therefore in the web terminal, like every Claude session. Use this for peers you want to watch.
Headless peer
Same wrapper, no TUI and no child process: its only Codex connection is the WebSocket to the daemon, and it adds no socket anywhere. The window shows one log line per event instead. To watch a headless peer, close it and reopen it with a pane. The thread resumes in seconds.
A message's journey
Here is what happens when a Claude session sends a brief to Sol and gets the review back. The important property is in the middle: delivery is acknowledged. The wrapper either gets a turn id back from the server or an error, immediately. There is no moment where a message has been typed somewhere and nobody knows whether Codex saw it.
%%{init: {"theme": "neutral"}}%%
sequenceDiagram
participant C as Claude session
participant W as Wrapper (Sol)
participant X as Codex daemon, over the WebSocket
C->>W: auth line + message line on Sol's socket
W->>W: verify token and connecting pid, append to the queue
W->>W: registry row: busy
W->>X: turn/start (text with a message id)
X-->>W: turn id, at once
Note over X: the model works, seconds or minutes
X-->>W: item/completed for each item of the turn
X-->>W: turn/completed
W->>X: thread/items/list
X-->>W: the items of the thread
W->>W: take the agent items after our message item, stop at the next user item
W->>C: auth line + reply line on the Claude session's socket
W->>W: registry row: idle, next queued message if any
The framing text
The wrapper does not hand Codex the raw message. It puts a short header in front so the peer knows who is talking and what to do with its answer. This framing is unchanged from today because it works:
Message from Claude session "Multi-model" via the peer bridge (id 0eacd25d).
Reply in plain text; your whole reply is sent back to them.
<the sender's text>
How the wrapper knows which answer is whose
A thread is a list of items in order: user items and agent items. Each message the wrapper sends becomes one user item carrying our message id. The reply to that message is the run of agent items that follows it, stopping at the next user item, whoever wrote it. Items typed in the pane carry an id too, one the TUI makes up, so the wrapper recognises its own items by their prefix, never by the mere presence of an id. This rule works when two senders queue up, when a message steers a turn already running, and when you type into the pane yourself. The spike ran exactly that sequence, two bridge turns and one typed turn, and the items came back as three ordered pairs. If the run is empty, the wrapper reports "no answer" instead of guessing. Review finding, 27 September: the reply is the final answer. Codex marks each agent item as commentary or final answer, and only the final answer is the reply. A turn that ends with no final answer, which is what a daemon restart mid-turn leaves behind, gets the "ended without an answer" notice with the commentary attached and labelled partial, never a reply that looks finished. If the item list turns out not to carry that mark, the wrapper delivers the run as before and the restart check at go-live decides what this paragraph says.
Priorities and steering
Every message in the protocol carries a priority, and there are exactly three values. Claude sessions honour them on the receiving side already. The wrapper maps them onto the two things a Codex thread can do: start a new turn, or steer a turn that is already running.
| Priority | What a Claude session does with it | What the wrapper does with it |
|---|---|---|
| now | Meant to be delivered at once, ahead of any tool boundary. We have not exercised it ourselves. | If a turn is running: turn/steer, the text joins the running turn. Otherwise: start a turn. |
| next | Delivers between two tool calls of the current turn. This is the default. | Queue it. Start a turn when the thread is idle. |
| later | Waits until the session is idle | Queue it. Start a turn when the thread is idle. |
%%{init: {"theme": "neutral"}}%%
flowchart LR
M[message arrives] --> P{priority}
P -- now --> Q{turn running?}
Q -- yes --> S[turn/steer into the running turn]
Q -- no --> T[turn/start]
P -- next or later --> F[append to the queue]
F --> I{thread idle?}
I -- yes --> T
I -- no --> F
Steering is native on the Codex side: the app-server has a turn/steer method next to turn/start and turn/interrupt. The reply rule from the previous section still holds for a steered message, because the steer becomes its own user item in the thread.
Waiting without a clock
Earlier bridges interrupted a Codex turn after fifteen minutes and told the sender it had timed out. That cut two real Sol answers short. Claude's protocol has no such clock, and neither does v4. A sender who wants to know when the peer is done asks for an idle notice, which is a feature every Claude session already supports and the wrapper supports the same way.
%%{init: {"theme": "neutral"}}%%
sequenceDiagram
participant C as Claude session
participant W as Wrapper (Sol)
C->>W: message + notify_when_idle
W->>W: remember the reply address, row: busy
Note over W: the turn runs for 25 minutes
W->>C: reply message (the review)
W->>C: one idle notice, then forget the subscription
Note over C: no clock anywhere, no message was ever interrupted
The wrapper never interrupts a turn. The only interrupt is you pressing Esc in the pane. If a turn ends without an answer, because you interrupted it, the server crashed, or Codex asked for an approval that the policy declines, the sender gets one short notice saying so, in the slot where the reply would have been.
The one extra we considered, and dropped
Claude keeps nothing on disk about a message in flight. The first draft of this page kept one tiny note, message id and sender, so that a wrapper reopened after a crash could tell the sender "your message was not re-sent and may have been processed". The owner asked what it bought. Honestly, little: it helps only after a wrapper crash in the middle of a turn, which is rare for a program that spends its life blocked on a socket; the sender already learns the peer died the moment its next send fails; the answer, if there is one, is in the thread and visible in the pane; and the worst outcome without the note is one duplicate Codex turn. Against that it was the only file the wrapper would write about a message, and the crash path is exactly where every earlier bridge grew its bugs. Dropped, 27 September. The wrapper writes nothing about a message, ever. Its log line is the only trace.
The other direction: Codex asks a Claude session
A Codex peer sometimes needs to ask the session that sent it work, or any other session, a question. In Codex that has to be a tool call, because a Codex session cannot open a socket by itself. Today's tool is synchronous: it writes a file, waits, polls every half second, and holds the turn open for up to fifteen minutes. Claude's equivalent is simply SendMessage: send, carry on, and the reply arrives later as an ordinary message. v4 does the same.
%%{init: {"theme": "neutral"}}%%
sequenceDiagram
participant X as Codex (Sol's turn)
participant M as MCP tool send_to_session
participant C as Claude session
participant W as Wrapper (Sol)
X->>M: send_to_session("Multi-model", "which file is authoritative?")
M->>C: auth line + message line on Multi-model's socket
M-->>X: "sent to Multi-model" and the turn goes on or ends
Note over C: answers when it gets to it
C->>W: auth line + message line on Sol's socket
W->>X: turn/start or turn/steer with the answer
This removes the ask files, the claim files, the poll loop and the tool timeout, about 150 lines across two files. It also gives every Codex peer the ability to message anyone at any time, which was planned as a whole separate slice. One behaviour changes: a peer that asks a question ends its turn, and the answer starts the next one, which is exactly how a Claude session behaves. The tool finds its own wrapper the way any Claude sender finds any peer: by name, through the registry rows, at the moment it sends. Nothing about the wrapper's socket is baked into the thread's environment, only the peer's name, so closing a peer and reopening it a minute later changes nothing the tool relies on. Review finding, 27 September: an earlier draft passed the socket path in, which pointed at a dead wrapper after a quick reopen.
Life of a wrapper
The wrapper has three states and no hidden ones. It is opening, it is serving, or it is closing. A daemon restart, which Codex does on its own schedule at upgrades, is handled inside the serving state by reconnecting to the same thread. Closing the tmux window, or running codex-peer.sh close Sol, ends everything.
%%{init: {"theme": "neutral"}}%%
stateDiagram-v2
[*] --> Opening
Opening --> Serving: server up, thread resumed, config sent, row written
Opening --> [*]: resume failed, log the version, exit
Serving --> Serving: message in, turn, reply out
Serving --> Reconnecting: connection dropped or the daemon restarted
Reconnecting --> Serving: reconnect, same thread, config sent again
Serving --> Closing: window closed, TUI exited or SIGTERM
Closing --> [*]: end the TUI, remove row, key and socket
Failure cases, plainly
| What happens | What the sender sees | What you see |
|---|---|---|
| The daemon restarts mid-turn, at a Codex upgrade or a crash | One notice: the turn ended without an answer. Not re-sent. | A log line with the CLI and daemon versions; the wrapper reconnects, and the TUI reconnects by itself |
| The wrapper itself dies | Queued messages are gone. The next send fails at connect. A turn already running finishes in the daemon; its answer sits in the thread, delivered to nobody. | The window is gone or shows the traceback. Reopen by name; the pane shows the thread, answer included. |
| Codex was upgraded since the peer opened | Nothing, unless a call fails, and then the failure carries the versions. | Codex restarts its own daemon; every peer reconnects and the log names the CLI and daemon versions. |
| No daemon is running when a peer opens | Nothing. The peer is not listed until it can serve. | The wrapper runs Codex's own daemon start once, waits up to fifteen seconds for the socket it just asked for, then fails loudly: the window prints the last log lines and stays open until you press a key. |
| You press Esc during a bridge turn | One notice: interrupted by the owner. | The pane behaves as it always did |
| A sender transmits the same frame twice | The second copy is dropped. Claude keeps recently seen message ids in memory and ignores a repeat from the same sender. | The same: a bounded in-memory set of sender and message id pairs, checked before a frame is queued. Nothing on disk, so a wrapper restart forgets it, exactly as a Claude session restart does. v4 never retries a frame of its own; one connection, one attempt, and an error to the caller. Review finding, 27 September. |
| Two wrappers open the same name | Both appear in the agent list, disambiguated as two Claude sessions with one name would be. | When the per-name file already exists, both join the same thread. Each answers only turns it started, so nothing doubles. Two first opens at the same instant make two threads instead, which is two peers with one name, exactly what two Claude sessions with one name are. Close one and codex archive its thread. Review finding, 27 September: accepted by design, no lock. |
What we deliberately left out, and what happens instead
This table is the heart of the design. Every row was in v2 or v3. Each was added for a reason that sounded right, and each became the place where the next bug lived. For each, the right column says what v4 does instead. In most rows the answer is "what Claude does".
| Left out | What it did | Why it is gone | Instead |
|---|---|---|---|
| Spool on disk | Wrote every message to a file with a state machine: queued, sent, replied, completed, needs owner, expired. Recovered records after a restart. | Recovery is where every double send lived. Three review rounds each found a new way for a recovered record to be sent twice. | A queue in memory, like Claude. A crash loses queued messages and the sender's next send fails at connect. |
| Two delivery paths with automatic fallback | Tried the app-server, fell back to typing into the TUI, reconciled in-flight messages across the switch. | It turned the bridge into a two-path exactly-once system. Most of v3's code and all of its refusals served this rule. The app-server path never carried a production message. | One transport per peer, chosen at open, kept for life. If it breaks, the peer is down, visibly. v2 is kept as a separate program you can run instead, once the peer is closed and the daemon has unloaded its thread, about a minute later. |
| A private app-server per peer | Each peer spawned its own app-server, one copy of the agent core per peer, with a private socket for the TUI. | Codex's own model is one shared daemon per user with thin clients, and that daemon already runs on this machine for the IDE. Four peers would have run four cores for no gain. | The wrapper connects to the shared daemon over its socket, as Codex's own clients do. Threads live in the daemon. Owner decision 2026-09-27. |
| Supervisor | One process that spawned a child per stored peer, reloaded config on a signal, reaped stale files. | Not in the protocol. It was also not running: nothing started it, so six peers were unreachable while it looked configured. | You open a window, a peer exists. Headless peers are opened the same way, without a TUI. |
| Typing into the TUI and reading rollout files | Pushed text into Codex's input and watched its log files to find the answer. | Never acknowledged, so a message could sit unseen behind a dialog. Six fix commits on day one were about guessing which log event belonged to which message. | JSON-RPC to the app-server: a turn id or an error, at once. The reply is read by message id. |
| Per-peer lock and name guard | A kernel file lock so only one process owned a peer's files. | Claude has no such lock; duplicate names are disambiguated in the agent list, and each wrapper answers only the turns it started. | Nothing. Two peers on one name behave like two Claude sessions on one name. |
| Reply timeout | Interrupted the turn after fifteen minutes and told the sender. | Cut two Sol answers short. Claude has no timeout. | Busy or idle in the row, plus the one-shot idle notice on request. |
| Version gate | A pinned copy of the binary, an hourly smoke test, a schema diff, a stale-pin flag. | Codex ships several times a day. Approving versions does not scale, and neither does a check on every open. | The version is a label in the row and in every failure line, exactly as Claude does with its own version. The first failure on a new release names it. |
| Synchronous ask tool | A tool that waited for a Claude session's answer inside the Codex turn, with claim files and a poll loop. | Claude's equivalent is an asynchronous send. | Send and carry on. The answer arrives as a message. |
| Undelivered ledger | A file of replies that could not be delivered. | Claude has none. | One log line. |
| Frame journal, pathname hygiene checks | Every JSON-RPC frame written to disk; checks that directories were ours, unlinked, correctly moded. | Evidence, not state. The design itself called the path checks hygiene that a same-user attacker gains nothing from. | A per-peer log with one line per event. The binding security check, the kernel's word on who connected, stays. |
What stays, because Claude has it too
- keep One process per session in a tmux window that dies with the window.
- keep The registry row: pid, name, socket path, status, tmux location, version as a label, plus model and effort, two static strings the Crucible tile already shows, and the bridge marker that tile keys on.
- keep The socket, the key file, the auth line, the kernel check of the connecting process id, and the process start-time recheck before a reply. Claude does all of these. This is parity, not extra.
- keep The in-memory queue with the three priorities.
- keep Replay protection by message id, in memory only, because Claude has it.
- keep Busy and idle, and the idle notice on request.
- keep A reply is a new message. No timeout.
How we got here
The bridge is two days old and has had four shapes. The lesson of the history is not that the earlier versions were careless. It is that each added machinery to be safer, and the machinery is what broke.
Bridge code only, tests excluded. v3 includes its 701-line app-server client module. The v4 figure is an estimate: 400 to 500 lines of wrapper plus a trimmed JSON-RPC client of about 250.
v1 and v2: typing and tailing
The bridge pushed text into the Codex TUI and read the answer from Codex's log files. It worked, and it was fragile in one specific way: a message could be swallowed by a dialog or bound to the wrong turn, and nothing said so. v2 added a durable spool so nothing was lost across restarts.
v3: the app-server, with v2 as automatic fallback
v3 spoke JSON-RPC to a Codex app-server, kept v2 underneath as a fallback path, and reconciled in-flight messages when switching between them. It was refused by review three times, each time for a message that could be sent twice, and every one of those sat in code that recovered or reconciled records across paths and processes. A kernel lock closed the last one and v3 was merged, but its app-server path has never carried a production message.
What the refusals taught
Every double send was in code that existed to recover or re-send. Remove re-sending and the whole class of bug goes away. v3 wrote that down as a rule, "the bridge never re-sends". v4 makes it trivially true: there is nothing on disk to re-send from.
What the outage taught
While v3 was being perfected, the supervisor that stored peers depended on was not running and nothing started it. "Always works" was pursued inside the process while nothing outside it restarted the process. v4 has no process that needs starting except the one in the window you opened.
Late tweaks folded into this write-up
Reviewing the whole design in one sitting showed a few places where earlier notes still carried something the later decisions had removed, and two small simplifications that follow the same rule. Each is listed here so nobody has to reconcile the notes.
- tweak The shared daemon instead of a private server per peer. The first draft of this page had every wrapper spawn its own
codex app-server. Measuring the box showed Codex already runs one shared daemon per user, started under systemd, with your IDE connected to it through a stdio proxy. Running a private core per peer duplicated that for no gain. Owner decision after the comparison below.Before: private server per peer After: shared daemon, direct connection Wrapper's Codex child codex app-server --listen unix://…, one per peerNone. One WebSocket to the daemon's socket Where the agent core runs Once per open peer Once, in the daemon, for all peers Memory per open peer Pane up to 590 MB, headless about 265 MB Pane about 430 MB, headless about 100 MB, measured: an attached TUI is a full 329 MB, and the daemon grows about 70 MB per loaded thread Sockets we create One private app-server socket per pane peer in /tmpNone. The daemon's own socket is Codex's business Code in the wrapper Spawn the server, pick its socket path, watch the socket, restart it if it dies Connect, reconnect if dropped, one daemon startretry. The September client class already does the connection in thirty linesTUI attach --remoteto the private socket--remoteto the daemonWhat one failure takes down One peer Every peer, until Codex restarts its daemon, which your IDE already depends on Proof so far September probes against a private server Codex's own client shape. Spike 3 on 27 September, three model turns on a throwaway thread: stands with conditions, all folded into this page The September probes chose a private server per peer for three reasons, and each now reads differently. The daemon self-updates on its own schedule: that was a reason to pin a private binary, and the owner has since decided that versions are labels, not gates, so a self-update mid-turn is one notice to the sender. A loaded thread keeps a writer lock until the server that holds it exits: with a private server, closing the peer released it. The spike showed the daemon unloads a thread sixty seconds after its last subscriber leaves and removes the lock, so closing a v4 peer releases its thread a minute later without stopping anything. Per-thread configuration is not persisted: true either way, the wrapper re-sends it on every connect. One thing the first version of this tweak got wrong: the daemon speaks WebSocket, and Codex's
app-server proxyonly copies bytes, so a plain JSON line into it gets no answer. The wrapper therefore connects to the daemon's socket directly, and needs no proxy child. The spike also found the stable address: Codex keeps a symlink at~/.codex/app-server-control/app-server-control.sockto the hashed name in/tmp, and reports it fromcodex app-server daemon version. One thing the shared daemon does not change: any process of our user can reach any thread through that socket, exactly as it could reach a private server's socket before. That is Codex's design and the same exposure as before. - tweak No lock and no name guard, anywhere. An earlier note said v2 and v4 should share a lock file. Claude has neither, and each wrapper answers only the turns it started, so two peers on one name cannot double a reply. v2 needs no change at all to be a standalone backup.
- tweak Close is closing the window.
codex-peer.sh close Solbecomestmux kill-windowon the peer's window. The wrapper cleans up on the hang-up signal. This replaces the pid lookup and start-time check in the shell script. - tweak One file per thread name.
~/.claude/codex-peer-threads/Solholds Sol's thread id. Peers never write a shared file, so there is nothing to contend over. Listing peers is listing that folder and reading the registry. - tweak No timeout in step six. The first wrapper spec still said "on timeout, interrupt". That sentence is gone. The pane's Esc key is the only interrupt.
- tweak The update prompt is switched off in the peer profile. The Codex binary has a setting named
check_for_update_on_startup. With several releases a day, the prompt would otherwise sit in front of the TUI on most opens. Its exact behaviour is on the verify list below. - tweak Names. Owner decisions 27 September: v4 takes the plain name.
codex-peer.shis the command (codex-peer.sh open Sol),codex_peer.pythe wrapper, plus a small JSON-RPC client module, the peer's send tool and their own tests. The first decision was new files under "codex-peer4" names; the owner then asked for something less odd, so v4 owns the plain name and the v3 files move unchanged intoharness/scripts/retired/codex-peer-v3/in the landing commit. The tree holds one bridge, git history holds every version, and v2 stays recoverable by hand from its commit. - tweak Visible only when ready. The registry row and socket are created last, after the thread has resumed and its configuration has been sent. A peer that appears in the agent list can always take a message. Earlier drafts registered first.
What the spike settled, and what the first open still checks
Most of this design rests on facts checked on this machine: the registry format, the two-line wire format, the three priorities, the kernel check, the app-server methods in the installed binary. The shared-daemon shape was then put through Spike 3 on 27 September: ten minutes, three model turns, one throwaway thread that was archived afterwards, the daemon never restarted. Its findings file, SPIKE-3-shared-daemon.md, sits in the plan folder beside the two September spikes.
Settled by the spike
- settled The daemon's socket has a stable address,
~/.codex/app-server-control/app-server-control.sock, and--remote unix://with that path attached a TUI at the first try. - settled The wrapper and an attached TUI share one thread. Bridge turns render live in the pane, a turn typed in the pane comes back to the wrapper in order, and the items read as three clean pairs.
- settled Notification order is fixed: status active, turn started, items, status idle, turn completed. The wrapper triggers on turn completed and reads busy or idle from the status notices.
- settled Reconnecting is clean: no replay, no duplicates. A warm resume takes eleven milliseconds and a cold one, after the thread was unloaded, just over a second.
- settled The daemon unloads an idle thread sixty seconds after its last subscriber leaves and removes its lock file. So the wrapper stays subscribed while a peer is open, and closing the peer frees the thread a minute later for anything else, including the v2 backup.
- settled A plain
codex resumejoins the daemon as well. Only--no-daemonruns the core in its own process, and that is refused while the daemon holds the thread. - settled An attached TUI weighs the same as a plain one, 329 MB. The daemon grows about 70 MB per loaded thread. Archiving a thread unloads it at once.
Still to check on the first open
- verify
check_for_update_on_startup = falseactually suppresses the update prompt. Every TUI start in the spike showed it, because the daemon had updated itself to 0.157.1 while the command line was still 0.157.0. Esc skips it. Enter would run the upgrade in the pane. - verify The exact field names of the idle-subscription frame and the idle-notice frame, copied from a frame a real Claude session sends. The wrapper logs any control frame it does not recognise, verbatim, for this purpose.
- verify What a peer sees when the daemon restarts or updates itself while the peer is open. The spike left this untested by rule, because your IDE was on the daemon. Owner decision 27 September: one deliberate restart, at a moment the owner names, with a peer open and the IDE idle.
- verify Whether the daemon ever unloads a thread while a turn is running. Its log says it unloads only idle threads, so this should be a no.
- verify The pane's TUI starts in a directory Codex already trusts. A plain resume in the spike stopped at a folder-trust prompt that wanted to write the config file.
- verify The peer's message tool now runs as a child of the daemon with the thread's own environment, as the September probe showed for a private server. Confirm the peer name reaches it.
- verify Close a peer and reopen it within a minute, while the daemon still holds its thread, then have Codex send one message: it must reach the new wrapper. In the same check, confirm the pane's own resume does not replace the wrapper's message tool with a copy that lacks the peer name.
- verify One reply and one steer on a long migrated thread, Sol's, return the right slice. The spikes only ever listed items on short threads, so the turn filter and the page cursor of
thread/items/listare unobserved on a long one.
Known carries at landing, 27 September
Both readers passed the final revision with no High finding and no double-send on any path. These remain, recorded with line numbers in the build state file, and form the first follow-up row after go-live, opened only when the owner says so. None touches the normal path: a message sent to an idle or busy peer that stays open is answered once.
- A now message sent right after a peer is reopened while Codex is mid-turn can be refused instead of steered, because the wrapper does not yet know the running turn's id.
- If reading the items of an older, already finished turn fails while a newer turn runs, that older message's sender gets no notice.
- A reconnect that finds the thread busy on a different turn than the one it left can leave the wrapper refusing to start queued messages until a now arrives. Needs two turns to change inside a two-second gap.
- A registry write failing at the instant a turn starts reports "refused" to the sender although the turn is running. The bridge does not re-send; a sender who does would double the work.
- The peer identity domain is copied from the first registry row found, not from a live one.
- A frame that is neither a control frame nor a text message is dropped without a log line.
- A close signal landing during the two-second reconnect wait, or during the admission of an idle subscription, can hang the wrapper or lose one idle notice.
- Status flips run a tmux query while holding the one lock, which can stall the reader for up to five seconds; a message over one megabyte is dropped without telling the sender.
Two more were fixed by the build lead just before landing, under the house size rule, because both readers named the same line and the same removal: a peer reopened mid-turn now starts its queue when that turn ends, and a refused start moves on to the next queued message.
How it gets built
One row, one whole file, reviewed once. The v3 experience showed that reviewing small deltas of concurrency code misses what one whole-file read finds, and that the cure for an unreadably large row is a smaller design, not a fourth delta. v4 is small enough to read in one sitting, so it is built and reviewed as one.
- Spike, done on 27 September in ten minutes and three model turns: the shared-daemon shape stands, with its conditions folded into this page.
- Build from this page as the spec, on the usual bench. Two files: the wrapper and a trimmed JSON-RPC client. Plus the small shell entry point and the rewritten MCP send tool.
- Test the things that can be wrong: the two-line wire format both ways, token and pid verification, the reply rule with interleaved senders and a pane-typed message carrying its own id in between, the three priorities, the idle notice, close-window cleanup, and a dropped daemon connection during a turn.
- Review once, whole file, by two independent readers, and land on their verdict. No fix rounds on findings that add machinery: a finding is answered by removing something or by a test, and if it needs new mechanism the design comes back to this page first.
- Migrate by copying the seven existing thread ids into the per-name files. The v3 files move to the retired folder, content unchanged, as the last commit of the landing.
- Open Sol as the first peer, walk the verify list, then the rest.
The rule for future changes
Before adding anything to the wrapper, ask whether a Claude session has it. If not, the default answer is no, and the exception has to be argued in writing. The in-flight note above was argued that way, and lost.
Glossary
- Session
- One running Claude Code or Codex process with its own conversation. On this machine, usually one tmux window each.
- Peer
- A session that other sessions can message. Every Claude session is one. A Codex session becomes one through the wrapper.
- Unix socket
- A file-like endpoint that lets two processes on the same machine talk. Ours live in
/run/user/1000/cc-socks/, one per session, readable only by our user. - Registry
- The folder
~/.claude/sessions/. Each session writes one JSON file about itself there. Listing agents means reading that folder. - SO_PEERCRED
- A way for the receiving end of a Unix socket to ask the kernel which process connected. It lets the wrapper confirm that the sender is who its message claims to be.
- procStart
- The start time of a process, read from the kernel. Checking it protects against a process id that was reused by an unrelated program.
- JSON-RPC
- A simple request and response format in JSON. The wrapper sends a request such as
turn/startand gets a response with the same id back, plus notifications the server sends on its own, such asturn/completed. - App-server
- Codex running as a service that owns threads and answers JSON-RPC instead of drawing a screen. Codex runs one shared daemon of it per user, under systemd, for its desktop app and IDE extension.
- Daemon socket
- The Unix socket the shared daemon listens on. Codex keeps a stable symlink to it at
~/.codex/app-server-control/app-server-control.sock. It speaks WebSocket, so the wrapper opens one WebSocket to it and sends JSON-RPC frames inside. - WebSocket
- A framing layer on top of a socket that carries whole messages. Python's
websocket-clientlibrary, already installed here, provides it in a few lines. - TUI
- The text interface of Codex that you see in a terminal. For a pane peer it attaches to the peer's thread on the daemon with
--remote. - Thread
- A Codex conversation, held by the daemon and kept on disk by Codex. Resuming a thread restores its context. The wrapper remembers each peer's thread id in one small file.
- Turn
- One request to the model and everything it does until it stops. A message from a Claude session becomes one turn, or joins a running one when steered.
- Item
- One entry in a thread: a user message, an agent message, a tool call. The reply to a message is the run of agent items after its user item.
- Steer
- Adding text to a turn that is already running instead of starting a new one. Codex supports it natively.
- Queue, FIFO
- A list where the first message in is the first message out. The wrapper's queue lives in memory only.
- Double send
- The same message delivered to Codex twice, so the peer does the work twice. Every earlier version had at least one path to it. v4 has none because nothing is stored to re-send.
- Acknowledged delivery
- Knowing at send time whether the message was accepted.
turn/startreturns a turn id or an error at once. Typing into a TUI returns nothing. - tmux
- The terminal multiplexer that holds every session's window on this machine, and what the web terminal mirrors.