fix(agent-claude-code): harden activity detection against 5 real-world edge cases (#1932)
* 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 (commit39c2b1dd). 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 in4e0e64abmade 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).
This commit is contained in:
parent
a610601158
commit
8c71bdebbd
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
"@aoagents/ao-plugin-agent-claude-code": patch
|
||||
---
|
||||
|
||||
Harden Claude Code activity detection against five real-world edge cases identified during PR #1927's analysis:
|
||||
|
||||
1. **Bookkeeping types → false-active.** `file-history-snapshot`, `attachment`, `pr-link`, `queue-operation`, `permission-mode`, `last-prompt`, `ai-title`, `agent-color`, `agent-name`, `custom-title` were falling through to the `default` switch branch and showing as `active` for 30s after Claude finished a turn. They now correctly map to `ready`/`idle` by age. Likely root cause of "Claude looks busy when it's done" reports.
|
||||
|
||||
2. **Multi-session disambiguation.** `findLatestSessionFile` picked newest-mtime, which is the wrong session's JSONL when two Claude sessions are running in the same workspace. Now prefers the UUID-named file (`<projectDir>/<claudeSessionUuid>.jsonl`) when `session.metadata.claudeSessionUuid` is set, falling back to newest-mtime otherwise.
|
||||
|
||||
3. **Symlinked workspaces.** `toClaudeProjectPath` was a pure string transform — symlink paths produced different slugs than what Claude itself wrote. Added `resolveWorkspaceForClaude(path)` that runs `realpathSync` (with fallback) and used it in all three slug-computing sites (`getClaudeActivityState`, `getSessionInfo`, `getRestoreCommand`).
|
||||
|
||||
4. **Process regex too narrow.** `(?:^|\/)claude(?:\s|$)` was missing several legitimate install variants — `.claude`, `claude-code`, `claude.exe`, `claude.js`, and npm shims like `node /opt/.../@anthropic-ai/claude-code/cli.js`. Broadened to `(?:^|\/)(?:\.)?claude(?:[-.][\w-]+)*(?:[\s/]|$)`; still rejects look-alikes (`claudia`, `claudine`).
|
||||
|
||||
5. **Silent permission-denied.** `findLatestSessionFile` was swallowing every `readdir` error silently — a missing `~/.claude/projects/<slug>/` (ENOENT) is normal, but a permission-denied (EACCES/EPERM) or fd-exhausted (EMFILE) misconfig left the session looking permanently `idle` on the dashboard with zero telemetry. Now logs a single `console.warn` for non-ENOENT errors.
|
||||
|
||||
193/193 plugin tests pass. No public-API change. New helper `resolveWorkspaceForClaude` is re-exported from `index.ts` for downstream consumers.
|
||||
|
|
@ -1,8 +1,17 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
realpathSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
utimesSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { toClaudeProjectPath, create } from "../index.js";
|
||||
import { resetWarnedReaddirPaths } from "../activity-detection.js";
|
||||
import {
|
||||
createActivitySignal,
|
||||
readLastActivityEntry,
|
||||
|
|
@ -115,7 +124,12 @@ describe("Claude Code Activity Detection", () => {
|
|||
const agent = create();
|
||||
|
||||
beforeEach(() => {
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "ao-activity-test-"));
|
||||
// realpathSync because /var/folders/... is a symlink to /private/var/folders/...
|
||||
// on macOS. getClaudeActivityState resolves symlinks before slugifying (so the
|
||||
// slug matches what Claude wrote), so the test setup must do the same — otherwise
|
||||
// the test JSONL lives under one slug and the code looks under another.
|
||||
// homedir() is mocked to fakeHome, so its realpath flows through naturally.
|
||||
fakeHome = realpathSync(mkdtempSync(join(tmpdir(), "ao-activity-test-")));
|
||||
workspacePath = join(fakeHome, "workspace");
|
||||
mkdirSync(workspacePath, { recursive: true });
|
||||
|
||||
|
|
@ -126,6 +140,9 @@ describe("Claude Code Activity Detection", () => {
|
|||
|
||||
// Mock isProcessRunning to always return true (we test exited separately)
|
||||
vi.spyOn(agent, "isProcessRunning").mockResolvedValue(true);
|
||||
|
||||
// Reset module-level warn dedupe so each test starts fresh.
|
||||
resetWarnedReaddirPaths();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -166,6 +183,106 @@ describe("Claude Code Activity Detection", () => {
|
|||
expect(await agent.getActivityState(makeSession({ workspacePath: null }))).toBeNull();
|
||||
});
|
||||
|
||||
it("logs a warning when ~/.claude/projects/<dir> is unreadable (EACCES)", async () => {
|
||||
// Make the existing project dir unreadable by stripping owner-read.
|
||||
// ENOENT (dir missing) is normal and stays silent; EACCES/EPERM must
|
||||
// surface so users can debug a silent-idle session.
|
||||
const { chmodSync } = await import("node:fs");
|
||||
chmodSync(projectDir, 0o000);
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
const result = await agent.getActivityState(makeSession());
|
||||
expect(result).toBeNull();
|
||||
expect(warn).toHaveBeenCalledOnce();
|
||||
// Non-capturing group: alternation MUST stay inside `(?:...)` or it
|
||||
// would parse as `(failed to read.*EACCES) | (EPERM)` and any string
|
||||
// containing the literal `EPERM` would pass the assertion.
|
||||
expect(warn.mock.calls[0]?.[0]).toMatch(/failed to read.*(?:EACCES|EPERM)/);
|
||||
} finally {
|
||||
chmodSync(projectDir, 0o755);
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("only warns ONCE per path across multiple polls (no log flood)", async () => {
|
||||
// Real bug this guards against: getActivityState runs on a polling
|
||||
// interval; without the dedupe set, a single EACCES path would log
|
||||
// 60+ lines/minute indefinitely.
|
||||
const { chmodSync } = await import("node:fs");
|
||||
chmodSync(projectDir, 0o000);
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
await agent.getActivityState(makeSession());
|
||||
await agent.getActivityState(makeSession());
|
||||
await agent.getActivityState(makeSession());
|
||||
expect(warn).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
chmodSync(projectDir, 0o755);
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT log when project dir simply doesn't exist (ENOENT is normal)", async () => {
|
||||
const badPath = join(fakeHome, "this-workspace-never-existed");
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
await agent.getActivityState(makeSession({ workspacePath: badPath }));
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers UUID-named JSONL when session.metadata.claudeSessionUuid is set", async () => {
|
||||
// Multi-session-in-one-worktree case: two .jsonl files exist, the
|
||||
// newest-mtime one is from a DIFFERENT session, and we don't want
|
||||
// to misread that. Use the metadata UUID to pick the right one.
|
||||
const myUuid = "aaa-111";
|
||||
// Newest-mtime file is the OTHER session's JSONL (would be wrong).
|
||||
writeJsonl([{ type: "progress", status: "doing other work" }], 0, "bbb-222.jsonl");
|
||||
// My session's JSONL is older, but it's the one we want.
|
||||
writeJsonl(
|
||||
[{ type: "assistant", message: { content: "my session" } }],
|
||||
10_000,
|
||||
`${myUuid}.jsonl`,
|
||||
);
|
||||
|
||||
const session = makeSession({ metadata: { claudeSessionUuid: myUuid } });
|
||||
const result = await agent.getActivityState(session);
|
||||
// 10s old + `assistant` → ready (NOT `active` from the other session's progress)
|
||||
expect(result?.state).toBe("ready");
|
||||
});
|
||||
|
||||
it("falls back to newest-mtime when UUID-named file doesn't exist yet", async () => {
|
||||
// Session just spawned, getSessionInfo hasn't captured the UUID yet,
|
||||
// OR the UUID was captured but the file was rotated/removed.
|
||||
// Should still find activity via newest-mtime fallback.
|
||||
writeJsonl([{ type: "user", message: { content: "hi" } }], 0, "actual-session.jsonl");
|
||||
|
||||
const session = makeSession({ metadata: { claudeSessionUuid: "uuid-that-doesnt-exist" } });
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("active");
|
||||
});
|
||||
|
||||
it("resolves symlinked workspace paths so slugs match what Claude wrote", async () => {
|
||||
// Claude resolves the symlink target before slugifying. If AO records
|
||||
// the symlink path and slugifies without realpath, the two slugs
|
||||
// diverge and findLatestSessionFile returns null forever. This test
|
||||
// confirms the realpath fix: write JSONL under the SLUG OF THE TARGET,
|
||||
// call getActivityState with the SYMLINK PATH, expect to find it.
|
||||
const { symlinkSync } = await import("node:fs");
|
||||
const target = workspacePath; // the real workspace dir
|
||||
const link = join(fakeHome, "symlinked-workspace");
|
||||
symlinkSync(target, link);
|
||||
|
||||
// JSONL is already in projectDir (which slugifies the real path).
|
||||
writeJsonl([{ type: "assistant", message: { content: "Done!" } }]);
|
||||
|
||||
// Calling with the SYMLINK should still find the file because of realpath.
|
||||
const result = await agent.getActivityState(makeSession({ workspacePath: link }));
|
||||
expect(result?.state).toBe("ready");
|
||||
});
|
||||
|
||||
it("returns null when project directory does not exist and AO activity is unavailable", async () => {
|
||||
const badPath = join(fakeHome, "nonexistent-workspace");
|
||||
expect(await agent.getActivityState(makeSession({ workspacePath: badPath }))).toBeNull();
|
||||
|
|
@ -276,19 +393,75 @@ describe("Claude Code Activity Detection", () => {
|
|||
expect((await agent.getActivityState(makeSession()))?.state).toBe("ready");
|
||||
});
|
||||
|
||||
it("returns 'active' for recent 'file-history-snapshot' (bookkeeping)", async () => {
|
||||
// Bookkeeping types Claude writes AFTER finishing a turn — these are
|
||||
// turn-end markers, not "Claude is working" signals. They must map to
|
||||
// ready/idle by age, NOT active. Previously they fell through to the
|
||||
// default branch and looked "active" for 30s; fixed in this PR.
|
||||
it("returns 'ready' for recent 'file-history-snapshot' (bookkeeping)", async () => {
|
||||
writeJsonl([{ type: "file-history-snapshot" }]);
|
||||
expect((await agent.getActivityState(makeSession()))?.state).toBe("active");
|
||||
expect((await agent.getActivityState(makeSession()))?.state).toBe("ready");
|
||||
});
|
||||
|
||||
it("returns 'active' for recent 'queue-operation' (bookkeeping)", async () => {
|
||||
it("returns 'ready' for recent 'queue-operation' (bookkeeping)", async () => {
|
||||
writeJsonl([{ type: "queue-operation" }]);
|
||||
expect((await agent.getActivityState(makeSession()))?.state).toBe("active");
|
||||
expect((await agent.getActivityState(makeSession()))?.state).toBe("ready");
|
||||
});
|
||||
|
||||
it("returns 'active' for recent 'pr-link' (bookkeeping)", async () => {
|
||||
writeJsonl([{ type: "pr-link" }]);
|
||||
expect((await agent.getActivityState(makeSession()))?.state).toBe("active");
|
||||
it("returns 'idle' (not 'ready') for recent 'pr-link' — re-snapshot noise", async () => {
|
||||
// Empirical evidence (ao-160): the SAME PR (#1911) was written as
|
||||
// pr-link 33 times in the last 200 lines, with new timestamps each
|
||||
// time. It's a periodic state snapshot, not a one-shot event.
|
||||
// Treat as noise so dormant sessions don't show as "ready" forever.
|
||||
writeJsonl([
|
||||
{
|
||||
type: "pr-link",
|
||||
prNumber: 1911,
|
||||
prUrl: "https://github.com/owner/repo/pull/1911",
|
||||
},
|
||||
]);
|
||||
expect((await agent.getActivityState(makeSession()))?.state).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns 'ready' for recent 'attachment' (bookkeeping)", async () => {
|
||||
writeJsonl([{ type: "attachment", attachment: { type: "skill_listing" } }]);
|
||||
expect((await agent.getActivityState(makeSession()))?.state).toBe("ready");
|
||||
});
|
||||
|
||||
// Pure UI-noise types (permission-mode, ai-title, agent-*, custom-title)
|
||||
// are written by Claude at random times (session attach, mode change,
|
||||
// title gen) and don't reflect actual activity. Real-world repro:
|
||||
// ao-144 had 73 trailing permission-mode + 73 trailing ai-title entries
|
||||
// over 6 dormant days, causing the dashboard to oscillate between
|
||||
// ready and idle. They now fall through to the AO JSONL pipeline; if
|
||||
// that has nothing, the stale-native fallback returns idle from
|
||||
// session.createdAt.
|
||||
it("returns 'idle' (not 'ready') for recent permission-mode noise — dormant session", async () => {
|
||||
writeJsonl([{ type: "permission-mode", permissionMode: "bypassPermissions" }]);
|
||||
expect((await agent.getActivityState(makeSession()))?.state).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns 'idle' (not 'ready') for recent ai-title noise — dormant session", async () => {
|
||||
writeJsonl([{ type: "ai-title", title: "Fix login bug" }]);
|
||||
expect((await agent.getActivityState(makeSession()))?.state).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns 'idle' for agent-color / agent-name / custom-title noise", async () => {
|
||||
writeJsonl([{ type: "agent-color", color: "#fff" }]);
|
||||
expect((await agent.getActivityState(makeSession()))?.state).toBe("idle");
|
||||
writeJsonl([{ type: "agent-name", name: "ao-161" }]);
|
||||
expect((await agent.getActivityState(makeSession()))?.state).toBe("idle");
|
||||
writeJsonl([{ type: "custom-title", title: "x" }]);
|
||||
expect((await agent.getActivityState(makeSession()))?.state).toBe("idle");
|
||||
});
|
||||
|
||||
it("noise last entry yields to AO JSONL when AO has actionable state", async () => {
|
||||
// Repro of the scenario where Claude IS active but the latest native
|
||||
// JSONL line happens to be noise. Native JSONL gets skipped, AO
|
||||
// activity JSONL has waiting_input from a recent terminal scrape,
|
||||
// cascade returns waiting_input.
|
||||
writeJsonl([{ type: "permission-mode" }]);
|
||||
writeActivityLog("waiting_input");
|
||||
expect((await agent.getActivityState(makeSession()))?.state).toBe("waiting_input");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -297,19 +470,19 @@ describe("Claude Code Activity Detection", () => {
|
|||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("agent interface spec types", () => {
|
||||
it("returns 'active' for recent 'tool_use' entry", async () => {
|
||||
writeJsonl([{ type: "tool_use" }]);
|
||||
expect((await agent.getActivityState(makeSession()))?.state).toBe("active");
|
||||
});
|
||||
|
||||
it("returns 'ready' for recent 'summary' entry", async () => {
|
||||
writeJsonl([{ type: "summary", summary: "Implemented login feature" }]);
|
||||
expect((await agent.getActivityState(makeSession()))?.state).toBe("ready");
|
||||
});
|
||||
|
||||
it("returns 'ready' for recent 'result' entry", async () => {
|
||||
writeJsonl([{ type: "result" }]);
|
||||
expect((await agent.getActivityState(makeSession()))?.state).toBe("ready");
|
||||
it("unknown types fall through to default branch — fresh → active", async () => {
|
||||
// Claude doesn't emit `tool_use` or `result` as top-level types (verified
|
||||
// on disk for #1927). Their explicit switch cases were removed and they
|
||||
// now fall to `default`, which maps fresh unknown types to active. This
|
||||
// test locks the default-branch semantics so future Claude type
|
||||
// additions are handled predictably until someone adds an explicit case.
|
||||
writeJsonl([{ type: "some-future-claude-type" }]);
|
||||
expect((await agent.getActivityState(makeSession()))?.state).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
type Session,
|
||||
} from "@aoagents/ao-core";
|
||||
import { execFile } from "node:child_process";
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { readdir, realpath, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
|
@ -45,16 +45,86 @@ export function toClaudeProjectPath(workspacePath: string): string {
|
|||
return normalized.replace(/[^a-zA-Z0-9-]/g, "-");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a workspace path through any symlinks BEFORE slugifying so AO's
|
||||
* computed Claude project dir matches what Claude itself writes.
|
||||
*
|
||||
* Without this, if AO records `workspacePath` as a symlink (e.g.
|
||||
* `/Users/me/symlinks/repo`) and Claude resolves it to the target
|
||||
* (`/Users/me/code/repo`) before computing its on-disk slug, the two slugs
|
||||
* diverge — AO looks in an empty `~/.claude/projects/<wrong-slug>/` dir
|
||||
* forever and the session looks permanently `idle`. Falls back to the
|
||||
* literal path on error (dangling symlink, race, etc.).
|
||||
*/
|
||||
export async function resolveWorkspaceForClaude(workspacePath: string): Promise<string> {
|
||||
try {
|
||||
return await realpath(workspacePath);
|
||||
} catch {
|
||||
return workspacePath;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Session file discovery
|
||||
// =============================================================================
|
||||
|
||||
/** Find the most recently modified .jsonl session file in a directory */
|
||||
export async function findLatestSessionFile(projectDir: string): Promise<string | null> {
|
||||
/** Module-level dedupe so EACCES/EPERM on a project dir warns ONCE per path
|
||||
* for the process lifetime, not on every poll cycle. getClaudeActivityState
|
||||
* is called every few seconds per session — without this, a single denied
|
||||
* path would flood logs at 60+ lines/minute indefinitely. Bounded by the
|
||||
* number of unique workspace slugs, which is small. */
|
||||
const warnedReaddirPaths = new Set<string>();
|
||||
|
||||
/** Reset the warned-paths dedupe set. Exported for testing only. */
|
||||
export function resetWarnedReaddirPaths(): void {
|
||||
warnedReaddirPaths.clear();
|
||||
}
|
||||
|
||||
/** Find Claude's JSONL session file for a project directory.
|
||||
*
|
||||
* When `preferredUuid` is provided (e.g. from `session.metadata.claudeSessionUuid`
|
||||
* captured by getSessionInfo), prefer `<projectDir>/<preferredUuid>.jsonl`
|
||||
* if it exists. This disambiguates the common case of multiple Claude
|
||||
* sessions running in the same workspace, where newest-mtime would pick
|
||||
* the WRONG session's JSONL whenever its sibling has just written.
|
||||
*
|
||||
* Falls back to newest-mtime when no UUID is given or the named file
|
||||
* doesn't exist yet (e.g. fresh session that hasn't been introspected).
|
||||
*
|
||||
* ENOENT on the project dir is normal and silent. Other errors
|
||||
* (EACCES, EPERM, EMFILE, ...) are logged via console.warn — once per
|
||||
* path for the process lifetime — so a permission-denied or fd-exhausted
|
||||
* misconfig doesn't silently mask the session as `idle` forever and
|
||||
* doesn't flood logs on every poll. */
|
||||
export async function findLatestSessionFile(
|
||||
projectDir: string,
|
||||
preferredUuid?: string,
|
||||
): Promise<string | null> {
|
||||
// Prefer the UUID-named file when we know it — disambiguates multi-session.
|
||||
if (preferredUuid) {
|
||||
const preferred = join(projectDir, `${preferredUuid}.jsonl`);
|
||||
try {
|
||||
await stat(preferred);
|
||||
return preferred;
|
||||
} catch {
|
||||
// Fall through to newest-mtime — the UUID-named file may not exist
|
||||
// yet (session just spawned, hasn't been introspected).
|
||||
}
|
||||
}
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(projectDir);
|
||||
} catch {
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && "code" in err && err.code !== "ENOENT") {
|
||||
if (!warnedReaddirPaths.has(projectDir)) {
|
||||
warnedReaddirPaths.add(projectDir);
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
console.warn(
|
||||
`[claude-code] failed to read ${projectDir} (${code}): ${err.message}. Session activity will fall back to AO JSONL only. (This warning is shown once per path for the process lifetime.)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -157,9 +227,15 @@ export async function findClaudeProcess(
|
|||
if (psOut === PROCESS_PROBE_INDETERMINATE) return PROCESS_PROBE_INDETERMINATE;
|
||||
|
||||
const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, "")));
|
||||
// Match "claude" as a word boundary — prevents false positives on
|
||||
// names like "claude-code" or paths that merely contain the substring.
|
||||
const processRe = /(?:^|\/)claude(?:\s|$)/;
|
||||
// Match "claude" plus common variants:
|
||||
// - bare `claude` / `/usr/local/bin/claude`
|
||||
// - dot-prefix shim `.claude`
|
||||
// - file extensions like `claude.exe`, `claude.js`, `claude.cjs`
|
||||
// - hyphenated names like `claude-code`
|
||||
// - node-shim cases like `node /path/@anthropic-ai/claude-code/cli.js`
|
||||
// (matches the path component containing "claude")
|
||||
// Still anchored at `/` or start-of-line so `claudia` etc. don't match.
|
||||
const processRe = /(?:^|\/)(?:\.)?claude(?:[-.][\w-]+)*(?:[\s/]|$)/;
|
||||
for (const line of psOut.split("\n")) {
|
||||
const cols = line.trimStart().split(/\s+/);
|
||||
if (cols.length < 3 || !ttySet.has(cols[1] ?? "")) continue;
|
||||
|
|
@ -210,26 +286,120 @@ export function classifyTerminalOutput(terminalOutput: string): ActivityState {
|
|||
const lines = terminalOutput.trim().split("\n");
|
||||
const lastLine = lines[lines.length - 1]?.trim() ?? "";
|
||||
|
||||
// Check the last line FIRST — if the prompt is visible, the agent is idle
|
||||
// regardless of historical output (e.g. "Reading file..." from earlier).
|
||||
// The ❯ is Claude Code's prompt character.
|
||||
// Empty prompt on the last line is unambiguously idle.
|
||||
if (/^[❯>$#]\s*$/.test(lastLine)) return "idle";
|
||||
|
||||
// Check the bottom of the buffer for permission prompts BEFORE checking
|
||||
// full-buffer active indicators. Historical "Thinking"/"Reading" text in
|
||||
// the buffer must not override a current permission prompt at the bottom.
|
||||
// Use a wider window (last 12 lines) than the bottom-of-buffer prompt
|
||||
// check above because Claude's spinner+status line, ⎿ tool-result lines,
|
||||
// and api-error text often sit 6-8 lines above the input area + footer.
|
||||
// All multi-line state checks (blocked, active) use this window — full
|
||||
// `terminalOutput` would let scrolled-off error text falsely return
|
||||
// "blocked" forever after a successful retry pushes the error out of
|
||||
// view but not out of scrollback.
|
||||
const wideTail = lines.slice(-12).join("\n");
|
||||
|
||||
// Check for blocked. Claude's persistent UI footer contains
|
||||
// "bypass permissions on (shift+tab to cycle)" on every session — the
|
||||
// tightened waiting_input regex no longer matches it, and the blocked
|
||||
// patterns below are specific to api-error retry text.
|
||||
//
|
||||
// Patterns observed empirically by capturing tmux output during a real
|
||||
// api-blocked retry loop (see PR #1932 description):
|
||||
// ⎿ Unable to connect to API (ConnectionRefused)
|
||||
// Retrying in 19s · attempt 7/10
|
||||
if (/Unable to connect to API/i.test(wideTail)) return "blocked";
|
||||
if (/Retrying in \d+s.*attempt \d+\/\d+/i.test(wideTail)) return "blocked";
|
||||
|
||||
// Check the bottom of the buffer for permission prompts. Historical
|
||||
// "Thinking"/"Reading" text earlier in the buffer must not override a
|
||||
// current permission prompt at the bottom.
|
||||
const tail = lines.slice(-5).join("\n");
|
||||
if (/Do you want to proceed\?/i.test(tail)) return "waiting_input";
|
||||
if (/\(Y\)es.*\(N\)o/i.test(tail)) return "waiting_input";
|
||||
if (/bypass.*permissions/i.test(tail)) return "waiting_input";
|
||||
// Match the ACTUAL permission-bypass prompt, NOT the static footer toggle.
|
||||
if (/bypass\s+all\s+future\s+permissions/i.test(tail)) return "waiting_input";
|
||||
|
||||
return "active";
|
||||
// Active: only when an explicit active-work indicator is present. Default
|
||||
// is IDLE — Claude's tmux pane has a persistent input area + footer that
|
||||
// looks identical between "just finished" and "working". Treating
|
||||
// unrecognized output as active caused dormant sessions (ao-160 etc.) to
|
||||
// get an "active" written to AO activity-JSONL every poll cycle, which the
|
||||
// age-decayed fallback then surfaced as ready forever.
|
||||
|
||||
// Strongest active signal: gerund (present-participle) status verb
|
||||
// followed by the trailing ellipsis "…". Claude cycles through many
|
||||
// status words (Germinating, Fluttering, Pondering, Mulling, Crafting,
|
||||
// Thinking, Reasoning, ...) and many spinner glyphs (✻ ✽ · ⠁ ⠈ etc.)
|
||||
// depending on animation frame. The gerund+ellipsis combo is the
|
||||
// consistent signal that survives glyph rotation. Past-tense lines like
|
||||
// "✻ Worked for 11s" or "✻ Crunched for 11s" lack the ellipsis and are
|
||||
// turn-complete summaries — they must NOT match (ao-143 repro).
|
||||
if (/\b\w+ing…/.test(wideTail)) return "active";
|
||||
// NOTE — these patterns look "active-ish" but are NOT, and should never
|
||||
// go back as active indicators:
|
||||
// - /\bCrunched\s+for\s+\d+s/ — past-tense turn summary (ao-154 repro:
|
||||
// "✻ Crunched for 22s" was making sessions perpetually "active")
|
||||
// - /\bWorked\s+for\s+\d+s/ — past-tense turn summary (ao-143 repro)
|
||||
// - /^\s*⎿\s+/ — prefixes past tool-results too
|
||||
// - /esc to interrupt/ — in the persistent UI footer
|
||||
// - bare /\bWorking\b/ — matches "working on issue #N" in recap
|
||||
// The gerund+ellipsis above catches present-tense forms across all
|
||||
// status words regardless of spinner glyph rotation.
|
||||
|
||||
// Word-based fallbacks for synthetic test inputs and rare cases where
|
||||
// the ellipsis isn't captured. Tight patterns to avoid false-firing on
|
||||
// benign text.
|
||||
if (/\bGerminating/i.test(wideTail)) return "active";
|
||||
if (/\b(?:Thinking|Working)\s*(?:…|\.\.\.)/i.test(wideTail)) return "active";
|
||||
if (/\bReading\s+file/i.test(wideTail)) return "active";
|
||||
if (/\bWriting\s+to\b/i.test(wideTail)) return "active";
|
||||
if (/\bSearching\s+codebase/i.test(wideTail)) return "active";
|
||||
if (/Press\s+up\s+to\s+edit\s+queued/i.test(wideTail)) return "active";
|
||||
|
||||
return "idle";
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Activity-state cascade
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Claude writes these types as UI-state snapshots at random times: on session
|
||||
* attach, on permission-mode change, on title regeneration, etc. They are
|
||||
* NOT correlated with whether Claude is actively working — a 6-day-dormant
|
||||
* session will still accumulate dozens of `permission-mode` and `ai-title`
|
||||
* entries just from being inspected.
|
||||
*
|
||||
* When one of these is the literal last JSONL entry, treat it as a "no
|
||||
* signal" — fall through to the AO activity-JSONL pipeline (terminal-
|
||||
* derived signal) rather than letting noise mtime decide the activity.
|
||||
*
|
||||
* Concrete bug this prevents: ao-144 had 73 trailing `permission-mode` +
|
||||
* 73 trailing `ai-title` entries written over 6 dormant days. Without
|
||||
* this skip, dashboard oscillated between `ready` (recent noise mtime)
|
||||
* and `idle` (old noise mtime) instead of staying `idle`.
|
||||
*
|
||||
* Conservative list — only the types that empirically run away. The other
|
||||
* bookkeeping types (file-history-snapshot, attachment, pr-link,
|
||||
* queue-operation, last-prompt) plausibly correlate with real activity
|
||||
* and stay in the explicit ready/idle case.
|
||||
*/
|
||||
const NOISE_JSONL_TYPES: ReadonlySet<string> = new Set([
|
||||
"permission-mode",
|
||||
"ai-title",
|
||||
"agent-color",
|
||||
"agent-name",
|
||||
"custom-title",
|
||||
// pr-link is also re-snapshot noise — verified on ao-160's JSONL where the
|
||||
// SAME PR (#1911) was written as a `pr-link` entry three times within
|
||||
// minutes (count: 33 pr-link vs 21 user messages in the last 200 lines).
|
||||
// The first emission is real; subsequent re-emissions are state snapshots.
|
||||
// We can't distinguish first vs Nth from the last line alone, so treat
|
||||
// all pr-link as noise. Real PR creation is still observable via the
|
||||
// assistant message and the gh-tracker side.
|
||||
"pr-link",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Determine current activity state for a Claude Code session.
|
||||
*
|
||||
|
|
@ -261,10 +431,16 @@ export async function getClaudeActivityState(
|
|||
|
||||
if (!session.workspacePath) return null;
|
||||
|
||||
const projectPath = toClaudeProjectPath(session.workspacePath);
|
||||
const projectPath = toClaudeProjectPath(await resolveWorkspaceForClaude(session.workspacePath));
|
||||
const projectDir = join(homedir(), ".claude", "projects", projectPath);
|
||||
|
||||
const sessionFile = await findLatestSessionFile(projectDir);
|
||||
// Prefer the UUID-named file when getSessionInfo has captured one — this
|
||||
// disambiguates multi-session-per-worktree, where newest-mtime would pick
|
||||
// the wrong session's JSONL whenever its sibling has just written.
|
||||
const rawUuid = session.metadata?.["claudeSessionUuid"];
|
||||
const preferredUuid =
|
||||
typeof rawUuid === "string" && rawUuid.trim() ? rawUuid.trim() : undefined;
|
||||
const sessionFile = await findLatestSessionFile(projectDir, preferredUuid);
|
||||
let staleNativeState: ActivityDetection | null = null;
|
||||
if (sessionFile) {
|
||||
const entry = await readLastJsonlEntry(sessionFile);
|
||||
|
|
@ -275,14 +451,24 @@ export async function getClaudeActivityState(
|
|||
// Claude writes this session's first native JSONL entry.
|
||||
if (session.createdAt && entry.modifiedAt < session.createdAt) {
|
||||
staleNativeState = { state: "idle", timestamp: session.createdAt };
|
||||
} else if (entry.lastType && NOISE_JSONL_TYPES.has(entry.lastType)) {
|
||||
// Last entry is UI-state noise (permission-mode / ai-title / etc.)
|
||||
// that doesn't reflect actual activity. Fall through to the AO
|
||||
// activity-JSONL pipeline for a terminal-derived answer; if that's
|
||||
// also empty, the staleNativeState below returns idle.
|
||||
staleNativeState = { state: "idle", timestamp: session.createdAt };
|
||||
} else {
|
||||
const ageMs = Date.now() - entry.modifiedAt.getTime();
|
||||
const timestamp = entry.modifiedAt;
|
||||
|
||||
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
|
||||
switch (entry.lastType) {
|
||||
// In-progress turn markers: very recent → active, older → ready/idle.
|
||||
// Removed `tool_use` and `result` cases that were in the spec but
|
||||
// never actually emitted by Claude (verified by disk survey for
|
||||
// #1927). The `default` branch handles them with the same semantics
|
||||
// if Claude ever introduces them.
|
||||
case "user":
|
||||
case "tool_use":
|
||||
case "progress":
|
||||
if (ageMs <= activeWindowMs) return { state: "active", timestamp };
|
||||
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
|
||||
|
|
@ -301,7 +487,18 @@ export async function getClaudeActivityState(
|
|||
|
||||
case "assistant":
|
||||
case "summary":
|
||||
case "result":
|
||||
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
|
||||
|
||||
// Bookkeeping types Claude writes AFTER a real event (file edits,
|
||||
// attachment context, queue housekeeping, prompt submit). Map to
|
||||
// ready/idle by age, same as assistant/summary. The pure re-snapshot
|
||||
// types (permission-mode, ai-title, agent-*, custom-title, pr-link)
|
||||
// are filtered out earlier by NOISE_JSONL_TYPES — they get written
|
||||
// continuously without indicating activity.
|
||||
case "file-history-snapshot":
|
||||
case "attachment":
|
||||
case "queue-operation":
|
||||
case "last-prompt":
|
||||
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
|
||||
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -375,6 +375,45 @@ describe("isProcessRunning", () => {
|
|||
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
|
||||
});
|
||||
|
||||
// Coverage for the broadened process regex — these are real install shapes
|
||||
// the previous narrow regex `/(?:^|\/)claude(?:\s|$)/` would have missed,
|
||||
// causing AO to declare sessions `exited` while Claude was still running.
|
||||
it.each([
|
||||
["bare binary", "claude"],
|
||||
["absolute path", "/opt/homebrew/bin/claude"],
|
||||
["dot-prefix shim", "/usr/local/lib/.claude"],
|
||||
["windows exe", "claude.exe"],
|
||||
["js shim", "claude.js"],
|
||||
["hyphenated name", "claude-code"],
|
||||
["node-shim npm install", "node /opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js"],
|
||||
])("returns true for %s (%s)", async (_label, args) => {
|
||||
mockExecFileAsync.mockImplementation((cmd: string) => {
|
||||
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys001\n", stderr: "" });
|
||||
if (cmd === "ps")
|
||||
return Promise.resolve({
|
||||
stdout: ` PID TT ARGS\n 123 ttys001 ${args}\n`,
|
||||
stderr: "",
|
||||
});
|
||||
return Promise.reject(new Error("unexpected"));
|
||||
});
|
||||
resetPsCache();
|
||||
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true);
|
||||
});
|
||||
|
||||
it("still rejects look-alike names (claudia, claudine)", async () => {
|
||||
mockExecFileAsync.mockImplementation((cmd: string) => {
|
||||
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys001\n", stderr: "" });
|
||||
if (cmd === "ps")
|
||||
return Promise.resolve({
|
||||
stdout: " PID TT ARGS\n 123 ttys001 claudia\n 124 ttys001 /bin/claudine\n",
|
||||
stderr: "",
|
||||
});
|
||||
return Promise.reject(new Error("unexpected"));
|
||||
});
|
||||
resetPsCache();
|
||||
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when tmux list-panes returns empty", async () => {
|
||||
mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
|
||||
|
|
@ -441,22 +480,6 @@ describe("isProcessRunning", () => {
|
|||
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match similar process names like claude-code", async () => {
|
||||
mockExecFileAsync.mockImplementation((cmd: string, args: string[]) => {
|
||||
if (cmd === "tmux" && args[0] === "list-panes") {
|
||||
return Promise.resolve({ stdout: "/dev/ttys001\n", stderr: "" });
|
||||
}
|
||||
if (cmd === "ps") {
|
||||
return Promise.resolve({
|
||||
stdout: " PID TT ARGS\n 100 ttys001 /usr/bin/claude-code\n",
|
||||
stderr: "",
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error("unexpected"));
|
||||
});
|
||||
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for tmux handle on Windows without spawning ps", async () => {
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
// ps should never be called — getCachedProcessList guards against Windows
|
||||
|
|
@ -515,6 +538,23 @@ describe("detectActivity", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("does NOT match Claude's persistent UI footer 'bypass permissions on (shift+tab to cycle)'", () => {
|
||||
// Regression test: the old `/bypass.*permissions/i` regex matched this
|
||||
// footer toggle (visible on EVERY Claude session) and falsely fired
|
||||
// waiting_input for every session that fell through to the AO JSONL
|
||||
// pipeline. ao-143/144/151 all flipped to waiting_input on dormant
|
||||
// sessions until this was tightened to require "all future".
|
||||
const footerOnly = [
|
||||
"✻ Crunched for 11s",
|
||||
"",
|
||||
"──────────────────────────────────────────────────────────",
|
||||
"❯ ",
|
||||
"──────────────────────────────────────────────────────────",
|
||||
" ⏵⏵ bypass permissions on (shift+tab to cycle)",
|
||||
].join("\n");
|
||||
expect(agent.detectActivity(footerOnly)).not.toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns active when queued message indicator is visible", () => {
|
||||
expect(agent.detectActivity("Press up to edit queued messages\n")).toBe("active");
|
||||
});
|
||||
|
|
@ -543,8 +583,131 @@ describe("detectActivity", () => {
|
|||
).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns active for non-empty output with no special patterns", () => {
|
||||
expect(agent.detectActivity("some random terminal output\n")).toBe("active");
|
||||
it("returns idle for non-empty output with no active-work indicators", () => {
|
||||
// Default-to-idle (changed from default-to-active in this PR). Claude's
|
||||
// tmux pane has a persistent input area + footer that looks identical
|
||||
// between "just finished" and "currently working". Treating
|
||||
// unrecognized output as active caused dormant sessions to get an
|
||||
// "active" written to AO activity-JSONL every poll cycle, which the
|
||||
// age-decayed fallback then surfaced as ready forever (ao-160 repro).
|
||||
expect(agent.detectActivity("some random terminal output\n")).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns idle for dormant session showing only Claude's input area + footer", () => {
|
||||
// Real captured output from a dormant session (ao-143 style): assistant
|
||||
// output above, separator, empty prompt line, separator, footer toggle.
|
||||
// The empty prompt ❯ is NOT the LAST line (footer is) so the existing
|
||||
// lastLine check misses it, and previously the default-to-active sent
|
||||
// every dormant session into the AO-JSONL active-loop.
|
||||
const dormant = [
|
||||
"※ recap: working on issue #143; next: wait for review",
|
||||
"",
|
||||
"──────────────────────────────────────────────────────────",
|
||||
"❯ ",
|
||||
"──────────────────────────────────────────────────────────",
|
||||
" ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
|
||||
].join("\n");
|
||||
expect(agent.detectActivity(dormant)).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns active when spinner+ellipsis is in the tail (✻ Fluttering…)", () => {
|
||||
// Real captured output from ao-161 mid-active-turn. The ✻ spinner
|
||||
// followed by a verb and trailing ellipsis is the canonical Claude
|
||||
// active indicator across all turn-status words (Germinating,
|
||||
// Fluttering, Thinking, Pondering, etc).
|
||||
const active = [
|
||||
"✻ Fluttering… (6m 49s · ↓ 26.9k tokens)",
|
||||
" ⎿ Tip: Use /feedback to help us improve!",
|
||||
"",
|
||||
"──────",
|
||||
"❯ ",
|
||||
"──────",
|
||||
" ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
|
||||
].join("\n");
|
||||
expect(agent.detectActivity(active)).toBe("active");
|
||||
});
|
||||
|
||||
it("returns idle for past-tense spinner status like '✻ Worked for 11s' (no ellipsis)", () => {
|
||||
// Real captured output from ao-143 dormant. The ✻ glyph appears in
|
||||
// past-tense turn summaries too — without the trailing ellipsis,
|
||||
// Claude is done, not active.
|
||||
const dormant = [
|
||||
"⏺ Posted: https://github.com/owner/repo/pull/1#comment-1",
|
||||
"",
|
||||
"✻ Worked for 11s",
|
||||
"",
|
||||
"※ recap: working on issue #143; next: wait for review",
|
||||
"──────",
|
||||
"❯ ",
|
||||
"──────",
|
||||
" ⏵⏵ bypass permissions on (shift+tab to cycle)",
|
||||
].join("\n");
|
||||
expect(agent.detectActivity(dormant)).toBe("idle");
|
||||
});
|
||||
|
||||
// Blocked detection from terminal regex — empirically captured from real
|
||||
// Claude output during api.anthropic.com block (see PR #1932).
|
||||
it("returns blocked for 'Unable to connect to API' error line", () => {
|
||||
const real = [
|
||||
"❯ what is 2+2? answer in one word.",
|
||||
" ⎿ Unable to connect to API (ConnectionRefused)",
|
||||
" Retrying in 19s · attempt 7/10",
|
||||
"",
|
||||
"✽ Germinating… (56s)",
|
||||
"",
|
||||
].join("\n");
|
||||
expect(agent.detectActivity(real)).toBe("blocked");
|
||||
});
|
||||
|
||||
it("returns blocked for FailedToOpenSocket error variant", () => {
|
||||
expect(
|
||||
agent.detectActivity(" ⎿ Unable to connect to API (FailedToOpenSocket)\n"),
|
||||
).toBe("blocked");
|
||||
});
|
||||
|
||||
it("returns blocked for retry counter alone (Retrying in Ns · attempt N/M)", () => {
|
||||
// If only the retry line is in the visible window (error scrolled off),
|
||||
// the retry counter is still a sufficient signal.
|
||||
expect(agent.detectActivity(" Retrying in 30s · attempt 9/10\n")).toBe("blocked");
|
||||
});
|
||||
|
||||
it("does NOT return blocked when API error has scrolled out of the visible window after a successful retry", () => {
|
||||
// Regression test: blocked detection must be bounded to the last 12
|
||||
// lines (wideTail), NOT the full terminalOutput buffer. Otherwise an
|
||||
// api_error that scrolled off the visible area after a successful
|
||||
// retry but stayed in scrollback would falsely return "blocked"
|
||||
// forever (Greptile review on PR #1932).
|
||||
const recoveredAndContinued = [
|
||||
" ⎿ Unable to connect to API (ConnectionRefused)",
|
||||
" Retrying in 1s · attempt 1/10",
|
||||
" ⎿ ✓ Connected, retry succeeded",
|
||||
"",
|
||||
"(many lines of work output below pushing the error off the visible area)",
|
||||
...Array.from({ length: 15 }, (_, i) => ` line ${i + 1} of subsequent work`),
|
||||
"",
|
||||
"✻ Fluttering… (2m 14s)",
|
||||
" ⎿ Tip: Use /feedback to help us improve!",
|
||||
"",
|
||||
"──────",
|
||||
"❯ ",
|
||||
"──────",
|
||||
" ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
|
||||
].join("\n");
|
||||
expect(agent.detectActivity(recoveredAndContinued)).toBe("active");
|
||||
});
|
||||
|
||||
it("blocked takes precedence over waiting_input when both 'bypass permissions' footer and api-error are present", () => {
|
||||
// Claude's static UI footer always contains "bypass permissions on …",
|
||||
// which the existing waiting_input regex matches. A real blocked state
|
||||
// must win over that incidental match.
|
||||
const real = [
|
||||
" ⎿ Unable to connect to API (ConnectionRefused)",
|
||||
" Retrying in 1s · attempt 5/10",
|
||||
"",
|
||||
"────────────────────────────────────────",
|
||||
" ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
|
||||
].join("\n");
|
||||
expect(agent.detectActivity(real)).toBe("blocked");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -26,10 +26,11 @@ import {
|
|||
findLatestSessionFile,
|
||||
getClaudeActivityState,
|
||||
isClaudeProcessAlive,
|
||||
resolveWorkspaceForClaude,
|
||||
toClaudeProjectPath,
|
||||
} from "./activity-detection.js";
|
||||
|
||||
export { resetPsCache, toClaudeProjectPath } from "./activity-detection.js";
|
||||
export { resetPsCache, resolveWorkspaceForClaude, toClaudeProjectPath } from "./activity-detection.js";
|
||||
|
||||
// =============================================================================
|
||||
// Metadata Updater Hook Script
|
||||
|
|
@ -767,7 +768,7 @@ function createClaudeCodeAgent(): Agent {
|
|||
if (!session.workspacePath) return null;
|
||||
|
||||
// Build the Claude project directory path
|
||||
const projectPath = toClaudeProjectPath(session.workspacePath);
|
||||
const projectPath = toClaudeProjectPath(await resolveWorkspaceForClaude(session.workspacePath));
|
||||
const projectDir = join(homedir(), ".claude", "projects", projectPath);
|
||||
|
||||
// Find the latest session JSONL file
|
||||
|
|
@ -797,7 +798,7 @@ function createClaudeCodeAgent(): Agent {
|
|||
if (!session.workspacePath) return null;
|
||||
|
||||
// Find Claude's project directory for this workspace
|
||||
const projectPath = toClaudeProjectPath(session.workspacePath);
|
||||
const projectPath = toClaudeProjectPath(await resolveWorkspaceForClaude(session.workspacePath));
|
||||
const projectDir = join(homedir(), ".claude", "projects", projectPath);
|
||||
|
||||
// Find the latest session JSONL file
|
||||
|
|
|
|||
Loading…
Reference in New Issue