A rule that can now only be wrong
HeadlessSession.runState() infers idle for raw engines from three timers
(packages/browser-runner/src/coding/headless.ts:278-288). Rule 1 is "produced output, then went
quiet for 1.5s → settled", and its comment explains it was written for a persistent interactive
CLI, where there is no other signal to read.
Raw engines are no longer persistent. :298 states it outright — "Raw engines run ONE-SHOT PER
TURN, not as a persistent interactive process" — oneShot is mode === "raw" (:313), each turn
is its own spawn (runOneShot, :412), and the close handler sets this.run = "idle"
(:455-457). So for exactly the engines rule 1 governs, process exit is an exact, already-wired
end-of-turn signal, and a timer can only ever fire before it.
The failure is not theoretical, and the codebase already documents both halves:
headless.test.ts:412-419 records the consequence — "a long build gets judged idle and the brain
sends turn 2 while turn 1 is still running".
runOneShot (:417-427) then kills the in-flight process, with a comment naming the same
cause: "the raw idle heuristic declares idle after a >1.5s output pause — so a long build could
be judged idle, the brain sends turn 2, and TWO engine processes edit the same repo at once."
That kill was the right fix for concurrent writers, but it converts the symptom from two processes
racing into work silently destroyed: codex exec running a test suite or a build goes quiet for
more than 1.5s, is judged idle, and is killed mid-turn by its own replacement. The Pilot then reasons
about a turn that never finished.
Rules 2 and 3 have the same property for one-shot engines — an 8s silent-turn rule and a 15-minute
backstop are both guesses about a process whose exit is observable.
Suggested fix
Make exit authoritative when oneShot is true: runState() returns "thinking" while procAlive,
and idle is set by close (which it already does). The timer rules stay for any genuinely persistent
non-Claude engine, if one is ever added — but they should be gated on that, not on mode === "raw",
which today means the opposite.
Worth checking as part of it whether runOneShot's pre-emptive kill is still needed once idle cannot
be reported early; if it is, it should say what it killed rather than dying silently, per the same
reasoning that put a non-zero exit code into the transcript at :445.
What this does not claim
It does not claim the 15-minute backstop is useless — a wedged process still needs a ceiling, and
removing it without a replacement would trade an early kill for a permanent hang. It also does not
touch the stream-json path, where a result event already ends the turn as a fact.
Found while assessing #249 (whether tmux should return as a Coder engine backend); the memo's point
was that this specific defect is the one people would blame on "no PTY", and it is not PTY-shaped.
Verified against main at d0e4095 by reading the file, not by reproducing a run.
A rule that can now only be wrong
HeadlessSession.runState()infers idle for raw engines from three timers(
packages/browser-runner/src/coding/headless.ts:278-288). Rule 1 is "produced output, then wentquiet for 1.5s → settled", and its comment explains it was written for a persistent interactive
CLI, where there is no other signal to read.
Raw engines are no longer persistent.
:298states it outright — "Raw engines run ONE-SHOT PERTURN, not as a persistent interactive process" —
oneShotismode === "raw"(:313), each turnis its own
spawn(runOneShot,:412), and theclosehandler setsthis.run = "idle"(
:455-457). So for exactly the engines rule 1 governs, process exit is an exact, already-wiredend-of-turn signal, and a timer can only ever fire before it.
The failure is not theoretical, and the codebase already documents both halves:
headless.test.ts:412-419records the consequence — "a long build gets judged idle and the brainsends turn 2 while turn 1 is still running".
runOneShot(:417-427) then kills the in-flight process, with a comment naming the samecause: "the raw idle heuristic declares idle after a >1.5s output pause — so a long build could
be judged idle, the brain sends turn 2, and TWO engine processes edit the same repo at once."
That kill was the right fix for concurrent writers, but it converts the symptom from two processes
racing into work silently destroyed:
codex execrunning a test suite or a build goes quiet formore than 1.5s, is judged idle, and is killed mid-turn by its own replacement. The Pilot then reasons
about a turn that never finished.
Rules 2 and 3 have the same property for one-shot engines — an 8s silent-turn rule and a 15-minute
backstop are both guesses about a process whose exit is observable.
Suggested fix
Make exit authoritative when
oneShotis true:runState()returns"thinking"whileprocAlive,and idle is set by
close(which it already does). The timer rules stay for any genuinely persistentnon-Claude engine, if one is ever added — but they should be gated on that, not on
mode === "raw",which today means the opposite.
Worth checking as part of it whether
runOneShot's pre-emptive kill is still needed once idle cannotbe reported early; if it is, it should say what it killed rather than dying silently, per the same
reasoning that put a non-zero exit code into the transcript at
:445.What this does not claim
It does not claim the 15-minute backstop is useless — a wedged process still needs a ceiling, and
removing it without a replacement would trade an early kill for a permanent hang. It also does not
touch the stream-json path, where a
resultevent already ends the turn as a fact.Found while assessing #249 (whether tmux should return as a Coder engine backend); the memo's point
was that this specific defect is the one people would blame on "no PTY", and it is not PTY-shaped.
Verified against
mainatd0e4095by reading the file, not by reproducing a run.