@trim21/personal-pi-extensions 0.0.160 → 0.0.162

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.160",
3
+ "version": "0.0.162",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -177,7 +177,7 @@ export class GhError extends Error {
177
177
  /** Run `gh` and return stdout. On non-zero exit, throws a `GhError` carrying the toolcall input and raw command. */
178
178
  export async function ghExec(
179
179
  args: string[],
180
- ctx: { cwd?: string; signal?: AbortSignal; input?: unknown },
180
+ ctx: { cwd?: string; signal?: AbortSignal; input?: unknown; timeout?: number },
181
181
  ): Promise<string> {
182
182
  const result = await runGh(args, ctx);
183
183
  if (result.code !== 0) {
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Agent discovery for the `spawn_agent` tool.
3
+ *
4
+ * Subagents are defined as markdown files in `~/.pi/agent/agents/*.md`
5
+ * (user-level only; project-local agents are intentionally not supported).
6
+ * Each file carries YAML frontmatter plus a system-prompt body:
7
+ *
8
+ * ---
9
+ * name: scout
10
+ * description: Fast codebase recon
11
+ * tools:
12
+ * - read
13
+ * - grep
14
+ * - find
15
+ * - ls
16
+ * model: claude-haiku-4-5 # optional
17
+ * thinkingLevel: high # optional; applied as "model:high"
18
+ * ---
19
+ * System prompt for the agent goes here.
20
+ *
21
+ * Frontmatter is validated with a typebox schema; files that fail validation
22
+ * (missing name/description, wrong field types) are skipped. If `tools` is
23
+ * omitted, the subagent runs with the read-only default toolset from the
24
+ * spawn-agent config (read/grep/find/ls) unless overridden there.
25
+ */
26
+
27
+ import { readdirSync, readFileSync } from "node:fs";
28
+ import { join } from "node:path";
29
+
30
+ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
31
+ import { Type } from "typebox";
32
+ import { Value } from "typebox/value";
33
+
34
+ /** Valid thinking levels, mirroring pi's ThinkingLevel type. */
35
+ const THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
36
+
37
+ const agentFrontmatterSchema = Type.Object({
38
+ name: Type.String({ minLength: 1 }),
39
+ description: Type.String({ minLength: 1 }),
40
+ tools: Type.Optional(Type.Array(Type.String())),
41
+ model: Type.Optional(Type.String()),
42
+ thinkingLevel: Type.Optional(Type.Union(THINKING_LEVELS.map((level) => Type.Literal(level)))),
43
+ });
44
+
45
+ function parseAgentFrontmatter(frontmatter: unknown) {
46
+ try {
47
+ return Value.Parse(agentFrontmatterSchema, frontmatter);
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ export interface AgentConfig {
54
+ name: string;
55
+ description: string;
56
+ /** Toolset from the frontmatter; undefined means "use the config default". */
57
+ tools?: string[];
58
+ model?: string;
59
+ /** Thinking level, applied as a ":level" suffix on the model id. */
60
+ thinkingLevel?: (typeof THINKING_LEVELS)[number];
61
+ systemPrompt: string;
62
+ filePath: string;
63
+ }
64
+
65
+ export function discoverAgents(dir = join(getAgentDir(), "agents")): AgentConfig[] {
66
+ let entries;
67
+ try {
68
+ entries = readdirSync(dir, { withFileTypes: true });
69
+ } catch {
70
+ return [];
71
+ }
72
+
73
+ const agents: AgentConfig[] = [];
74
+ for (const entry of entries) {
75
+ if (!entry.name.endsWith(".md")) continue;
76
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
77
+
78
+ const filePath = join(dir, entry.name);
79
+ let content: string;
80
+ try {
81
+ content = readFileSync(filePath, "utf8");
82
+ } catch {
83
+ continue;
84
+ }
85
+
86
+ const { frontmatter, body } = parseFrontmatter(content);
87
+ const fm = parseAgentFrontmatter(frontmatter);
88
+ if (!fm) continue; // missing name/description or wrong field types → not an agent
89
+
90
+ agents.push({
91
+ name: fm.name,
92
+ description: fm.description,
93
+ tools: fm.tools,
94
+ model: fm.model,
95
+ thinkingLevel: fm.thinkingLevel,
96
+ systemPrompt: body,
97
+ filePath,
98
+ });
99
+ }
100
+ return agents;
101
+ }
102
+
103
+ export function formatAgentList(agents: AgentConfig[]): string {
104
+ if (agents.length === 0) return "none";
105
+ return agents.map((a) => `${a.name}: ${a.description}`).join("; ");
106
+ }
@@ -0,0 +1,478 @@
1
+ /**
2
+ * spawn_agent tool — delegate a task to a subagent running in a separate pi
3
+ * process with an isolated context window.
4
+ *
5
+ * The subagent definition comes from `~/.pi/agent/agents/*.md` (markdown with
6
+ * YAML frontmatter, see spawn-agent-agents.ts). The extension discovers the
7
+ * available subagent types once at startup and appends them to the system
8
+ * prompt on every agent start (same pattern as the bwrap extension), so the
9
+ * model always knows which `agent` names it can pass to the tool. Execution
10
+ * is blocking: the tool awaits the subagent process until it exits and
11
+ * returns its final output to the parent model. Progress is streamed through
12
+ * `onUpdate`, the same channel the built-in bash tool uses for live output.
13
+ * Progress is a rolling log: `tool: <name>` lines for tool calls and
14
+ * `text: <content>` lines for completed text blocks, keeping the last
15
+ * `MAX_PROGRESS_LINES` lines.
16
+ *
17
+ * Security default: without an explicit `tools:` in the frontmatter, the
18
+ * subagent only gets read-only tools (read/grep/find/ls) — no bash/write/edit.
19
+ */
20
+
21
+ import { spawn } from "node:child_process";
22
+ import { existsSync } from "node:fs";
23
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
24
+ import { tmpdir } from "node:os";
25
+ import { basename, dirname, join } from "node:path";
26
+
27
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
28
+ import type { Message } from "@earendil-works/pi-ai";
29
+ import {
30
+ type ExtensionAPI,
31
+ getMarkdownTheme,
32
+ truncateTail,
33
+ withFileMutationQueue,
34
+ } from "@earendil-works/pi-coding-agent";
35
+ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
36
+ import { Type } from "typebox";
37
+
38
+ import { type AgentConfig, discoverAgents, formatAgentList } from "./spawn-agent-agents.js";
39
+
40
+ // ── constants ────────────────────────────────────────────────────────────────
41
+
42
+ /** Subagent output returned to the parent model is capped at 50KB. */
43
+ const MAX_OUTPUT_BYTES = 50 * 1024;
44
+ /** Read-only toolset used when an agent does not declare `tools`. */
45
+ const DEFAULT_TOOLS = ["read", "grep", "find", "ls"];
46
+ /** Progress log keeps only the most recent lines (rolling window). */
47
+ const MAX_PROGRESS_LINES = 5;
48
+
49
+ // ── schema ───────────────────────────────────────────────────────────────────
50
+
51
+ const spawnAgentSchema = Type.Object({
52
+ agent: Type.String({
53
+ description:
54
+ "Name of the subagent type to invoke. Choose one of the available subagent types listed in your system prompt.",
55
+ }),
56
+ task: Type.String({ description: "Task to delegate to the subagent" }),
57
+ });
58
+
59
+ // ── result types ─────────────────────────────────────────────────────────────
60
+
61
+ interface UsageStats {
62
+ input: number;
63
+ output: number;
64
+ cacheRead: number;
65
+ cacheWrite: number;
66
+ cost: number;
67
+ contextTokens: number;
68
+ turns: number;
69
+ }
70
+
71
+ interface SubagentDetails {
72
+ agent: string;
73
+ task: string;
74
+ exitCode: number;
75
+ messages: Message[];
76
+ stderr: string;
77
+ usage: UsageStats;
78
+ model?: string;
79
+ stopReason?: string;
80
+ errorMessage?: string;
81
+ }
82
+
83
+ // ── helpers ──────────────────────────────────────────────────────────────────
84
+
85
+ function getFinalOutput(messages: Message[]): string {
86
+ for (let i = messages.length - 1; i >= 0; i--) {
87
+ const msg = messages[i];
88
+ if (msg.role === "assistant") {
89
+ for (const part of msg.content) {
90
+ if (part.type === "text") return part.text;
91
+ }
92
+ }
93
+ }
94
+ return "";
95
+ }
96
+
97
+ function formatTokens(count: number): string {
98
+ if (count < 1000) return count.toString();
99
+ if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
100
+ if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
101
+ return `${(count / 1_000_000).toFixed(1)}M`;
102
+ }
103
+
104
+ function formatUsageStats(usage: UsageStats, model?: string): string {
105
+ const parts: string[] = [];
106
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
107
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
108
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
109
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
110
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
111
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
112
+ if (usage.contextTokens > 0) parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
113
+ if (model) parts.push(model);
114
+ return parts.join(" ");
115
+ }
116
+
117
+ /**
118
+ * Resolve how to spawn the subagent process. Running through the current
119
+ * entry script (when available) keeps model/tool/extension config identical
120
+ * to the parent; otherwise fall back to the `pi` binary on PATH.
121
+ */
122
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
123
+ const currentScript = process.argv[1];
124
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
125
+ if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
126
+ return { command: process.execPath, args: [currentScript, ...args] };
127
+ }
128
+ const execName = basename(process.execPath).toLowerCase();
129
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
130
+ if (!isGenericRuntime) return { command: process.execPath, args };
131
+ return { command: "pi", args };
132
+ }
133
+
134
+ async function writePromptToTempFile(agentName: string, prompt: string): Promise<string> {
135
+ const dir = await mkdtemp(join(tmpdir(), "pi-spawn-agent-"));
136
+ const safeName = agentName.replaceAll(/[^\w.-]+/g, "_");
137
+ const filePath = join(dir, `prompt-${safeName}.md`);
138
+ await withFileMutationQueue(filePath, async () => {
139
+ await writeFile(filePath, prompt, { encoding: "utf8", mode: 0o600 });
140
+ });
141
+ return filePath;
142
+ }
143
+
144
+ export function buildSubagentArgs(
145
+ agent: AgentConfig,
146
+ task: string,
147
+ systemPromptPath: string | undefined,
148
+ ): string[] {
149
+ // --mode json: emit events as JSON lines; -p: single-shot answer;
150
+ // --no-session: ephemeral, do not persist. --no-extensions keeps the
151
+ // subagent clean (no recursive spawn_agent, no sandbox surprises).
152
+ const args: string[] = ["--mode", "json", "-p", "--no-session", "--no-extensions"];
153
+ // Thinking level rides on the model shorthand ("model:level"); it cannot be
154
+ // set without a model, so a level without a model is ignored.
155
+ const model =
156
+ agent.model !== undefined && agent.thinkingLevel !== undefined
157
+ ? `${agent.model}:${agent.thinkingLevel}`
158
+ : agent.model;
159
+ if (model) args.push("--model", model);
160
+ // Read-only default unless the agent explicitly declares a toolset.
161
+ const tools = agent.tools ?? DEFAULT_TOOLS;
162
+ args.push("--tools", tools.join(","));
163
+ if (systemPromptPath) args.push("--append-system-prompt", systemPromptPath);
164
+ args.push(`Task: ${task}`);
165
+ return args;
166
+ }
167
+
168
+ // ── subagent runner ──────────────────────────────────────────────────────────
169
+
170
+ type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
171
+
172
+ export async function runAgent(
173
+ agent: AgentConfig,
174
+ task: string,
175
+ cwd: string,
176
+ signal: AbortSignal | undefined,
177
+ onUpdate: OnUpdateCallback | undefined,
178
+ ): Promise<SubagentDetails> {
179
+ const result: SubagentDetails = {
180
+ agent: agent.name,
181
+ task,
182
+ exitCode: 0,
183
+ messages: [],
184
+ stderr: "",
185
+ usage: {
186
+ input: 0,
187
+ output: 0,
188
+ cacheRead: 0,
189
+ cacheWrite: 0,
190
+ cost: 0,
191
+ contextTokens: 0,
192
+ turns: 0,
193
+ },
194
+ model: agent.model,
195
+ };
196
+
197
+ let tmpPromptPath: string | null = null;
198
+ try {
199
+ if (agent.systemPrompt.trim()) {
200
+ tmpPromptPath = await writePromptToTempFile(agent.name, agent.systemPrompt);
201
+ }
202
+ const args = buildSubagentArgs(agent, task, tmpPromptPath ?? undefined);
203
+ const invocation = getPiInvocation(args);
204
+ const proc = spawn(invocation.command, invocation.args, {
205
+ cwd,
206
+ shell: false,
207
+ stdio: ["ignore", "pipe", "pipe"],
208
+ // Mark the child as a subagent so extensions running inside it (e.g.
209
+ // bwrap's subagent policy) can recognize and treat it accordingly.
210
+ env: { ...process.env, PI_SUBAGENT_CHILD: "1" },
211
+ });
212
+
213
+ let logLines: string[] = [];
214
+
215
+ const pushLogLine = (line: string) => {
216
+ logLines.push(line);
217
+ if (logLines.length > MAX_PROGRESS_LINES) {
218
+ logLines = logLines.slice(-MAX_PROGRESS_LINES);
219
+ }
220
+ };
221
+
222
+ const emitUpdate = () => {
223
+ // Usage line rides on the last row so the TUI always shows live token
224
+ // cost; it lives outside the rolling window so it is never trimmed.
225
+ const usageLine = formatUsageStats(result.usage, result.model);
226
+ const lines = usageLine ? [...logLines, usageLine] : logLines;
227
+ onUpdate?.({
228
+ content: [{ type: "text", text: lines.join("\n") || "(running...)" }],
229
+ details: { ...result },
230
+ });
231
+ };
232
+
233
+ let buffer = "";
234
+
235
+ const processLine = (line: string) => {
236
+ if (!line.trim()) return;
237
+ let event: unknown;
238
+ try {
239
+ event = JSON.parse(line);
240
+ } catch {
241
+ return; // not a JSON event line
242
+ }
243
+ if (!isRecord(event)) return;
244
+
245
+ if (event.type === "message_update" && isRecord(event.assistantMessageEvent)) {
246
+ // A completed text block (text_end carries the full content) becomes a
247
+ // `text:` log line. Deltas/thinking are intentionally not logged.
248
+ const delta = event.assistantMessageEvent;
249
+ if (delta.type === "text_end" && typeof delta.content === "string") {
250
+ pushLogLine(`text: ${delta.content}`);
251
+ emitUpdate();
252
+ }
253
+ } else if (event.type === "tool_execution_start" && typeof event.toolName === "string") {
254
+ pushLogLine(`tool: ${event.toolName}`);
255
+ emitUpdate();
256
+ } else if (event.type === "message_end" && isRecord(event.message)) {
257
+ const msg = event.message as unknown as Message;
258
+ result.messages.push(msg);
259
+ if (msg.role === "assistant") {
260
+ result.usage.turns++;
261
+ const usage: Record<string, unknown> = isRecord(msg.usage) ? msg.usage : {};
262
+ result.usage.input += num(usage.input);
263
+ result.usage.output += num(usage.output);
264
+ result.usage.cacheRead += num(usage.cacheRead);
265
+ result.usage.cacheWrite += num(usage.cacheWrite);
266
+ result.usage.cost += num(isRecord(usage.cost) ? usage.cost.total : undefined);
267
+ result.usage.contextTokens = num(usage.totalTokens);
268
+ if (!result.model && typeof msg.model === "string") result.model = msg.model;
269
+ if (typeof msg.stopReason === "string") result.stopReason = msg.stopReason;
270
+ if (typeof msg.errorMessage === "string") result.errorMessage = msg.errorMessage;
271
+ }
272
+ emitUpdate();
273
+ }
274
+ };
275
+
276
+ proc.stdout.on("data", (data: Buffer) => {
277
+ buffer += data.toString();
278
+ const lines = buffer.split("\n");
279
+ buffer = lines.pop() ?? "";
280
+ for (const line of lines) processLine(line);
281
+ });
282
+
283
+ proc.stderr.on("data", (data: Buffer) => {
284
+ result.stderr += data.toString();
285
+ });
286
+
287
+ const exitCode = await new Promise<number>((resolve) => {
288
+ proc.on("close", (code) => {
289
+ if (buffer.trim()) processLine(buffer);
290
+ resolve(code ?? 0);
291
+ });
292
+ proc.on("error", () => resolve(1));
293
+
294
+ const kill = () => {
295
+ proc.kill("SIGTERM");
296
+ setTimeout(() => {
297
+ if (!proc.killed) proc.kill("SIGKILL");
298
+ }, 5000);
299
+ };
300
+ if (signal) {
301
+ if (signal.aborted) kill();
302
+ else signal.addEventListener("abort", kill, { once: true });
303
+ }
304
+ });
305
+
306
+ result.exitCode = exitCode;
307
+ return result;
308
+ } finally {
309
+ if (tmpPromptPath) {
310
+ try {
311
+ await rm(tmpPromptPath, { force: true });
312
+ await rm(dirname(tmpPromptPath), { recursive: true, force: true });
313
+ } catch {
314
+ // best-effort cleanup
315
+ }
316
+ }
317
+ }
318
+ }
319
+
320
+ function isRecord(v: unknown): v is Record<string, unknown> {
321
+ return typeof v === "object" && v !== null;
322
+ }
323
+
324
+ function num(v: unknown): number {
325
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
326
+ }
327
+
328
+ /** Session entry customType used to mark the injected subagent list. */
329
+ export function formatAgentListSection(agents: AgentConfig[]): string {
330
+ const lines = agents.map((a) => `- \`${a.name}\`: ${a.description}`);
331
+ return [
332
+ "## Available subagents",
333
+ "",
334
+ "You can delegate tasks to the following subagent types by calling the `spawn_agent` tool with their name in the `agent` parameter:",
335
+ "",
336
+ ...lines,
337
+ ].join("\n");
338
+ }
339
+
340
+ // ── extension ────────────────────────────────────────────────────────────────
341
+
342
+ export default function spawnAgent(pi: ExtensionAPI) {
343
+ // Discover the available subagent types once at extension startup. The
344
+ // extension owns this discovery: the model never has to guess agent names
345
+ // or read the agent directory itself. Editing ~/.pi/agent/agents/*.md
346
+ // requires /reload to take effect.
347
+ const agents = discoverAgents();
348
+ const agentListSection = agents.length > 0 ? formatAgentListSection(agents) : null;
349
+
350
+ if (agentListSection) {
351
+ // Same pattern as the bwrap extension: append the list to the system
352
+ // prompt on every agent start. The system prompt is rebuilt each turn
353
+ // anyway, so a persistent per-session injection would add no value.
354
+ pi.on("before_agent_start", (event) => {
355
+ return { systemPrompt: `${event.systemPrompt}\n\n${agentListSection}` };
356
+ });
357
+ }
358
+
359
+ pi.registerTool<typeof spawnAgentSchema, SubagentDetails>({
360
+ name: "spawn_agent",
361
+ label: "spawn_agent",
362
+ description: [
363
+ "Delegate a task to a subagent running in a separate pi process with an isolated context window.",
364
+ "The `agent` parameter must be one of the available subagent types listed in the system prompt.",
365
+ `Subagents run read-only (${DEFAULT_TOOLS.join(", ")}) unless the agent declares an explicit toolset.`,
366
+ ].join(" "),
367
+ parameters: spawnAgentSchema,
368
+
369
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
370
+ const agent = agents.find((a) => a.name === params.agent);
371
+ if (!agent) {
372
+ return {
373
+ content: [
374
+ {
375
+ type: "text",
376
+ text: `Unknown agent "${params.agent}". Available agents: ${formatAgentList(agents)}`,
377
+ },
378
+ ],
379
+ details: {
380
+ agent: params.agent,
381
+ task: params.task,
382
+ exitCode: 1,
383
+ messages: [],
384
+ stderr: "",
385
+ usage: {
386
+ input: 0,
387
+ output: 0,
388
+ cacheRead: 0,
389
+ cacheWrite: 0,
390
+ cost: 0,
391
+ contextTokens: 0,
392
+ turns: 0,
393
+ },
394
+ },
395
+ isError: true,
396
+ };
397
+ }
398
+
399
+ const result = await runAgent(agent, params.task, ctx.cwd, signal, onUpdate);
400
+
401
+ const isError =
402
+ result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
403
+ if (isError) {
404
+ const reason =
405
+ result.stopReason ?? (result.exitCode === 0 ? "failed" : `exit ${result.exitCode}`);
406
+ const message =
407
+ result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
408
+ return {
409
+ content: [
410
+ { type: "text", text: `Subagent "${result.agent}" failed (${reason}): ${message}` },
411
+ ],
412
+ details: result,
413
+ isError: true,
414
+ };
415
+ }
416
+
417
+ const output = getFinalOutput(result.messages) || "(no output)";
418
+ const truncation = truncateTail(output, { maxBytes: MAX_OUTPUT_BYTES });
419
+ const text = truncation.truncated
420
+ ? `${truncation.content}\n\n[Output truncated to ${formatTokens(truncation.content.length)} bytes. Full result preserved in tool details.]`
421
+ : output;
422
+ return { content: [{ type: "text", text }], details: result };
423
+ },
424
+
425
+ renderCall(args, theme) {
426
+ const preview = args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task;
427
+ let text = theme.fg("toolTitle", theme.bold("spawn_agent ")) + theme.fg("accent", args.agent);
428
+ text += `\n ${theme.fg("dim", preview)}`;
429
+ return new Text(text, 0, 0);
430
+ },
431
+
432
+ renderResult(result, { expanded }, theme) {
433
+ const details = result.details;
434
+ const isError =
435
+ details.exitCode !== 0 ||
436
+ details.stopReason === "error" ||
437
+ details.stopReason === "aborted";
438
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
439
+ const finalOutput = getFinalOutput(details.messages);
440
+ const usageStr = formatUsageStats(details.usage, details.model);
441
+
442
+ if (expanded) {
443
+ const container = new Container();
444
+ const header = `${icon} ${theme.fg("toolTitle", theme.bold(details.agent))}${
445
+ details.stopReason ? ` ${theme.fg("error", `[${details.stopReason}]`)}` : ""
446
+ }`;
447
+ container.addChild(new Text(header, 0, 0));
448
+ if (isError && details.errorMessage) {
449
+ container.addChild(new Text(theme.fg("error", `Error: ${details.errorMessage}`), 0, 0));
450
+ }
451
+ container.addChild(new Spacer(1));
452
+ container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
453
+ container.addChild(new Text(theme.fg("dim", details.task), 0, 0));
454
+ if (finalOutput) {
455
+ container.addChild(new Spacer(1));
456
+ container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
457
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, getMarkdownTheme()));
458
+ }
459
+ if (usageStr) {
460
+ container.addChild(new Spacer(1));
461
+ container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
462
+ }
463
+ return container;
464
+ }
465
+
466
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold(details.agent))}`;
467
+ if (isError && details.errorMessage) {
468
+ text += `\n${theme.fg("error", `Error: ${details.errorMessage}`)}`;
469
+ } else if (finalOutput) {
470
+ text += `\n${theme.fg("toolOutput", finalOutput.split("\n").slice(0, 5).join("\n"))}`;
471
+ } else {
472
+ text += `\n${theme.fg("muted", "(no output)")}`;
473
+ }
474
+ if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
475
+ return new Text(text, 0, 0);
476
+ },
477
+ });
478
+ }