The same loop, different seams
Every harness has a log, a tool registry, a context budget and a permission layer. They differ in which of those you may replace — I read four to answer.
The right question
The last post concluded that a harness is four parts you can pull out. If that holds, every harness has all four, and the comparison question has to be a different one.
The question I use: when it breaks, what am I allowed to replace without editing the core?
To answer it I read the source of four other harnesses. Every claim below carries path:LINE at a pinned commit.
| Harness | Where the loop lives | Replaceable? |
|---|---|---|
| Codex | codex-rs/core/src/session/turn.rs:163 | Partly — see below |
| Gemini CLI | packages/core/src/core/client.ts:924 | Partly — the loop body is hard-coded |
| Claude Code | Not published | Hooks only |
| OpenHands | Not in this repository | By a protocol boundary |
The finding that made me correct my own notes
My earlier notes concluded Codex does not let you replace the loop. True, but incomplete — and I only found that out by reading the file I had skipped.
Codex has exactly the “loop is a plugin” structure:
pub(crate) trait SessionTask: Send + Sync + 'static {pub(crate). And the only production call site names a concrete type:
self.start_task(turn_context, Vec::new(), RegularTask::new())So an out-of-tree crate cannot install a different SessionTask. That is why I wrote “no”.
Where I was wrong is in a file I had not read: the Stop hook. Inside the turn loop there is this:
let stop_outcome = run_turn_stop_hooks(…).await;…if stop_outcome.should_block { // record a new prompt into the session stop_hook_active = true; continue; // the loop runs another iteration}A third-party hook can block the turn from ending and inject the prompt for the next one. It cannot replace the loop, but it controls whether the loop keeps going.
This is where I had to split one concept into two:
- Means: write a different implementation of the loop itself.
- Codex: no. The trait is
pub(crate); the call site names a concrete type. - Gemini CLI: no. Three hard-coded loop bodies in core.
- Claude Code: unreadable, the engine is closed.
- Means: decide when the loop stops, and what the next iteration carries.
- Codex: yes. The
Stophook returnsshould_blockand injects a prompt. - Gemini CLI: partly, through
BeforeAgent/AfterAgenthooks. - This is the axis that actually separates them.
Three other answers to the same question
Gemini CLI publishes the tool registry but hard-codes the loop. The registration method is public and typed:
registerTool(tool: AnyDeclarativeTool): void {A third party can add a tool without editing core. But the loop body sits in three different places in core:
| Place | Line |
|---|---|
packages/core/src/core/client.ts | 924 |
packages/core/src/agent/legacy-agent-session.ts | 184 |
packages/core/src/agents/local-executor.ts | 685 |
Each is its own while (true) or recursive function, and none of them can be registered.
Claude Code publishes the seam but not the engine. The repo carries a 13,044-line TypeScript declaration generated from the binary, listing events like turn.start, turn.step and tool.call. You can attach to the hook chain. But what runs the loop is a closed binary, and that same declaration says: EARLY ACCESS: this surface may change between releases without notice.
OpenHands answers with a version boundary. At the commit I read, the repo is a client — 2,059 TypeScript files against 9 Python files, all of them CI. The loop lives in a separate image:
"versions": { "agentServer": "1.49.6" },"compatibility": { "minimumAgentServer": "1.28.0" },"images": { "agentServer": "ghcr.io/openhands/agent-server" }Rather than making the loop a plugin, they put it behind a protocol and a version number. That is a third answer, and a “no privileged core” framing does not describe it.
Four layers stacked on each other
On the harness I read, configuration is not one file but four layers applied in order:
View diagram data
type: layers
title: Four configuration layers stacked
desc: A profile boots by applying four patch layers to an empty list; each later layer wins over the one before
nodes:
- id: bundles
label: Bundles in profile order
kind: input
- id: profile
label: The profile's cordis.patch.yml
- id: home
label: The home-level patch
- id: overlay
label: The --patch flag at launch
kind: optional
focal: true
- id: result
label: The final plugin tree
focal: true
edges:
- [bundles, profile]
- [profile, home]
- [home, overlay]
- [overlay, result]That is the first price, and it is real: to find where a behaviour comes from you must dump the whole tree. Four layers, each overriding the one before, and nothing lists the answer for you.
It is also the benefit: the topmost layer always wins, so you can never be stuck because a bundle set a bad default.
Five more prices
This is the part I want every comparison to carry: not only what works well, but what it costs.
| Choice | The price |
|---|---|
| The log is the source; history is derived | There is no second copy to read quickly |
| An unknown event means refusing to rebuild | Resume fails loudly instead of silently coming back empty |
| The sandbox speaks only about the filesystem | The network is not protected at this layer |
| 100% coverage per file | No small repo can afford this |
| The session format is one generation ahead | v4 is finalized; v3 is what shipped |
The first two deserve a note.
A session is an append-only log, and the history sent to the model is derived from it, never stored separately. That means there is no array to read quickly — every question about the past is a rebuild.
And this is one of the best design decisions I have read:
Absent means required: a reader meeting an unrecognized type without this marker MUST refuse to reconstruct the session instead of silently dropping the event, because an unrecognized required event may change how the rest of the log is interpreted. … defaulting to required means a forgotten marker over-refuses (an inconvenience) rather than silently resuming a gutted session.— dsh, docs/subsystems/session.md
They chose to fail loudly. An unknown event missing that marker must refuse to rebuild, rather than skip it and resume a session with its guts pulled out. The default leans toward refusal, because refusing is annoying while resuming empty is a silent disaster.
Criteria first, conclusions after
I read three Anthropic posts on harness design. They tell their story well, but they tell what already worked — a full harness running 6 hours for 9. That is n=1, and not repeatable.
So this post publishes its criteria first:
- Measured how: reading source at pinned commits and comparing structure. Not speed, not quality.
- What it answers: how they differ. Not which is better.
- The limits: Claude Code’s engine is unreadable. OpenHands has no loop in the repo. Those two rows are inference, not observation.
Do this now
- Write down what you are allowed to replaceNot what you can configure. What you can change at 2am when the system is broken, without rebuilding the core.
- Separate 'replace' from 'drive'Two different questions. Codex lets you drive the loop but not replace it. Knowing which cell you are in picks the right way to extend.
- Publish your criteria before comparingIf you do not say what you measured and what you skipped, every comparison you write is storytelling.
- State the price, not only the benefitEvery architectural choice has an invoice. A lesson is only worth something when the invoice is attached.
The rule
Four harnesses, four answers to the same question. None of them is right for everyone, because they optimise for four different users.
The question is not “which harness is best”. It is “what am I allowed to replace when it breaks, at 2am, without rebuilding the core”.— tusgino
- Every harness has the four parts; the axis is which part you may replace, not which parts exist.
- “Loop as a plugin” is not unique in shape — Codex has exactly that structure, kept
pub(crate). - Separate “replace the loop” from “drive the loop”: Codex allows the second through the
Stophook. - Publish criteria before conclusions, and say plainly where you inferred rather than observed.
- Every architectural choice has a price; a comparison that omits prices is storytelling.