Skip to content
Open
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
136 changes: 110 additions & 26 deletions packages/runtime/src/agents/watchers/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,18 @@
* determines agent status from journal entries, and emits events
* mapped to mux sessions via the project directory encoded in folder names.
*
* Directory structure: ~/.claude/projects/<encoded-path>/<session-id>.jsonl
* Directory structure:
* ~/.claude/projects/<encoded-path>/<session-id>.jsonl — main session transcript
* ~/.claude/projects/<encoded-path>/<session-id>/subagents/
* agent-<subagent-id>.jsonl — subagent transcripts
* agent-<subagent-id>.meta.json — subagent metadata (ignored)
* Encoded path: /Users/foo/myproject → -Users-foo-myproject
*
* Subagents spawned via the Task tool write to the nested subagents/ dir.
* The watcher treats each subagent transcript as an independent thread
* (threadId = "agent-<id>", same projectDir as the parent session) so the
* detail panel lists the main session plus every live subagent.
*
* All file I/O is async to avoid blocking the server event loop.
*
* ## Claude Code JSONL Lifecycle (observed v2.1.87)
Expand Down Expand Up @@ -102,6 +111,15 @@ interface SessionState {

const POLL_MS = 2000;
const STALE_MS = 5 * 60 * 1000;
/** Nested directory under each session dir that holds subagent transcripts */
const SUBAGENTS_SUBDIR = "subagents";
/** Matches a Claude Code session UUID folder: 8-4-4-4-12 lowercase hex */
const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

/** True if the given directory name looks like a Claude Code session UUID. */
function isSessionDirName(name: string): boolean {
return SESSION_UUID_RE.test(name);
}
/** How long to wait before promoting tool_use "running" → "waiting" (permission prompt heuristic) */
const TOOL_USE_WAIT_MS = 3000;
/** How long a "running" session can go without file growth before we assume the process died */
Expand Down Expand Up @@ -421,6 +439,22 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
this.scanPromise = null;
}

/** Scan a directory for recent .jsonl files and call processFile on each.
* Used for both project-level dirs (main session transcripts) and nested
* <session-uuid>/subagents/ dirs (subagent transcripts). */
private async scanJsonlDir(dirPath: string, projectDir: string, now: number): Promise<void> {
let files: string[];
try { files = await readdir(dirPath); } catch { return; }
for (const file of files) {
if (!file.endsWith(".jsonl")) continue;
const filePath = join(dirPath, file);
let fileStat;
try { fileStat = await stat(filePath); } catch { continue; }
if (now - fileStat.mtimeMs > STALE_MS) continue;
await this.processFile(filePath, projectDir);
}
}

