Bug
The dashboard activity state for Claude Code sessions is frequently wrong. Three concrete, live-reproduced defects, all rooted in the same architectural problem: getClaudeActivityState trusts Claude's noisy native transcript (read first, aged off file mtime, classified by entry type) over the clean, hook-pushed .ao/activity.jsonl event log.
Source: live AO session ao-189 | Reported by: @harshitsinghbhandari | Analyzed against: 5d0b624f | AO: 0.9.1 | node v22.14.0
Confidence: High (reproduced live on a real session; root cause traced to file:line)
This is a focused, evidence-backed sibling of the umbrella redesign #1899. It is not fully covered there — #1899 enumerates 15 breakages but does not list the idle_prompt → waiting_input mapping defect or the assistant → ready shadowing of a freshly-pushed active. Related: #1899 (umbrella), #1894 (sticky-state wallclock decay), #1934 (do we need a separate ready state). In-flight hook-migration PRs that should converge with the fix: #2030 (opencode), #1950 (codex).
Reproduction (live .ao/activity.jsonl from session ao-189)
Defect A — active work shown as "Ready". While actively running tools / generating a response:
16:32:53.366 ready ← Stop hook: previous turn ended
16:32:53.425 active
16:33:01.489 active ← actively working (running a Bash tool) RIGHT NOW
Hook log correctly says active. Dashboard showed Ready. The pushed active was ignored.
Defect B — normal idle shown as "Waiting for input" for ~15 min.
16:15:46 ready
16:16:47 waiting_input trigger: "Notification (idle_prompt)" ← turn finished, awaiting user
16:31:34 active ← user sent next message 15 min later
idle_prompt (Claude's "I'm done, waiting for you" nudge) was logged as waiting_input and, because actionable states are sticky (never decay), the dashboard showed "Waiting for input" for the entire 15-minute wait.
Defect C — ready/idle flapping (root cause shared with A): the activity clock is the log file's mtime, which Claude's noise re-snapshot writes (permission-mode, ai-title, pr-link) refresh even while idle → state oscillates ready⇄idle.
Root Cause
-
mtime as the activity clock (Defects A & C). readLastJsonlEntry returns modifiedAt: fileStat.mtime (packages/core/src/utils.ts:170) and readLastActivityEntry likewise (packages/core/src/activity-log.ts:118). The native transcript's mtime is bumped by noise writes unrelated to real activity, so age-based classification is unstable. The clock should be the embedded per-entry entry.ts (which getActivityFallbackState already uses correctly — activity-log.ts:170).
-
Native-first cascade + entry-type classification shadows pushed active (Defect A). getClaudeActivityState reads the native transcript first and returns before consulting the hook log. It maps assistant/summary entries to ready/idle — never active (packages/plugins/agent-claude-code/src/activity-detection.ts:429-431). While the agent is generating output (newest native entry = assistant), it is classified ready even though the hook log has a fresh active from PreToolUse/PostToolUse. The pushed truth loses to the polled noise.
-
idle_prompt conflated with permission_prompt (Defect B). The activity-updater hook maps both notification types to waiting_input (packages/plugins/agent-claude-code/src/index.ts:451-459, and the Node/Windows variant ~573-581). Only permission_prompt / PermissionRequest is a genuine blocked-on-human state; idle_prompt is the turn-end "waiting for next instruction" signal and should be ready (decaying to idle), not a sticky waiting_input.
Fix
Make the hook-pushed .ao/activity.jsonl the source of truth for activity; demote the native transcript to cost/summary/uuid extraction, blocked detection, and a fallback only when hooks are absent. Concretely:
- A tool_use is unambiguously
working/active. A fresh pushed active event (PreToolUse/PostToolUse) must win — never be overridden by an assistant→ready read from the native transcript.
- Classify off
entry.ts, never fileStat.mtime.
- Remap
idle_prompt → ready (only permission_prompt/PermissionRequest → waiting_input).
- Add hysteresis so state can't flap within one threshold window.
- Bound sticky actionable staleness so a missed clearing event can't pin
waiting_input forever.
This is the per-Claude instance of the unified single-clock reducer being designed (intended to be shared across claude-code/codex/opencode after #2030 and #1950 land), and aligns with #1899's inbox-vs-liveness split.
Impact
- The dashboard's primary signal (is this agent working / done / blocked?) is untrustworthy for Claude sessions: active work reads as idle/ready, and finished-and-waiting reads as blocked.
- Knock-on effects: notifier accuracy, stuck-detection, and any auto-reaction keyed on activity state inherit the wrong state.
Bug
The dashboard activity state for Claude Code sessions is frequently wrong. Three concrete, live-reproduced defects, all rooted in the same architectural problem:
getClaudeActivityStatetrusts Claude's noisy native transcript (read first, aged off file mtime, classified by entry type) over the clean, hook-pushed.ao/activity.jsonlevent log.Source: live AO session
ao-189| Reported by: @harshitsinghbhandari | Analyzed against:5d0b624f| AO: 0.9.1 | node v22.14.0Confidence: High (reproduced live on a real session; root cause traced to file:line)
This is a focused, evidence-backed sibling of the umbrella redesign #1899. It is not fully covered there — #1899 enumerates 15 breakages but does not list the
idle_prompt → waiting_inputmapping defect or theassistant → readyshadowing of a freshly-pushedactive. Related: #1899 (umbrella), #1894 (sticky-state wallclock decay), #1934 (do we need a separatereadystate). In-flight hook-migration PRs that should converge with the fix: #2030 (opencode), #1950 (codex).Reproduction (live
.ao/activity.jsonlfrom session ao-189)Defect A — active work shown as "Ready". While actively running tools / generating a response:
Hook log correctly says
active. Dashboard showed Ready. The pushedactivewas ignored.Defect B — normal idle shown as "Waiting for input" for ~15 min.
idle_prompt(Claude's "I'm done, waiting for you" nudge) was logged aswaiting_inputand, because actionable states are sticky (never decay), the dashboard showed "Waiting for input" for the entire 15-minute wait.Defect C — ready/idle flapping (root cause shared with A): the activity clock is the log file's mtime, which Claude's noise re-snapshot writes (
permission-mode,ai-title,pr-link) refresh even while idle → state oscillates ready⇄idle.Root Cause
mtime as the activity clock (Defects A & C).
readLastJsonlEntryreturnsmodifiedAt: fileStat.mtime(packages/core/src/utils.ts:170) andreadLastActivityEntrylikewise (packages/core/src/activity-log.ts:118). The native transcript's mtime is bumped by noise writes unrelated to real activity, so age-based classification is unstable. The clock should be the embedded per-entryentry.ts(whichgetActivityFallbackStatealready uses correctly —activity-log.ts:170).Native-first cascade + entry-type classification shadows pushed
active(Defect A).getClaudeActivityStatereads the native transcript first and returns before consulting the hook log. It mapsassistant/summaryentries toready/idle— neveractive(packages/plugins/agent-claude-code/src/activity-detection.ts:429-431). While the agent is generating output (newest native entry =assistant), it is classifiedreadyeven though the hook log has a freshactivefromPreToolUse/PostToolUse. The pushed truth loses to the polled noise.idle_promptconflated withpermission_prompt(Defect B). The activity-updater hook maps both notification types towaiting_input(packages/plugins/agent-claude-code/src/index.ts:451-459, and the Node/Windows variant ~573-581). Onlypermission_prompt/PermissionRequestis a genuine blocked-on-human state;idle_promptis the turn-end "waiting for next instruction" signal and should beready(decaying toidle), not a stickywaiting_input.Fix
Make the hook-pushed
.ao/activity.jsonlthe source of truth for activity; demote the native transcript to cost/summary/uuid extraction,blockeddetection, and a fallback only when hooks are absent. Concretely:working/active. A fresh pushedactiveevent (PreToolUse/PostToolUse) must win — never be overridden by anassistant→readyread from the native transcript.entry.ts, neverfileStat.mtime.idle_prompt → ready(onlypermission_prompt/PermissionRequest→waiting_input).waiting_inputforever.This is the per-Claude instance of the unified single-clock reducer being designed (intended to be shared across claude-code/codex/opencode after #2030 and #1950 land), and aligns with #1899's inbox-vs-liveness split.
Impact