13 min read

The Loop Is Easy. Feeding It Is Triage.

Part 2 of reading Dive into Claude Code from its source: the query loop is a heartbeat, and the real work is the five-level triage that runs before every beat.

Table of Contents

Post one landed on one claim: about 98% of Claude Code is harness, and the model is a small loop buried inside it. That was the tour. This is the first room I promised to actually walk into — the core layer, the loop itself.

Here’s the twist that reorganized my head. The loop is a heartbeat, almost boring to describe, and the real medicine runs before every beat, where it looks a lot like triage.

Confession first, because I’ve earned it. For a long time I assumed “context management” was the model being clever — some emergent knack for holding onto what mattered and letting go of the rest. It isn’t. It’s the harness running an emergency room: before every single model call, it reads the pressure in the context window and decides who gets treated, how cheaply, and who goes to the operating table. The model never sees the triage, and for the longest time neither did I. Call it scar tissue.

Same caveat as post one, and it bites harder here than anywhere: all of this comes from a paper that reverse-engineers Claude Code from public TypeScript at one snapshot, version 2.1.88“Dive into Claude Code”, not official Anthropic documentation, and some of it inferred. When a number is a guess, I flag it.

The heartbeat, and the class that isn’t the engine

The loop is an async generator the paper calls queryLoop() (§4.1). One turn is nine fixed steps: resolve settings, build a mutable State object, assemble the context, run the five shapers, call the model, dispatch whatever tools it named, run the permission gate, collect the results, check whether to stop. Then it loops. That State object gets reassigned as a whole at each of seven “continue” sites — seven places a turn can bounce back to the top instead of ending. That’s the entire brain: a while loop with good manners.

Now the red herring. There is a file called QueryEngine.ts, and the name is a trap, because it is not the engine (§3.4). It’s a conversation wrapper for the headless (claude -p, non-interactive) and SDK paths only, and the interactive terminal skips it entirely to call query() directly. The paper’s line is the one to keep: “the shared code path is the loop function, not the engine class.” Name a file Engine and it pulls your eyes first, before you notice the actual loop sitting next door.

flowchart LR
    T["interactive terminal"] --> Q["<b>query()</b> — the loop<br/>the shared path"]
    H["headless / SDK"] --> E["QueryEngine.ts<br/>conversation wrapper"]
    E --> Q
    classDef path fill:#dbeafe,stroke:#2563eb,color:#111827
    classDef loop fill:#fed7aa,stroke:#ea580c,color:#111827,stroke-width:3px
    class T,H,E path
    class Q loop

The loop follows ReAct — reason, act, observe, repeat. No tree search, no backtracking (§4.1). It can fan out to subagents, but the core stays stubbornly reactive, simple on purpose.

The ward has a fixed bed count

So why does any of this need triage? Because of the one constraint the whole core layer bends around: the context window. 200K tokens on older models, 1M on the Claude 4.6 series (§3.1). That’s the bed count, and it does not grow mid-turn.

Every turn adds mass. Tool outputs, file reads, the model’s own replies — all of it piles into the same finite window. Let it overflow and the turn dies with a prompt_too_long. So before every model call, five reduction strategies run in sequence, each treating a different kind of pressure, cheapest intervention first (§4.3). The whole ladder is one fight: keep the minimum sufficient context in a window that refuses to get bigger. And you don’t wheel a paper cut into surgery.

flowchart LR
    Z["resolve settings<br/>+ build State"] --> A["assemble<br/>context"]
    A --> T["<b>5 shapers</b><br/>triage here"]
    T --> M(["call model"])
    M --> D["dispatch<br/>tools"]
    D --> G["permission<br/>gate"]
    G --> R["collect<br/>results"]
    R -->|"continue"| A
    R -->|"stop condition"| E(["turn ends"])
    classDef pipe fill:#dbeafe,stroke:#2563eb,color:#111827
    classDef triage fill:#fed7aa,stroke:#ea580c,color:#111827,stroke-width:3px
    classDef model fill:#ede9fe,stroke:#7c3aed,color:#111827
    class Z,A,D,G,R pipe
    class T triage
    class M,E model

The shapers are not an occasional cleanup job. They run on the path to every model call. Triage before every beat.

Five interventions, cheapest first

Here’s the ladder. Five shapers, in order, each aimed at a distinct pressure. Three sit behind feature flags — which means a given build might not run all five. Same caveat as always: flags make builds differ (§4.3, §12.5).

