Skip to content

Commit 02a1def

Browse files
fix(sessions): an interrupted turn stops reading "working" (v0.325.0) (#594)
Claude Code fires NOTHING on a user interrupt (Esc / Ctrl-C): no Stop, no StopFailure, no SessionEnd. A UserInterrupt event has been requested (anthropics/claude-code#9516) and does not exist. So the turn never ended server-side and the session spun until the 2h wedged-turn ceiling. The signal we DO get is the Notification hook: the TUI parks at its prompt — including "Interrupted · What should Claude do instead?" — and claude raises idle_prompt (159 of them on the live instapods box). notify() now ends the turn as well as ringing the bell, for all three human-blocked kinds (idle_prompt, permission_prompt, agent_needs_input): being blocked on a human is by definition not generating. The session then reads `needs you`, which is the honest state — claude is literally asking what to do instead. The other half of the loop: markTurnBusy (a submitted prompt) retires the open waiting card. Without it an interrupted session would keep reading `needs you` — which outranks `working` — through the entire next turn. An interrupted UNATTENDED run is deliberately left alive rather than reaped: a human stopped it on purpose and now owns it. 8 new assertions in scripts/turn-lifecycle-test.cjs (40 total); the interrupt gap is documented in docs/session-lifecycle-hooks.md. Claude-Session: https://claude.ai/code/session_015ZUfffxY4hKv7M6wMCcaTz Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent bee87b1 commit 02a1def

6 files changed

Lines changed: 82 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@ new version heading in the same commit.
88

99
## [Unreleased]
1010

11+
## [0.325.0] — 2026-08-08
12+
### Fixed
13+
- **An INTERRUPTED turn (Esc / Ctrl-C) kept reading "working".** Claude Code fires **nothing** on a user
14+
interrupt — no `Stop`, no `StopFailure`, no `SessionEnd`; a `UserInterrupt` event has been requested
15+
([anthropics/claude-code#9516](https://github.com/anthropics/claude-code/issues/9516)) and does not
16+
exist. So the turn never ended server-side and the session spun until the 2h wedged-turn ceiling.
17+
The signal we *do* get is the `Notification` hook: the TUI parks at its prompt — including
18+
`Interrupted · What should Claude do instead?` — and claude raises `idle_prompt` (159 of them on the
19+
live instapods box). `notify()` now **ends the turn** as well as ringing the bell, for all three
20+
human-blocked kinds (`idle_prompt`, `permission_prompt`, `agent_needs_input`) — being blocked on a
21+
human is by definition not generating. The session reads **`needs you`**, which is the honest state.
22+
- **The other half of that loop:** `markTurnBusy` (a submitted prompt) now retires the open waiting card.
23+
Without it a session that had been interrupted would keep reading `needs you` — which outranks
24+
`working` — through the entire next turn.
25+
26+
1127
## [0.324.0] — 2026-08-08
1228
### Fixed
1329
- **Sessions that had finished showed the "working" spinner.** `term_sessions.busy_since` — the flag the

docs/session-lifecycle-hooks.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Reference for the events themselves: <https://code.claude.com/docs/en/hooks>.
1111
| Hook event | Script | Route | What it does |
1212
|---|---|---|---|
1313
| `PreToolUse` | `terminal/gate-hook.sh` | `/api/gate` | **The invariant** — every governed effect passes the gateway. Unrelated to status; listed so the table is the whole picture. |
14-
| `Notification` | `terminal/notify-hook.sh` | `/api/notify` | `permission_prompt` / `idle_prompt` / `agent_needs_input` → an inbox card + the per-session "needs you" bell. Other `notification_type`s (auth, elicitation, `agent_completed`) are dropped. |
14+
| `Notification` | `terminal/notify-hook.sh` | `/api/notify` | `permission_prompt` / `idle_prompt` / `agent_needs_input` → an inbox card + the per-session "needs you" bell, **and a turn-END** (blocked on a human is not generating). Other `notification_type`s (auth, elicitation, `agent_completed`) are dropped. |
1515
| `UserPromptSubmit` | `terminal/lifecycle-hook.sh` | `/api/session-event` | **Turn START**`markTurnBusy` (stamps `busy_since`). |
1616
| `Stop` | `terminal/stop-hook.sh` | `/api/turn-idle` | **Turn END**`markTurnIdle`: clears `busy_since` for every lane, and for an *unattended* run tears the pane down (the pile-up guard releases). |
1717
| `StopFailure` | `terminal/lifecycle-hook.sh` | `/api/session-event` | **Turn END, errored** (`rate_limit`, `overloaded`, `server_error`, …) → same as `Stop`, plus a `session.turn.failed` audit carrying `error_type`. |
@@ -27,6 +27,23 @@ server-side: the run keeps reading "working", the automations pile-up guard keep
2727
unattended run parks as a zombie until a 24-hour reaper finds it — the shape behind the recurring
2828
"weekly-limit zombie sessions" incidents.
2929

30+
### There is no interrupt hook — the bell stands in for one
31+
32+
When a human hits **Esc** (or Ctrl-C) mid-turn, Claude Code fires **nothing**: no `Stop`, no
33+
`StopFailure`, no `SessionEnd`. A `UserInterrupt` event has been requested
34+
([anthropics/claude-code#9516](https://github.com/anthropics/claude-code/issues/9516)) and does not exist.
35+
So an interrupted turn used to sit on the console reading "working" until the 2h ceiling.
36+
37+
The signal we *do* get is the `Notification` hook: the TUI parks at its prompt — including the
38+
`Interrupted · What should Claude do instead?` prompt — and claude raises `idle_prompt` (159 of them on
39+
the live instapods box). `notify()` therefore **ends the turn** as well as posting the bell, and the
40+
session reads `needs you`, which is the honest state: claude is waiting for you to say what to do
41+
instead. Typing the next prompt fires `UserPromptSubmit`, which retires the waiting card and puts the
42+
spinner back — the other half of the loop.
43+
44+
Note this makes the teardown deliberate: an interrupted **unattended** run is left alive rather than
45+
reaped, because a human stopped it on purpose and now owns it.
46+
3047
### Why `SessionEnd`'s reason must be read, not assumed
3148

3249
`clear`, `resume` and `compact` are **mid-run** events. Treating any `SessionEnd` as terminal would mark a

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "agent-os",
3-
"version": "0.324.0",
3+
"version": "0.325.0",
44
"description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.",
55
"license": "MIT",
66
"type": "commonjs",

scripts/turn-lifecycle-test.cjs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,39 @@ console.log('\n\x1b[1m6) every terminal transition drops the flag\x1b[0m');
169169
assert(row(id).busy_since === null, 'markEnded clears it');
170170
}
171171

172-
console.log('\n\x1b[1m7) an unknown event is ignored, not a crash\x1b[0m');
172+
console.log('\n\x1b[1m7) an INTERRUPTED turn (Esc) stops reading "working"\x1b[0m');
173+
console.log(' (Esc/Ctrl-C fires NO Stop, StopFailure or SessionEnd — there is no interrupt hook at all,');
174+
console.log(' anthropics/claude-code#9516 — so the Notification bell is the only signal we get)');
175+
{
176+
const id = mk();
177+
assert(seen(id).working === true, 'mid-turn → working');
178+
tm.notify(id, 'agent-demo', 'idle_prompt', 'Interrupted · What should Claude do instead?');
179+
assert(row(id).busy_since === null, 'the idle notification ends the turn — blocked on a human is not generating');
180+
assert(seen(id).working === false, 'no spinner');
181+
const card = aos.db.prepare("SELECT COUNT(*) c FROM messages WHERE session_id = ? AND type = 'notification' AND status = 'open'").get(id).c;
182+
assert(card === 1, 'and it reads `needs you` — an open waiting card, which outranks working');
183+
}
184+
{
185+
// …and typing the next prompt closes that loop: the card is stale the moment the human answers.
186+
const id = mk();
187+
tm.notify(id, 'agent-demo', 'idle_prompt', 'waiting');
188+
tm.recordLifecycle(id, 'UserPromptSubmit');
189+
const card = aos.db.prepare("SELECT COUNT(*) c FROM messages WHERE session_id = ? AND type = 'notification' AND status = 'open'").get(id).c;
190+
assert(card === 0, 'a submitted prompt retires the waiting card');
191+
assert(seen(id).working === true, 'back to working, not stuck on `needs you`');
192+
}
193+
{
194+
const id = mk();
195+
tm.notify(id, 'agent-demo', 'permission_prompt', 'Claude needs permission');
196+
assert(row(id).busy_since === null, 'a permission prompt ends the turn too');
197+
}
198+
{
199+
const id = mk();
200+
tm.notify(id, 'agent-demo', 'auth_success', 'logged in'); // a noise kind
201+
assert(row(id).busy_since != null, 'a NOISE notification kind changes nothing');
202+
}
203+
204+
console.log('\n\x1b[1m8) an unknown event is ignored, not a crash\x1b[0m');
173205
{
174206
const id = mk();
175207
const before = row(id).busy_since;

src/terminal.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3036,6 +3036,11 @@ export class TerminalManager {
30363036
const now = Date.now();
30373037
this.db.prepare('UPDATE term_sessions SET busy_since = ?, last_activity = ?, updated_at = ? WHERE id = ? AND busy_since IS NULL')
30383038
.run(now, now, now, sessionId);
3039+
// A prompt submitted IS the human answering — retire any open "waiting on you" card, or the session
3040+
// would keep reading `needs you` (which outranks `working`) through the whole turn it just started.
3041+
// This is the close of the loop the interrupt case opens: interrupt → idle_prompt card → you type →
3042+
// card gone, spinner back.
3043+
this.clearNotifications(sessionId);
30393044
}
30403045

30413046
/** A turn ENDED — drop `busy_since`. The one place that clears it, so every end path (Stop hook,
@@ -4835,6 +4840,13 @@ export class TerminalManager {
48354840
// newer `agent_needs_input` Claude Code emits when it's blocked on the human. Auth/elicitation noise
48364841
// and the per-turn `agent_completed` are dropped here (session completion is signalled by markEnded).
48374842
if (kind !== 'permission_prompt' && kind !== 'idle_prompt' && kind !== 'agent_needs_input') return;
4843+
// Blocked on a human is, by definition, NOT generating — so this is a turn-END signal as much as it
4844+
// is a bell, and it's the only one we get for a turn the user INTERRUPTED (Esc / Ctrl-C fires no
4845+
// `Stop`, no `StopFailure`, no `SessionEnd` — there is no interrupt hook at all, see
4846+
// anthropics/claude-code#9516). Without this clear, an interrupted session sat on the console reading
4847+
// "working" until the 2h wedged-turn ceiling. `idle_prompt` is what claude raises once the TUI has
4848+
// been sitting at its prompt — including the "Interrupted · What should Claude do instead?" prompt.
4849+
this.clearTurnBusy(sessionId);
48384850
this.clearNotifications(sessionId);
48394851
const fallback = kind === 'permission_prompt' ? 'Claude needs permission to continue.' : 'Claude is waiting for your input.';
48404852
const body = (message || '').trim() || fallback;

0 commit comments

Comments
 (0)