Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 53 additions & 5 deletions packages/cli/src/core/activity.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -61,24 +61,59 @@ 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
.mockImplementationOnce(callbackOk("4242 /usr/local/bin/node /usr/local/bin/claude\n"))
.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");
Expand All @@ -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
Expand All @@ -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();
Expand Down
141 changes: 93 additions & 48 deletions packages/cli/src/core/activity.server.ts
Original file line number Diff line number Diff line change
@@ -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<string> } | 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<Record<AgentId, RegExp>> = {
"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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match the executable instead of arbitrary command arguments

When a Codex session is started as codex exec "compare Claude...", codex exec --help describes [PROMPT] as the initial instructions, so that text is part of the command line. This loop sees the claude pattern first and buckets the running Codex process as claude-code; likewise, any non-agent process with one of these words in an argument is treated as that agent. Discovery can therefore mark an unrelated stale session ACTIVE/HIGH (and keep it ongoing) while failing to mark the actual Codex session active. Restrict matching to the executable or CLI entrypoint rather than the entire argument string.

Useful? React with 👍 / 👎.

}
return null;
}

function addCwd(map: Map<AgentId, Set<string>>, 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<AgentId, Set<string>> } | 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<Set<string>> {
const cwds = new Set<string>();
/** 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<Map<AgentId, Set<string>>> {
const result = new Map<AgentId, Set<string>>();
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<pid>" 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<Set<string>> {
const cwds = new Set<string>();
async function liveAgentCwdsMac(): Promise<Map<AgentId, Set<string>>> {
const result = new Map<AgentId, Set<string>>();

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<AgentId, string[]>();
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<Set<string>> {
if (liveClaudeCwdsCache && Date.now() - liveClaudeCwdsCache.at < PROCESS_SCAN_CACHE_MS) {
return liveClaudeCwdsCache.cwds;
async function liveAgentCwds(): Promise<Map<AgentId, Set<string>>> {
if (liveAgentCwdsCache && Date.now() - liveAgentCwdsCache.at < PROCESS_SCAN_CACHE_MS) {
return liveAgentCwdsCache.cwds;
}

let cwds: Set<string>;
if (process.platform === "linux") cwds = await liveClaudeCwdsLinux();
else if (process.platform === "darwin") cwds = await liveClaudeCwdsMac();
else cwds = new Set();
let cwds: Map<AgentId, Set<string>>;
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;
}
Expand All @@ -115,15 +160,15 @@ export async function detectActivity(input: ActivityInput): Promise<SessionActiv
}

const ageMs = Date.now() - lastActivity;
const liveCwds = await liveClaudeCwds();
const hasLiveProcess = liveCwds.has(input.workspace);
const liveCwds = await liveAgentCwds();
const hasLiveProcess = (liveCwds.get(input.agentId) ?? new Set()).has(input.workspace);

const signals: string[] = [];
let status: SessionStatus;
let confidence: ActivityConfidence;

if (hasLiveProcess) {
signals.push("matching claude process running with this workspace as cwd");
signals.push(`matching ${input.agentId} process running with this workspace as cwd`);
status = "ACTIVE";
confidence = ageMs <= RECENT_THRESHOLD_MS ? "HIGH" : "MEDIUM";
} else if (ageMs <= RECENT_THRESHOLD_MS) {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/core/discover.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function sessionId(discovered: DiscoveredSession): string {
}

async function toSession(discovered: DiscoveredSession, existing: Session | null): Promise<Session> {
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,
Expand Down
Loading