Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ new version heading in the same commit.

## [Unreleased]

## [0.325.0] — 2026-08-08
### Fixed
- **An INTERRUPTED turn (Esc / Ctrl-C) kept reading "working".** Claude Code fires **nothing** on a user
interrupt — no `Stop`, no `StopFailure`, no `SessionEnd`; a `UserInterrupt` event has been requested
([anthropics/claude-code#9516](https://github.com/anthropics/claude-code/issues/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 reads **`needs you`**, which is the honest state.
- **The other half of that loop:** `markTurnBusy` (a submitted prompt) now retires the open waiting card.
Without it a session that had been interrupted would keep reading `needs you` — which outranks
`working` — through the entire next turn.


## [0.324.0] — 2026-08-08
### Fixed
- **Sessions that had finished showed the "working" spinner.** `term_sessions.busy_since` — the flag the
Expand Down
19 changes: 18 additions & 1 deletion docs/session-lifecycle-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Reference for the events themselves: <https://code.claude.com/docs/en/hooks>.
| Hook event | Script | Route | What it does |
|---|---|---|---|
| `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. |
| `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. |
| `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. |
| `UserPromptSubmit` | `terminal/lifecycle-hook.sh` | `/api/session-event` | **Turn START** → `markTurnBusy` (stamps `busy_since`). |
| `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). |
| `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`. |
Expand All @@ -27,6 +27,23 @@ server-side: the run keeps reading "working", the automations pile-up guard keep
unattended run parks as a zombie until a 24-hour reaper finds it — the shape behind the recurring
"weekly-limit zombie sessions" incidents.

### There is no interrupt hook — the bell stands in for one

When a human hits **Esc** (or Ctrl-C) mid-turn, Claude Code fires **nothing**: no `Stop`, no
`StopFailure`, no `SessionEnd`. A `UserInterrupt` event has been requested
([anthropics/claude-code#9516](https://github.com/anthropics/claude-code/issues/9516)) and does not exist.
So an interrupted turn used to sit on the console reading "working" until the 2h ceiling.

The signal we *do* get is the `Notification` hook: the TUI parks at its prompt — including the
`Interrupted · What should Claude do instead?` prompt — and claude raises `idle_prompt` (159 of them on
the live instapods box). `notify()` therefore **ends the turn** as well as posting the bell, and the
session reads `needs you`, which is the honest state: claude is waiting for you to say what to do
instead. Typing the next prompt fires `UserPromptSubmit`, which retires the waiting card and puts the
spinner back — the other half of the loop.

Note this makes the teardown deliberate: an interrupted **unattended** run is left alive rather than
reaped, because a human stopped it on purpose and now owns it.

### Why `SessionEnd`'s reason must be read, not assumed

`clear`, `resume` and `compact` are **mid-run** events. Treating any `SessionEnd` as terminal would mark a
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-os",
"version": "0.324.0",
"version": "0.325.0",
"description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.",
"license": "MIT",
"type": "commonjs",
Expand Down
34 changes: 33 additions & 1 deletion scripts/turn-lifecycle-test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,39 @@ console.log('\n\x1b[1m6) every terminal transition drops the flag\x1b[0m');
assert(row(id).busy_since === null, 'markEnded clears it');
}

console.log('\n\x1b[1m7) an unknown event is ignored, not a crash\x1b[0m');
console.log('\n\x1b[1m7) an INTERRUPTED turn (Esc) stops reading "working"\x1b[0m');
console.log(' (Esc/Ctrl-C fires NO Stop, StopFailure or SessionEnd — there is no interrupt hook at all,');
console.log(' anthropics/claude-code#9516 — so the Notification bell is the only signal we get)');
{
const id = mk();
assert(seen(id).working === true, 'mid-turn → working');
tm.notify(id, 'agent-demo', 'idle_prompt', 'Interrupted · What should Claude do instead?');
assert(row(id).busy_since === null, 'the idle notification ends the turn — blocked on a human is not generating');
assert(seen(id).working === false, 'no spinner');
const card = aos.db.prepare("SELECT COUNT(*) c FROM messages WHERE session_id = ? AND type = 'notification' AND status = 'open'").get(id).c;
assert(card === 1, 'and it reads `needs you` — an open waiting card, which outranks working');
}
{
// …and typing the next prompt closes that loop: the card is stale the moment the human answers.
const id = mk();
tm.notify(id, 'agent-demo', 'idle_prompt', 'waiting');
tm.recordLifecycle(id, 'UserPromptSubmit');
const card = aos.db.prepare("SELECT COUNT(*) c FROM messages WHERE session_id = ? AND type = 'notification' AND status = 'open'").get(id).c;
assert(card === 0, 'a submitted prompt retires the waiting card');
assert(seen(id).working === true, 'back to working, not stuck on `needs you`');
}
{
const id = mk();
tm.notify(id, 'agent-demo', 'permission_prompt', 'Claude needs permission');
assert(row(id).busy_since === null, 'a permission prompt ends the turn too');
}
{
const id = mk();
tm.notify(id, 'agent-demo', 'auth_success', 'logged in'); // a noise kind
assert(row(id).busy_since != null, 'a NOISE notification kind changes nothing');
}

console.log('\n\x1b[1m8) an unknown event is ignored, not a crash\x1b[0m');
{
const id = mk();
const before = row(id).busy_since;
Expand Down
12 changes: 12 additions & 0 deletions src/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3036,6 +3036,11 @@ export class TerminalManager {
const now = Date.now();
this.db.prepare('UPDATE term_sessions SET busy_since = ?, last_activity = ?, updated_at = ? WHERE id = ? AND busy_since IS NULL')
.run(now, now, now, sessionId);
// A prompt submitted IS the human answering — retire any open "waiting on you" card, or the session
// would keep reading `needs you` (which outranks `working`) through the whole turn it just started.
// This is the close of the loop the interrupt case opens: interrupt → idle_prompt card → you type →
// card gone, spinner back.
this.clearNotifications(sessionId);
}

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