Accueil / Articles / Why Claude Code Agents Shouldn't Be Trusted to Report Their Own Status

Cet article est publié en anglais.

Why Claude Code Agents Shouldn't Be Trusted to Report Their Own Status

Using a Stop hook, an event log, and dollar budgets to run Claude Code sessions as an org chart, plus four harness surprises that 74 passing tests missed.

2106 mots

Run more than one Claude Code session at once and you'll eventually try the obvious thing: tell one session to manage the others. It works for roughly five minutes. After trying it across several projects with the same failure every time, the useful move was to stop treating it as a prompting problem and treat it as an architecture problem. Here's what failed, what got built, and six harness behaviors that no unit test could have caught, four of which only appeared when a real session ran.

Three failures, not one

The symptom everybody notices is silence: a subordinate never reports back, even when told to say so once it's finished. Yet that's the mildest of three separate problems.

Subordinates don't report. The instruction sits in the model's context, and context gets compacted, exhausted, or simply outcompeted by the task. By the time the work ends, the reminder to report has been pushed out by four hundred lines of test output. This isn't a discipline problem. It's a memory-hierarchy problem, and nicer phrasing won't fix it.

Supervisors can't observe. Easy to overlook, this one comes from how Claude Code works. A session executes only while a user turn is in progress, and in between it does nothing at all: it isn't polling, waiting or thinking. A subordinate that does send a report therefore reaches no one. The message sits in a session that stays dormant until a human types something.

Nothing notices failure. Picture two sessions editing the same file and quietly overwriting each other, a session exhausting its context mid-task and dying, or one spinning in a loop. Viewed from outside, every one of those looks just like a session that's slow.

That third problem is the expensive one, and the naive fix of asking sessions to report back never touches it.

Reporting is something that happens to the model

What changed everything was one principle: a report can't depend on the model deciding to send it. It has to be triggered around the model, not by it.

If a report relies on the model choosing to file it, it's only as reliable as the model's remaining context. Take it out of the model's hands and it becomes as reliable as the process lifecycle.

Claude Code has a suitable mechanism. A Stop hook runs whenever a session ends a turn, and the harness invokes it rather than the model. The model may have forgotten, may be out of context, may even have crashed, and the hook still runs:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node",
            "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/stop-report.mjs"],
            "timeout": 15
          }
        ]
      }
    ]
  }
}

That's the single load-bearing idea. Everything else is scaffolding around it.

The design

The resulting tool runs Claude Code sessions as an organization. An Executive talks to you, Managers own workstreams, and Staff do the work. Roles come from position, not labels: a Staff member who recruits a team of its own acts as a Manager over that branch.

                          you
                           ▲
            results ───────┼─────── objective
                           ▼
                 ┌───────────────────┐
                 │     Executive     │
                 └─────────┬─────────┘
             ┌─────────────┴─────────────┐
             ▼                           ▼
       ┌───────────┐               ┌───────────┐
       │  Manager  │               │  Manager  │
       └─────┬─────┘               └─────┬─────┘
        ┌────┴────┐                 ┌────┴────┐
        ▼         ▼                 ▼         ▼
     ┌─────┐   ┌─────┐           ┌─────┐   ┌─────┐
     │Staff│   │Staff│           │Staff│   │Staff│
     └──┬──┘   └──┬──┘           └──┬──┘   └──┬──┘
        └─ peers ─┘                 └─ peers ─┘

Each member reports to exactly one supervisor, and escalation moves one hop at a time. Peers settle overlapping work among themselves and bring in a Manager only when they can't.

Three choices deserve explanation, because each could have gone another way.

State is an append-only event log. As many as twelve OS processes write to one directory at the same time. Locks are avoided: every state change is one appended line, and the OS guarantees such an append is atomic. There's no stored org chart either, because it's computed by folding the log whenever someone reads it. Killing a session can't corrupt anything. At worst it leaves a stale working record, and catching that is the job of stall detection.

Supervisors chase. Every supervisor sorts its direct reports on every turn and escalates up a fixed ladder: nudge, read the transcript, reassign. The middle rung matters more than it seems. A long test run and an infinite loop look identical from the outside, and only reading the real output tells them apart. That's a judgment for a model, not a timeout.

Budgets are chained and counted in dollars. A colleague objected that on a subscription, dollars feel abstract and tokens are what you actually sense. Dollars still won, for two reasons. --max-budget-usd is the only cap the harness really enforces, whereas a token ceiling is just a number in a JSON file that nothing has to honor. And tokens aren't comparable across model tiers: a million tokens can cost about ten times more on the most capable model than on the cheapest. A token budget can't constrain a hierarchy whose purpose is mixing tiers, since a manager could stay well inside it while multiplying the bill by ten. Tokens are logged as a gauge. Dollars are the constraint.

The sequence for one assignment, start to finish, includes an alternate branch in the middle, and that's the load-bearing part: step 5 exists so the system never has to rely on step 4.

The state a clock can't see

Stall detection looks like a plain timeout problem, and largely it is. One state, though, is both the most urgent in the system and invisible to anything that measures silence.

