From aeaa41bea76672bc830676d46e2fccb72f363a17 Mon Sep 17 00:00:00 2001 From: aMgLn Date: Wed, 2 Sep 2026 15:50:50 +0900 Subject: [PATCH] =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=84=B8=EC=8A=A4=20=EA=B0=90=EC=A7=80=EB=A5=BC=20cla?= =?UTF-8?q?ude=20=EC=A0=84=EC=9A=A9=EC=97=90=EC=84=9C=20=EC=97=90=EC=9D=B4?= =?UTF-8?q?=EC=A0=84=ED=8A=B8=EB=B3=84=EB=A1=9C=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 문제 재현: 이 대화 자체의 실시간 Claude Code 세션은 정확히 ACTIVE/HIGH로 잡히는 것을 실제로 확인함 — 그런데 activity.server.ts의 라이브 프로세스 스캔이 "claude" 바이너리만 찾도록 하드코딩되어 있어서, Codex/Gemini CLI/OpenCode/Aider 세션은 실제로 실행 중이어도 타임스탬프 휴리스틱 (RECENT/IDLE/STALE)으로만 판정되고 ACTIVE+HIGH에는 절대 도달하지 못함 - 이 머신에 실제 설치된 바이너리로 codex/gemini/opencode 프로세스명을 확인함 (aider는 설치되어 있지 않아 실제 검증은 못 했고, pip 패키지의 잘 알려진 콘솔 스크립트 이름을 그대로 사용) - detectActivity에 agentId를 추가해 세션 자신의 에이전트에 해당하는 프로세스만 매칭하도록 함 — 같은 워크스페이스에 다른 에이전트의 살아있는 프로세스가 있어도 엉뚱하게 ACTIVE로 잘못 표시되지 않도록 실제 테스트로 검증(같은 워크스페이스, codex 프로세스 실행 중 → codex 세션은 ACTIVE, gemini-cli 세션은 여전히 RECENT) - macOS 경로는 매칭된 에이전트별로 별도 lsof 호출을 하도록 해서, 기존에 검증된 단일 에이전트 파싱 로직을 그대로 재사용(여러 pid를 한 lsof 호출로 묶어 p 라인으로 구분하는 새로운 파싱 로직을 추가하지 않음) - 실제 /proc 스캔을 쓰는 진짜 프로세스 스폰 테스트 3개 추가(claude-code, codex, custom 폴백), macOS 목 테스트 2개 추가 — 전체 100개 테스트 통과 - 이 세션 자체의 실시간 워크스페이스로 다시 확인: 변경 후에도 여전히 ACTIVE/HIGH로 정확히 잡힘 (회귀 없음) --- docs/REFERENCE.md | 8 +- packages/cli/src/core/activity.server.test.ts | 58 ++++++- packages/cli/src/core/activity.server.ts | 141 ++++++++++++------ packages/cli/src/core/discover.server.ts | 2 +- 4 files changed, 152 insertions(+), 57 deletions(-) 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,