%%{init: {"flowchart": {"wrappingWidth": 280}}}%%
flowchart TD
    P["context pressure<br/>before a model call"] --> B
    B["<b>budget reduction</b><br/>oversized tool outputs<br/><i>always on</i>"] -->|"still over"| S
    S["<b>snip</b><br/>temporal depth<br/><i>HISTORY_SNIP</i>"] -->|"still over"| MC
    MC["<b>microcompact</b><br/>cache overhead<br/><i>CACHED_MICROCOMPACT</i>"] -->|"still over"| CC
    CC["<b>context collapse</b><br/>very long histories<br/><i>CONTEXT_COLLAPSE</i>"] -->|"pressure persists"| AC
    AC["<b>auto-compact — last resort</b><br/>semantic compression<br/><i>default on, disableable</i>"]
    classDef start fill:#f3f4f6,stroke:#9ca3af,color:#111827
    classDef l1 fill:#bbf7d0,stroke:#16a34a,color:#111827
    classDef l2 fill:#d9f99d,stroke:#65a30d,color:#111827
    classDef l3 fill:#fef08a,stroke:#ca8a04,color:#111827
    classDef l4 fill:#fed7aa,stroke:#ea580c,color:#111827
    classDef l5 fill:#fecaca,stroke:#dc2626,color:#111827,stroke-width:3px
    class P start
    class B l1
    class S l2
    class MC l3
    class CC l4
    class AC l5

Budget reduction treats oversized tool outputs. applyToolResultBudget(), always on. It caps how many characters any single tool result can occupy and swaps the overflow for a content reference — a pointer the model can re-fetch on demand instead of the raw text. Dump a 40,000-line log into the window and this is what quietly trims it. It runs first for a mechanical reason: microcompact, further down the ladder, works purely by tool_use_id and never inspects content at all — so the oversized raw blobs have to be trimmed here, while a shaper is still willing to read them.

Snip treats temporal depth — history that has simply gotten long. snipCompactIfNeeded(), gated behind HISTORY_SNIP. It actually drops older turns from the history — gone, not summarized. One sharp detail hides here: the tokens it frees have to be manually plumbed forward to auto-compact, because the token counter reads input_tokens — the usage figure the API reports back on each response — off the last assistant message, and that message survives the snip. Space the counter can’t see is space the system will re-fight over.

Microcompact treats cache overhead. Instead of summarizing anything, it works on completed tool-call exchanges by their tool_use_id alone — never reading what they say — and drops their bulk rather than paraphrasing it, which is why it’s far cheaper than a real summary. The time-based path is always on; the cache-aware path is gated behind CACHED_MICROCOMPACT, and it holds off writing its boundary messages (the little records marking where it trimmed) until the API response returns, so it can use the real cache_deleted_input_tokens figure instead of an estimate.

Context collapse also treats long histories — but here’s the difference from snip that matters: snip deletes old turns, while collapse keeps every one of them and simply shows the model a shorter view. Gated behind CONTEXT_COLLAPSE. The paper describes it beautifully: “a read-time projection over the REPL’s full history.” The summaries live in a separate collapse store, not in the live message array; the full history stays intact for reconstruction. Nothing shows up on your screen. Hold that fact — it comes back to bite.

Auto-compact is the operating room. On by default, and the only one of the five you can switch off. When the cheaper four haven’t relieved the pressure, it calls compactConversation() (compact.ts), fires PreCompact hooks, has the model write a full summary of the conversation, and rebuilds the window around it. It’s the drastic one — the scar — and it only fires if pressure persists after the other four have tried (§4.3).

Read the ladder top to bottom and it’s a triage protocol, not a compression algorithm. Trim the biggest wound. Then the oldest, then the cache, then project the rest — and only then operate.

When the patient still crashes

Triage assumes you catch the pressure early. Sometimes you don’t. The core layer keeps a second set of moves for a turn that’s already failing (§4.4).

If the model tries to emit more than fits, there’s a max-output-tokens recovery: up to three attempts per turn, MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3, subject to a flag. Three tries, then it stops trying. If the window overflows outright — prompt_too_long — it attempts a collapse-overflow recovery plus a reactive compaction (at most once per turn) before it gives up. Behind that sit a streaming fallback and a fallback model. Recovery is bounded on purpose. An ER that never discharges anyone is just a morgue with better lighting.

And every turn ends on one of five stop conditions (§4.5):

  1. the model returns text and asks for no tools — the normal exit;
  2. it hits maxTurns;
  3. the context overflows past recovery (prompt_too_long);
  4. a hook intervenes (hook_stopped_continuation);
  5. something explicitly aborts.

Four of those five are the loop failing safely. Only the first is the loop finishing its work. That ratio tells you what the core layer actually spends its energy worrying about.

%%{init: {"flowchart": {"wrappingWidth": 280}}}%%
flowchart TD
    C["model call"] --> R{"what came back?"}
    R -->|"text, no tools"| S1(["stop 1: done — the only success"])
    R -->|"output won't fit"| REC1["max-output-tokens recovery<br/>≤ 3 attempts per turn"]
    REC1 -->|"retry"| C
    REC1 -->|"exhausted"| S3
    R -->|"prompt_too_long"| REC2["collapse-overflow recovery<br/>+ reactive compaction, once/turn"]
    REC2 -->|"recovered"| C
    REC2 -->|"still overflowing"| S3(["stop 3: overflow"])
    C -.->|"turn budget hit"| S2(["stop 2: maxTurns"])
    C -.->|"hook intervenes"| S4(["stop 4: hook stop"])
    C -.->|"abort"| S5(["stop 5: abort"])
    classDef ok fill:#bbf7d0,stroke:#16a34a,color:#111827
    classDef rec fill:#fef08a,stroke:#ca8a04,color:#111827
    classDef fail fill:#fecaca,stroke:#dc2626,color:#111827
    classDef node fill:#dbeafe,stroke:#2563eb,color:#111827
    class C,R node
    class REC1,REC2 rec
    class S1 ok
    class S2,S3,S4,S5 fail

