fix(server): mark Claude terminals as needing input when Claude goes idle (#6022)

Claude Code reports the kind of notification in `notification_type`, but
the hook parser only read `matcher` and `reason`, which Claude never sends
in Notification input. Every idle_prompt notification resolved to nothing,
so a Claude terminal waiting for the user never showed as needing input.

Closes #5845

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-04 06:10:19 +02:00
committed by GitHub
parent 4869214bcb
commit 79a3942393
3 changed files with 15 additions and 6 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ Claude hook mapping:
- `UserPromptSubmit` → `running` - `UserPromptSubmit` → `running`
- `Stop`, `StopFailure`, `SessionEnd` → `idle` - `Stop`, `StopFailure`, `SessionEnd` → `idle`
- `Notification` with `reason` or `matcher` equal to `idle_prompt` → `needs-input` - `Notification` with `notification_type` equal to `idle_prompt` → `needs-input`
Claude does not run `Stop` when the user interrupts a turn. A standalone Ctrl-C or Escape input Claude does not run `Stop` when the user interrupts a turn. A standalone Ctrl-C or Escape input
while terminal activity is working clears the activity without finished attention. The same while terminal activity is working clears the activity without finished attention. The same
+12 -3
View File
@@ -17,6 +17,15 @@ function inputFrom(value: string) {
}; };
} }
function claudeNotification(notificationType: string) {
return JSON.stringify({
session_id: "session-1",
hook_event_name: "Notification",
message: "Claude is waiting for your input",
notification_type: notificationType,
});
}
function ttyInput() { function ttyInput() {
return { return {
isTTY: true, isTTY: true,
@@ -123,7 +132,7 @@ describe("runHooksCommand", () => {
const send = await runHook( const send = await runHook(
claudeProvider.id, claudeProvider.id,
claudeProvider.events[4].event, claudeProvider.events[4].event,
inputFrom('{"reason":"idle_prompt"}'), inputFrom(claudeNotification("idle_prompt")),
); );
expectPostedState(send, "needs-input"); expectPostedState(send, "needs-input");
@@ -131,11 +140,11 @@ describe("runHooksCommand", () => {
it.each(["permission_prompt", "elicitation_prompt", "elicitation_response", "auth_success"])( it.each(["permission_prompt", "elicitation_prompt", "elicitation_response", "auth_success"])(
"ignores Claude %s notifications", "ignores Claude %s notifications",
async (reason) => { async (notificationType) => {
const send = await runHook( const send = await runHook(
claudeProvider.id, claudeProvider.id,
claudeProvider.events[4].event, claudeProvider.events[4].event,
inputFrom(JSON.stringify({ reason })), inputFrom(claudeNotification(notificationType)),
); );
expect(send.calls).toEqual([]); expect(send.calls).toEqual([]);
@@ -40,8 +40,8 @@ function isIdlePrompt(raw: string | null): boolean {
try { try {
const notification = JSON.parse(raw) as unknown; const notification = JSON.parse(raw) as unknown;
if (!notification || typeof notification !== "object") return false; if (!notification || typeof notification !== "object") return false;
const payload = notification as { matcher?: unknown; reason?: unknown }; const payload = notification as { notification_type?: unknown };
return payload.matcher === "idle_prompt" || payload.reason === "idle_prompt"; return payload.notification_type === "idle_prompt";
} catch { } catch {
return false; return false;
} }