pi-fast-subagent 0.1.2 → 0.2.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 +1 -13
  2. package/index.ts +383 -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
@@ -108,17 +107,6 @@ subagent({
108
107
  })
109
108
  ```
110
109
 
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
110
  ## Notes
123
111
 
124
112
  - 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(
@@ -352,54 +462,186 @@ export default function (pi: ExtensionAPI) {
352
462
  label: "Subagent",
353
463
  description: [
354
464
  "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.",
465
+ "Modes: single ({ agent, task }), parallel ({ tasks: [...] }).",
357
466
  "Agents defined as .md files in ~/.pi/agent/agents/ (user) or .pi/agents/ (project).",
358
467
  "Use { action: 'list' } to discover available agents.",
359
468
  ].join(" "),
360
469
  parameters: SubagentParams,
361
470
 
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
- };
471
+ renderResult(result: AgentToolResult<unknown>, { isPartial, expanded }: ToolRenderResultOptions, theme: Theme) {
472
+ const agentText = result.content?.[0]?.type === "text" ? (result.content[0] as any).text as string : "";
473
+ const details = (result.details ?? {}) as SubagentDetails;
474
+ const toolCalls = details.toolCalls ?? [];
475
+
476
+ // ── Parallel / Chain mode renders ────────────────────────────────
477
+ if (details.mode === "parallel" && details.parallelAgents) {
478
+ const agents = details.parallelAgents;
479
+ const doneCount = agents.filter((a) => a.status === "done" || a.status === "error").length;
480
+
481
+ function agentToolRow(t: ToolCallEntry): string {
482
+ const arg = t.argSummary || "";
483
+ const call = `${t.name}(${arg})`;
484
+ if (t.result === undefined) return theme.fg("dim", call);
485
+ const dur = t.durMs != null ? (t.durMs < 1000 ? ` ${t.durMs}ms` : ` ${(t.durMs / 1000).toFixed(1)}s`) : "";
486
+ return `${call}${t.isError ? " ✗" : ` ✓${dur}`}`;
487
+ }
488
+
489
+ function wrapL(text: string, w: number): string[] {
490
+ try { return wrapTextWithAnsi(text, w); } catch { return [truncateToWidth(text, w, "...")]; }
491
+ }
370
492
 
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);
493
+ const cache: { width?: number } = {};
494
+ return {
495
+ invalidate() { cache.width = undefined; },
496
+ render(width: number): string[] {
497
+ const out: string[] = [];
498
+ const header = details.running
499
+ ? `Parallel (${doneCount}/${agents.length} done)`
500
+ : `Parallel: ${agents.filter((a) => a.status === "done").length}/${agents.length} succeeded`;
501
+ out.push(truncateToWidth(header, width, "..."));
502
+
503
+ for (const a of agents) {
504
+ const dur = a.durMs != null ? (a.durMs < 1000 ? ` ${a.durMs}ms` : ` ${(a.durMs / 1000).toFixed(1)}s`) : "";
505
+ const mark = a.status === "pending" ? theme.fg("dim", "⋅") : a.status === "running" ? theme.fg("dim", "→") : a.status === "done" ? `✓${dur}` : `✗${dur}`;
506
+
507
+ if (expanded) {
508
+ // Full solo-style block per agent
509
+ out.push("");
510
+ out.push(truncateToWidth(`[${a.name}] ${mark}`, width, "..."));
511
+ out.push(truncateToWidth(`Prompt:`, width, "..."));
512
+ out.push(truncateToWidth(` ${a.taskSummary}`, width, "..."));
513
+ for (const t of a.toolCalls ?? []) {
514
+ out.push(truncateToWidth(agentToolRow(t), width, "..."));
515
+ }
516
+ if (a.responseText) {
517
+ out.push("Response:");
518
+ const preview = truncateToVisualLines(a.responseText, 6, width - 2);
519
+ for (const l of preview.visualLines) out.push(truncateToWidth(" " + l, width, "..."));
520
+ if (preview.skippedCount > 0) out.push(truncateToWidth(theme.fg("dim", ` … ${preview.skippedCount} more lines`), width, "..."));
521
+ } else if (a.status === "running") {
522
+ out.push(theme.fg("dim", " running..."));
523
+ }
524
+ } else {
525
+ // Collapsed: compact one-liner
526
+ const row = ` [${a.name}] ${mark} ${a.taskSummary}`;
527
+ out.push(truncateToWidth(row, width, "..."));
528
+ // Show tool call rows compactly
529
+ for (const t of a.toolCalls ?? []) {
530
+ out.push(truncateToWidth(` ${agentToolRow(t)}`, width, "..."));
531
+ }
532
+ if (a.responseText && (a.status === "done" || a.status === "error")) {
533
+ const preview = truncateToVisualLines(a.responseText, 2, width - 4);
534
+ for (const l of preview.visualLines) out.push(truncateToWidth(" " + l, width, "..."));
535
+ }
536
+ }
537
+ }
538
+
539
+ out.push("");
540
+ const status = details.running
541
+ ? ["running", details.usage?.turns ? `${details.usage.turns} turn${details.usage.turns > 1 ? "s" : ""}` : ""].filter(Boolean).join(" · ")
542
+ : formatUsage(details.usage ?? { input: 0, output: 0, cost: 0, turns: 0 }, details.model);
543
+ const expandHint = !expanded ? keyHint("app.tools.expand", "expand for full output") : "";
544
+ out.push(truncateToWidth([status, expandHint].filter(Boolean).join(" "), width, "..."));
545
+ return out;
546
+ },
547
+ };
381
548
  }
382
549
 
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.
550
+ function statusLine(): string {
551
+ if (details.running) {
552
+ const parts: string[] = ["running"];
553
+ if (details.usage?.turns) parts.push(`${details.usage.turns} turn${details.usage.turns > 1 ? "s" : ""}`);
554
+ if (details.elapsedMs != null) parts.push(formatDuration(details.elapsedMs));
555
+ if (details.model) parts.push(details.model);
556
+ return parts.join(" · ");
557
+ }
558
+ return formatUsage(details.usage ?? { input: 0, output: 0, cost: 0, turns: 0 }, details.model);
559
+ }
560
+
561
+ // Name(arg) ✓ 0.3s or Name(arg) (dim, still running)
562
+ function toolRow(t: ToolCallEntry): string {
563
+ const arg = t.argSummary ? t.argSummary : "";
564
+ const call = `${t.name}(${arg})`;
565
+ if (t.result === undefined) return theme.fg("dim", call);
566
+ const dur = t.durMs != null
567
+ ? t.durMs < 1000 ? ` ${t.durMs}ms` : ` ${(t.durMs / 1000).toFixed(1)}s`
568
+ : "";
569
+ return `${call}${t.isError ? " ✗" : ` ✓${dur}`}`;
570
+ }
571
+
572
+ function wrapLine(text: string, w: number): string[] {
573
+ try { return wrapTextWithAnsi(text, w); } catch { return [truncateToWidth(text, w, "...")]; }
574
+ }
575
+
576
+ const cache: { width?: number; responseLines?: string[]; skipped?: number } = {};
577
+
394
578
  return {
395
- invalidate() {},
579
+ invalidate() { cache.width = undefined; },
396
580
  render(width: number): string[] {
397
- return styledLines.map((line) => truncateToWidth(line, width, "...", true));
581
+ const out: string[] = [];
582
+ const indent = " ";
583
+
584
+ // ── Prompt ────────────────────────────────────────────────────
585
+ if (details.task) {
586
+ out.push("Prompt:");
587
+ const taskLines = details.task.split("\n");
588
+ if (expanded) {
589
+ for (const line of taskLines) {
590
+ for (const w of wrapLine(indent + line, width)) out.push(w);
591
+ }
592
+ } else {
593
+ // Single truncated line in collapsed
594
+ const oneLiner = taskLines[0] ?? "";
595
+ out.push(truncateToWidth(indent + oneLiner, width, "..."));
596
+ }
597
+ }
598
+
599
+ // ── Tool calls ─────────────────────────────────────────────
600
+ for (const t of toolCalls) {
601
+ out.push(truncateToWidth(toolRow(t), width, "..."));
602
+ if (expanded && t.result !== undefined) {
603
+ for (const line of t.result.split("\n")) {
604
+ for (const w of wrapLine(theme.fg("dim", indent + line), width)) out.push(w);
605
+ }
606
+ }
607
+ }
608
+
609
+ // ── Response ────────────────────────────────────────────
610
+ const responseText = agentText || (isPartial ? "" : "");
611
+ if (responseText || isPartial) {
612
+ out.push("Response:");
613
+ if (expanded) {
614
+ for (const line of responseText.split("\n")) {
615
+ for (const w of wrapLine(indent + line, width)) out.push(w);
616
+ }
617
+ } else {
618
+ const PREVIEW_LINES = 6;
619
+ if (cache.width !== width) {
620
+ const preview = truncateToVisualLines(responseText, PREVIEW_LINES, width - indent.length);
621
+ cache.responseLines = preview.visualLines.map((l) => truncateToWidth(indent + l, width, "..."));
622
+ cache.skipped = preview.skippedCount;
623
+ cache.width = width;
624
+ }
625
+ out.push(...(cache.responseLines ?? []));
626
+ }
627
+ }
628
+
629
+ // ── Status ───────────────────────────────────────────────
630
+ const status = statusLine();
631
+ const expandHint = !expanded && (cache.skipped ?? 0) > 0
632
+ ? keyHint("app.tools.expand", `expand · ${cache.skipped} lines hidden`)
633
+ : !expanded && toolCalls.some((t) => t.result !== undefined)
634
+ ? keyHint("app.tools.expand", "expand for tool outputs")
635
+ : "";
636
+ const statusWithHint = [status, expandHint].filter(Boolean).join(" ");
637
+ if (statusWithHint) out.push(truncateToWidth(statusWithHint, width, "..."));
638
+
639
+ return out;
398
640
  },
399
641
  };
400
642
  },
401
643
 
402
- async execute(_id, params, signal, onUpdate, ctx) {
644
+ async execute(_id: string, params: Record<string, any>, signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback<unknown> | undefined, ctx: ExtensionContext): Promise<any> {
403
645
  const cwd = params.cwd ?? ctx.cwd;
404
646
  const agents = discoverAgents(cwd);
405
647
 
@@ -413,7 +655,7 @@ export default function (pi: ExtensionAPI) {
413
655
  };
414
656
 
415
657
  // ── Management: list ──────────────────────────────────────────────────────
416
- if (params.action === "list" || (!params.agent && !params.tasks && !params.chain)) {
658
+ if (params.action === "list" || (!params.agent && !params.tasks)) {
417
659
  if (agents.length === 0) {
418
660
  return {
419
661
  content: [{
@@ -459,18 +701,19 @@ export default function (pi: ExtensionAPI) {
459
701
  return {
460
702
  content: [{ type: "text", text: getFinalText(result) }],
461
703
  details: {
704
+ task: params.task,
462
705
  usage: result.usage,
463
706
  running: false,
464
707
  elapsedMs: undefined,
465
708
  model: result.model,
466
- },
709
+ toolCalls: result.toolCalls,
710
+ } satisfies SubagentDetails,
467
711
  isError: result.exitCode !== 0,
468
712
  };
469
713
  }
470
714
 
471
- // ── Parallel mode ─────────────────────────────────────────────────────────
715
+ // ── Parallel mode ─────────────────────────────────────────────
472
716
  if (params.tasks && params.tasks.length > 0) {
473
- // Expand count shorthand
474
717
  const expanded: Array<{ agent: string; task: string; model?: string; cwd?: string }> = [];
475
718
  for (const t of params.tasks) {
476
719
  const n = t.count ?? 1;
@@ -478,138 +721,63 @@ export default function (pi: ExtensionAPI) {
478
721
  }
479
722
 
480
723
  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}`;
