diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 74b1ae7..2b2554e 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -91,9 +91,11 @@ change. Implemented: Claude Code / Codex / Gemini CLI / OpenCode / Aider adapters (§3–§4; Aider is opt-in via `AIDER_SEARCH_ROOTS` — it has no central session directory, unlike the other four), ACTIVE/RECENT/IDLE/STALE -activity detection with confidence (§5) — note the live-process signal in `activity.server.ts` currently -only recognizes `claude` processes, so non-Claude-Code sessions never reach `ACTIVE`+`HIGH` confidence that -way, only via timestamp heuristics — local heuristic KEEP/ARCHIVE/JUNK classification with +activity detection with confidence (§5) — the live-process signal in `activity.server.ts` matches each +session against its *own* agent's live process (a live `codex` process only makes a Codex session ACTIVE, +never a Claude Code or Gemini CLI one at the same workspace), for claude-code/codex/gemini-cli/opencode; not +yet empirically verified for aider specifically (its `aider` console-script name is documented, not tested +against a real running instance) — local heuristic KEEP/ARCHIVE/JUNK classification with reason/confidence/evidence (§7), a local heuristic one-line summary per session (§6/§22, `summarize.server.ts`; no LLM — title/first message plus the classifier's own outcome signal), list/show/search/cleanup (dry-run + apply)/archive/restore/delete-to-trash/audit (§9–§12; delete isn't diff --git a/packages/cli/src/core/activity.server.test.ts b/packages/cli/src/core/activity.server.test.ts index f2cc8c3..8aefcae 100644 --- a/packages/cli/src/core/activity.server.test.ts +++ b/packages/cli/src/core/activity.server.test.ts @@ -50,7 +50,7 @@ describe("detectActivity", () => { try { await new Promise((resolve) => setTimeout(resolve, 200)); - const result = await detectActivity({ workspace, lastActivityAt: new Date().toISOString() }); + const result = await detectActivity({ agentId: "claude-code", workspace, lastActivityAt: new Date().toISOString() }); expect(result.status).toBe("ACTIVE"); expect(result.confidence).toBe("HIGH"); @@ -61,16 +61,51 @@ describe("detectActivity", () => { }, ); + // Same real /proc mechanism as the claude-code test above, but for a different agent — confirms live + // detection isn't hardcoded to just "claude" and that a codex process doesn't get picked up under some + // other agent's id. + it.runIf(originalPlatform === "linux")("reports ACTIVE/HIGH on linux for a live codex-flavored process", async () => { + setPlatform("linux"); + const { detectActivity } = await import("./activity.server.js"); + + const workspace = await realpath(await mkdtemp(join(tmpdir(), "sessionforge-activity-"))); + const child = spawn("node", ["-e", "/* codex */ setTimeout(() => {}, 10000)"], { cwd: workspace, stdio: "ignore" }); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + + const result = await detectActivity({ agentId: "codex", workspace, lastActivityAt: new Date().toISOString() }); + expect(result.status).toBe("ACTIVE"); + expect(result.confidence).toBe("HIGH"); + + // The live process is codex-flavored, not gemini-cli — a session that happens to share the same + // workspace but belongs to a different agent must not be reported as live off someone else's process. + const otherAgent = await detectActivity({ agentId: "gemini-cli", workspace, lastActivityAt: new Date().toISOString() }); + expect(otherAgent.status).toBe("RECENT"); + } finally { + child.kill(); + await rm(workspace, { recursive: true, force: true }); + } + }); + it("falls back to timestamp heuristics on linux when no live process matches the workspace", async () => { setPlatform("linux"); const { detectActivity } = await import("./activity.server.js"); - const result = await detectActivity({ workspace: "/no/such/live/workspace", lastActivityAt: new Date().toISOString() }); + const result = await detectActivity({ agentId: "claude-code", workspace: "/no/such/live/workspace", lastActivityAt: new Date().toISOString() }); expect(result.status).toBe("RECENT"); expect(result.confidence).toBe("MEDIUM"); }); + it("falls back to timestamp heuristics for 'custom' agents — no known binary name to match against", async () => { + setPlatform("linux"); + const { detectActivity } = await import("./activity.server.js"); + + const result = await detectActivity({ agentId: "custom", workspace: "/no/such/live/workspace", lastActivityAt: new Date().toISOString() }); + + expect(result.status).toBe("RECENT"); + }); + it("reports ACTIVE on macos by combining mocked ps + lsof output (no /proc there)", async () => { setPlatform("darwin"); execFileMock @@ -78,7 +113,7 @@ describe("detectActivity", () => { .mockImplementationOnce(callbackOk("p4242\nfcwd\nn/Users/dev/project\n")); const { detectActivity } = await import("./activity.server.js"); - const result = await detectActivity({ workspace: "/Users/dev/project", lastActivityAt: new Date().toISOString() }); + const result = await detectActivity({ agentId: "claude-code", workspace: "/Users/dev/project", lastActivityAt: new Date().toISOString() }); expect(result.status).toBe("ACTIVE"); expect(result.confidence).toBe("HIGH"); @@ -87,12 +122,25 @@ describe("detectActivity", () => { expect(execFileMock.mock.calls[1][0]).toBe("lsof"); }); + it("reports ACTIVE on macos for a live opencode process, running its own separate lsof call", async () => { + setPlatform("darwin"); + execFileMock + .mockImplementationOnce(callbackOk("4242 /usr/local/bin/node /usr/local/bin/claude\n5151 /usr/local/bin/opencode\n")) + .mockImplementation(callbackOk("p5151\nfcwd\nn/Users/dev/other-project\n")); + + const { detectActivity } = await import("./activity.server.js"); + const result = await detectActivity({ agentId: "opencode", workspace: "/Users/dev/other-project", lastActivityAt: new Date().toISOString() }); + + expect(result.status).toBe("ACTIVE"); + expect(result.confidence).toBe("HIGH"); + }); + it("falls back to timestamp heuristics on macos when ps finds no matching process", async () => { setPlatform("darwin"); execFileMock.mockImplementationOnce(callbackOk("99 /usr/bin/unrelated-tool\n")); const { detectActivity } = await import("./activity.server.js"); - const result = await detectActivity({ workspace: "/Users/dev/project", lastActivityAt: new Date().toISOString() }); + const result = await detectActivity({ agentId: "claude-code", workspace: "/Users/dev/project", lastActivityAt: new Date().toISOString() }); expect(result.status).toBe("RECENT"); expect(execFileMock).toHaveBeenCalledTimes(1); // no lsof call needed when ps found nothing @@ -102,7 +150,7 @@ describe("detectActivity", () => { setPlatform("win32"); const { detectActivity } = await import("./activity.server.js"); - const result = await detectActivity({ workspace: "C:\\Users\\dev\\project", lastActivityAt: new Date().toISOString() }); + const result = await detectActivity({ agentId: "claude-code", workspace: "C:\\Users\\dev\\project", lastActivityAt: new Date().toISOString() }); expect(result.status).toBe("RECENT"); expect(execFileMock).not.toHaveBeenCalled(); diff --git a/packages/cli/src/core/activity.server.ts b/packages/cli/src/core/activity.server.ts index 8cd5f0b..657b894 100644 --- a/packages/cli/src/core/activity.server.ts +++ b/packages/cli/src/core/activity.server.ts @@ -1,109 +1,154 @@ import { execFile } from "node:child_process"; import { readdir, readFile, readlink } from "node:fs/promises"; import { promisify } from "node:util"; -import type { ActivityConfidence, SessionActivity, SessionStatus } from "./types.server.js"; +import type { ActivityConfidence, AgentId, SessionActivity, SessionStatus } from "./types.server.js"; const execFileAsync = promisify(execFile); const RECENT_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes const IDLE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours -let liveClaudeCwdsCache: { at: number; cwds: Set } | null = null; +/** + * Binary name each agent's own CLI process shows up as in `ps`/`/proc` cmdline output — verified against + * real installs on this machine for claude/codex/gemini-cli/opencode. aider's `aider` console-script name + * is the well-documented pip package entry point but wasn't available to verify the same way (not + * installed here). "custom" has no knowable binary name, so it's intentionally absent — it never gets + * live-process detection, only the timestamp fallback below. + */ +const AGENT_PROCESS_PATTERNS: Partial> = { + "claude-code": /\bclaude\b/, + codex: /\bcodex\b/, + "gemini-cli": /\bgemini\b/, + opencode: /\bopencode\b/, + aider: /\baider\b/, +}; + +function matchAgent(cmdline: string): AgentId | null { + for (const [agent, pattern] of Object.entries(AGENT_PROCESS_PATTERNS) as [AgentId, RegExp][]) { + if (pattern.test(cmdline)) return agent; + } + return null; +} + +function addCwd(map: Map>, agent: AgentId, cwd: string): void { + let set = map.get(agent); + if (!set) { + set = new Set(); + map.set(agent, set); + } + set.add(cwd); +} + +let liveAgentCwdsCache: { at: number; cwds: Map> } | null = null; const PROCESS_SCAN_CACHE_MS = 5000; -/** Linux: /proc gives an exact, instant cmdline + cwd per pid — no subprocess spawn needed. */ -async function liveClaudeCwdsLinux(): Promise> { - const cwds = new Set(); +/** Linux: /proc gives an exact, instant cmdline + cwd per pid — no subprocess spawn needed. One scan + * covers every agent at once, each pid bucketed by whichever pattern matched its cmdline. */ +async function liveAgentCwdsLinux(): Promise>> { + const result = new Map>(); let pids: string[]; try { pids = (await readdir("/proc")).filter((entry) => /^\d+$/.test(entry)); } catch { - return cwds; + return result; } await Promise.all( pids.map(async (pid) => { try { const cmdline = (await readFile(`/proc/${pid}/cmdline`, "utf8")).replace(/\0/g, " ").trim(); - if (!/\bclaude\b/.test(cmdline)) return; + const agent = matchAgent(cmdline); + if (!agent) return; const cwd = await readlink(`/proc/${pid}/cwd`); - cwds.add(cwd); + addCwd(result, agent, cwd); } catch { // process exited mid-scan, or unreadable (permissions) — ignore } }), ); - return cwds; + return result; } /** * macOS has no /proc. `ps -axww -o pid=,command=` (the `ww` disables ps's own line-truncation, which - * would otherwise cut off long command lines) finds candidate pids, then a single `lsof -d cwd` call for - * all of them at once reads each one's working directory — same information /proc gives on Linux, just - * via two subprocess calls instead of a filesystem read. Both `ps` and `lsof` ship with every macOS - * install, on Apple Silicon and Intel alike (this is a CPU-architecture-independent OS convention). + * would otherwise cut off long command lines) finds candidate pids per agent, then one `lsof -d cwd` call + * per matched agent reads that agent's pids' working directories — same information /proc gives on Linux, + * just via subprocess calls instead of a filesystem read. Separate calls per agent (rather than one call + * for every pid, disambiguated by lsof's own per-process "p" lines) keeps the proven single-agent + * parsing logic unchanged instead of adding new, harder-to-verify multi-pid output parsing. Both `ps` and + * `lsof` ship with every macOS install, on Apple Silicon and Intel alike. */ -async function liveClaudeCwdsMac(): Promise> { - const cwds = new Set(); +async function liveAgentCwdsMac(): Promise>> { + const result = new Map>(); let psOutput: string; try { psOutput = (await execFileAsync("ps", ["-axww", "-o", "pid=,command="])).stdout; } catch { - return cwds; + return result; } - const matchingPids: string[] = []; + const pidsByAgent = new Map(); for (const line of psOutput.split("\n")) { const trimmed = line.trim(); const spaceIndex = trimmed.indexOf(" "); if (spaceIndex === -1) continue; const pid = trimmed.slice(0, spaceIndex); const command = trimmed.slice(spaceIndex + 1); - if (/\bclaude\b/.test(command)) matchingPids.push(pid); + const agent = matchAgent(command); + if (!agent) continue; + const pids = pidsByAgent.get(agent); + if (pids) pids.push(pid); + else pidsByAgent.set(agent, [pid]); } - if (matchingPids.length === 0) return cwds; - try { - const lsofOutput = (await execFileAsync("lsof", ["-a", "-d", "cwd", "-p", matchingPids.join(","), "-Fn"])).stdout; - for (const line of lsofOutput.split("\n")) { - if (line.startsWith("n")) cwds.add(line.slice(1)); - } - } catch { - // lsof can partially fail (e.g. permission denied for another user's process) — a partial result - // from the pids it could read is still useful, so this isn't treated as a hard failure. - } + await Promise.all( + [...pidsByAgent.entries()].map(async ([agent, pids]) => { + try { + const lsofOutput = (await execFileAsync("lsof", ["-a", "-d", "cwd", "-p", pids.join(","), "-Fn"])).stdout; + for (const line of lsofOutput.split("\n")) { + if (line.startsWith("n")) addCwd(result, agent, line.slice(1)); + } + } catch { + // lsof can partially fail (e.g. permission denied for another user's process) — a partial result + // from the pids it could read is still useful, so this isn't treated as a hard failure. Failing + // for one agent's pids doesn't affect the others, since each runs its own independent call. + } + }), + ); - return cwds; + return result; } /** - * Scans for running `claude` processes and returns their working directories, so a session can be - * reported ACTIVE with real evidence instead of only a recent-timestamp guess (GOAL.md §5: "Do not claim - * a session is active when only file modification time suggests activity"). + * Scans for running agent CLI processes and returns their working directories per agent, so a session can + * be reported ACTIVE with real evidence instead of only a recent-timestamp guess (GOAL.md §5: "Do not + * claim a session is active when only file modification time suggests activity"). * - * Linux and macOS both get this signal. Windows has no equivalent without a native addon — there's no - * standard API or WMI class exposing a process's current working directory the way /proc or lsof do — so - * it returns an empty set and every session falls through to the timestamp-only heuristic below - * (RECENT/IDLE/STALE at MEDIUM confidence). That's a real fidelity gap on Windows, not silently papered - * over: it's why this function never fabricates ACTIVE/HIGH there. + * Linux and macOS both get this signal, for every agent with a known binary name (see + * AGENT_PROCESS_PATTERNS). Windows has no equivalent without a native addon — there's no standard API or + * WMI class exposing a process's current working directory the way /proc or lsof do — so it returns an + * empty map and every session falls through to the timestamp-only heuristic below (RECENT/IDLE/STALE at + * MEDIUM confidence). That's a real fidelity gap on Windows, not silently papered over: it's why this + * function never fabricates ACTIVE/HIGH there. */ -async function liveClaudeCwds(): Promise> { - if (liveClaudeCwdsCache && Date.now() - liveClaudeCwdsCache.at < PROCESS_SCAN_CACHE_MS) { - return liveClaudeCwdsCache.cwds; +async function liveAgentCwds(): Promise>> { + if (liveAgentCwdsCache && Date.now() - liveAgentCwdsCache.at < PROCESS_SCAN_CACHE_MS) { + return liveAgentCwdsCache.cwds; } - let cwds: Set; - if (process.platform === "linux") cwds = await liveClaudeCwdsLinux(); - else if (process.platform === "darwin") cwds = await liveClaudeCwdsMac(); - else cwds = new Set(); + let cwds: Map>; + if (process.platform === "linux") cwds = await liveAgentCwdsLinux(); + else if (process.platform === "darwin") cwds = await liveAgentCwdsMac(); + else cwds = new Map(); - liveClaudeCwdsCache = { at: Date.now(), cwds }; + liveAgentCwdsCache = { at: Date.now(), cwds }; return cwds; } export interface ActivityInput { + agentId: AgentId; workspace: string; lastActivityAt: string; } @@ -115,15 +160,15 @@ export async function detectActivity(input: ActivityInput): Promise { - const activity = await detectActivity({ workspace: discovered.workspace, lastActivityAt: discovered.lastActivityAt }); + const activity = await detectActivity({ agentId: discovered.agent, workspace: discovered.workspace, lastActivityAt: discovered.lastActivityAt }); const classification = classifySession({ status: activity.status, lastActivityAt: discovered.lastActivityAt,