Skip to content

Codex as a second v1 harness

Decision

v1 ships two harness adapters, claude-code and codex, behind the same Harness/RunningAgent traits, the same Capabilities proofs, and the same cascade delivery protocol. Earlier drafts deferred Codex entirely; the user-story tier for it was a degraded SIGINT+resume path, on the assumption that Codex had no hook surface worth building a boundary on.

Why the deferral was reversed

The deferral was a statement about Codex's hook surface, not about kiki's architecture. That surface changed. Codex now ships a lifecycle-hook system whose PreToolUse event covers shell, apply_patch, and MCP tool calls, can deny a call with a reason returned to the model, and carries session_id, turn-scoped turn_id, and per-call tool_use_id identities; a Stop event reports turn completion, and SessionStart distinguishes fresh starts from resumes. Every ingredient the cascade boundary needs — an exclusive pre-tool admission point over file-touching tools, a payload channel back to the model, turn identity for acknowledgement, and a resume path for RestartStartup — now exists to be proven rather than approximated.

Meanwhile nothing in the protocol was ever Claude-shaped. PreToolUse and PostToolBatch are kiki's entry points; SoftBatch binds an incarnation, model turn, and tool batch without caring how the harness spells them. Deferring Codex bought no simplification beyond one fewer integration gate.

The boundary mapping

Claude Code's hard case is the parallel tool batch: several concurrent PreToolUse invocations from one assistant response, none of which proves the model consumed another's synthetic result. Codex's hard case is the opposite — it dispatches the tool calls of a model turn serially and has no batch-completion event, so the question is what a "batch" even is.

The answer keeps the protocol untouched: parallel_batch_boundary requires an unambiguous boundary, not parallelism. Once the integration gate proves a pinned Codex version never runs two tool calls of one turn concurrently — subagent-originated calls included — and requests a new tool only after the model consumed the previous call's result, each batch contains exactly one call and the concurrent-sibling steps of the delivery protocol are vacuously satisfied. The proof is version-scoped on purpose: a Codex version that starts dispatching concurrently without a provable completion boundary silently loses nothing — it loses the capability at the next incarnation check and runs with RestartStartup delivery only.

Completion and consumption each took three attempts to get right, and the failures are worth recording. The obvious completion mapping, the single call's PostToolUse event, is wrong for exactly the case that matters: a blocked call is denied, a denied call never executes, and a call that never executes fires no post-tool event — soft delivery would wait forever on a proof that structurally cannot arrive. The second attempt, the emitting hook's own post-stdout confirmation, fails the crash test: that RPC is the protocol's explicitly weaker best-effort evidence, and it dies with the hook. What actually survives a hook crash is the harness's next event: under the serial-dispatch proof, the next turn-scoped hook arrival — a PreToolUse with a serially later tool_use_id, or the turn's Stop — cannot happen before the blocked call resolved, so its admission transaction records batch_completed_at: a crash-surviving, harness-driven PostToolBatch report, structurally parallel to Claude Code's.

Consumption has the same trap one layer deeper. Hook-side write evidence — MarkDelivered, or any durable mark the hook records around its own stdout — proves the hook emitted the deny, not that the harness applied it. A hook that exceeds the harness-side timeout, or whose output the harness rejects, writes into a void while the model receives a substituted result; acknowledging on write evidence would acknowledge a payload the model never saw. The proof that closes the gap is the harness's own bookkeeping: Codex durably persists every tool result in its session record, deny reasons included, because resume must reconstruct exactly what the model saw. That record is the delivery receipt — acknowledgement requires the blocked tool_use_id's recorded result to be the byte-identical saved payload, which also makes the deny-fidelity gate proof runtime-enforced. A contrary receipt redelivers byte-identically on the arriving batch — but only once, because a receipt that keeps coming back contrary is no longer a transient: it is evidence the deny channel itself rejects this payload on this version, and redelivering down a channel proven defective is an infinite loop wearing a retry's clothes. The second contrary receipt fails the intent over to RestartStartup, whose delivery is the replacement process's mandatory first input and does not touch the hook output; bounded restart attempts that keep failing their handshake stop loud — agent hard-paused, attention event raised, human required — rather than looping. The bound obeys the same single-authority rule as everything else on the intent: attempts and exhaustion are durable intent state written in the transactions that make them, because an in-memory retry counter is a loop that merely waits for a daemon restart to forget it. And a dead end that only a human can enter must be one a human can leave: kk repair gives an exhausted intent exactly two exits — a journaled re-arm that grants one fresh bounded cycle, or a one-shot-approved discard that acknowledges without delivery proof and says so before it is approved. The re-arm boundary is a monotonic delivery epoch stamped onto each attempt row, not a timestamp: "after the re-arm" is exactly the kind of wall-clock ordering the rest of the book refuses to trust, and the epoch id makes the fresh cycle unambiguous under skew, rollback, or restart. An unreadable receipt defers acknowledgement when nothing is pending and fails closed into RestartStartup when a delivery is — the crash-safety invariant prefers a rare duplicate delivery over an acknowledgement by inference. Three supporting properties are version proofs: the launcher answers for a dead sidecar under an active barrier with a generic blocking deny so a barrier-covered call never fails open, the generated config pins the hook timeout above the slow path's bound, and the session record durably contains deny results so receipts exist to be read.

Identity needed the same care. Codex's turn_id scopes the whole agentic loop for one user input — many sequential model steps share it — so it cannot serve as the protocol's model_turn_id: two consecutive tool calls with one turn_id are separated by a model step that consumed the first call's result, and keying acknowledgement on turn_id would postpone it until the next user prompt. Under the serial-dispatch proof, each tool_use_id names a distinct model step and durable hook-arrival order is step order, so a later PreToolUse with a distinct, serially later tool_use_id is the later-model-turn acknowledgement proof. The Stop event, like PostToolBatch, never acknowledges.

Two further proofs have no Claude analogue and are therefore stated explicitly. Deny-payload fidelity: Codex returns a denied call's reason to the model as the tool result, and that channel must carry the saved payload byte-identically. Coverage: structured_tool_hooks is true only when every workspace-observing tool in the pinned version fires PreToolUse or is disabled by the generated configuration — a harness whose file-touching tools can bypass the boundary has no safe soft barrier.

Isolation via CODEX_HOME, with no fallback

Claude Code's exclusivity story needs two mechanisms because its settings sources always layer: preferred launch-scoped settings, and an ownership-tracked merge into <workspace>/.claude/settings.local.json when isolation is unavailable. Codex resolves configuration, hooks, trust, sessions, and authentication from one CODEX_HOME root, so the adapter gets a stronger primitive: point CODEX_HOME at the generated per-thread directory and the user's real ~/.codex/ is structurally excluded rather than overridden. Kiki pre-trusts exactly its own hooks in the generated trust store; Codex's own trust model leaves non-managed workspace and plugin hooks untrusted by default, and the gate proves that exclusion per version rather than assuming it.

Because the primitive is stronger, there is deliberately no workspace-merge fallback: kiki never writes anything under <workspace>/.codex/, and a failed isolation proof fails the incarnation before the agent receives a prompt. The relocation has one sharp edge — CODEX_HOME also moves authentication — so provisioning credentials into the generated home is part of the launch gate, and a launch that would fall back to the user's real home for auth is a failed proof, not a convenience.

What this does not change

The cascade invariants, the sync_intent protocol authority, RestartStartup semantics, and the capability-driven degradation ladder are untouched; Codex slots into them. Harnesses beyond these two remain deferred behind the same traits, and the transcript projector for Codex rollout JSONL ships with v1.x transcript capture like its Claude counterpart.

docs/reference · built with VitePress