724
+ const emptyUsage = { input: 0, output: 0, cost: 0, turns: 0 };
725
+ const parallelAgents: AgentRowStatus[] = expanded.map((t) => ({
726
+ name: t.agent,
727
+ taskSummary: t.task.length > 60 ? t.task.slice(0, 57) + "..." : t.task,
728
+ status: "pending" as const,
729
+ }));
730
+ let runningUsage = { ...emptyUsage };
731
+
732
+ const emitParallel = (running: boolean) => onUpdate?.({
733
+ content: [{ type: "text", text: "" }],
734
+ details: { mode: "parallel", parallelAgents: [...parallelAgents], usage: { ...runningUsage }, running, toolCalls: [] } satisfies SubagentDetails,
506
735
  });
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
736
 
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 = "";
535
-
536
- const stepResults: Array<RunResult & { agentName: string; step: number }> = [];
737
+ emitParallel(true);
537
738
 
538
- for (let i = 0; i < params.chain.length; i++) {
539
- const step = params.chain[i];
540
- const { agent, error } = findAgent(step.agent);
739
+ const parentDepth = _currentDepth;
740
+ const allResults = await mapConcurrent(expanded, concurrency, async (t, i) => {
741
+ parallelAgents[i]!.status = "running";
742
+ emitParallel(true);
743
+ const { agent, error } = findAgent(t.agent);
541
744
  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
- });
562
- }
563
-
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
- };
745
+ parallelAgents[i]!.status = "error";
746
+ emitParallel(true);
747
+ return { agentName: t.agent, output: "", exitCode: 1, error, model: undefined, toolCalls: [] as ToolCallEntry[], usage: emptyUsage };
583
748
  }
