You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: docs/session-lifecycle-hooks.md
+18-1Lines changed: 18 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -11,7 +11,7 @@ Reference for the events themselves: <https://code.claude.com/docs/en/hooks>.
11
11
| Hook event | Script | Route | What it does |
12
12
|---|---|---|---|
13
13
|`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. |
|`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). |
17
17
|`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
27
27
unattended run parks as a zombie until a 24-hour reaper finds it — the shape behind the recurring
28
28
"weekly-limit zombie sessions" incidents.
29
29
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
+
30
47
### Why `SessionEnd`'s reason must be read, not assumed
31
48
32
49
`clear`, `resume` and `compact` are **mid-run** events. Treating any `SessionEnd` as terminal would mark a
0 commit comments