The scar you can’t see

Here’s the part that should bother you, because it bothered me. Most of this triage is invisible (§12.3). Context collapse produces no user-visible output at all — I said hold that. Microcompact’s cache-awareness is opaque by design. Auto-compact rewrites what the model remembers, and you get a small note, maybe, but never the diff.

Two costs sit underneath the quiet.

First, the summary is lossy and — per external research the paper cites — non-deterministic across runs, and the compaction call itself is a blocking inference stall (§12.3, cim2026parallelcompaction). Flag that as external and inferred, not code the authors verified. But the direction is intuitive: a summary drops detail, and which detail it drops isn’t guaranteed to be the same twice. Your two-hour session can quietly become a stale-but-confident version of itself, and the transcript won’t announce it.

Second, even the cheap path isn’t free. A code comment in the source, from a January 2026 experiment, notes that one path is “98% cache miss” and costs about 0.76% of fleet cache_creation (§7.3) — the fingerprint of a flag deciding whether compaction reuses the main conversation’s prompt cache. A fraction of a percent across a whole fleet is still real money. Compaction is never free; it’s just usually cheaper than dying.

And the mechanism is careful in a way I have to respect: compaction appends, it never rewrites. buildPostCompactMessages() lays down a boundary marker tagged with headUuid, anchorUuid, and tailUuid, and the loader patches the message chain at read time (§7.3). Your transcript on disk stays honest, but the window you’re feeding the model no longer matches it.

%%{init: {"flowchart": {"wrappingWidth": 280}}}%%
flowchart TD
    subgraph DISK["transcript on disk — append-only, never rewritten"]
      direction LR
      m1["m1"] --> m2["m2"] --> m3["m3"] --> m4["m4"] --> m5["m5"] --> BM["boundary marker<br/>head · anchor · tail UUID"] --> SUM["summary"]
    end
    DISK -->|"loader patches the chain<br/>at read time"| VIEW
    subgraph VIEW["window the model actually sees"]
      direction LR
      SUM2["summary"] --> m4b["m4"] --> m5b["m5"]
    end
    classDef old fill:#f3f4f6,stroke:#9ca3af,color:#111827
    classDef kept fill:#dbeafe,stroke:#2563eb,color:#111827
    classDef comp fill:#fed7aa,stroke:#ea580c,color:#111827
    class m1,m2,m3 old
    class m4,m5,m4b,m5b kept
    class BM,SUM,SUM2 comp

This is the same lesson post one closed on, one layer down. There, the credit you never noticed went to the harness instead of the model; here, the loss you never notice is triage doing its job. Both are invisible, both are load-bearing, and “invisible but load-bearing” is exactly the seam where observability has to live — the thread this whole series keeps pulling on.

The loop is easy. The triage is the job.

The heartbeat is nine steps and a while, and anyone can read it in an afternoon. The engineering — the part that decides whether a long session survives or collapses into a summary that lost the one line you needed — is the five-level triage that runs before the model ever speaks. Cheapest first, operating room last, most of it happening where you cannot see it.

I stopped calling it “the model managing its context” the day I read this. It’s a ward, and the model is the patient, not the doctor.

Next layer is safety and action — the permission gate that stands between “the model proposed it” and “your shell ran it.” The room where deny beats allow.

So sit with this, whatever agent you drive. When your long session quietly gets dumber three hours in, do you know which of the five just operated on it? And if the summary dropped the one detail that mattered, would anything on your screen ever tell you?

Notes: the sections I cited

  • §3.4 — QueryEngine clarification: the QueryEngine.ts class wraps headless/SDK conversations; the interactive CLI calls query() directly. The shared path is the loop function, not the class.
  • §4.1 — The query pipeline: queryLoop() as a nine-step async generator with seven “continue” sites; ReAct, no backtracking.
  • §4.3 — The five context shapers: budget reduction → snip → microcompact → context collapse → auto-compact, cheapest first, each treating a distinct pressure.
  • §4.4 — Recovery mechanisms: MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3, reactive compaction at most once per turn, collapse-overflow recovery, fallback model.
  • §4.5 — Stop conditions: no tool use, maxTurns, context overflow, hook intervention, explicit abort.
  • §7.3 — Compaction pipeline & persistence: buildPostCompactMessages(), the head/anchor/tail UUID chain patching, and the “98% cache miss / ~0.76% of fleet cache_creation” code comment.
  • §12.3 — Efficiency vs transparency: compaction is largely invisible; external research on non-deterministic, blocking summary compaction (hedged as inferred).

Next: the safety and action layer (§5) — permissions, and why deny beats allow.