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:
parent
298057044f
commit
a610601158
|
|
@ -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).
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue