From abc3b6a541ad60e742ecf37779426ebe4a551874 Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Fri, 24 Apr 2026 11:37:05 +0100 Subject: [PATCH] feat(claude-code): surface subagent transcripts in detail panel Claude Code writes Task-tool subagent transcripts to a nested path: ~/.claude/projects///subagents/agent-.jsonl The watcher previously used a non-recursive readdir + .jsonl filter on the top-level project directory, so only the main session transcript was picked up and every subagent stayed invisible to the tracker. The detail panel in turn showed just the parent session, even while two or three Task-tool subagents were running. Fix: descend one level into any / subdir, scan and watch its subagents/ child. Each subagent is emitted as its own thread (threadId = "agent-") under the parent's projectDir, so the tracker stores them as distinct per-session instances and the TUI For-loop renders them alongside the main session. Scoped to Claude Code session UUID dirs (8-4-4-4-12 hex) so we don't recurse into unrelated children. Polling (POLL_MS=2s) plus fs.watch on the session dir catch late-spawning subagents without requiring all paths to exist at start-up. Tests: - seed discovers main session + multiple subagents under one project - live spawn after seed is picked up via the session-dir watcher - non-UUID siblings with a subagents/ subdir are ignored Verified live against ~/.claude/projects/: 7 main sessions + 2 active subagent threads surface where previously 0 subagents did. Co-authored-by: Isaac --- .../src/agents/watchers/claude-code.ts | 136 ++++++++++++++---- .../runtime/test/claude-code-watcher.test.ts | 92 ++++++++++++ 2 files changed, 202 insertions(+), 26 deletions(-) diff --git a/packages/runtime/src/agents/watchers/claude-code.ts b/packages/runtime/src/agents/watchers/claude-code.ts index a3b1d4e..748e55e 100644 --- a/packages/runtime/src/agents/watchers/claude-code.ts +++ b/packages/runtime/src/agents/watchers/claude-code.ts @@ -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//.jsonl + * Directory structure: + * ~/.claude/projects//.jsonl — main session transcript + * ~/.claude/projects///subagents/ + * agent-.jsonl — subagent transcripts + * agent-.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-", 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) @@ -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 */ @@ -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 + * /subagents/ dirs (subagent transcripts). */ + private async scanJsonlDir(dirPath: string, projectDir: string, now: number): Promise { + 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 { try { let dirs: string[]; @@ -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 //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 { @@ -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 / 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; } @@ -476,16 +565,14 @@ 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 /subagents/. + this.setupSubagentWatchers(dirPath, projectDir); } - // Watch projects dir for new project directories + // Watch projects dir for new project directories (and, for live sessions, + // new / subdirs that will sprout a subagents/ child). try { const w = watch(this.projectsDir, (eventType, filename) => { if (eventType !== "rename" || !filename) return; @@ -493,13 +580,10 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher { 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 /subagents/ subdirs. + this.watchJsonlDir(dirPath, projectDir); + this.setupSubagentWatchers(dirPath, projectDir); }); this.fsWatchers.push(w); } catch {} diff --git a/packages/runtime/test/claude-code-watcher.test.ts b/packages/runtime/test/claude-code-watcher.test.ts index 0c09686..1cfeae8 100644 --- a/packages/runtime/test/claude-code-watcher.test.ts +++ b/packages/runtime/test/claude-code-watcher.test.ts @@ -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 /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 /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"); + }); });