749
+ const agentStart = Date.now();
750
+ const agentOnUpdate: OnUpdate = (partial) => {
751
+ const d = partial.details as SubagentDetails | undefined;
752
+ parallelAgents[i]!.toolCalls = d?.toolCalls ? [...d.toolCalls] : parallelAgents[i]!.toolCalls;
753
+ parallelAgents[i]!.responseText = (partial.content?.[0] as any)?.text || parallelAgents[i]!.responseText;
754
+ emitParallel(true);
755
+ };
756
+ const result = await runAgent(agent, t.task, t.cwd ?? cwd, t.model, signal, agentOnUpdate, parentDepth);
757
+ parallelAgents[i]!.status = result.exitCode === 0 ? "done" : "error";
758
+ parallelAgents[i]!.durMs = Date.now() - agentStart;
759
+ parallelAgents[i]!.toolCalls = result.toolCalls;
760
+ parallelAgents[i]!.responseText = result.output;
761
+ 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 };
762
+ emitParallel(true);
763
+ return { ...result, agentName: t.agent, toolCalls: result.toolCalls ?? [] };
764
+ });
584
765
 
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 },
766
+ const totalUsage = allResults.reduce(
767
+ (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 }),
768
+ emptyUsage,
597
769
  );
770
+ const outputs = allResults.map((r) => `[${r.agentName}] ${r.exitCode === 0 ? "✓" : "✗"}\n${getFinalText(r)}`).join("\n\n");
598
771
 
599
772
  return {
600
- content: [{
601
- type: "text",
602
- text: [
603
- last.output,
604
- "",
605
- `Chain: ${stepResults.length} steps · ${formatUsage(totalUsage)}`,
606
- ].join("\n"),
607
- }],
773
+ content: [{ type: "text", text: outputs }],
774
+ details: { mode: "parallel", parallelAgents, usage: totalUsage, running: false, toolCalls: [] } satisfies SubagentDetails,
608
775
  };
609
776
  }
610
777
 
778
+ // ── Chain mode ────────────────────────────────────────────
611
779
  // Shouldn't reach here
612
- return { content: [{ type: "text", text: "Provide agent+task, tasks array, or chain array." }] };
780
+ return { content: [{ type: "text", text: "Provide agent+task or tasks array." }] };
613
781
  },
614
782
  });
615
783
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fast-subagent",
3
- "version": "0.1.2",
3
+ "version": "0.2.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
  }