private async scanInternal(): Promise<void> {
try {
let dirs: string[];
Expand All @@ -433,16 +467,19 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {

const projectDir = decodeProjectDir(dir);

let files: string[];
try { files = await readdir(dirPath); } catch { continue; }

for (const file of files) {
if (!file.endsWith(".jsonl")) continue;
const filePath = join(dirPath, file);
let fileStat;
try { fileStat = await stat(filePath); } catch { continue; }
if (now - fileStat.mtimeMs > STALE_MS) continue;
await this.processFile(filePath, projectDir);
// Main session transcripts live directly under the project dir.
await this.scanJsonlDir(dirPath, projectDir, now);

// Subagent transcripts live in <project>/<session-uuid>/subagents/.
let entries: string[];
try { entries = await readdir(dirPath); } catch { continue; }
for (const entry of entries) {
if (!isSessionDirName(entry)) continue;
const subagentsDir = join(dirPath, entry, SUBAGENTS_SUBDIR);
try {
if (!(await stat(subagentsDir)).isDirectory()) continue;
} catch { continue; }
await this.scanJsonlDir(subagentsDir, projectDir, now);
}
}
} finally {
Expand All @@ -467,6 +504,58 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
}
}

/** Watch a directory for .jsonl file changes and dispatch them to processFile. */
private watchJsonlDir(dirPath: string, projectDir: string): void {
try {
const w = watch(dirPath, (_eventType, filename) => {
if (!filename?.endsWith(".jsonl")) return;
this.processFile(join(dirPath, filename), projectDir);
});
this.fsWatchers.push(w);
} catch {}
}

/** Watch a <session-uuid>/ directory for the subagents/ subdir appearing,
* and start watching subagents/ as soon as it does. Harmless if the dir
* already exists — we skip re-adding a watcher in that case. */
private watchSessionDirForSubagents(sessionDirPath: string, projectDir: string): void {
const subagentsDir = join(sessionDirPath, SUBAGENTS_SUBDIR);
// If subagents/ already exists, watch it now.
try {
if (require("fs").statSync(subagentsDir).isDirectory()) {
this.watchJsonlDir(subagentsDir, projectDir);
return;
}
} catch {}

// Otherwise, watch the session dir for subagents/ being created.
try {
const w = watch(sessionDirPath, (_eventType, filename) => {
if (filename !== SUBAGENTS_SUBDIR) return;
try {
if (!require("fs").statSync(subagentsDir).isDirectory()) return;
} catch { return; }
this.watchJsonlDir(subagentsDir, projectDir);
});
this.fsWatchers.push(w);
} catch {}
}

/** Walk a project dir and set up subagent-related watchers for any
* existing session UUID subdirs. */
private setupSubagentWatchers(projectDirPath: string, projectDir: string): void {
let entries: string[];
try { entries = require("fs").readdirSync(projectDirPath); } catch { return; }
for (const entry of entries) {
if (!isSessionDirName(entry)) continue;
const sessionDirPath = join(projectDirPath, entry);
try {
if (!require("fs").statSync(sessionDirPath).isDirectory()) continue;
} catch { continue; }
this.watchSessionDirForSubagents(sessionDirPath, projectDir);
}
}

private setupWatchers(): void {
let dirs: string[];
try { dirs = require("fs").readdirSync(this.projectsDir); } catch { return; }
Expand All @@ -476,30 +565,25 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
try { if (!require("fs").statSync(dirPath).isDirectory()) continue; } catch { continue; }

const projectDir = decodeProjectDir(dir);
try {
const w = watch(dirPath, (_eventType, filename) => {
if (!filename?.endsWith(".jsonl")) return;
this.processFile(join(dirPath, filename), projectDir);
});
this.fsWatchers.push(w);
} catch {}
// Watch main session transcripts at the top level.
this.watchJsonlDir(dirPath, projectDir);
// Watch subagent transcripts nested under <session-uuid>/subagents/.
this.setupSubagentWatchers(dirPath, projectDir);
}

// Watch projects dir for new project directories
// Watch projects dir for new project directories (and, for live sessions,
// new <session-uuid>/ subdirs that will sprout a subagents/ child).
try {
const w = watch(this.projectsDir, (eventType, filename) => {
if (eventType !== "rename" || !filename) return;
const dirPath = join(this.projectsDir, filename);
try { if (!require("fs").statSync(dirPath).isDirectory()) return; } catch { return; }

const projectDir = decodeProjectDir(filename);
try {
const sub = watch(dirPath, (_et, fn) => {
if (!fn?.endsWith(".jsonl")) return;
this.processFile(join(dirPath, fn), projectDir);
});
this.fsWatchers.push(sub);
} catch {}
// Watch the new project dir for main-session .jsonl files plus any
// existing or future <session-uuid>/subagents/ subdirs.
this.watchJsonlDir(dirPath, projectDir);
this.setupSubagentWatchers(dirPath, projectDir);
});
this.fsWatchers.push(w);
} catch {}
Expand Down
92 changes: 92 additions & 0 deletions packages/runtime/test/claude-code-watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,4 +465,96 @@ describe("ClaudeCodeAgentWatcher", () => {
const runningEvents = postSeed.filter((e) => e.status === "running");
expect(runningEvents.length).toBeGreaterThanOrEqual(1);
});

test("seeds subagent transcripts nested under <session-uuid>/subagents/", async () => {
const projDir = join(tmpDir, "-projects-myapp");
mkdirSync(projDir, { recursive: true });

// Main session transcript (running)
const mainSessionId = "01afc0dd-c3cd-4cdf-953b-29f621478d90";
writeFileSync(
join(projDir, `${mainSessionId}.jsonl`),
JSON.stringify({ message: { role: "user", content: "parent prompt" } }) + "\n",
);

// Two subagent transcripts in <mainSessionId>/subagents/
const subagentsDir = join(projDir, mainSessionId, "subagents");
mkdirSync(subagentsDir, { recursive: true });
writeFileSync(
join(subagentsDir, "agent-af575bd0d1edc3250.jsonl"),
JSON.stringify({ message: { role: "user", content: "research volue" } }) + "\n",
);
writeFileSync(
join(subagentsDir, "agent-afd87e8897eb5b3aa.jsonl"),
JSON.stringify({ message: { role: "user", content: "audit opensessions" } }) + "\n",
);
// Non-jsonl sibling files must be ignored (meta.json exists alongside each subagent transcript)
writeFileSync(join(subagentsDir, "agent-af575bd0d1edc3250.meta.json"), "{}");

watcher.start(ctx);
await watcher.flush();

const threadIds = events.map((e) => e.threadId).sort();
expect(threadIds).toEqual([
mainSessionId,
"agent-af575bd0d1edc3250",
"agent-afd87e8897eb5b3aa",
].sort());
expect(new Set(events.map((e) => e.session))).toEqual(new Set(["myapp-session"]));
});

test("picks up a subagent file that appears after seed (live spawn)", async () => {
const projDir = join(tmpDir, "-projects-myapp");
mkdirSync(projDir, { recursive: true });

const mainSessionId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
writeFileSync(
join(projDir, `${mainSessionId}.jsonl`),
JSON.stringify({ message: { role: "user", content: "parent prompt" } }) + "\n",
);

watcher.start(ctx);
await watcher.flush();
const seedCount = events.length;

// Subagent dir appears after seed (the way Claude Code actually spawns them).
const subagentsDir = join(projDir, mainSessionId, "subagents");
mkdirSync(subagentsDir, { recursive: true });
writeFileSync(
join(subagentsDir, "agent-deadbeefcafef00d.jsonl"),
JSON.stringify({ message: { role: "user", content: "spawned subagent" } }) + "\n",
);
await watcher.flush();

const postSeed = events.slice(seedCount);
const subEvents = postSeed.filter((e) => e.threadId === "agent-deadbeefcafef00d");
expect(subEvents.length).toBeGreaterThanOrEqual(1);
expect(subEvents[0]!.session).toBe("myapp-session");
});

test("does not descend into non-UUID subdirectories", async () => {
const projDir = join(tmpDir, "-projects-myapp");
mkdirSync(projDir, { recursive: true });

writeFileSync(
join(projDir, "session-main.jsonl"),
JSON.stringify({ message: { role: "user", content: "main" } }) + "\n",
);

// Non-UUID subdir that happens to contain a subagents/ with a .jsonl file.
// We must not pick this up — only real Claude Code session UUID dirs count.
const bogusDir = join(projDir, "not-a-session", "subagents");
mkdirSync(bogusDir, { recursive: true });
writeFileSync(
join(bogusDir, "agent-bogus.jsonl"),
JSON.stringify({ message: { role: "user", content: "bogus" } }) + "\n",
);

watcher.start(ctx);
await watcher.flush();

const threadIds = events.map((e) => e.threadId);
expect(threadIds).toContain("session-main");
expect(threadIds).not.toContain("agent-bogus");
});
});