Skip to content

Read what a Claude Code notification means before calling it 'needs you' (#306) - #310

Merged
scgopi merged 2 commits into
mainfrom
fix/306-idle-prompt
Sep 7, 2026
Merged

scgopi merged 2 commits into
mainfrom
fix/306-idle-prompt

Conversation

@scgopi

@scgopi scgopi commented Sep 7, 2026

Copy link
Copy Markdown
Owner

The defect

graphcode's Notification hook for Claude Code reported presence=awaitingInput for every notification, without reading the payload. Claude Code fires a notification about sixty seconds after a turn ends — notification_type: idle_prompt, meaning still idle — so every resting loop flipped to "needs a human" a minute after it stopped, and stayed there. Staged --follow-up messages and Mailroom watch wakes deliver only on presence == idle (deliversLater, drainPendingFollowUps), so for any Claude Code loop idle over a minute — most loops, most of the time — they were parked for good. MailWatcher measured it: two loops flipping at 60.0 s and 60.1 s, 0 of 18 watched posts typed in, all 18 staged.

The change

A notification.sh reporter beside activity.sh and usage.sh — the Notification hook is the one whose meaning depends on its payload. It reads notification_type and maps:

kind presence why
idle_prompt idle (activity cleared) "still idle" confirms idle rather than leaving it
permission_prompt, elicitation_dialog awaitingInput a real question to the human — the state the sidebar shows so a person learns a loop needs them
auth_success unchanged says nothing about presence
anything else, or no kind at all awaitingInput today's behaviour, on purpose: the mirror failure — a loop that genuinely needs someone, invisible — is worse than the one fixed

The hook command runs the reporter and, if the script is unreadable, falls back to today's report (awaitingInput), never to nothing. Remote hosts are handed the reporter beside the settings that name it, as with the other two scripts.

Audit of what keys off awaitingInput: the drain and deliversLater (the bug); and every NEEDS-YOU surface — the sidebar pill, the canvas attention rail, the titlebar indicator, AttentionRollup — which has therefore been over-reporting for every loop idle past a minute: the second symptom, now gone with the first. MessageBus.deliverability keys on LoopState.awaitingInput (a turn-based check), not on presence, and is unaffected — which is why a plain node send still arrived while --follow-up did not. The OpenCode plugin's question tool and Copilot's log reader map genuine prompts only and are untouched.

Verification

Gate: full Xcode suite (gate on this head in flight; numbers and exit codes in a comment when it completes), swiftlint 0 errors, swift-format clean, graphcoded and graphcode-cli schemes build; SwiftPM build and CLI smoke pass; Linux green.

NotificationPresenceTests runs the real generated reporter under /bin/sh with the payload on stdin against a zmx that records what it was asked to set — the shape PresenceReportingTests established, because the risk is a sed surviving Swift, JSON and shell: idle_promptpresence=idle with the activity cleared; permission_prompt and elicitation_dialogawaitingInput; an unknown kind and an absent field → awaitingInput; auth_success → nothing set; no $ZMX_SESSION → nothing set; the hook JSON runs the reporter and falls back to awaitingInput; the remote fragment carries the script. And the end-to-end path MailWatcher measured: a store with a watch armed, a matching post and a --follow-up staged for a busy loop delivers nothing on the presence the reporter writes for a permission prompt, and both on the presence it writes for idle_prompt.

Closes #306. Related: #304 (what the drain then did wrong to the backlog once it ran).

🤖 Generated with Claude Code

https://claude.ai/code/session_01DeGL2CxuGmq16RSZpJYm2N

scgopi and others added 2 commits September 6, 2026 17:47
…ou' (#306)

The Notification hook reported presence=awaitingInput for every
notification without reading its payload. Claude Code fires one about
sixty seconds after a turn ends — idle_prompt, meaning still idle — so
every resting loop flipped to 'needs a human' a minute after it stopped,
and since staged follow-ups and Mailroom wakes deliver only on idle, they
were parked for good: 0 of 18 watched posts arrived. A notification.sh
reporter now reads notification_type: idle_prompt confirms idle;
permission_prompt and elicitation_dialog stay awaitingInput; auth_success
leaves presence alone; an unknown or absent kind keeps today's behaviour,
because a loop that genuinely needs someone becoming invisible is the
worse failure. The hook falls back to today's report when the script is
unreadable, and remote hosts are handed the script. Every NEEDS-YOU
surface had been over-reporting for any loop idle past a minute.

Closes #306.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DeGL2CxuGmq16RSZpJYm2N
@scgopi

scgopi commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Gate on d956d34, private DerivedData: xcodebuild test exit 0 — 1659 tests / 174 suites / 0 failures; graphcode-cli build exit 0; graphcoded build exit 0; swiftlint 0 errors; swift-format clean; Linux green on this head (with the CLI smoke).

The one amendment since opening: the end-to-end test attached a bare /dev/null descriptor for the poll's connection, which the suite's descriptor reuse can drop as "disconnected" so the poll never drains — it passed focused and failed once in a full run. It now attaches through a socketpair with a reader draining it and closes the daemon end through OutboundChannels. Source unchanged.

scgopi added a commit that referenced this pull request Sep 7, 2026
Drain staged follow-ups once, in order (#304)

`drainPendingFollowUps` awaited the actor per item, so two drains interleaved over
one snapshot and the final `pendingFollowUps = remaining` was a lost update. That
single defect produced three symptoms that looked like separate bugs: duplicates,
reordering, and — the one nobody saw, because it is invisible — messages dropped
entirely. An end-to-end loop measured 5 of 18 posts never arriving and #357
delivered before #347.

The drain now takes and clears in one actor step, folds retries back in front of
anything queued meanwhile, and refuses to re-enter. Reproduced before the fix:
three staged messages delivered three times each, out of order, and a wake sent
for a post the reader had already read.

Reviewed independently. The reviewer wrote five of its own tests and ran them
against both this head and main's GraphStore in the same worktree, so its findings
are measured before/after rather than read. Two follow-ups it raised, neither
blocking and both filed separately: a later follow-up can jump the queue when the
cached presence the poll wrote disagrees with the live reading the drain takes,
and the presence read has no deadline — an await that never returns holds the
guard forever. It checked whether the guard makes that worse than main and it does
not; main re-enters into the same hang and re-delivers everything before the stuck
item as well.

Gated on the merged result together with #310, since the two fix the same
user-visible failure from opposite ends: 1662 tests / 175 suites / 0 failures, no
restarts, CLI smoke green.

Closes #304.
@scgopi
scgopi merged commit 53891f5 into main Sep 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A Claude Code loop idle over 60s never receives a watch wake or a --follow-up message

1 participant