pi-fast-subagent 0.1.2 → 0.3.0

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.
Files changed (3) hide show
  1. package/README.md +19 -13
  2. package/index.ts +449 -215
  3. package/package.json +6 -1
package/README.md CHANGED
@@ -8,8 +8,7 @@ Runs subagents with `createAgentSession()` in same process instead of spawning `
8
8
 
9
9
  - Single mode: `{ agent, task }`
10
10
  - Parallel mode: `{ tasks: [...] }`
11
- - Chain mode: `{ chain: [...] }`
12
- - Per-call or per-step model override
11
+ - Per-call model override
13
12
  - User + project agent discovery
14
13
  - Project agents override user agents
15
14
  - Max nesting depth guard
@@ -60,6 +59,24 @@ model: anthropic/claude-haiku-4-5
60
59
  You are code exploration specialist. Read relevant files, trace data flow, summarize findings clearly.
61
60
  ```
62
61
 
62
+ ## Slash Commands
63
+
64
+ ### `/agent`
65
+
66
+ List all available agents:
67
+
68
+ ```
69
+ /agent
70
+ ```
71
+
72
+ Show details for a specific agent (description, file path, model, tools, system prompt):
73
+
74
+ ```
75
+ /agent scout
76
+ ```
77
+
78
+ Tab-completion is supported for agent names.
79
+
63
80
  ## Usage
64
81
 
65
82
  ### List agents
@@ -108,17 +125,6 @@ subagent({
108
125
  })
