Skip to content

Commit 68af2e9

Browse files
recuu-pfegclaude
andcommitted
feat: stream Manager tool execution to UI in real-time
When Manager has tool access enabled, use --verbose --output-format stream-json to parse tool_use events during LLM execution. - ClaudeCliProvider: parse stream-json, emit onToolUse for Read/Grep/Bash - Manager: pass onToolUse callback to LLM provider - setup.ts: broadcast tool_use as AGENT_ACTIVITY to terminal panel - Text chunks still stream to chat panel via onChunk - Final result parsed from stream-json "result" event Users now see "manager: Read src/app.tsx" in the agent strip/terminal while Manager is thinking. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cf4a184 commit 68af2e9

3 files changed

Lines changed: 82 additions & 6 deletions

File tree

src/llm-provider.ts

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export interface LlmCallOptions {
2525
timeoutMs?: number;
2626
/** Called with each text chunk for streaming */
2727
onChunk?: (chunk: string) => void;
28+
/** Called when the LLM executes a tool (Read, Grep, etc.) */
29+
onToolUse?: (tool: string, summary: string) => void;
2830
/** Working directory for CLI-based providers */
2931
cwd?: string;
3032
/** Enable tool access (provider-specific) */
@@ -48,7 +50,7 @@ export class ClaudeCliProvider implements LlmProvider {
4850
readonly id = "claude-cli";
4951

5052
async call(opts: LlmCallOptions): Promise<string> {
51-
const { systemPrompt, history, userMessage, model, timeoutMs = 300_000, onChunk, cwd, enableTools, allowedTools } = opts;
53+
const { systemPrompt, history, userMessage, model, timeoutMs = 300_000, onChunk, onToolUse, cwd, enableTools, allowedTools } = opts;
5254

5355
const contextLines = history.slice(-6).map((m) => {
5456
const label = m.role === "user" ? "User" : "Manager";
@@ -62,12 +64,19 @@ export class ClaudeCliProvider implements LlmProvider {
6264
const env = { ...process.env };
6365
delete env.CLAUDECODE;
6466

67+
// Use stream-json when tools are enabled (allows parsing tool_use events)
68+
const useStreamJson = !!enableTools;
69+
6570
const args = [
6671
"--print",
6772
"--system-prompt", systemPrompt,
6873
"--model", model,
6974
];
7075

76+
if (useStreamJson) {
77+
args.push("--verbose", "--output-format", "stream-json");
78+
}
79+
7180
if (enableTools) {
7281
args.push("--permission-mode", "plan");
7382
if (allowedTools?.length) {
@@ -98,11 +107,58 @@ export class ClaudeCliProvider implements LlmProvider {
98107

99108
let stdout = "";
100109
let stderr = "";
110+
let resultText = "";
111+
let stdoutBuffer = "";
101112

102113
child.stdout?.on("data", (chunk: Buffer) => {
103-
const text = chunk.toString();
104-
stdout += text;
105-
if (onChunk && text) onChunk(text);
114+
const raw = chunk.toString();
115+
stdout += raw;
116+
117+
if (useStreamJson) {
118+
// Parse stream-json JSONL for tool_use events and text
119+
stdoutBuffer += raw;
120+
const lines = stdoutBuffer.split("\n");
121+
stdoutBuffer = lines.pop() ?? "";
122+
123+
for (const line of lines) {
124+
if (!line.trim()) continue;
125+
try {
126+
const event = JSON.parse(line) as Record<string, unknown>;
127+
128+
// Extract tool_use events
129+
if (event.type === "content_block_start") {
130+
const block = event.content_block as Record<string, unknown> | undefined;
131+
if (block?.type === "tool_use" && typeof block.name === "string") {
132+
onToolUse?.(block.name, summarizeTool(block.name, block.input as Record<string, unknown>));
133+
}
134+
}
135+
if (event.type === "assistant") {
136+
const content = (event.message as Record<string, unknown>)?.content;
137+
if (Array.isArray(content)) {
138+
for (const b of content) {
139+
if (b.type === "tool_use" && typeof b.name === "string") {
140+
onToolUse?.(b.name, summarizeTool(b.name, b.input as Record<string, unknown>));
141+
}
142+
if (b.type === "text" && typeof b.text === "string") {
143+
onChunk?.(b.text);
144+
}
145+
}
146+
}
147+
}
148+
149+
// Capture final result
150+
if (event.type === "result" && typeof event.result === "string") {
151+
resultText = event.result;
152+
onChunk?.(event.result);
153+
}
154+
} catch {
155+
// Not JSON — treat as raw text
156+
onChunk?.(line);
157+
}
158+
}
159+
} else {
160+
if (onChunk && raw) onChunk(raw);
161+
}
106162
});
107163
child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
108164

@@ -126,10 +182,12 @@ export class ClaudeCliProvider implements LlmProvider {
126182
ui.debug("llm", `Claude CLI stderr: ${stderr.trim().slice(0, 300)}`);
127183
}
128184
if (code === 0) {
129-
if (!stdout.trim()) {
185+
// stream-json: use parsed resultText; plain: use raw stdout
186+
const response = useStreamJson ? (resultText || stdout.trim()) : stdout.trim();
187+
if (!response) {
130188
ui.warn(`[llm] Claude CLI returned empty response (stderr: ${stderr.trim().slice(0, 200) || "none"})`);
131189
}
132-
resolve(stdout.trim() || "(no response)");
190+
resolve(response || "(no response)");
133191
} else {
134192
reject(new Error(`Claude CLI exited with code ${code}: ${stderr.slice(0, 200)}`));
135193
}
@@ -192,6 +250,18 @@ export class OpenAiApiProvider implements LlmProvider {
192250
// Factory
193251
// ---------------------------------------------------------------------------
194252

253+
/** Brief summary of a tool invocation for UI display */
254+
function summarizeTool(name: string, input?: Record<string, unknown>): string {
255+
if (!input) return name;
256+
switch (name) {
257+
case "Read": return input.file_path ? `Read ${String(input.file_path).split("/").slice(-2).join("/")}` : "Read";
258+
case "Grep": return input.pattern ? `Grep "${input.pattern}"` : "Grep";
259+
case "Glob": return input.pattern ? `Glob ${input.pattern}` : "Glob";
260+
case "Bash": { const cmd = input.command as string | undefined; return cmd ? `Bash: ${cmd.slice(0, 50)}` : "Bash"; }
261+
default: return name;
262+
}
263+
}
264+
195265
/**
196266
* Create the appropriate LLM provider based on configuration.
197267
* If llmBaseUrl + llmApiKey are provided, uses OpenAI-compatible API.

src/manager.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ export class Manager {
137137
onProposals?: (proposals: Array<Record<string, string>>) => void;
138138
/** Callback to stream text chunks via WebSocket */
139139
onStreamChunk?: (chunk: string) => void;
140+
/** Callback when Manager executes a tool (Read, Grep, etc.) */
141+
onToolUse?: (tool: string, summary: string) => void;
140142
/** Callback when a spawn_agent needs user approval */
141143
onApprovalRequest?: (approval: PendingApproval) => void;
142144
/** Callback when Manager activity changes (for WS broadcast) */
@@ -352,6 +354,7 @@ export class Manager {
352354
userMessage,
353355
model: this.model,
354356
onChunk: this.onStreamChunk,
357+
onToolUse: this.onToolUse,
355358
cwd: this.reposDir,
356359
enableTools: !!this.reposDir,
357360
allowedTools: this.reposDir ? ["Read", "Grep", "Glob", "Bash", "Agent"] : undefined,

src/setup.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,9 @@ export async function setup(cliArgs: CliArgs, runner: AgentRunner): Promise<Setu
255255
mgr.onStreamChunk = (chunk) => { wsServer?.broadcast({ type: WS_MSG.CHAT_STREAM, from: "manager", content: chunk, timestamp: new Date().toISOString() }); };
256256
mgr.onApprovalRequest = (approval) => { wsServer?.broadcast({ type: WS_MSG.APPROVAL_REQUEST, approval_id: approval.id, role: approval.role, task_ids: approval.taskIds, timestamp: new Date().toISOString() }); };
257257
mgr.onActivityChange = (activity) => { wsServer?.broadcast({ type: WS_MSG.STATUS, from: "manager", content: activity, timestamp: new Date().toISOString() }); };
258+
mgr.onToolUse = (tool, summary) => {
259+
wsServer?.broadcast({ type: WS_MSG.AGENT_ACTIVITY, agent_name: "manager", content: summary, kind: "tool", tool, timestamp: new Date().toISOString() });
260+
};
258261
} catch (err) { ui.warn(`WebSocket server failed to start: ${err}`); }
259262

260263
mgr.start();

0 commit comments

Comments
 (0)