Consider a member reporting blocked. It has just communicated, so its time since last contact is zero and every idle check scores it healthy. In reality it has stopped working and waits for a ruling that only its supervisor can give. No check will flag it, since it politely announced that it was stuck.

The smoke test exposed this early, ahead of the harder bugs. A subordinate sent blocked: beyond_capability, and the supervisor's status view showed it as OK. Once you notice it, the fix is small. Noticing required a live run.

Then it ran for real

By this point there were 74 unit tests, all green, with CI passing on Linux and Windows across two Node versions. The design held up on paper and in the suite, and confidence was high.

Then a single real background session got a trivial job, creating a file containing the word "hello". It failed four times in a row for four unrelated reasons. None were coding mistakes. Each was the harness behaving differently from what had been assumed, and each failed silently.

1. Session IDs are ignored in the background

Identity had been designed around a neat idea: the parent assigns the child's session ID up front, so the org chart is written before the child exists and there's never a moment when a subordinate runs unregistered.

In practice, claude --bg --session-id <uuid> ignores the UUID you give it, and the background session makes up its own. The outcome was oddly fitting. The Stop hook searched the org chart for the harness's session ID, found nobody, and exited without filing anything, so the safeguard that existed to guarantee a report did nothing.

The remedy was to hand the parent's chosen ID to the child through an environment variable and treat that as authoritative, using the harness ID only if the variable is absent.

2. Nobody can answer a permission prompt

The subordinate followed its brief perfectly. It read the assignment, understood the protocol, and ran its first command, the inbox check, exactly as told.

Then it froze permanently on a permission prompt. The acceptEdits permission mode approves file edits yet leaves shell commands unapproved, and a background session has no human attached to click yes. The supervisor can't tell this apart from a stall, even though the subordinate is doing everything right while waiting on an approval that can't arrive.

The fix was to pre-approve the CLI at spawn time with --allowedTools. List both shell tools, because which one a model reaches for depends on the platform: PowerShell on Windows, Bash elsewhere.

3. --allowedTools is variadic and swallows the prompt

With permissions added, the next spawn started and instantly reported that it was idle and waiting for a prompt.

--allowedTools <tools...> is variadic, so it keeps consuming arguments past a boolean flag. This command:

claude --allowedTools "Bash(node:*)" --bg "<the actual prompt>"

absorbs the prompt into the tool list and launches a session with no instructions, patiently waiting for input that never comes. The option stops cleanly at a flag that takes its own value, so this form works:

claude --allowedTools "Bash(node:*)" --model haiku --bg "<the actual prompt>"

Now the permission rules are emitted first, right before --session-id, so correctness doesn't hinge on argument order elsewhere in the command.

4. Edits want a worktree first

On the fourth try the session announced it needed to enter a worktree before editing. It attempted to create one, failed because the scratch repository had no commits (the base branch "HEAD" couldn't be resolved), then tried a git commit that needed an approval nobody could give.

That's partly the test environment's fault. The underlying fact is still worth knowing: background sessions lean toward worktree isolation for writes, and isolation is closer to a default than an option.

Two more found by probing

--append-system-prompt-file exists but is undocumented. It's missing from claude --help, yet probing it returns an "argument missing" error instead of an unknown-option error. That matters because a role brief runs about thirty lines with quotes and newlines, and passing it inline breaks three ways on Windows: cmd.exe can't carry newlines in an argument, quote escaping varies by shell, and command lines cap near 8191 characters. Writing the brief to a file and passing its path avoids all three.

--plugin-dir doesn't reach spawned sessions. Running a plugin from a clone applies only to that session, so the sessions it spawns start with no hooks and no role skills and ignore a protocol they were never given. An installed plugin doesn't have this problem, so installing properly is the recommended route, and the config includes an explicit forwarding option for the clone case.

A green suite still wasn't enough

A further case shows why live tests alone aren't the answer. Continuous integration caught a defect that local runs never revealed. Events in the log were ordered by millisecond timestamp, with ties settled by a random event ID. Fast machines often write a burst of events inside one millisecond, so nudge, nudge, report could fold in as report, nudge, nudge, and a member that had just reported would still count as un-nudged. The projection was non-deterministic and could read differently each time it loaded.

Locally on Windows, all 74 tests passed. On Linux, CI failed twice.

Sharper timestamps weren't the answer. Since appends are atomic, the order of lines in the file already mirrors the order of writes across all processes. Breaking ties on line position gives an ordering that's both deterministic and causally right.

What to take away

None of this shows that AI-assisted development is unreliable. The code was the inexpensive part, and most of it was right. What it shows is where confidence should come from. A green suite proves the system does what you modeled. It can't tell you whether your model of the platform is accurate, and all of the failures above sat in exactly that blind spot, because the tests carried the same mistaken assumptions as the implementation.

What worked was making falsification cheap and doing it repeatedly. Four live runs, each lasting a few minutes and costing almost nothing on the smallest model, exposed four architectural defects that 74 tests had approved.

If you're building on an agent platform, budget for that loop on purpose. The interesting bugs aren't in your code. They sit in the seam between your code and a harness whose behavior you inferred instead of verified. It's also worth keeping those harness facts in a separate notes file apart from the design spec, since they describe the platform and not your project.