109
126
  ```
110
127
 
111
- ### Chain
112
-
113
- ```js
114
- subagent({
115
- chain: [
116
- { agent: "scout", task: "Explore app structure" },
117
- { agent: "scout", task: "Based on this: {previous}\n\nExtract only auth flow." }
118
- ]
119
- })
120
- ```
121
-
122
128
  ## Notes
123
129
 
124
130
  - Async/background isolation not supported in-process
package/index.ts CHANGED
@@ -4,13 +4,20 @@
4
4
  * Uses createAgentSession() to run subagents in the same process as pi —
5
5
  * no subprocess spawn, no cold-start overhead.
6
6
  *
7
- * Drop-in replacement for pi-subagents subprocess mode.
8
- * Supports: single, parallel, chain.
7
+ * Supports: single, parallel.
9
8
  * Agent .md files are compatible with pi-subagents frontmatter format.
10
9
  */
11
10
 
12
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
13
- import { truncateToWidth } from "@mariozechner/pi-tui";
11
+ import type {
12
+ AgentToolResult,
13
+ AgentToolUpdateCallback,
14
+ ExtensionAPI,
15
+ ExtensionContext,
16
+ ToolRenderResultOptions,
17
+ } from "@mariozechner/pi-coding-agent";
18
+ import { Theme } from "@mariozechner/pi-coding-agent";
19
+ import { truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
20
+ import { truncateToVisualLines, keyHint } from "@mariozechner/pi-coding-agent";
14
21
  import {
15
22
  AuthStorage,
16
23
  createAgentSession,
@@ -19,9 +26,70 @@ import {
19
26
  ModelRegistry,
20
27
  SessionManager,
21
28
  } from "@mariozechner/pi-coding-agent";
29
+
30
+ type DefaultResourceLoaderOptions = ConstructorParameters<typeof DefaultResourceLoader>[0];
22
31
  import { Type } from "@sinclair/typebox";
23
32
  import { type AgentConfig, discoverAgents } from "./agents.js";
24
33
 
34
+ // ─── Tool arg summarizer (compact one-liner per tool call) ─────────────────────
35
+
36
+ function shortPath(p: unknown): string {
37
+ if (typeof p !== "string") return "";
38
+ const cwd = process.cwd();
39
+ if (p.startsWith(cwd + "/")) return p.slice(cwd.length + 1);
40
+ return p.replace(/^\/Users\/[^/]+\/[^/]+\//, "");
41
+ }
42
+
43
+ function summarizeToolArgs(toolName: unknown, toolInput: unknown): string {
44
+ const name = String(toolName ?? "");
45
+ const input =
46
+ toolInput && typeof toolInput === "object" ? (toolInput as Record<string, unknown>) : {};
47
+ const filePath = (): string => shortPath(input.path ?? input.file_path) || "";
48
+ switch (name) {
49
+ case "Read":
50
+ case "read":
51
+ case "Write":
52
+ case "write":
53
+ case "Edit":
54
+ case "edit":
55
+ return filePath();
56
+ case "Bash":
57
+ case "bash": {
58
+ const cmd = String(input.command ?? "");
59
+ return cmd.length > 80 ? cmd.slice(0, 77) + "..." : cmd;
60
+ }
61
+ case "Glob":
62
+ case "glob":
63
+ return String(input.pattern ?? "");
64
+ case "find": {
65
+ const pat = String(input.pattern ?? "");
66
+ const p = shortPath(input.path);
67
+ return p ? `${pat} in ${p}` : pat;
68
+ }
69
+ case "Grep":
70
+ case "grep": {
71
+ const pat = String(input.pattern ?? "");
72
+ const g = input.glob ? ` ${input.glob}` : "";
73
+ return `${pat}${g}`;
74
+ }
75
+ case "ls":
76
+ return shortPath(input.path) || "";
77
+ case "subagent": {
78
+ const agent = String(input.agent ?? "");
79
+ const t = String(input.task ?? "");
80
+ const summary = t.length > 50 ? t.slice(0, 47) + "..." : t;
81
+ return agent ? `${agent}: ${summary}` : summary;
82
+ }
83
+ default: {
84
+ for (const v of Object.values(input)) {
85
+ if (typeof v === "string" && v.length > 0)
86
+ return v.length > 60 ? v.slice(0, 57) + "..." : v;
87
+ }
88
+ return "";
89
+ }
90
+ }
91
+ }
92
+
25
93
  // ─── Shared auth (created once, reused across calls) ─────────────────────────
26
94
 
27
95
  let _authStorage: ReturnType<typeof AuthStorage.create> | null = null;
@@ -38,14 +106,45 @@ function getAuth() {
38
106
  const MAX_DEPTH = 2;
39
107
  const DEPTH_ENV = "PI_FAST_SUBAGENT_DEPTH";
40
108
 
109
+ interface ToolCallEntry {
110
+ id: string;
111
+ name: string;
112
+ argSummary: string;
113
+ result?: string;
114
+ isError?: boolean;
115
+ durMs?: number;
116
+ }
117
+
41
118
  interface RunResult {
42
119
  output: string;
43
120
  exitCode: number;
44
121
  error?: string;
45
122
  model?: string;
123
+ toolCalls: ToolCallEntry[];
46
124
  usage: { input: number; output: number; cost: number; turns: number };
47
125
  }
48
126
 
127
+ interface AgentRowStatus {
128
+ name: string;
129
+ taskSummary: string;
130
+ status: "pending" | "running" | "done" | "error";
131
+ durMs?: number;
132
+ toolCalls?: ToolCallEntry[];
133
+ responseText?: string;
134
+ }
135
+
136
+ interface SubagentDetails {
137
+ mode?: "single" | "parallel";
138
+ task?: string;
139
+ // parallel
140
+ parallelAgents?: AgentRowStatus[];
141
+ usage: RunResult["usage"];
142
+ running: boolean;
143
+ elapsedMs?: number;
144
+ model?: string;
145
+ toolCalls: ToolCallEntry[];
146
+ }
147
+
49
148
  type OnUpdate = (partial: { content: [{ type: "text"; text: string }]; details: unknown }) => void;
50
149
 
51
150
  function formatDuration(ms: number): string {
@@ -55,6 +154,9 @@ function formatDuration(ms: number): string {
55
154
  return m > 0 ? `${m}m ${rem}s` : `${rem}s`;
56
155
  }
57
156
 
157
+ // Module-level depth counter — avoids process.env race conditions in parallel mode
158
+ let _currentDepth = 0;
159
+
58
160
  async function runAgent(
59
161
  agent: AgentConfig,
60
162
  task: string,
@@ -62,13 +164,15 @@ async function runAgent(
62
164
  modelOverride: string | undefined,
63
165
  signal: AbortSignal | undefined,
64
166
  onUpdate: OnUpdate | undefined,
167
+ parentDepth?: number,
65
168
  ): Promise<RunResult> {
66
- const depth = parseInt(process.env[DEPTH_ENV] ?? "0", 10);
169
+ const depth = parentDepth ?? _currentDepth;
67
170
  if (depth >= MAX_DEPTH) {
68
171
  return {
69
172
  output: "",
70
173
  exitCode: 1,
71
174
  error: `Max subagent depth (${MAX_DEPTH}) exceeded. Increase PI_FAST_SUBAGENT_DEPTH env to allow deeper nesting.`,
175
+ toolCalls: [],
72
176
  usage: { input: 0, output: 0, cost: 0, turns: 0 },
73
177
  };
74
178
  }
@@ -77,7 +181,7 @@ async function runAgent(
77
181
  const agentDir = getAgentDir();
78
182
 
79
183
  // Build resource loader — no extensions/context files to keep subagent lean
80
- const loaderOptions: ConstructorParameters<typeof DefaultResourceLoader>[0] = {
184
+ const loaderOptions: DefaultResourceLoaderOptions = {
81
185
  cwd,
82
186
  agentDir,
83
187
  noExtensions: true,
@@ -124,54 +228,78 @@ async function runAgent(
124
228
  let detectedModel: string | undefined;
125
229
  const startedAt = Date.now();
126
230
  const configuredModel = modelOverride ?? agent.model;
231
+ const toolCalls: ToolCallEntry[] = [];
232
+ const toolStartTimes = new Map<string, number>();
127
233
 
128
- onUpdate?.({
129
- content: [{ type: "text", text: "Starting subagent..." }],
130
- details: {
131
- agent: agent.name,
132
- usage,
133
- running: true,
134
- elapsedMs: 0,
135
- model: configuredModel,
136
- },
137
- });
138
-
139
- const heartbeat = setInterval(() => {
234
+ function emitUpdate(): void {
140
235
  onUpdate?.({
141
- content: [{ type: "text", text: currentDelta || lastOutput || "Running..." }],
236
+ content: [{ type: "text", text: currentDelta || lastOutput || "" }],
142
237
  details: {
143
- agent: agent.name,
238
+ task,
144
239
  usage,
145
240
  running: true,
146
241
  elapsedMs: Date.now() - startedAt,
147
242
  model: detectedModel ?? configuredModel,
148
- },
243
+ toolCalls: [...toolCalls],
244
+ } satisfies SubagentDetails,
149
245
  });
150
- }, 1000);
246
+ }
247
+
248
+ emitUpdate();
249
+
250
+ const heartbeat = setInterval(emitUpdate, 1000);
151
251
 
152
252
  const unsubscribe = session.subscribe((event: any) => {
253
+ // Stream tool execution events
254
+ if (event.type === "tool_execution_start") {
255
+ toolStartTimes.set(event.toolCallId, Date.now());
256
+ toolCalls.push({
257
+ id: event.toolCallId,
258
+ name: event.toolName,
259
+ argSummary: summarizeToolArgs(event.toolName, event.args),
260
+ });
261
+ emitUpdate();
262
+ return;
263
+ }
264
+
265
+ if (event.type === "tool_execution_end") {
266
+ const startedAtTool = toolStartTimes.get(event.toolCallId);
267
+ toolStartTimes.delete(event.toolCallId);
268
+ const resultText: string = (event.result?.content ?? [])
269
+ .filter((p: any) => p.type === "text")
270
+ .map((p: any) => p.text as string)
271
+ .join("\n");
272
+ let entry: ToolCallEntry | undefined;
273
+ for (let i = toolCalls.length - 1; i >= 0; i--) {
274
+ if (toolCalls[i]!.id === event.toolCallId) { entry = toolCalls[i]; break; }
275
+ }
276
+ if (!entry) {
277
+ for (let i = toolCalls.length - 1; i >= 0; i--) {
278
+ if (toolCalls[i]!.name === event.toolName && toolCalls[i]!.result === undefined) { entry = toolCalls[i]; break; }
279
+ }
280
+ }
281
+ if (entry) {
282
+ entry.result = resultText;
283
+ entry.isError = event.isError;
284
+ entry.durMs = startedAtTool != null ? Date.now() - startedAtTool : undefined;
285
+ }
286
+ emitUpdate();
287
+ return;
288
+ }
289
+
153
290
  // Stream text deltas live to the UI
154
291
  if (event.type === "message_update") {
155
292
  const e = event.assistantMessageEvent;
156
293
  if (e?.type === "text_delta" && e.delta) {
157
294
  currentDelta += e.delta;
158
- onUpdate?.({
159
- content: [{ type: "text", text: currentDelta }],
160
- details: {
161
- agent: agent.name,
162
- usage,
163
- running: true,
164
- elapsedMs: Date.now() - startedAt,
165
- model: detectedModel ?? configuredModel,
166
- },
167
- });
295
+ emitUpdate();
168
296
  }
169
297
  return;
170
298
  }
171
299
 
172
300
  if (event.type !== "message_end" || !event.message) return;
173
301
  const msg = event.message;
174
- if (msg.role !== "assistant") return;
302
+ if (msg.role !== "assistant") return; // usage/model only tracked for assistant turns
175
303
 
176
304
  usage.turns++;
177
305
  const u = msg.usage;
@@ -204,9 +332,10 @@ async function runAgent(
204
332
  });
205
333
  });
206
334
 
207
- // Propagate depth to any nested fast-subagent calls
208
- const prevDepth = process.env[DEPTH_ENV];
335
+ // Propagate depth to nested calls — use module counter (safe for parallel) + env for subprocess compat
336
+ const prevEnvDepth = process.env[DEPTH_ENV];
209
337
  process.env[DEPTH_ENV] = String(depth + 1);
338
+ _currentDepth = depth + 1;
210
339
 
211
340
  let exitCode = 0;
212
341
  let error: string | undefined;
@@ -228,11 +357,12 @@ async function runAgent(
228
357
  clearInterval(heartbeat);
229
358
  unsubscribe();
230
359
  session.dispose();
231
- if (prevDepth === undefined) delete process.env[DEPTH_ENV];
232
- else process.env[DEPTH_ENV] = prevDepth;
360
+ if (prevEnvDepth === undefined) delete process.env[DEPTH_ENV];
361
+ else process.env[DEPTH_ENV] = prevEnvDepth;
362
+ _currentDepth = depth;
233
363
  }
234
364
 
235
- return { output: lastOutput, exitCode, error, model: detectedModel, usage };
365
+ return { output: lastOutput, exitCode, error, model: detectedModel, toolCalls, usage };
236
366
  }
237
367
 
238
368
  // ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -289,19 +419,6 @@ const TaskItem = Type.Object({
289
419
  count: Type.Optional(Type.Number({ description: "Repeat this task N times" })),
290
420
  });
291
421
 
292
- const ChainItem = Type.Object({
293
- agent: Type.String({ description: "Agent name" }),
294
- task: Type.Optional(
295
- Type.String({
296
- description:
297
- "Task template. Supports {previous} (output from prior step) and {task} (first step task). " +
298
- "Defaults to {previous} for steps 2+.",
299
- }),
300
- ),
301
- model: Type.Optional(Type.String({ description: "Model override (provider/model)" })),
302
- cwd: Type.Optional(Type.String({ description: "Working directory" })),
303
- });
304
-
305
422
  const SubagentParams = Type.Object({
306
423
  // Single mode
307
424
  agent: Type.Optional(Type.String({ description: "Agent name (single mode)" })),
@@ -319,13 +436,6 @@ const SubagentParams = Type.Object({
319
436
  Type.Number({ description: "Max parallel concurrency (default: 4)", default: 4 }),
320
437
  ),
321
438
 
322
- // Chain mode
323
- chain: Type.Optional(
324
- Type.Array(ChainItem, {
325
- description: "Sequential chain. Use {previous} in task to receive prior step output.",
326
- }),
327
- ),
328
-
329
439
  // Management
330
440
  action: Type.Optional(
331
441
  Type.Union(
@@ -347,59 +457,257 @@ const SubagentParams = Type.Object({
347
457
  // ─── Extension entry point ────────────────────────────────────────────────────
348
458
 
349
459
  export default function (pi: ExtensionAPI) {
460
+ // ─── /agent slash command ─────────────────────────────────────────────────
461
+ pi.registerCommand("agent", {
462
+ description: "List available subagents. Usage: /agent [name] — show details for a specific agent.",
463
+ getArgumentCompletions(prefix: string) {
464
+ const agents = discoverAgents(process.cwd());
465
+ return agents
466
+ .filter((a) => a.name.startsWith(prefix))
467
+ .map((a) => ({ value: a.name, label: a.name, description: a.description }));
468
+ },
469
+ async handler(args: string, ctx) {
470
+ const agents = discoverAgents(ctx.cwd);
471
+ const name = args.trim();
472
+
473
+ if (name) {
474
+ const agent = agents.find((a) => a.name === name);
475
+ if (!agent) {
476
+ const list = agents.map((a) => a.name).join(", ") || "none";
477
+ ctx.ui.notify(`Unknown agent "${name}". Available: ${list}`, "warning");
478
+ return;
479
+ }
480
+ const lines = [
481
+ `## ${agent.name} [${agent.source}]`,
482
+ `File: ${agent.filePath}`,
483
+ `Description: ${agent.description}`,
484
+ agent.model ? `Model: ${agent.model}` : "",
485
+ agent.tools ? `Tools: ${agent.tools.join(", ")}` : "",
486
+ agent.systemPrompt ? `\nSystem prompt:\n${agent.systemPrompt}` : "",
487
+ ].filter(Boolean).join("\n");
488
+ ctx.ui.notify(lines, "info");
489
+ return;
490
+ }
491
+
492
+ if (agents.length === 0) {
493
+ ctx.ui.notify(
494
+ "No agents found.\n" +
495
+ "Add .md files to:\n" +
496
+ " ~/.pi/agent/agents/ (user-level)\n" +
497
+ " .pi/agents/ (project-level)\n" +
498
+ "\nFrontmatter required: name, description. Optional: model, tools.",
499
+ "info"
500
+ );
501
+ return;
502
+ }
503
+
504
+ const userAgents = agents.filter((a) => a.source === "user");
505
+ const projectAgents = agents.filter((a) => a.source === "project");
506
+
507
+ const lines: string[] = [`Agents (${agents.length}):`];
508
+ if (projectAgents.length) {
509
+ lines.push("\nProject (.pi/agents/):");
510
+ for (const a of projectAgents) {
511
+ lines.push(` ${a.name}${a.model ? ` [${a.model}]` : ""} — ${a.description}`);
512
+ }
513
+ }
514
+ if (userAgents.length) {
515
+ lines.push("\nUser (~/.pi/agent/agents/):");
516
+ for (const a of userAgents) {
517
+ lines.push(` ${a.name}${a.model ? ` [${a.model}]` : ""} — ${a.description}`);
518
+ }
519
+ }
520
+ lines.push("");
521
+ lines.push("Tip: /agent <name> for details · Add .md files to .pi/agents/ to create new agents");
522
+ ctx.ui.notify(lines.join("\n"), "info");
523
+ },
524
+ });
525
+
350
526
  pi.registerTool({
351
527
  name: "subagent",
352
528
  label: "Subagent",
353
529
  description: [
354
530
  "Delegate tasks to specialized subagents. Runs IN-PROCESS — no subprocess cold-start overhead.",
355
- "Modes: single ({ agent, task }), parallel ({ tasks: [...] }), chain ({ chain: [...] }).",
356
- "Chain supports {task} (first step task) and {previous} (prior step output) template vars.",
531
+ "Modes: single ({ agent, task }), parallel ({ tasks: [...] }).",
357
532
  "Agents defined as .md files in ~/.pi/agent/agents/ (user) or .pi/agents/ (project).",
358
533
  "Use { action: 'list' } to discover available agents.",
359
534
  ].join(" "),
360
535
  parameters: SubagentParams,
361
536
 
362
- renderResult(result, { isPartial }, theme) {
363
- const text = result.content?.[0]?.type === "text" ? result.content[0].text : "";
364
- const details = (result.details ?? {}) as {
365
- usage?: RunResult["usage"];
366
- running?: boolean;
367
- elapsedMs?: number;
368
- model?: string;
369
- };
537
+ renderResult(result: AgentToolResult<unknown>, { isPartial, expanded }: ToolRenderResultOptions, theme: Theme) {
538
+ const agentText = result.content?.[0]?.type === "text" ? (result.content[0] as any).text as string : "";
539
+ const details = (result.details ?? {}) as SubagentDetails;
540
+ const toolCalls = details.toolCalls ?? [];
541
+
542
+ // ── Parallel / Chain mode renders ────────────────────────────────
543
+ if (details.mode === "parallel" && details.parallelAgents) {
544
+ const agents = details.parallelAgents;
545
+ const doneCount = agents.filter((a) => a.status === "done" || a.status === "error").length;
546
+
547
+ function agentToolRow(t: ToolCallEntry): string {
548
+ const arg = t.argSummary || "";
549
+ const call = `${t.name}(${arg})`;
550
+ if (t.result === undefined) return theme.fg("dim", call);
551
+ const dur = t.durMs != null ? (t.durMs < 1000 ? ` ${t.durMs}ms` : ` ${(t.durMs / 1000).toFixed(1)}s`) : "";
552
+ return `${call}${t.isError ? " ✗" : ` ✓${dur}`}`;
553
+ }
554
+
555
+ function wrapL(text: string, w: number): string[] {
556
+ try { return wrapTextWithAnsi(text, w); } catch { return [truncateToWidth(text, w, "...")]; }
557
+ }
370
558
 
371
- const statusLines: string[] = [];
372
- if (details.running) {
373
- const statusParts: string[] = ["running"];
374
- if (details.usage?.turns) statusParts.push(`${details.usage.turns} turn${details.usage.turns > 1 ? "s" : ""}`);
375
- if (details.elapsedMs !== undefined) statusParts.push(formatDuration(details.elapsedMs));
376
- if (details.model) statusParts.push(details.model);
377
- statusLines.push(statusParts.join(" · "));
378
- } else if (details.usage) {
379
- const usageStr = formatUsage(details.usage, details.model);
380
- if (usageStr) statusLines.push(usageStr);
559
+ const cache: { width?: number } = {};
560
+ return {
561
+ invalidate() { cache.width = undefined; },
562
+ render(width: number): string[] {
563
+ const out: string[] = [];
564
+ const header = details.running
565
+ ? `Parallel (${doneCount}/${agents.length} done)`
566
+ : `Parallel: ${agents.filter((a) => a.status === "done").length}/${agents.length} succeeded`;
567
+ out.push(truncateToWidth(header, width, "..."));
568
+
569
+ for (const a of agents) {
570
+ const dur = a.durMs != null ? (a.durMs < 1000 ? ` ${a.durMs}ms` : ` ${(a.durMs / 1000).toFixed(1)}s`) : "";
571
+ const mark = a.status === "pending" ? theme.fg("dim", "⋅") : a.status === "running" ? theme.fg("dim", "→") : a.status === "done" ? `✓${dur}` : `✗${dur}`;
572
+
573
+ if (expanded) {
574
+ // Full solo-style block per agent
575
+ out.push("");
576
+ out.push(truncateToWidth(`[${a.name}] ${mark}`, width, "..."));
577
+ out.push(truncateToWidth(`Prompt:`, width, "..."));
578
+ out.push(truncateToWidth(` ${a.taskSummary}`, width, "..."));
579
+ for (const t of a.toolCalls ?? []) {
580
+ out.push(truncateToWidth(agentToolRow(t), width, "..."));
581
+ }
582
+ if (a.responseText) {
583
+ out.push("Response:");
584
+ const preview = truncateToVisualLines(a.responseText, 6, width - 2);
585
+ for (const l of preview.visualLines) out.push(truncateToWidth(" " + l, width, "..."));
586
+ if (preview.skippedCount > 0) out.push(truncateToWidth(theme.fg("dim", ` … ${preview.skippedCount} more lines`), width, "..."));
587
+ } else if (a.status === "running") {
588
+ out.push(theme.fg("dim", " running..."));
589
+ }
590
+ } else {
591
+ // Collapsed: compact one-liner
592
+ const row = ` [${a.name}] ${mark} ${a.taskSummary}`;
593
+ out.push(truncateToWidth(row, width, "..."));
594
+ // Show tool call rows compactly
595
+ for (const t of a.toolCalls ?? []) {
596
+ out.push(truncateToWidth(` ${agentToolRow(t)}`, width, "..."));
597
+ }
598
+ if (a.responseText && (a.status === "done" || a.status === "error")) {
599
+ const preview = truncateToVisualLines(a.responseText, 2, width - 4);
600
+ for (const l of preview.visualLines) out.push(truncateToWidth(" " + l, width, "..."));
601
+ }
602
+ }
603
+ }
604
+
605
+ out.push("");
606
+ const status = details.running
607
+ ? ["running", details.usage?.turns ? `${details.usage.turns} turn${details.usage.turns > 1 ? "s" : ""}` : ""].filter(Boolean).join(" · ")
608
+ : formatUsage(details.usage ?? { input: 0, output: 0, cost: 0, turns: 0 }, details.model);
609
+ const expandHint = !expanded ? keyHint("app.tools.expand", "expand for full output") : "";
610
+ out.push(truncateToWidth([status, expandHint].filter(Boolean).join(" "), width, "..."));
611
+ return out;
612
+ },
613
+ };
614
+ }
615
+
616
+ function statusLine(): string {
617
+ if (details.running) {
618
+ const parts: string[] = ["running"];
619
+ if (details.usage?.turns) parts.push(`${details.usage.turns} turn${details.usage.turns > 1 ? "s" : ""}`);
620
+ if (details.elapsedMs != null) parts.push(formatDuration(details.elapsedMs));
621
+ if (details.model) parts.push(details.model);
622
+ return parts.join(" · ");
623
+ }
624
+ return formatUsage(details.usage ?? { input: 0, output: 0, cost: 0, turns: 0 }, details.model);
625
+ }
626
+
627
+ // Name(arg) ✓ 0.3s or Name(arg) (dim, still running)
628
+ function toolRow(t: ToolCallEntry): string {
629
+ const arg = t.argSummary ? t.argSummary : "";
630
+ const call = `${t.name}(${arg})`;
631
+ if (t.result === undefined) return theme.fg("dim", call);
632
+ const dur = t.durMs != null
633
+ ? t.durMs < 1000 ? ` ${t.durMs}ms` : ` ${(t.durMs / 1000).toFixed(1)}s`
634
+ : "";
635
+ return `${call}${t.isError ? " ✗" : ` ✓${dur}`}`;
381
636
  }
382
637
 
383
- // Apply dim per-line rather than across the whole block to avoid ANSI codes spanning
384
- // newlines, which confuses wrapTextWithAnsi ANSI state tracking.
385
- const textLines = (text || (isPartial ? "Running..." : "")).split("\n");
386
- const styledLines = isPartial
387
- ? [...textLines.map((l) => theme.fg("dim", l)), ...statusLines]
388
- : [...textLines, ...statusLines];
389
-
390
- // Use a custom component with truncateToWidth per line instead of Text + wrapTextWithAnsi.
391
- // visibleWidth (used by wrapTextWithAnsi) undercounts wide chars (emoji/CJK), causing the
392
- // TUI to crash with "Rendered line exceeds terminal width". truncateToWidth uses
393
- // graphemeWidth internally and correctly measures wide chars.
638
+ function wrapLine(text: string, w: number): string[] {
639
+ try { return wrapTextWithAnsi(text, w); } catch { return [truncateToWidth(text, w, "...")]; }
640
+ }
641
+
642
+ const cache: { width?: number; responseLines?: string[]; skipped?: number } = {};
643
+
394
644
  return {
395
- invalidate() {},
645
+ invalidate() { cache.width = undefined; },
396
646
  render(width: number): string[] {
397
- return styledLines.map((line) => truncateToWidth(line, width, "...", true));
647
+ const out: string[] = [];
648
+ const indent = " ";
649
+
650
+ // ── Prompt ────────────────────────────────────────────────────
651
+ if (details.task) {
652
+ out.push("Prompt:");
653
+ const taskLines = details.task.split("\n");
654
+ if (expanded) {
655
+ for (const line of taskLines) {
656
+ for (const w of wrapLine(indent + line, width)) out.push(w);
657
+ }
658
+ } else {
659
+ // Single truncated line in collapsed
660
+ const oneLiner = taskLines[0] ?? "";
661
+ out.push(truncateToWidth(indent + oneLiner, width, "..."));
662
+ }
663
+ }
664
+
665
+ // ── Tool calls ─────────────────────────────────────────────
666
+ for (const t of toolCalls) {
667
+ out.push(truncateToWidth(toolRow(t), width, "..."));
668
+ if (expanded && t.result !== undefined) {
669
+ for (const line of t.result.split("\n")) {
670
+ for (const w of wrapLine(theme.fg("dim", indent + line), width)) out.push(w);
671
+ }
672
+ }
673
+ }
674
+
675
+ // ── Response ────────────────────────────────────────────
676
+ const responseText = agentText || (isPartial ? "" : "");
677
+ if (responseText || isPartial) {
678
+ out.push("Response:");
679
+ if (expanded) {
680
+ for (const line of responseText.split("\n")) {
681
+ for (const w of wrapLine(indent + line, width)) out.push(w);
682
+ }
683
+ } else {
684
+ const PREVIEW_LINES = 6;
685
+ if (cache.width !== width) {
686
+ const preview = truncateToVisualLines(responseText, PREVIEW_LINES, width - indent.length);
687
+ cache.responseLines = preview.visualLines.map((l) => truncateToWidth(indent + l, width, "..."));
688
+ cache.skipped = preview.skippedCount;
689
+ cache.width = width;
690
+ }
691
+ out.push(...(cache.responseLines ?? []));
692
+ }
693
+ }
694
+
695
+ // ── Status ───────────────────────────────────────────────
696
+ const status = statusLine();
697
+ const expandHint = !expanded && (cache.skipped ?? 0) > 0
698
+ ? keyHint("app.tools.expand", `expand · ${cache.skipped} lines hidden`)
699
+ : !expanded && toolCalls.some((t) => t.result !== undefined)
700
+ ? keyHint("app.tools.expand", "expand for tool outputs")
701
+ : "";
702
+ const statusWithHint = [status, expandHint].filter(Boolean).join(" ");
703
+ if (statusWithHint) out.push(truncateToWidth(statusWithHint, width, "..."));
704
+
705
+ return out;
398
706
  },
399
707
  };
400
708
  },
401
709
 
402
- async execute(_id, params, signal, onUpdate, ctx) {
710
+ async execute(_id: string, params: Record<string, any>, signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback<unknown> | undefined, ctx: ExtensionContext): Promise<any> {
403
711
  const cwd = params.cwd ?? ctx.cwd;
404
712
  const agents = discoverAgents(cwd);
405
713
 
@@ -413,7 +721,7 @@ export default function (pi: ExtensionAPI) {
413
721
  };
414
722
 
415
723
  // ── Management: list ──────────────────────────────────────────────────────
416
- if (params.action === "list" || (!params.agent && !params.tasks && !params.chain)) {
724
+ if (params.action === "list" || (!params.agent && !params.tasks)) {
417
725
  if (agents.length === 0) {
418
726
  return {
419
727
  content: [{
@@ -459,18 +767,19 @@ export default function (pi: ExtensionAPI) {
459
767
  return {
460
768
  content: [{ type: "text", text: getFinalText(result) }],
461
769
  details: {
770
+ task: params.task,
462
771
  usage: result.usage,
463
772
  running: false,
464
773
  elapsedMs: undefined,
465
774
  model: result.model,
466
- },
775
+ toolCalls: result.toolCalls,
776
+ } satisfies SubagentDetails,
467
777
  isError: result.exitCode !== 0,
468
778
  };
469
779
  }
470
780
 
471
- // ── Parallel mode ─────────────────────────────────────────────────────────
781
+ // ── Parallel mode ─────────────────────────────────────────────
472
782
  if (params.tasks && params.tasks.length > 0) {
473
- // Expand count shorthand
474
783
  const expanded: Array<{ agent: string; task: string; model?: string; cwd?: string }> = [];
475
784
  for (const t of params.tasks) {
476
785
  const n = t.count ?? 1;
@@ -478,138 +787,63 @@ export default function (pi: ExtensionAPI) {
478
787
  }
479
788
 
480
789
  const concurrency = params.concurrency ?? 4;
481
- let doneCount = 0;
482
-
483
- const allResults = await mapConcurrent(
484
- expanded,
485
- concurrency,
486
- async (t, _i) => {
487
- const { agent, error } = findAgent(t.agent);
488
- if (error || !agent) {
489
- return { agentName: t.agent, output: "", exitCode: 1, error, model: undefined, usage: { input: 0, output: 0, cost: 0, turns: 0 } };
490
- }
491
- const result = await runAgent(agent, t.task, t.cwd ?? cwd, t.model, signal, undefined);
492
- doneCount++;
493
- onUpdate?.({
494
- content: [{ type: "text", text: `Parallel: ${doneCount}/${expanded.length} done...` }],
495
- details: {},
496
- });
497
- return { ...result, agentName: t.agent };
498
- },
499
- );
500
-
501
- const successCount = allResults.filter((r) => r.exitCode === 0).length;
502
- const summaries = allResults.map((r) => {
503
- const out = getFinalText(r);
504
- const preview = out.length > 300 ? `${out.slice(0, 300)}...` : out;
505
- return `**[${r.agentName}]** ${r.exitCode === 0 ? "✓" : "✗"}\n${preview}`;
790
+ const emptyUsage = { input: 0, output: 0, cost: 0, turns: 0 };
791
+ const parallelAgents: AgentRowStatus[] = expanded.map((t) => ({
792
+ name: t.agent,
793
+ taskSummary: t.task.length > 60 ? t.task.slice(0, 57) + "..." : t.task,
794
+ status: "pending" as const,
795
+ }));
796
+ let runningUsage = { ...emptyUsage };
797
+
798
+ const emitParallel = (running: boolean) => onUpdate?.({
799
+ content: [{ type: "text", text: "" }],
800
+ details: { mode: "parallel", parallelAgents: [...parallelAgents], usage: { ...runningUsage }, running, toolCalls: [] } satisfies SubagentDetails,
506
801
  });
507
- const totalUsage = allResults.reduce(
508
- (acc, r) => ({
509
- input: acc.input + r.usage.input,
510
- output: acc.output + r.usage.output,
511
- cost: acc.cost + r.usage.cost,
512
- turns: acc.turns + r.usage.turns,
513
- }),
514
- { input: 0, output: 0, cost: 0, turns: 0 },
515
- );
516
802
 
517
- return {
518
- content: [{
519
- type: "text",
520
- text: [
521
- `Parallel: ${successCount}/${allResults.length} succeeded`,
522
- "",
523
- summaries.join("\n\n"),
524
- "",
525
- formatUsage(totalUsage),
526
- ].join("\n"),
527
- }],
528
- };
529
- }
530
-
531
- // ── Chain mode ────────────────────────────────────────────────────────────
532
- if (params.chain && params.chain.length > 0) {
533
- const firstTask = params.chain[0]?.task ?? "";
534
- let previousOutput = "";
803
+ emitParallel(true);
535
804
 
536
- const stepResults: Array<RunResult & { agentName: string; step: number }> = [];
537
-
538
- for (let i = 0; i < params.chain.length; i++) {
539
- const step = params.chain[i];
540
- const { agent, error } = findAgent(step.agent);
805
+ const parentDepth = _currentDepth;
806
+ const allResults = await mapConcurrent(expanded, concurrency, async (t, i) => {
807
+ parallelAgents[i]!.status = "running";
808
+ emitParallel(true);
809
+ const { agent, error } = findAgent(t.agent);
541
810
  if (error || !agent) {
542
- return {
543
- content: [{ type: "text", text: `Chain stopped at step ${i + 1}: ${error ?? "Not found"}` }],
544
- isError: true,
545
- };
546
- }
547
-
548
- // Resolve task template
549
- let task = step.task ?? (i === 0 ? firstTask : "{previous}");
550
- task = task
551
- .replace(/\{previous\}/g, previousOutput)
552
- .replace(/\{task\}/g, firstTask);
553
-
554
- if (onUpdate) {
555
- onUpdate({
556
- content: [{
557
- type: "text",
558
- text: `Chain step ${i + 1}/${params.chain.length}: ${step.agent}...`,
559
- }],
560
- details: {},
561
- });
811
+ parallelAgents[i]!.status = "error";
812
+ emitParallel(true);
813
+ return { agentName: t.agent, output: "", exitCode: 1, error, model: undefined, toolCalls: [] as ToolCallEntry[], usage: emptyUsage };
562
814
  }
815
+ const agentStart = Date.now();
816
+ const agentOnUpdate: OnUpdate = (partial) => {
817
+ const d = partial.details as SubagentDetails | undefined;
818
+ parallelAgents[i]!.toolCalls = d?.toolCalls ? [...d.toolCalls] : parallelAgents[i]!.toolCalls;
819
+ parallelAgents[i]!.responseText = (partial.content?.[0] as any)?.text || parallelAgents[i]!.responseText;
820
+ emitParallel(true);
821
+ };
822
+ const result = await runAgent(agent, t.task, t.cwd ?? cwd, t.model, signal, agentOnUpdate, parentDepth);
823
+ parallelAgents[i]!.status = result.exitCode === 0 ? "done" : "error";
824
+ parallelAgents[i]!.durMs = Date.now() - agentStart;
825
+ parallelAgents[i]!.toolCalls = result.toolCalls;
826
+ parallelAgents[i]!.responseText = result.output;
827
+ runningUsage = { input: runningUsage.input + result.usage.input, output: runningUsage.output + result.usage.output, cost: runningUsage.cost + result.usage.cost, turns: runningUsage.turns + result.usage.turns };
828
+ emitParallel(true);
829
+ return { ...result, agentName: t.agent, toolCalls: result.toolCalls ?? [] };
830
+ });
563
831
 
564
- const result = await runAgent(
565
- agent,
566
- task,
567
- step.cwd ?? cwd,
568
- step.model,
569
- signal,
570
- onUpdate,
571
- );
572
-
573
- stepResults.push({ ...result, agentName: step.agent, step: i + 1 });
574
-
575
- if (result.exitCode !== 0) {
576
- return {
577
- content: [{
578
- type: "text",
579
- text: `Chain failed at step ${i + 1} (${step.agent}): ${result.error ?? "(no output)"}`,
580
- }],
581
- isError: true,
582
- };
583
- }
584
-
585
- previousOutput = result.output;
586
- }
587
-
588
- const last = stepResults[stepResults.length - 1];
589
- const totalUsage = stepResults.reduce(
590
- (acc, r) => ({
591
- input: acc.input + r.usage.input,
592
- output: acc.output + r.usage.output,
593
- cost: acc.cost + r.usage.cost,
594
- turns: acc.turns + r.usage.turns,
595
- }),
596
- { input: 0, output: 0, cost: 0, turns: 0 },
832
+ const totalUsage = allResults.reduce(
833
+ (acc, r) => ({ input: acc.input + r.usage.input, output: acc.output + r.usage.output, cost: acc.cost + r.usage.cost, turns: acc.turns + r.usage.turns }),
834
+ emptyUsage,
597
835
  );
836
+ const outputs = allResults.map((r) => `[${r.agentName}] ${r.exitCode === 0 ? "✓" : "✗"}\n${getFinalText(r)}`).join("\n\n");
598
837
 
599
838
  return {
600
- content: [{
601
- type: "text",
602
- text: [
603
- last.output,
604
- "",
605
- `Chain: ${stepResults.length} steps · ${formatUsage(totalUsage)}`,
606
- ].join("\n"),
607
- }],
839
+ content: [{ type: "text", text: outputs }],
840
+ details: { mode: "parallel", parallelAgents, usage: totalUsage, running: false, toolCalls: [] } satisfies SubagentDetails,
608
841
  };
609
842
  }
610
843
 
844
+ // ── Chain mode ────────────────────────────────────────────
611
845
  // Shouldn't reach here
612
- return { content: [{ type: "text", text: "Provide agent+task, tasks array, or chain array." }] };
846
+ return { content: [{ type: "text", text: "Provide agent+task or tasks array." }] };
613
847
  },
614
848
  });
615
849
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fast-subagent",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "In-process subagent delegation for pi with single, parallel, and chain modes",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -34,5 +34,10 @@
34
34
  "@mariozechner/pi-tui": "*",
35
35
  "@sinclair/typebox": "*"
36
36
  },
37
+ "devDependencies": {
38
+ "@mariozechner/pi-coding-agent": "^0.68.0",
39
+ "@mariozechner/pi-tui": "^0.68.0",
40
+ "@sinclair/typebox": "^0.34.41"
41
+ },
37
42
  "license": "MIT"
38
43
  }