Running several coding agents at once is useful only if each can fail on its own. A wedged MCP server in one conversation should not freeze the refactor, investigation and deployment happening beside it, nor should an idle chat keep an entire runtime resident merely to remember what was said.

The interfaces still encourage us to think of agents as rows of messages. Underneath, each conversation edits files, runs shell commands, opens network connections, calls external tools and leaves child processes behind. Once I began treating agents as long-lived participants in my development environment, the single-process model felt like an extraordinary amount of faith to place in cooperative cancellation and a well-behaved event loop.

I built that expectation into codex-swift, my Swift port and expansion of OpenAI’s Codex agent. Every loaded conversation runs in a disposable process, so I can stop a hopeless job without disturbing anything unrelated.

Give each conversation a process

Each codex-session worker owns one conversation’s model turn, tools, sandbox and MCP children. The smaller codexd supervisor stays outside that boundary and handles connections, request routing, subscriptions, resource policy and worker lifecycles. It does not execute the risky work it supervises.

A heartbeat watchdog terminates and quarantines a worker which stops showing signs of life. An overloaded worker rejects new turns with the protocol’s -32001 overload error instead of allowing requests to pile up invisibly. When the last client leaves a conversation, the worker can unload and release its resources while the durable history remains on disk.

The process split gives failure a physical address. Closed IPC tells the supervisor that the worker is gone; the worker’s process group can be reaped with every shell child it spawned; a later request reconstructs the conversation from its append-only rollout. A hung Promise has no equivalent boundary. The runtime can observe it and ask it to stop, but the kernel cannot kill only the Promise and clean up whatever it forked.

A hung tool call stays inside its worker.

I test that failure boundary at two levels. testWatchdogTerminatesOnlyWorkerThatMissesHeartbeat binds a stale and a live worker to the supervisor, expires one heartbeat and verifies that only the stale worker is terminated. The live g6_poison_worker.sh gate goes further: it poisons one spawned worker while the daemon remains healthy, confirms that a quiet session continues completing turns, and that a fresh session can start after the poisoned one fails.

When the kernel has to intervene

I chose Swift because this agent belongs on the Mac, where the service can use the operating system rather than recreating it in application code. launchd owns the supervisor’s lifecycle, credentials live in the Keychain, and worker commands run under default-deny Seatbelt profiles which exclude writes outside the project and network access unless permission has been granted.

Every worker becomes the leader of its own process group, preventing a background child from quietly outliving the conversation that created it. testSpawnedWorkerTerminateReapsForkedDescendant constructs exactly that nuisance, terminates the worker and verifies that the child disappears. The worker also sets task_set_phys_footprint_limit, giving the kernel a memory ceiling it can enforce even when the process responsible for monitoring itself has become the problem.

Approvals, audit trails, sandbox policy and careful tool design remain distinct responsibilities. The process model gives those controls a useful container and limits the damage when one conversation stops cooperating.

Fifty sessions, one expired token

Authentication and the model catalogue have to be shared, so I keep them in codex-broker instead of copying them into every worker. If fifty sessions encounter an expired credential together, they wait on one refresh instead of stampeding the issuer with fifty requests.

I test the broker by creating the stampede it exists to prevent. testConcurrentRefreshSingleFlights sends fifty concurrent callers to an expiring token and asserts that far fewer than fifty refreshes reach the issuer. testAuthRefreshCoalesces applies 200 simultaneous 401 responses and expects a single refresh.

Memory was another place where I did not want one conversation to pay for everybody else’s work. I put retrieval, scoring, SQLite and optional local inference in codex-memory, away from the active turn loop. On Apple Silicon, small high-frequency tasks such as memory extraction and scoring can run through MLX-Swift on device without attaching their cost or failure to an expensive coding turn.

Keep the fan-out invisible

Process isolation would become a miserable product feature if every client had to understand it. codexd preserves OpenAI’s Codex app-server protocol, so an existing client can connect without knowing which process currently owns a conversation. The append-only rollout JSONL is compatible on disk as well; the Rust codex CLI can read a session written by the Swift service.

That promise covers 77 protocol methods and 37 typed response structs with schema parity; another 84 pinned generic responses carry explicit policy. A schema oracle, generated from 526 TypeScript manifests at a pinned upstream revision, is diffed on every release because I do not trust myself to maintain that surface by memory.

Because the fan-out stays inside the daemon, I can run several agents concurrently, let an idle conversation release its worker and terminate a hopeless one without thinking about watchdogs, process groups or rollout replay.

Chris Chabot · May 2026