Skip to content

Commit 369c4d5

Browse files
FulAppiOSclaude
andcommitted
feat(server): show ultra-mode workflow subagents on the map
Ultra mode (the Workflow tool) writes its agent transcripts one level deeper than classic Task subagents: <sessionId>/subagents/workflows/wf_<runId>/agent-*.jsonl alongside agent-*.meta.json and an orchestration journal.jsonl. The FileWatcher now descends into subagents/workflows/<run>/ and picks up agent-*.jsonl transcripts only (journal/meta files are skipped), so workflow agents stream through the existing parser and appear in the village like any other subagent. resolveSubagentLabel learns the nested path shape so the parent transcript is still found; workflow prompts are not in the parent, so labels fall back to the prompt's first sentence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a42dbab commit 369c4d5

4 files changed

Lines changed: 160 additions & 5 deletions

File tree

server/src/parsers/subagent-label.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,43 @@ describe('resolveSubagentLabel', () => {
116116
expect(label!.endsWith('…')).toBe(true);
117117
});
118118

119+
test('resolves labels for workflow (ultra mode) agent paths', async () => {
120+
const workflowDir = join(projectDir, parentSessionId, 'subagents', 'workflows', 'wf_2ca7ddd8-324');
121+
await mkdir(workflowDir, { recursive: true });
122+
const workflowAgentPath = join(workflowDir, 'agent-a4f3fba033446cc4f.jsonl');
123+
await writeFile(workflowAgentPath, jsonline({
124+
type: 'user',
125+
message: { role: 'user', content: 'You are a senior engineer writing a plan. Read the spec first.' },
126+
}));
127+
128+
const label = await resolveSubagentLabel(workflowAgentPath);
129+
expect(label).toBe('You are a senior engineer writing a plan');
130+
});
131+
132+
test('workflow agents still match parent Agent invocations when present', async () => {
133+
const workflowDir = join(projectDir, parentSessionId, 'subagents', 'workflows', 'wf_deadbeef-123');
134+
await mkdir(workflowDir, { recursive: true });
135+
const workflowAgentPath = join(workflowDir, 'agent-bb11cc22dd33ee44f.jsonl');
136+
await writeFile(workflowAgentPath, jsonline({
137+
type: 'user',
138+
message: { role: 'user', content: 'Audit module Y' },
139+
}));
140+
await writeFile(parentPath, jsonline({
141+
type: 'assistant',
142+
message: {
143+
role: 'assistant',
144+
content: [{
145+
type: 'tool_use',
146+
name: 'Agent',
147+
input: { description: 'Audit Y', prompt: 'Audit module Y' },
148+
}],
149+
},
150+
}));
151+
152+
const label = await resolveSubagentLabel(workflowAgentPath);
153+
expect(label).toBe('Audit Y');
154+
});
155+
119156
test('ignores parent Agent invocations with non-matching prompt', async () => {
120157
await writeFile(subagentPath, jsonline({
121158
type: 'user',

server/src/parsers/subagent-label.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,22 @@ function matchAgentLabel(parentText: string, targetPrompt: string): string | und
7272
}
7373

7474
/**
75-
* Given a subagent JSONL path (`.../<parentSessionId>/subagents/agent-*.jsonl`),
75+
* Given a subagent JSONL path (`.../<parentSessionId>/subagents/agent-*.jsonl`,
76+
* or the ultra-mode variant `.../<parentSessionId>/subagents/workflows/wf_<runId>/agent-*.jsonl`),
7677
* return a human-readable label by correlating the child's first user prompt
7778
* against the parent's `Agent` tool_use invocations. Falls back to the first
7879
* sentence of the subagent prompt when the parent can't be found or doesn't
79-
* carry an exact prompt match.
80+
* carry an exact prompt match — always the case for workflow agents, whose
81+
* prompts live in the Workflow script rather than in the parent transcript.
8082
*
8183
* Returns `undefined` for non-subagent paths or when nothing usable is available.
8284
*/
8385
export async function resolveSubagentLabel(filePath: string): Promise<string | undefined> {
84-
const subagentsDir = dirname(filePath);
86+
let subagentsDir = dirname(filePath);
87+
// Workflow runs nest two levels deeper: subagents/workflows/wf_<runId>/
88+
if (basename(dirname(subagentsDir)) === 'workflows') {
89+
subagentsDir = dirname(dirname(subagentsDir));
90+
}
8591
if (basename(subagentsDir) !== 'subagents') return undefined;
8692

8793
const parentSessionDir = dirname(subagentsDir);
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
2+
import { mkdtemp, rm, mkdir, writeFile, appendFile } from 'node:fs/promises';
3+
import { join } from 'node:path';
4+
import { tmpdir } from 'node:os';
5+
import { FileWatcher } from './file-watcher';
6+
7+
let claudeDir: string;
8+
let projectDir: string;
9+
10+
beforeEach(async () => {
11+
claudeDir = await mkdtemp(join(tmpdir(), 'file-watcher-'));
12+
projectDir = join(claudeDir, 'projects', 'slug');
13+
await mkdir(projectDir, { recursive: true });
14+
});
15+
16+
afterEach(async () => {
17+
await rm(claudeDir, { recursive: true, force: true });
18+
});
19+
20+
function jsonline(obj: unknown): string {
21+
return `${JSON.stringify(obj)}\n`;
22+
}
23+
24+
interface ScanResult {
25+
newSessions: string[];
26+
updates: string[];
27+
}
28+
29+
function makeWatcher(): { watcher: FileWatcher; result: ScanResult } {
30+
const result: ScanResult = { newSessions: [], updates: [] };
31+
const watcher = new FileWatcher({
32+
claudeDirs: [claudeDir],
33+
onNewSession: (sessionId) => {
34+
result.newSessions.push(sessionId);
35+
},
36+
onSessionUpdate: (sessionId) => {
37+
result.updates.push(sessionId);
38+
},
39+
});
40+
return { watcher, result };
41+
}
42+
43+
describe('FileWatcher workflow (ultra mode) discovery', () => {
44+
test('discovers agent JSONLs nested under subagents/workflows/wf_*/', async () => {
45+
const sessionId = 'e20383a7-8361-4ea9-bfee-305ea79263bc';
46+
await writeFile(join(projectDir, `${sessionId}.jsonl`), jsonline({ type: 'user' }));
47+
48+
const subagentsDir = join(projectDir, sessionId, 'subagents');
49+
await mkdir(subagentsDir, { recursive: true });
50+
await writeFile(join(subagentsDir, 'agent-1111111111111111.jsonl'), jsonline({ type: 'user' }));
51+
52+
const workflowDir = join(subagentsDir, 'workflows', 'wf_2ca7ddd8-324');
53+
await mkdir(workflowDir, { recursive: true });
54+
await writeFile(join(workflowDir, 'agent-a4f3fba033446cc4f.jsonl'), jsonline({ type: 'user' }));
55+
await writeFile(join(workflowDir, 'agent-a4f3fba033446cc4f.meta.json'), '{"agentType":"workflow-subagent"}');
56+
await writeFile(join(workflowDir, 'journal.jsonl'), jsonline({ type: 'started' }));
57+
58+
const { watcher, result } = makeWatcher();
59+
await watcher.scan();
60+
61+
expect(result.newSessions).toContain(sessionId);
62+
expect(result.newSessions).toContain('agent-1111111111111111');
63+
expect(result.newSessions).toContain('agent-a4f3fba033446cc4f');
64+
// Orchestration journal and meta files must not become agents
65+
expect(result.newSessions).not.toContain('journal');
66+
expect(result.newSessions).not.toContain('agent-a4f3fba033446cc4f.meta');
67+
});
68+
69+
test('emits updates when a workflow agent JSONL grows', async () => {
70+
const sessionId = 'f207718a-066f-4609-82cb-eccf36a9e5a2';
71+
const workflowDir = join(projectDir, sessionId, 'subagents', 'workflows', 'wf_9ca5aadf-8ab');
72+
await mkdir(workflowDir, { recursive: true });
73+
const agentPath = join(workflowDir, 'agent-afa8678eb14a03831.jsonl');
74+
await writeFile(agentPath, jsonline({ type: 'user' }));
75+
76+
const { watcher, result } = makeWatcher();
77+
await watcher.scan();
78+
expect(result.newSessions).toContain('agent-afa8678eb14a03831');
79+
80+
await appendFile(agentPath, jsonline({ type: 'assistant' }));
81+
await watcher.scan();
82+
expect(result.updates).toContain('agent-afa8678eb14a03831');
83+
});
84+
});

server/src/watchers/file-watcher.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,41 @@ export class FileWatcher {
128128
if (subFiles === null) continue;
129129

130130
for (const subFile of subFiles) {
131-
if (!subFile.endsWith('.jsonl')) continue;
132-
await this.processJsonlFile(join(subagentsDir, subFile), subFile.replace('.jsonl', ''), claudeDir);
131+
if (subFile.endsWith('.jsonl')) {
132+
await this.processJsonlFile(join(subagentsDir, subFile), subFile.replace('.jsonl', ''), claudeDir);
133+
continue;
134+
}
135+
136+
// Ultra mode (Workflow tool) nests agent JSONLs one level deeper:
137+
// <sessionId>/subagents/workflows/wf_<runId>/agent-*.jsonl
138+
// (alongside agent-*.meta.json and an orchestration journal.jsonl)
139+
if (subFile !== 'workflows') continue;
140+
await this.scanWorkflowsDir(join(subagentsDir, subFile), claudeDir);
133141
}
134142
}
135143
}
136144
}
137145

146+
private async scanWorkflowsDir(workflowsDir: string, claudeDir: string): Promise<void> {
147+
const runDirs = await readdir(workflowsDir).catch(() => null);
148+
if (runDirs === null) return;
149+
150+
for (const runDir of runDirs) {
151+
const runPath = join(workflowsDir, runDir);
152+
const runStat = await stat(runPath).catch(() => null);
153+
if (runStat === null || !runStat.isDirectory()) continue;
154+
155+
const runFiles = await readdir(runPath).catch(() => null);
156+
if (runFiles === null) continue;
157+
158+
for (const runFile of runFiles) {
159+
// Only agent transcripts — skips journal.jsonl and agent-*.meta.json
160+
if (!runFile.startsWith('agent-') || !runFile.endsWith('.jsonl')) continue;
161+
await this.processJsonlFile(join(runPath, runFile), runFile.replace('.jsonl', ''), claudeDir);
162+
}
163+
}
164+
}
165+
138166
private async processJsonlFile(filePath: string, sessionId: string, claudeDir: string): Promise<void> {
139167
const fileStat = await stat(filePath).catch(() => null);
140168
if (fileStat === null) return;

0 commit comments

Comments
 (0)