---
title: "The same loop, different seams"
description: "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."
pubDate: 2026-09-24
author: tusgino
tags: [ai-engineering, architecture, agents, infrastructure]
series:
  id: tu-llm-den-harness
  title: "From LLM to Harness"
  order: 6
cover: /images/covers/cung-mot-vong-lap-hai-kien-truc.svg
coverAlt: "Four identical loops, each with a cut at a different position"
lang: en
faq:
  - q: "Which harness is best?"
    a: "That question cannot be answered, and it is not the right question. The right one is: when it breaks, what am I allowed to replace without editing the core."
  - q: "Is making the loop a plugin the differentiator?"
    a: "Not if you only mean the shape. Codex has exactly that structure but keeps it private. The real difference is whether that seam is reachable from outside."
  - q: "Why not compare speed or quality?"
    a: "Because I cannot measure them. I read source and compared structure. Every comparison here is about how they differ, not which is better."
  - q: "What does it cost to make everything a plugin?"
    a: "To find where a behaviour comes from you must dump the whole config tree. Four patch layers stack, and the topmost one wins."
---

<TLDR>Every harness has four parts: a log, a tool registry, a context budget, a permission layer. They do not differ in which parts exist. They differ in which parts you may replace.</TLDR>

## 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:

```rust title="codex-rs/core/src/tasks/mod.rs"
pub(crate) trait SessionTask: Send + Sync + 'static {
```

`pub(crate)`. And the only production call site names a concrete type:

```rust title="codex-rs/core/src/tasks/mod.rs:532"
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:

```rust title="codex-rs/core/src/session/turn.rs"
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:

<Versus aTitle="Replace the loop" bTitle="Drive the loop">
  <div slot="a">
    <ul>
      <li>Means: write a different implementation of the loop itself.</li>
      <li>Codex: no. The trait is <code>pub(crate)</code>; the call site names a concrete type.</li>
      <li>Gemini CLI: no. Three hard-coded loop bodies in core.</li>
      <li>Claude Code: unreadable, the engine is closed.</li>
    </ul>
  </div>
  <div slot="b">
    <ul>
      <li>Means: decide when the loop stops, and what the next iteration carries.</li>
      <li>Codex: <strong>yes</strong>. The <code>Stop</code> hook returns <code>should_block</code> and injects a prompt.</li>
      <li>Gemini CLI: partly, through <code>BeforeAgent</code>/<code>AfterAgent</code> hooks.</li>
      <li>This is the axis that actually separates them.</li>
    </ul>
  </div>
</Versus>

## 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:

```ts title="packages/core/src/tools/tool-registry.ts"
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:

```json title="config/defaults.json"
"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:

```diagram
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:

<Quote cite="dsh, docs/subsystems/session.md">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.</Quote>

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 $200, against 20 minutes 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.

<Callout type="warn" title="Do not read this as a ranking">
These four harnesses are not running the same race. They answer the same question four ways, and each way has a different price. Which is more expensive depends on what you will have to change.
</Callout>

## Do this now

<Steps>
<Step title="Write down what you are allowed to replace">Not what you can configure. What you can change at 2am when the system is broken, without rebuilding the core.</Step>
<Step title="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.</Step>
<Step title="Publish your criteria before comparing">If you do not say what you measured and what you skipped, every comparison you write is storytelling.</Step>
<Step title="State the price, not only the benefit">Every architectural choice has an invoice. A lesson is only worth something when the invoice is attached.</Step>
</Steps>

## The rule

Four harnesses, four answers to the same question. None of them is right for everyone, because they optimise for four different users.

<Quote cite="tusgino">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".</Quote>

<KeyTakeaways>
- 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 `Stop` hook.
- 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.
</KeyTakeaways>
