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
Claude Code's Notification hook fires for two semantically different cases, and AO's Claude Code adapter maps both to the same activity state, waiting_input:
permission_prompt — the agent is genuinely blocked, can't proceed without a tool-permission decision.
idle_prompt — Claude Code's own client-side "I'm done and quiet for now" signal, fired after an ordinary end-of-turn idle period. Not a real blocker.
Source: internal investigation | Analyzed against:8d405c42 (origin/main) Confidence: High — traced exact code path, root cause confirmed against docs and live session data. Priority: medium — no data loss, but a persistent, high-frequency, user-visible misclassification with a known-correct minimal fix.
Both notification types collapse to domain.ActivityWaitingInput. Since ActivityWaitingInput is sticky (domain/activity.go, IsSticky()), and deriveStatus() (backend/internal/service/session/status.go) checks Activity.State == ActivityWaitingInput and returns StatusNeedsInputbefore ever evaluating PR facts, any session that merely finished a turn and went idle for a while — which is the normal, common resting state between turns — gets permanently mislabeled needs_input, identical to a session that's actually stuck on a real permission decision.
Evidence this is a defect, not intended behavior
docs/architecture.md's own state-machine diagram (Lifecycle Management section) documents the intended meaning of this state explicitly:
note right of Waiting
Agent needs input
Waiting for user
end note
— i.e. waiting_input is documented to mean "the agent genuinely needs a human/orchestrator decision," not "the agent is merely idle." idle_prompt firing after ordinary end-of-turn idling doesn't meet that bar, so mapping it to the same state as permission_prompt contradicts the state's own documented purpose.
Live confirmation of user-visible impact
Session agent-orchestrator-6 had an open, non-draft PR (#2485) with reviewDecision: REVIEW_REQUIRED — which should display as review_pending per deriveStatus()'s PR-pipeline mapping. Instead it displayed needs_input, solely because the session was sitting idle (activity.state: waiting_input) with no actual blocker. Confirmed via gh pr view 2485 + ao session get agent-orchestrator-6.
Reproduction
Deterministic, via the same hidden CLI path the harness itself uses (backend/internal/cli/hooks.go, ao hooks <agent> <event>, reads AO_SESSION_ID env + JSON payload from stdin, derives activity state, POSTs to the daemon):
Pick (or spawn) any live, non-terminated AO session; note its id.
Run, in that session's environment (or with AO_SESSION_ID exported to match it):
export AO_SESSION_ID=<session-id>echo'{"notification_type":"idle_prompt"}'| ao hooks claude-code notification
Check the result:
ao session get <session-id> --json
Actual:activity.state: "waiting_input", status: "needs_input" (or, if the session has an open PR, its real PR-derived status is masked by needs_input). Expected: since nothing about the agent is actually blocked, activity.state should reflect ordinary idleness (idle), and status should reflect the session's real state (idle, or its PR's actual pipeline status).
Real-world equivalent (non-deterministic, timing-dependent): let any Claude Code-backed AO session finish a turn and sit untouched past Claude Code's internal idle-notification threshold (undocumented exact duration) — the same transition occurs organically via the real hook, no manual invocation needed.
Suggested Fix
Narrow notificationState() so only permission_prompt maps to ActivityWaitingInput; idle_prompt maps to ActivityIdle instead:
This is a deliberately narrow, minimal fix — it does not change deriveStatus()'s precedence (confirmed separately to be intentional, documented design, not itself defective), does not touch the frontend, and does not address the reaper's staleness-exemption behavior for waiting_input (tracked separately, see Related).
Impact
Sessions with open PRs show needs_input instead of their real, more actionable PR-pipeline status (e.g. review_pending, mergeable, ci_failed), for as long as they sit idle — which is most of the time in normal usage.
Sidebar/kanban "Needs You" zone gets populated with sessions that aren't actually blocked on anything, diluting its signal.
False-positive NotificationNeedsInput alerts/telemetry fire for ordinary end-of-turn idling, not just genuine blocks.
Related
#2174 — reports the same user-visible symptom (waiting_input masking PR status) but attributes it to deriveStatus()'s precedence and proposes reversing it. That precedence is confirmed intentional, documented architecture (docs/architecture.md, "Status Derivation" section, explicitly titled "using this precedence (highest to lowest)"); reversing it would also hide genuine permission-blocks behind PR status. This issue's fix addresses the actual upstream root cause instead — narrowing what triggers waiting_input in the first place, leaving the documented precedence untouched.
Other harnesses (droid) have an analogous idle/permission conflation in their own native Notification-equivalent hook, but lack a discriminator field in their payload to fix it the same way — out of scope here, tracked separately if needed.
Bug
Claude Code's
Notificationhook fires for two semantically different cases, and AO's Claude Code adapter maps both to the same activity state,waiting_input:permission_prompt— the agent is genuinely blocked, can't proceed without a tool-permission decision.idle_prompt— Claude Code's own client-side "I'm done and quiet for now" signal, fired after an ordinary end-of-turn idle period. Not a real blocker.Source: internal investigation | Analyzed against:
8d405c42(origin/main)Confidence: High — traced exact code path, root cause confirmed against docs and live session data.
Priority: medium — no data loss, but a persistent, high-frequency, user-visible misclassification with a known-correct minimal fix.
Root Cause
backend/internal/adapters/agent/claudecode/activity.go,notificationState():Both notification types collapse to
domain.ActivityWaitingInput. SinceActivityWaitingInputis sticky (domain/activity.go,IsSticky()), andderiveStatus()(backend/internal/service/session/status.go) checksActivity.State == ActivityWaitingInputand returnsStatusNeedsInputbefore ever evaluating PR facts, any session that merely finished a turn and went idle for a while — which is the normal, common resting state between turns — gets permanently mislabeledneeds_input, identical to a session that's actually stuck on a real permission decision.Evidence this is a defect, not intended behavior
docs/architecture.md's own state-machine diagram (Lifecycle Management section) documents the intended meaning of this state explicitly:— i.e.
waiting_inputis documented to mean "the agent genuinely needs a human/orchestrator decision," not "the agent is merely idle."idle_promptfiring after ordinary end-of-turn idling doesn't meet that bar, so mapping it to the same state aspermission_promptcontradicts the state's own documented purpose.Live confirmation of user-visible impact
Session
agent-orchestrator-6had an open, non-draft PR (#2485) withreviewDecision: REVIEW_REQUIRED— which should display asreview_pendingperderiveStatus()'s PR-pipeline mapping. Instead it displayedneeds_input, solely because the session was sitting idle (activity.state: waiting_input) with no actual blocker. Confirmed viagh pr view 2485+ao session get agent-orchestrator-6.Reproduction
Deterministic, via the same hidden CLI path the harness itself uses (
backend/internal/cli/hooks.go,ao hooks <agent> <event>, readsAO_SESSION_IDenv + JSON payload from stdin, derives activity state, POSTs to the daemon):AO_SESSION_IDexported to match it):activity.state: "waiting_input",status: "needs_input"(or, if the session has an open PR, its real PR-derived status is masked byneeds_input).Expected: since nothing about the agent is actually blocked,
activity.stateshould reflect ordinary idleness (idle), andstatusshould reflect the session's real state (idle, or its PR's actual pipeline status).Real-world equivalent (non-deterministic, timing-dependent): let any Claude Code-backed AO session finish a turn and sit untouched past Claude Code's internal idle-notification threshold (undocumented exact duration) — the same transition occurs organically via the real hook, no manual invocation needed.
Suggested Fix
Narrow
notificationState()so onlypermission_promptmaps toActivityWaitingInput;idle_promptmaps toActivityIdleinstead:This is a deliberately narrow, minimal fix — it does not change
deriveStatus()'s precedence (confirmed separately to be intentional, documented design, not itself defective), does not touch the frontend, and does not address the reaper's staleness-exemption behavior forwaiting_input(tracked separately, see Related).Impact
needs_inputinstead of their real, more actionable PR-pipeline status (e.g.review_pending,mergeable,ci_failed), for as long as they sit idle — which is most of the time in normal usage.NotificationNeedsInputalerts/telemetry fire for ordinary end-of-turn idling, not just genuine blocks.Related
waiting_inputmasking PR status) but attributes it toderiveStatus()'s precedence and proposes reversing it. That precedence is confirmed intentional, documented architecture (docs/architecture.md, "Status Derivation" section, explicitly titled "using this precedence (highest to lowest)"); reversing it would also hide genuine permission-blocks behind PR status. This issue's fix addresses the actual upstream root cause instead — narrowing what triggerswaiting_inputin the first place, leaving the documented precedence untouched.waiting_inputsessions is a separate, already-filed concern. Note: fixing this issue as scoped (narrowingwaiting_input) will incidentally increase exposure to bug(lifecycle): orchestrator terminated mid-turn by a stale-activity/probe race, then silently replaced with no linkage or notification #2501's failure mode for sessions that previously landed inwaiting_inputviaidle_prompt(they lose an accidental reaper-exemption shield once reclassified asidle). This is a known, accepted tradeoff of shipping this fix on its own — bug(lifecycle): orchestrator terminated mid-turn by a stale-activity/probe race, then silently replaced with no linkage or notification #2501 should be addressed on its own timeline, not as a blocker here.droid) have an analogous idle/permission conflation in their own nativeNotification-equivalent hook, but lack a discriminator field in their payload to fix it the same way — out of scope here, tracked separately if needed.