Back in May I wrote a sentence into the README of codex-swift: “A session that hangs or crashes can’t stall or take down the others.” No session of mine had ever crashed when I wrote it. None has crashed since; I built for it anyway.
I don’t want prompt discipline to be the only thing keeping an agent that edits files, runs commands, reaches MCP servers, and handles auth inside its bounds.
codex-swift is my Swift port and expansion of OpenAI’s Codex coding agent. It makes the coding agent the top-level orchestrator, with an authentication broker, memory daemon, web UI, and Telegram channel behind it. Most integrations instead have a larger always-on assistant call a coding agent as one tool.
One conversation, one process
The runtime uses four processes. The first split contains a hung tool call. In a single-process CLI, one stuck conversation blocks every other task. Each conversation therefore receives a disposable codex-session worker that owns its turn loop, model calls, tools, sandbox, and MCP child processes. Risky work runs there because the worker owns only one conversation. The codexd supervisor watches for a silent worker and remains outside its failure boundary. It owns transports, request routing, subscriptions, resource policy, and worker lifecycles.
The hang I’m betting on gets a watchdog: workers post heartbeats, and the supervisor terminates and quarantines the ones that fall silent. When a worker dies, codexd should see the IPC link close, reap that thread, and rebuild the conversation from its append-only rollout. An idle thread unloads its worker and keeps the conversation on disk. An overloaded worker gets new turns rejected with the protocol’s overload error, -32001, without anyone calling the whole daemon broken.
Durability has the most coverage. Resume replays the rollout from last_committed_seq + 1, preventing a crash during a write from applying one record twice. g6_true_reboot_resume.sh reboots the host between the two halves of its run. The persistence document calls this the canary for fsync and WAL-checkpoint correctness.
I looked for a test that kills a worker during a turn and proves recovery. It is not there. The suite kills one level higher. g6_active_turn_crash.sh creates a temporary launchd installation, slows the mock model to ten seconds, waits for turn/started, and sends kill -9 to codexd during the turn. launchd restarts it under a new PID. The client resumes the thread, finds the interrupted turn, and completes a fresh one. testResumeAfterQuiesceReconstructsAndContinues covers cooperative worker unloading and checks that the previous assistant message survives reconstruction. Killing a live codex-session while codexd continues remains an untested design claim. That is the next test to write.
If ten sessions hit an expired credential in the same moment, the naive design fires ten refreshes at the token endpoint. It’s the kind of bug you otherwise get to fix exactly once, at the worst possible time. That stampede is why codex-broker runs apart, owning auth and the model catalog. It collapses the ten refreshes into one: a single request goes out, the result is persisted, and the rest are woken with it. This is the failure I could reproduce, so I did. The test stands up fifty concurrent callers against an expiring token and counts how many refreshes reach the issuer:
/// STRESS / ATTACK: concurrent callers on an expiring token must single-flight
/// the refresh (the broker collapses them) — not stampede the issuer with N
/// refreshes. Tolerant assertion: far fewer than the caller count.
func testConcurrentRefreshSingleFlights() async {
// ...expiring token, plus a mock exchanger that counts refreshes...
await withTaskGroup(of: String?.self) { g in
for _ in 0..<50 { g.addTask { await mgr.validAccessToken() } }
for await r in g { XCTAssertEqual(r, "NEW") }
}
let n = await rec.count()
XCTAssertLessThan(n, 50, "no single-flighting: \(n) concurrent refreshes hit the issuer")
XCTAssertGreaterThanOrEqual(n, 1)
}
With fifty callers, the issuer should receive far fewer than fifty refreshes, ideally one. A second test, testAuthRefreshCoalesces, sends 200 simultaneous 401 responses and asserts a single refresh.
Keychain, Seatbelt, launchd
A long-lived agent can use macOS facilities that do not fit comfortably in a one-shot binary. Upstream Codex now daemonises through codex remote-control, available since v0.131.0 that May, but remains one process and failure domain. codexd runs under launchd, and credentials live in the Keychain instead of dotfiles. Every worker command runs inside a default-deny Seatbelt profile and cannot write outside the project or reach the network without permission. Each worker has its own process group, preventing a background child from outliving the worker. testSpawnedWorkerTerminateReapsForkedDescendant spawns that case, terminates the worker, and checks that the child is gone. A worker also sets task_set_phys_footprint_limit, allowing the kernel to terminate it after exceeding the configured RSS ceiling.
The fourth process, codex-memory, keeps retrieval, scoring, local inference, and SQLite work outside the turn loop. It also provides the small-model path. On Apple Silicon, high-frequency work runs on device through MLX-Swift. A Qwen model performs memory extraction, contextualisation, and scoring, while a dedicated Nomic embedder creates vectors. Small decisions avoid a network call and do not share a failure boundary with an expensive coding turn.
77 methods, 84 response shapes
With four processes where upstream has one, I kept feeling the pull to invent a new protocol to match. The constraint I gave myself is the app-server protocol OpenAI’s Codex team ships: existing clients connect to codexd without ever learning there’s a fan-out behind it. That surface is 77 protocol methods and 84 response shapes, checked against a schema oracle of 526 TypeScript manifest files, generated from a pinned upstream revision and diffed on every release; an upstream rename breaks the gate until the Swift surface picks it up. I don’t trust myself to keep a surface that wide compatible by hand, so the diff catches what I’d miss. The constraint runs below the wire too: session files are the same append-only rollout JSONL as upstream, byte-compatible on disk, so the Rust codex CLI can still read a session this daemon wrote.
The process split does not provide approvals, sandbox rules, audit trails, or careful tool design. It contains failures: a bad session costs one worker while the rest of the daemon continues.
I’ve never seen one conversation take codexd down with it. If you run codex-swift and find a way, file an issue.