diff --git a/.changeset/claude-activity-detection-split.md b/.changeset/claude-activity-detection-split.md new file mode 100644 index 000000000..043b65f52 --- /dev/null +++ b/.changeset/claude-activity-detection-split.md @@ -0,0 +1,8 @@ +--- +"@aoagents/ao-core": patch +"@aoagents/ao-plugin-agent-claude-code": patch +--- + +Split Claude Code activity-detection logic out of `index.ts` into a dedicated `activity-detection.ts` module. Removes two unreachable switch branches (`case "permission_request"` → `waiting_input` and `case "error"` → `blocked`) that targeted JSONL types Claude never actually emits. `waiting_input` continues to flow through the AO activity-JSONL safety net added in #1903. + +Closes the `blocked` gap for Claude Code: extend `readLastJsonlEntry` in core to also surface top-level `subtype` and `level` fields, and map `{type:"system", level:"error"}` → `blocked` in the cascade. This catches Claude's real api_error shape (`{type:"system", subtype:"api_error", level:"error", cause:{code:"ConnectionRefused"|"FailedToOpenSocket"|...}}`) so a session stuck in the API retry loop now reports `blocked` instead of `ready`. New fields on `readLastJsonlEntry` are additive and don't break existing callers (Codex, OpenCode, Aider). diff --git a/packages/core/src/__tests__/utils.test.ts b/packages/core/src/__tests__/utils.test.ts index b370ba619..e925ba31f 100644 --- a/packages/core/src/__tests__/utils.test.ts +++ b/packages/core/src/__tests__/utils.test.ts @@ -116,6 +116,33 @@ describe("readLastJsonlEntry", () => { expect(result!.lastType).toBe("x"); expect(result!.payloadType).toBeNull(); }); + + it("extracts top-level subtype and level (Claude system-entry shape)", async () => { + // Real Claude writes API errors as {"type":"system","subtype":"api_error", + // "level":"error","cause":{...}}. Consumers (e.g. claude-code plugin's + // getClaudeActivityState) need both fields to classify activity correctly. + const path = setup( + '{"type":"system","subtype":"api_error","level":"error","cause":{"code":"ConnectionRefused"}}\n', + ); + const result = await readLastJsonlEntry(path); + expect(result!.lastType).toBe("system"); + expect(result!.lastSubtype).toBe("api_error"); + expect(result!.lastLevel).toBe("error"); + }); + + it("returns lastSubtype/lastLevel null when fields are absent", async () => { + const path = setup('{"type":"assistant","message":"hello"}\n'); + const result = await readLastJsonlEntry(path); + expect(result!.lastSubtype).toBeNull(); + expect(result!.lastLevel).toBeNull(); + }); + + it("returns lastSubtype/lastLevel null when fields are non-string", async () => { + const path = setup('{"type":"x","subtype":42,"level":{"nested":true}}\n'); + const result = await readLastJsonlEntry(path); + expect(result!.lastSubtype).toBeNull(); + expect(result!.lastLevel).toBeNull(); + }); }); describe("isGitBranchNameSafe", () => { diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 958678719..8c1a48524 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -140,11 +140,17 @@ async function readLastLine(filePath: string): Promise { * Reads backwards from end of file — pure Node.js, no external binaries. * * @param filePath - Path to the JSONL file - * @returns Object containing the last entry's type and file mtime, or null if empty/invalid + * @returns Object containing the last entry's `type`, nested `payload.type` (Codex shape), + * top-level `subtype` and `level` (Claude `system`-entry shape), and the file mtime. + * Returns null if the file is empty or unreadable. */ -export async function readLastJsonlEntry( - filePath: string, -): Promise<{ lastType: string | null; payloadType: string | null; modifiedAt: Date } | null> { +export async function readLastJsonlEntry(filePath: string): Promise<{ + lastType: string | null; + payloadType: string | null; + lastSubtype: string | null; + lastLevel: string | null; + modifiedAt: Date; +} | null> { try { const [line, fileStat] = await Promise.all([readLastLine(filePath), stat(filePath)]); @@ -154,15 +160,23 @@ export async function readLastJsonlEntry( if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { const obj = parsed as Record; const lastType = typeof obj.type === "string" ? obj.type : null; + const lastSubtype = typeof obj.subtype === "string" ? obj.subtype : null; + const lastLevel = typeof obj.level === "string" ? obj.level : null; let payloadType: string | null = null; if (typeof obj.payload === "object" && obj.payload !== null && !Array.isArray(obj.payload)) { const payload = obj.payload as Record; if (typeof payload.type === "string") payloadType = payload.type; } - return { lastType, payloadType, modifiedAt: fileStat.mtime }; + return { lastType, payloadType, lastSubtype, lastLevel, modifiedAt: fileStat.mtime }; } - return { lastType: null, payloadType: null, modifiedAt: fileStat.mtime }; + return { + lastType: null, + payloadType: null, + lastSubtype: null, + lastLevel: null, + modifiedAt: fileStat.mtime, + }; } catch { return null; } diff --git a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts index 6199b3b19..bc2735913 100644 --- a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts +++ b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts @@ -252,6 +252,30 @@ describe("Claude Code Activity Detection", () => { expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); }); + it("returns 'blocked' for 'system' api_error (level: error)", async () => { + writeJsonl([ + { + type: "system", + subtype: "api_error", + level: "error", + cause: { code: "ConnectionRefused" }, + }, + ]); + expect((await agent.getActivityState(makeSession()))?.state).toBe("blocked"); + }); + + it("returns 'ready' for non-error 'system' subtypes (compact_boundary)", async () => { + writeJsonl([{ type: "system", subtype: "compact_boundary", level: "info" }]); + expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); + }); + + it("requires BOTH api_error subtype AND error level for 'blocked'", async () => { + // A future error-level diagnostic that isn't api_error must NOT be + // silently classified as blocked. + writeJsonl([{ type: "system", subtype: "future_diagnostic", level: "error" }]); + expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); + }); + it("returns 'active' for recent 'file-history-snapshot' (bookkeeping)", async () => { writeJsonl([{ type: "file-history-snapshot" }]); expect((await agent.getActivityState(makeSession()))?.state).toBe("active"); @@ -278,16 +302,6 @@ describe("Claude Code Activity Detection", () => { expect((await agent.getActivityState(makeSession()))?.state).toBe("active"); }); - it("returns 'waiting_input' for 'permission_request'", async () => { - writeJsonl([{ type: "permission_request" }]); - expect((await agent.getActivityState(makeSession()))?.state).toBe("waiting_input"); - }); - - it("returns 'blocked' for 'error'", async () => { - writeJsonl([{ type: "error" }]); - expect((await agent.getActivityState(makeSession()))?.state).toBe("blocked"); - }); - it("returns 'ready' for recent 'summary' entry", async () => { writeJsonl([{ type: "summary", summary: "Implemented login feature" }]); expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); @@ -324,13 +338,11 @@ describe("Claude Code Activity Detection", () => { expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); }); - it("'permission_request' ignores staleness (always waiting_input)", async () => { - writeJsonl([{ type: "permission_request" }], 400_000); - expect((await agent.getActivityState(makeSession()))?.state).toBe("waiting_input"); - }); - - it("'error' ignores staleness (always blocked)", async () => { - writeJsonl([{ type: "error" }], 400_000); + it("'system' api_error ignores staleness (always blocked)", async () => { + writeJsonl( + [{ type: "system", subtype: "api_error", level: "error" }], + 400_000, + ); expect((await agent.getActivityState(makeSession()))?.state).toBe("blocked"); }); diff --git a/packages/plugins/agent-claude-code/src/activity-detection.ts b/packages/plugins/agent-claude-code/src/activity-detection.ts new file mode 100644 index 000000000..3022c4011 --- /dev/null +++ b/packages/plugins/agent-claude-code/src/activity-detection.ts @@ -0,0 +1,333 @@ +import { + readLastJsonlEntry, + readLastActivityEntry, + checkActivityLogState, + getActivityFallbackState, + isWindows, + PROCESS_PROBE_INDETERMINATE, + DEFAULT_READY_THRESHOLD_MS, + DEFAULT_ACTIVE_WINDOW_MS, + type ActivityDetection, + type ActivityState, + type ProcessProbeResult, + type RuntimeHandle, + type Session, +} from "@aoagents/ao-core"; +import { execFile } from "node:child_process"; +import { readdir, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +// ============================================================================= +// Project-path slug +// ============================================================================= + +/** + * Convert a workspace path to Claude's project directory path. + * Claude stores sessions at ~/.claude/projects/{encoded-path}/ + * + * Verified against Claude Code's actual on-disk slugs: every non-alphanumeric + * character (other than `-`) is replaced with `-`. That includes `/`, `.`, + * `:`, and crucially `_` — AO's per-project data dirs are named like + * `_`, and without underscore folding the slug AO computes + * misses the directory Claude actually wrote (issue #1611). + * + * Windows: `C:\Users\dev\project` → `C--Users-dev-project` — Claude leaves the + * colon-position as a dash rather than stripping it. Verified via on-disk QA + * during the Windows port (commit 582c5373). Stripping the colon (as #1611 + * inadvertently did) breaks JSONL lookup on Windows. + */ +export function toClaudeProjectPath(workspacePath: string): string { + const normalized = workspacePath.replace(/\\/g, "/"); + return normalized.replace(/[^a-zA-Z0-9-]/g, "-"); +} + +// ============================================================================= +// Session file discovery +// ============================================================================= + +/** Find the most recently modified .jsonl session file in a directory */ +export async function findLatestSessionFile(projectDir: string): Promise { + let entries: string[]; + try { + entries = await readdir(projectDir); + } catch { + return null; + } + + const jsonlFiles = entries.filter((f) => f.endsWith(".jsonl") && !f.startsWith("agent-")); + if (jsonlFiles.length === 0) return null; + + const withStats = await Promise.all( + jsonlFiles.map(async (f) => { + const fullPath = join(projectDir, f); + try { + const s = await stat(fullPath); + return { path: fullPath, mtime: s.mtimeMs }; + } catch { + return { path: fullPath, mtime: 0 }; + } + }), + ); + withStats.sort((a, b) => b.mtime - a.mtime); + return withStats[0]?.path ?? null; +} + +// ============================================================================= +// Process detection +// ============================================================================= + +/** + * TTL cache for `ps -eo pid,tty,args` output. Without this, listing N sessions + * would spawn N concurrent `ps` processes, each taking 30+ seconds on machines + * with many processes. The cache ensures `ps` is called at most once per TTL + * window regardless of how many sessions are being enriched. + */ +type ProcessListResult = string | typeof PROCESS_PROBE_INDETERMINATE; +let psCache: { + output: ProcessListResult; + timestamp: number; + promise?: Promise; +} | null = null; +const PS_CACHE_TTL_MS = 5_000; + +/** Reset the ps cache. Exported for testing only. */ +export function resetPsCache(): void { + psCache = null; +} + +async function getCachedProcessList(): Promise { + // ps -eo is a Unix-only command; on Windows the tmux branch is never taken + // in normal operation, but guard here to avoid a spurious spawn error if + // a stale tmux handle is encountered. + if (isWindows()) return ""; + const now = Date.now(); + if (psCache && now - psCache.timestamp < PS_CACHE_TTL_MS) { + if (psCache.promise) return psCache.promise; + return psCache.output; + } + + const promise = execFileAsync("ps", ["-eo", "pid,tty,args"], { + timeout: 30_000, + }) + .then(({ stdout }) => { + if (psCache?.promise === promise) { + psCache = { output: stdout || PROCESS_PROBE_INDETERMINATE, timestamp: Date.now() }; + } + return stdout || PROCESS_PROBE_INDETERMINATE; + }) + .catch(() => { + if (psCache?.promise === promise) { + psCache = { output: PROCESS_PROBE_INDETERMINATE, timestamp: Date.now() }; + } + return PROCESS_PROBE_INDETERMINATE; + }); + + psCache = { output: "", timestamp: now, promise }; + + return promise; +} + +/** + * Check if a process named "claude" is running in the given runtime handle's context. + * Uses ps to find processes by TTY (for tmux) or by PID. + */ +export async function findClaudeProcess( + handle: RuntimeHandle, +): Promise { + try { + if (handle.runtimeName === "tmux" && handle.id) { + if (isWindows()) return null; + const { stdout: ttyOut } = await execFileAsync( + "tmux", + ["list-panes", "-t", handle.id, "-F", "#{pane_tty}"], + { timeout: 30_000 }, + ); + const ttys = ttyOut + .trim() + .split("\n") + .map((t) => t.trim()) + .filter(Boolean); + if (ttys.length === 0) return null; + + const psOut = await getCachedProcessList(); + 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|$)/; + for (const line of psOut.split("\n")) { + const cols = line.trimStart().split(/\s+/); + if (cols.length < 3 || !ttySet.has(cols[1] ?? "")) continue; + const args = cols.slice(2).join(" "); + if (processRe.test(args)) { + return parseInt(cols[0] ?? "0", 10); + } + } + return null; + } + + // For process runtime, check if the PID stored in handle data is alive + const rawPid = handle.data["pid"]; + const pid = typeof rawPid === "number" ? rawPid : Number(rawPid); + if (Number.isFinite(pid) && pid > 0) { + try { + process.kill(pid, 0); + return pid; + } catch (err: unknown) { + // EPERM means the process exists but we lack permission to signal it + if (err instanceof Error && "code" in err && err.code === "EPERM") { + return pid; + } + return null; + } + } + + return null; + } catch { + return PROCESS_PROBE_INDETERMINATE; + } +} + +export async function isClaudeProcessAlive(handle: RuntimeHandle): Promise { + const pid = await findClaudeProcess(handle); + if (pid === PROCESS_PROBE_INDETERMINATE) return PROCESS_PROBE_INDETERMINATE; + return pid !== null; +} + +// ============================================================================= +// Terminal output classification +// ============================================================================= + +/** Classify Claude Code's activity state from terminal output (pure, sync). */ +export function classifyTerminalOutput(terminalOutput: string): ActivityState { + if (!terminalOutput.trim()) return "idle"; + + 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. + 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. + 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"; + + return "active"; +} + +// ============================================================================= +// Activity-state cascade +// ============================================================================= + +/** + * Determine current activity state for a Claude Code session. + * + * Cascade: + * 1. Process check (returns null on INDETERMINATE, exited on dead) + * 2. Native JSONL: read last entry, map type+mtime → state + * 3. AO activity JSONL: `checkActivityLogState` for actionable states + * (waiting_input/blocked) terminal regex picked up + * 4. AO activity JSONL: `getActivityFallbackState` for age-decayed fallback + * 5. Stale native (entry predates session) returned only if nothing else + * + * Note: Claude does NOT emit `permission_request` or top-level `error` + * as JSONL types. `waiting_input` flows through the terminal regex → + * AO activity JSONL path. `blocked` is detected from native JSONL via + * `{type:"system", level:"error"}` (Claude's api_error shape). + */ +export async function getClaudeActivityState( + session: Session, + readyThresholdMs: number | undefined, + isProcessAlive: (handle: RuntimeHandle) => Promise = isClaudeProcessAlive, +): Promise { + const threshold = readyThresholdMs ?? DEFAULT_READY_THRESHOLD_MS; + + const exitedAt = new Date(); + if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt }; + const running = await isProcessAlive(session.runtimeHandle); + if (running === PROCESS_PROBE_INDETERMINATE) return null; + if (!running) return { state: "exited", timestamp: exitedAt }; + + if (!session.workspacePath) return null; + + const projectPath = toClaudeProjectPath(session.workspacePath); + const projectDir = join(homedir(), ".claude", "projects", projectPath); + + const sessionFile = await findLatestSessionFile(projectDir); + let staleNativeState: ActivityDetection | null = null; + if (sessionFile) { + const entry = await readLastJsonlEntry(sessionFile); + if (entry) { + // If the JSONL entry predates this session, it's from a previous session + // in the same worktree. Fall through to the AO safety net first: the + // terminal may have already surfaced waiting_input/blocked before + // Claude writes this session's first native JSONL entry. + if (session.createdAt && entry.modifiedAt < session.createdAt) { + 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) { + case "user": + case "tool_use": + case "progress": + if (ageMs <= activeWindowMs) return { state: "active", timestamp }; + return { state: ageMs > threshold ? "idle" : "ready", timestamp }; + + case "system": + // Claude writes API errors as `{type:"system", subtype:"api_error", + // level:"error", cause:{...}}`. Require BOTH the subtype AND the + // level so a future error-level diagnostic that isn't actually + // fatal doesn't get silently classified as blocked. Other system + // subtypes (compact_boundary, local_command, turn_duration, etc.) + // are normal turn-end markers. + if (entry.lastSubtype === "api_error" && entry.lastLevel === "error") { + return { state: "blocked", timestamp }; + } + return { state: ageMs > threshold ? "idle" : "ready", timestamp }; + + case "assistant": + case "summary": + case "result": + return { state: ageMs > threshold ? "idle" : "ready", timestamp }; + + default: + if (ageMs <= activeWindowMs) return { state: "active", timestamp }; + return { state: ageMs > threshold ? "idle" : "ready", timestamp }; + } + } + } + // Session file exists but no parseable entry — fall through to AO JSONL + // checks below instead of returning early, so terminal-derived + // waiting_input/blocked can still be detected. + } + + // Fallback: check AO activity JSONL (terminal-derived) for + // waiting_input/blocked when Claude's native JSONL is unavailable. + const activityResult = await readLastActivityEntry(session.workspacePath); + const activityState = checkActivityLogState(activityResult); + if (activityState) return activityState; + + // Last fallback: use the AO entry with age-based decay when native + // session lookup is missing or unparseable (e.g. Claude project slug drift). + const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold); + const fallback = getActivityFallbackState(activityResult, activeWindowMs, threshold); + if (fallback) return fallback; + + if (staleNativeState) return staleNativeState; + + return null; +} diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 7235b05b4..310ca3add 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -1,14 +1,7 @@ import { shellEscape, - readLastJsonlEntry, normalizeAgentPermissionMode, isWindows, - PROCESS_PROBE_INDETERMINATE, - DEFAULT_READY_THRESHOLD_MS, - DEFAULT_ACTIVE_WINDOW_MS, - readLastActivityEntry, - checkActivityLogState, - getActivityFallbackState, recordTerminalActivity, type Agent, type AgentSessionInfo, @@ -23,14 +16,20 @@ import { type Session, type WorkspaceHooksConfig, } from "@aoagents/ao-core"; -import { execFile, execFileSync } from "node:child_process"; -import { readdir, readFile, stat, open, writeFile, mkdir, chmod } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { readFile, stat, open, writeFile, mkdir, chmod } from "node:fs/promises"; import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { basename, join } from "node:path"; -import { promisify } from "node:util"; +import { + classifyTerminalOutput, + findLatestSessionFile, + getClaudeActivityState, + isClaudeProcessAlive, + toClaudeProjectPath, +} from "./activity-detection.js"; -const execFileAsync = promisify(execFile); +export { resetPsCache, toClaudeProjectPath } from "./activity-detection.js"; // ============================================================================= // Metadata Updater Hook Script @@ -401,56 +400,6 @@ export const manifest = { // JSONL Helpers // ============================================================================= -/** - * Convert a workspace path to Claude's project directory path. - * Claude stores sessions at ~/.claude/projects/{encoded-path}/ - * - * Verified against Claude Code's actual on-disk slugs: every non-alphanumeric - * character (other than `-`) is replaced with `-`. That includes `/`, `.`, - * `:`, and crucially `_` — AO's per-project data dirs are named like - * `_`, and without underscore folding the slug AO computes - * misses the directory Claude actually wrote (issue #1611). - * - * Windows: `C:\Users\dev\project` → `C--Users-dev-project` — Claude leaves the - * colon-position as a dash rather than stripping it. Verified via on-disk QA - * during the Windows port (commit 582c5373). Stripping the colon (as #1611 - * inadvertently did) breaks JSONL lookup on Windows. - * - * Exported for testing purposes. - */ -export function toClaudeProjectPath(workspacePath: string): string { - const normalized = workspacePath.replace(/\\/g, "/"); - return normalized.replace(/[^a-zA-Z0-9-]/g, "-"); -} - -/** Find the most recently modified .jsonl session file in a directory */ -async function findLatestSessionFile(projectDir: string): Promise { - let entries: string[]; - try { - entries = await readdir(projectDir); - } catch { - return null; - } - - const jsonlFiles = entries.filter((f) => f.endsWith(".jsonl") && !f.startsWith("agent-")); - if (jsonlFiles.length === 0) return null; - - // Sort by mtime descending - const withStats = await Promise.all( - jsonlFiles.map(async (f) => { - const fullPath = join(projectDir, f); - try { - const s = await stat(fullPath); - return { path: fullPath, mtime: s.mtimeMs }; - } catch { - return { path: fullPath, mtime: 0 }; - } - }), - ); - withStats.sort((a, b) => b.mtime - a.mtime); - return withStats[0]?.path ?? null; -} - interface JsonlLine { type?: string; summary?: string; @@ -612,163 +561,6 @@ function extractCost(lines: JsonlLine[]): CostEstimate | undefined { }; } -// ============================================================================= -// Process Detection -// ============================================================================= - -/** - * TTL cache for `ps -eo pid,tty,args` output. Without this, listing N sessions - * would spawn N concurrent `ps` processes, each taking 30+ seconds on machines - * with many processes. The cache ensures `ps` is called at most once per TTL - * window regardless of how many sessions are being enriched. - */ -type ProcessListResult = string | typeof PROCESS_PROBE_INDETERMINATE; -let psCache: { - output: ProcessListResult; - timestamp: number; - promise?: Promise; -} | null = null; -const PS_CACHE_TTL_MS = 5_000; - -/** Reset the ps cache. Exported for testing only. */ -export function resetPsCache(): void { - psCache = null; -} - -async function getCachedProcessList(): Promise { - // ps -eo is a Unix-only command; on Windows the tmux branch is never taken - // in normal operation, but guard here to avoid a spurious spawn error if - // a stale tmux handle is encountered. - if (isWindows()) return ""; - const now = Date.now(); - if (psCache && now - psCache.timestamp < PS_CACHE_TTL_MS) { - // Cache hit — return resolved output or wait for in-flight request - if (psCache.promise) return psCache.promise; - return psCache.output; - } - - // Cache miss or expired — start a single `ps` call and share the promise. - // Guard both callbacks so they only update psCache if it still belongs to - // this request — a newer request may have replaced it while we were waiting. - const promise = execFileAsync("ps", ["-eo", "pid,tty,args"], { - timeout: 30_000, - }) - .then(({ stdout }) => { - if (psCache?.promise === promise) { - psCache = { output: stdout || PROCESS_PROBE_INDETERMINATE, timestamp: Date.now() }; - } - return stdout || PROCESS_PROBE_INDETERMINATE; - }) - .catch(() => { - if (psCache?.promise === promise) { - psCache = { output: PROCESS_PROBE_INDETERMINATE, timestamp: Date.now() }; - } - return PROCESS_PROBE_INDETERMINATE; - }); - - // Store the in-flight promise so concurrent callers share it - psCache = { output: "", timestamp: now, promise }; - - return promise; -} - -/** - * Check if a process named "claude" is running in the given runtime handle's context. - * Uses ps to find processes by TTY (for tmux) or by PID. - */ -async function findClaudeProcess( - handle: RuntimeHandle, -): Promise { - try { - // For tmux runtime, get the pane TTY and find claude on it - if (handle.runtimeName === "tmux" && handle.id) { - if (isWindows()) return null; - const { stdout: ttyOut } = await execFileAsync( - "tmux", - ["list-panes", "-t", handle.id, "-F", "#{pane_tty}"], - { timeout: 30_000 }, - ); - // Iterate all pane TTYs (multi-pane sessions) — succeed on any match - const ttys = ttyOut - .trim() - .split("\n") - .map((t) => t.trim()) - .filter(Boolean); - if (ttys.length === 0) return null; - - const psOut = await getCachedProcessList(); - 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|$)/; - for (const line of psOut.split("\n")) { - const cols = line.trimStart().split(/\s+/); - if (cols.length < 3 || !ttySet.has(cols[1] ?? "")) continue; - const args = cols.slice(2).join(" "); - if (processRe.test(args)) { - return parseInt(cols[0] ?? "0", 10); - } - } - return null; - } - - // For process runtime, check if the PID stored in handle data is alive - const rawPid = handle.data["pid"]; - const pid = typeof rawPid === "number" ? rawPid : Number(rawPid); - if (Number.isFinite(pid) && pid > 0) { - try { - process.kill(pid, 0); // Signal 0 = check existence - return pid; - } catch (err: unknown) { - // EPERM means the process exists but we lack permission to signal it - if (err instanceof Error && "code" in err && err.code === "EPERM") { - return pid; - } - return null; - } - } - - // No reliable way to identify the correct process for this session - return null; - } catch { - return PROCESS_PROBE_INDETERMINATE; - } -} - -// ============================================================================= -// Terminal Output Patterns for detectActivity -// ============================================================================= - -/** Classify Claude Code's activity state from terminal output (pure, sync). */ -function classifyTerminalOutput(terminalOutput: string): ActivityState { - // Empty output — can't determine state - if (!terminalOutput.trim()) return "idle"; - - 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. - 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. - 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"; - - // Everything else is "active" — the agent is processing, waiting for - // output, or showing content. Specific patterns (e.g. "esc to interrupt", - // "Thinking", "Reading") all map to "active" so no need to check them - // individually. - return "active"; -} - // ============================================================================= // Hook Setup Helper // ============================================================================= @@ -959,95 +751,16 @@ function createClaudeCodeAgent(): Agent { }, async isProcessRunning(handle: RuntimeHandle): Promise { - const pid = await findClaudeProcess(handle); - if (pid === PROCESS_PROBE_INDETERMINATE) return PROCESS_PROBE_INDETERMINATE; - return pid !== null; + return isClaudeProcessAlive(handle); }, async getActivityState( session: Session, readyThresholdMs?: number, ): Promise { - const threshold = readyThresholdMs ?? DEFAULT_READY_THRESHOLD_MS; - - // Check if process is running first - const exitedAt = new Date(); - if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt }; - const running = await this.isProcessRunning(session.runtimeHandle); - if (running === PROCESS_PROBE_INDETERMINATE) return null; - if (!running) return { state: "exited", timestamp: exitedAt }; - - // Process is running - check JSONL session file for activity - if (!session.workspacePath) { - // No workspace path — cannot determine activity without it - return null; - } - - const projectPath = toClaudeProjectPath(session.workspacePath); - const projectDir = join(homedir(), ".claude", "projects", projectPath); - - const sessionFile = await findLatestSessionFile(projectDir); - let staleNativeState: ActivityDetection | null = null; - if (sessionFile) { - const entry = await readLastJsonlEntry(sessionFile); - if (entry) { - // If the JSONL entry predates this session, it's from a previous session - // in the same worktree. Fall through to the AO safety net first: the - // terminal may have already surfaced waiting_input/blocked before - // Claude writes this session's first native JSONL entry. - if (session.createdAt && entry.modifiedAt < session.createdAt) { - 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) { - case "user": - case "tool_use": - case "progress": - if (ageMs <= activeWindowMs) return { state: "active", timestamp }; - return { state: ageMs > threshold ? "idle" : "ready", timestamp }; - - case "assistant": - case "system": - case "summary": - case "result": - return { state: ageMs > threshold ? "idle" : "ready", timestamp }; - - case "permission_request": - return { state: "waiting_input", timestamp }; - - case "error": - return { state: "blocked", timestamp }; - - default: - if (ageMs <= activeWindowMs) return { state: "active", timestamp }; - return { state: ageMs > threshold ? "idle" : "ready", timestamp }; - } - } - } - - // Session file exists but no parseable entry — fall through to AO JSONL - // checks below instead of returning early, so terminal-derived - // waiting_input/blocked can still be detected. - } - - // Fallback: check AO activity JSONL (terminal-derived) for - // waiting_input/blocked when Claude's native JSONL is unavailable. - const activityResult = await readLastActivityEntry(session.workspacePath); - const activityState = checkActivityLogState(activityResult); - if (activityState) return activityState; - - // Last fallback: use the AO entry with age-based decay when native - // session lookup is missing or unparseable (e.g. Claude project slug drift). - const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold); - const fallback = getActivityFallbackState(activityResult, activeWindowMs, threshold); - if (fallback) return fallback; - - if (staleNativeState) return staleNativeState; - - return null; + return getClaudeActivityState(session, readyThresholdMs, (handle) => + this.isProcessRunning(handle), + ); }, async getSessionInfo(session: Session): Promise {