refactor(agent-claude-code): split activity detection; remove dead JSONL cases; detect blocked from api_error (#1927)

* refactor(agent-claude-code): split activity detection into its own module; remove dead JSONL cases

Move `getActivityState`, `findClaudeProcess`, the ps-cache, `classifyTerminalOutput`, `findLatestSessionFile`, and `toClaudeProjectPath` into a new `activity-detection.ts`. `index.ts` shrinks by ~190 lines and now delegates via thin wrappers; `toClaudeProjectPath` and `resetPsCache` are re-exported so existing unit-test and integration-test import sites keep working.

Drop two switch branches that could never fire: `case "permission_request"` → `waiting_input` and `case "error"` → `blocked`. Verified by reading every JSONL under `~/.claude/projects/`: Claude emits `agent-color, agent-name, ai-title, assistant, attachment, custom-title, file-history-snapshot, last-prompt, permission-mode, pr-link, progress, queue-operation, summary, system, user` — but never a top-level `permission_request` or `error`. Permission prompts have no native-JSONL signal at all (they only sit in the terminal until the user answers); API errors arrive under `type:"system"` with `subtype:"api_error"` / `level:"error"`, not as a top-level `error` type. `waiting_input` and `blocked` continue to flow through the terminal-regex → AO activity-JSONL path that was added upstream — that path is now the only source, which matches reality.

Behavior preserved: the cascade (process check → native JSONL → AO actionable → AO age-decay fallback → stale-native) is byte-equivalent for every type Claude actually emits. Four tests that wrote fake `permission_request` / `error` JSONL deleted.

* feat(agent-claude-code,core): detect blocked from Claude api_error JSONL entries

Extend `readLastJsonlEntry` in core to also surface top-level `subtype` and `level` fields (additive — existing return shape unchanged). Map `{type:"system", level:"error"}` to `blocked` in `getClaudeActivityState`.

Claude writes API errors as `{type:"system", subtype:"api_error", level:"error", cause:{code:"ConnectionRefused"|"FailedToOpenSocket"|...}, retryAttempt:N, maxRetries:10}`. When the LAST JSONL entry has that shape, Claude is mid-retry-loop or has exhausted retries — surfacing as `blocked` lets stuck-detection fire. Other `system` subtypes (`compact_boundary`, `local_command`, `turn_duration`, `away_summary`, etc.) continue to map to `ready`/`idle` by age via the `entry.lastLevel !== "error"` branch.

Closes the only remaining "no live producer" gap from the PR description: previously `blocked` had a slot in the cascade but no writer. Now native JSONL fills it directly, no AO activity-log roundtrip needed.

Tests:
- "blocked for 'system' api_error (level: error)"
- "ready for non-error 'system' subtypes (compact_boundary)"
- "'system' api_error ignores staleness (always blocked)"

Existing `system` test (no level field) continues to map to `ready` — verified.

* fix(agent-claude-code): require api_error subtype AND error level for blocked

Tighten the system-entry gate per @greptile-apps review on #1927. Today only `api_error` carries `level:"error"` under `type:"system"`, but pinning both fields makes intent explicit and prevents silent drift if Claude ever adds a non-fatal error-level diagnostic later.

Test locks the tightened gate: `{type:"system", subtype:"future_diagnostic", level:"error"}` → `ready` (not `blocked`).

* test(core): cover lastSubtype/lastLevel extraction in readLastJsonlEntry

Per reviewer note on #1927: the consumer side (claude-code plugin) tested the new fields, but the producer side in core didn't. Lock the extraction behavior so future refactors of `readLastJsonlEntry` can't regress silently.

Covers: real Claude api_error shape, absent fields, non-string field types.
This commit is contained in:
Harshit Singh Bhandari 2026-05-19 18:00:54 +05:30 committed by GitHub
parent 298057044f
commit a610601158
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 431 additions and 324 deletions

View File

@ -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).

View File

@ -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", () => {

View File

@ -140,11 +140,17 @@ async function readLastLine(filePath: string): Promise<string | null> {
* 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<string, unknown>;
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<string, unknown>;
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;
}

View File

@ -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");
});

View File

@ -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
* `<sanitized>_<hash>`, 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<string | null> {
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<ProcessListResult>;
} | 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<ProcessListResult> {
// 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<number | null | typeof PROCESS_PROBE_INDETERMINATE> {
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<ProcessProbeResult> {
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<ProcessProbeResult> = isClaudeProcessAlive,
): Promise<ActivityDetection | null> {
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;
}

View File

@ -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
* `<sanitized>_<hash>`, 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<string | null> {
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<ProcessListResult>;
} | 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<ProcessListResult> {
// 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<number | null | typeof PROCESS_PROBE_INDETERMINATE> {
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<ProcessProbeResult> {
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<ActivityDetection | null> {
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<AgentSessionInfo | null> {