* fix(agent-claude-code): map bookkeeping JSONL types to ready, not active
Claude writes several types AFTER finishing a turn — `file-history-snapshot`, `attachment`, `pr-link`, `queue-operation`, `permission-mode`, `last-prompt`, `ai-title`, `agent-color`, `agent-name`, `custom-title`. Until now they fell through to the `default` switch branch and looked `active` for 30s, making finished sessions appear busy. This was almost certainly the root cause of "Claude looks like it's still working when it's done" reports (the #1908 family).
Add explicit cases mapping each to `ready`/`idle` by age, same as `assistant`/`summary`. Three existing tests that asserted these returned `active` were testing the buggy behavior — updated to the correct behavior, plus four new tests covering `attachment`, `permission-mode`, and `ai-title`.
* fix(agent-claude-code): broaden process regex to match real install variants
`(?:^|\/)claude(?:\s|$)` rejected several legitimate Claude installs, causing AO to declare sessions `exited` while Claude was still running:
- `claude-code` (some Anthropic CLI variants)
- `claude.exe` / `claude.js` / `claude.cjs` (Windows / shim installs)
- `.claude` (dot-prefix shim)
- `node /opt/.../@anthropic-ai/claude-code/cli.js` (npm shim — path-contains-claude case)
New regex `(?:^|\/)(?:\.)?claude(?:[-.][\w-]+)*(?:[\s/]|$)` allows optional dot prefix, hyphen/dot suffix chains, and `/` terminator (for paths with claude as a path component). Still anchored at `/` or start-of-line so `claudia`/`claudine` etc. don't false-match.
Tests: 7-case `it.each` for each install shape returning true, dedicated rejection test for `claudia`/`claudine`. Old strict test (`does not match claude-code`) deleted — it was testing the bug.
* fix(agent-claude-code): warn on non-ENOENT failures reading ~/.claude/projects/
Previously `findLatestSessionFile` swallowed every readdir error silently, so a permission-denied or fd-exhausted misconfig on `~/.claude/projects/<slug>` would leave the session looking permanently `idle` on the dashboard with zero telemetry — debugging this took hours in the past (filed as one of the gotchas in #1927's description).
Now: ENOENT (dir hasn't been created yet) stays silent because that's normal during the early lifecycle. Every other errno (EACCES, EPERM, EMFILE, etc.) emits a single console.warn naming the dir and the errno, plus noting that activity will fall back to the AO JSONL only. Caller behavior unchanged (still returns null).
Tests: one for EACCES (via chmod 0o000) confirming warn fires with the errno, one for the missing-dir case confirming warn does NOT fire.
* fix(agent-claude-code): resolve symlinked workspace paths before slugifying
If AO records `session.workspacePath` as a symlink (e.g. `/Users/me/symlinks/repo`) and Claude resolves the target before computing its on-disk slug (e.g. `/Users/me/code/repo`), the two slugs diverge and `findLatestSessionFile` looks in an empty `~/.claude/projects/<wrong-slug>/` dir forever. Session looks permanently `idle` on the dashboard with no telemetry.
Add `resolveWorkspaceForClaude(workspacePath)` (try realpathSync, fall back to literal on error). Use it in all three sites that slugify a workspace path: `getClaudeActivityState`, `getSessionInfo`, `getRestoreCommand`. Re-exported from `index.ts` for downstream consumers.
Kept `toClaudeProjectPath` as a pure string transform — the realpath happens in the caller, so the slug function stays trivially testable.
New test confirms: write JSONL under the target's slug, call getActivityState with the symlink path, expect `ready` (was `null` before the fix).
Test-setup also needed `realpathSync(mkdtempSync(...))` because /var/folders is itself a symlink on macOS, otherwise existing tests would set up JSONL under one slug and the code would now look under another.
* fix(agent-claude-code): disambiguate multi-session via claudeSessionUuid
When two Claude sessions are running in the same workspace, `findLatestSessionFile` picked newest-mtime — which is the WRONG session's JSONL whenever its sibling has just written. The `getSessionInfo` method already captures `session.metadata.claudeSessionUuid` (the UUID Claude uses as its JSONL filename), but `getClaudeActivityState` was ignoring it.
`findLatestSessionFile` now accepts an optional `preferredUuid`. If provided, it checks `<projectDir>/<preferredUuid>.jsonl` first via `stat()` and returns that path on success. Falls back to newest-mtime when the UUID is missing or the named file doesn't exist yet (fresh session not yet introspected, or file rotated/removed).
`getClaudeActivityState` reads `session.metadata.claudeSessionUuid` (coerces unknown→string, trims, treats empty as undefined) and passes it through.
Tests:
- prefers UUID-named JSONL when set, ignoring newer sibling file
- falls back to newest-mtime when UUID-named file doesn't exist
* chore: changeset for claude-activity-edge-cases
* fix(agent-claude-code): use ES import for realpathSync in test setup
CI lint rejected the `require()` form. Move realpathSync to the top-level node:fs import.
* refactor(agent-claude-code): drop dead tool_use and result switch cases
Both types were inherited from the Agent-interface spec but never actually emitted by Claude (verified on disk for #1927). They now fall to the `default` branch.
Behavior change:
- `tool_use` → previously in user/progress branch → identical to default. No real-world effect.
- `result` → previously in assistant/summary branch (never active) → now default (can be active when fresh). No real-world effect because Claude doesn't emit `result`.
If Claude ever introduces these types and we want different semantics, we add explicit cases back. Until then, the dead switch entries were noise.
Tests:
- Removed `returns 'active' for recent 'tool_use' entry` (default branch does the same)
- Removed `returns 'ready' for recent 'result' entry` (was testing dead behavior)
- Added `unknown types fall through to default branch — fresh → active` to lock the default-branch semantics for any future Claude type addition.
* feat(agent-claude-code): detect blocked from terminal regex too
Until now `blocked` was only sourced from native JSONL (`{type:"system", subtype:"api_error", level:"error"}` from #1927). The terminal-regex pipeline (`detectActivity` → `recordTerminalActivity` → AO activity-JSONL → `checkActivityLogState`) only emitted `waiting_input`/`idle`/`active`, never `blocked`. So when Claude's native JSONL was unreadable (slug drift, fresh session pre-introspection, etc.) the blocked state was unreachable via the safety net.
Patterns observed empirically by capturing tmux output during a real api.anthropic.com block (api blocked via /etc/hosts, fresh `ao spawn` session, `tmux capture-pane` after retries started):
⎿ Unable to connect to API (ConnectionRefused)
Retrying in 19s · attempt 7/10
Add two matches to `classifyTerminalOutput`:
- /Unable to connect to API/i — primary error wording
- /Retrying in \\d+s.*attempt \\d+\\/\\d+/i — retry counter (fires even when error scrolled off)
Placed BEFORE the existing waiting_input checks because Claude's static UI footer contains `bypass permissions on (shift+tab to cycle)` which the existing `bypass.*permissions` regex matches — without this ordering, a real blocked state would lose to that incidental match.
Tests cover: real captured output, alternate error code (FailedToOpenSocket), retry counter alone, and the bypass-permissions-footer precedence case.
* fix(agent-claude-code): address review feedback on PR #1932
Three issues raised by @greptile-apps review:
1. console.warn flooded on every poll (P1). getClaudeActivityState runs on
a polling interval — without a dedupe, a single EACCES path would log
60+ lines/minute indefinitely. Added module-level Set<string> keyed by
projectDir; warn only on first miss per path for the process lifetime.
Exported resetWarnedReaddirPaths for test isolation.
2. realpathSync blocked the event loop in fully-async callers (P2).
Switched to async realpath from node:fs/promises. Updated the three
call sites (getClaudeActivityState, getSessionInfo, getRestoreCommand)
to await. resolveWorkspaceForClaude is now Promise<string>.
3. Test regex /failed to read.*EACCES|EPERM/ parsed as
(failed to read.*EACCES) | (EPERM) because | has lowest precedence.
Wrapped the alternation in a non-capturing group: (?:EACCES|EPERM).
Also added a new test confirming the dedupe works: three consecutive polls
against an EACCES path produce exactly one warn.
* fix(agent-claude-code): skip UI-noise JSONL types when reading last entry
Regression caused by the earlier bookkeeping-types fix on this branch (commit 39c2b1dd). That commit moved `permission-mode`, `ai-title`, `agent-color`, `agent-name`, `custom-title` into the explicit ready/idle bookkeeping case alongside `file-history-snapshot` etc. — but these five types are UI-state snapshots Claude writes at random times (session attach, permission-mode change, title regeneration). They are NOT correlated with real activity.
Empirical proof: ao-144 had 73 trailing `permission-mode` + 73 trailing `ai-title` entries written over 6 dormant days. Last real assistant message was 2026-05-13 12:57. With the earlier fix in place, the dashboard oscillated between `ready` (recent noise mtime) and `idle` (older noise mtime) instead of staying `idle`. User reported "I am not seeing active but ready for all session" — this is what they were seeing.
Fix: define `NOISE_JSONL_TYPES` containing the five UI-state types. When the literal last entry is one of these, skip the native-JSONL classification and fall through to the AO activity-JSONL pipeline (terminal-derived). If that's also empty, the existing `staleNativeState` fallback returns `idle` from `session.createdAt`, which is correct for dormant sessions.
For sessions that ARE actively working but happen to have a noise type as the latest line, the AO JSONL fallback's terminal scrape catches the active state.
Removed the five noise types from the explicit bookkeeping case in the switch. Kept `file-history-snapshot`, `attachment`, `pr-link`, `queue-operation`, `last-prompt` — these plausibly correlate with real activity (file edits, PR creation, prompt submission).
Tests: updated `permission-mode → ready` to `→ idle`, same for `ai-title`. Added explicit tests for the other three noise types and a test confirming the AO JSONL pipeline still wins when last native entry is noise but AO has actionable state.
* fix(agent-claude-code): tighten waiting_input regex to ignore static UI footer
The existing `/bypass.*permissions/i` regex matched Claude's persistent UI footer `⏵⏵ bypass permissions on (shift+tab to cycle)` — which is visible on EVERY active Claude session. As long as native JSONL classification returned a definitive answer, this didn't matter. The previous noise-skip commit on this branch (faf43042) made many more sessions fall through to the AO JSONL pipeline, which exposed the bug: ao-143/144/151 all flipped to waiting_input even though they were genuinely dormant.
Tightened the regex to require "all future" — matching the actual permission-bypass prompt ("bypass all future permissions for this session") rather than the footer toggle ("bypass permissions on"). The other two waiting_input regexes (`Do you want to proceed?`, `(Y)es...(N)o`) are unchanged.
Verified: dashboard's stored AO activity-JSONL entries had `state: "waiting_input"` with trigger snippets containing the persistent footer text. With this fix, classifyTerminalOutput on the exact same terminal capture returns active instead.
Tests: existing `bypass all future permissions for this session` test still passes (the prompt phrase contains the new tighter pattern). Added a regression test confirming the static footer alone does NOT match waiting_input.
* fix(agent-claude-code): treat pr-link as re-snapshot noise too
Empirical evidence from production: ao-160's JSONL had the same PR (#1911) written as a `pr-link` entry 33 times in the last 200 lines, alternating with permission-mode/ai-title in the same trio pattern. Internal timestamps showed re-emissions ~1 minute apart (15:24:40, 15:26:27, 15:27:42 — same PR, three writes within ~3 minutes).
User-visible bug: ao-139 (last real work 11h ago) and ao-160 (last real work over a day ago) were both showing as `ready` on the dashboard because the file mtime kept moving forward via these pr-link re-snapshots, and pr-link was in the explicit ready/idle bookkeeping case.
Fix: move `pr-link` from the bookkeeping case into NOISE_JSONL_TYPES so the cascade skips it and falls through to the AO JSONL pipeline (and ultimately to the stale-native idle fallback for genuinely dormant sessions). Real PR creation is still observable via the assistant message that asked for it and via the gh-tracker.
Verified: 200/200 plugin tests pass. The existing pr-link test updated to expect idle (matches new behavior); added comment with the empirical evidence.
* fix(agent-claude-code): tighten terminal classification — default to idle, gerund+ellipsis = active
Root cause of dashboard showing ready for dormant sessions ao-143/144/151/154/160/139: classifyTerminalOutput defaulted to "active" for any output that didn't match a specific idle/blocked/waiting_input pattern. Claude's persistent UI footer + empty input area always look the same between "just finished" and "currently working", and the existing lastLine idle-check missed dormant sessions because the LAST line is the static footer, not the ❯ prompt.
So every poll cycle, recordTerminalActivity wrote "active" entries to AO activity-JSONL for dormant sessions. The cascade's age-decayed fallback then surfaced those as ready (30s–5min decay) forever. Pr-link being treated as turn-end bookkeeping in 4e0e64ab made things even worse (sessions oscillated between ready and idle).
Fix — invert the default:
- classifyTerminalOutput now defaults to "idle" and only returns "active"
when an explicit active-work indicator is present.
- Strongest active signal: gerund-form status word followed by trailing
ellipsis "…". Claude rotates many spinner glyphs (✻ ✽ · ⠁ ⠈ etc.) and
status words (Germinating, Fluttering, Pondering, Mulling, Crafting,
Thinking, Reasoning…), but the gerund+ellipsis combo is consistent.
Past-tense lines like "✻ Worked for 11s" or "✻ Crunched for 11s" lack
the ellipsis and stay idle.
- Removed reliance on:
- bare "esc to interrupt" (in static footer, not just active)
- "Working" without ellipsis (false-matches "working on issue #N")
- "⎿" line prefix (also appears in past tool-result content)
- Word-based fallbacks kept for specific cases: Reading file / Writing to
/ Searching codebase / Press up to edit queued / Germinating /
Crunched for Ns / Thinking…|Working…
- Widened tail window from 5 to 12 lines because Claude's spinner line
sits 6-8 lines above the bottom (above input area + footer).
Verified live on tmux captures from 6 dormant sessions (ao-143/144/151/154/160/139) and 1 active session (ao-161): correct classification for all 7. Previous code returned "active" for all 6 dormant ones; new code returns "idle".
Tests: 203/203 pass. Updated the "non-empty output with no patterns" test to expect idle (matches new behavior — was testing the bug). Added dormant-pane and active-pane regression tests using real captured strings.
* fix(agent-claude-code): tighten terminal classification — default to idle, gerund+ellipsis = active
Followup to f1fc21bc: also drop the "/\bCrunched\s+for\s+\d+\s*s/i" pattern. It's past-tense like "Worked for Ns" — Claude shows "✻ Crunched for 22s" as a turn-complete summary, not an active indicator. ao-154's terminal had exactly this line, making every poll write "active" to AO activity-JSONL.
After this commit the only past-tense traps left would also lack the gerund+ellipsis signal, so the gerund+ellipsis check above handles all real active spinners. The remaining word-based fallbacks (Germinating, Thinking…/Working…, Reading file, Writing to, Searching codebase, Press up to edit queued) all stay because they're either gerund-form or specific enough to be unambiguous.
Live-verified on tmux captures from all 7 sessions:
- ao-161 (active turn, "Fluttering…") → active ✅
- ao-143 ("Worked for 11s") → idle ✅
- ao-154 ("Crunched for 22s") → idle ✅ (was returning active before this commit)
- ao-144/151/160/139 (no active indicators in pane) → idle ✅
Tests: 203/203.
* fix(agent-claude-code): bound blocked detection to wideTail, not full buffer
Per @greptile-apps P1 review on PR #1932: `Unable to connect to API` / `Retrying in Ns · attempt N/M` were being matched against the entire `terminalOutput` rather than the last-12-lines `wideTail`. If Claude hit an api_error, retried successfully, and then generated enough subsequent output to push the error off the visible window but not out of full scrollback, every subsequent poll would re-detect "blocked" forever.
Move the `wideTail = lines.slice(-12).join("\n")` definition earlier in the function and use it for the two blocked patterns too. All existing blocked test fixtures are ≤12 lines so they pass unchanged.
Added a regression test that simulates the recovered-and-continued scenario: error text early in the input, then 15+ lines of subsequent work, then an active spinner at the bottom. Old code returned "blocked" (saw the error in the full buffer); new code returns "active" (wideTail only sees the spinner).