@d3ara1n/pi-subagent 0.4.0 → 0.5.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.
package/README.md CHANGED
@@ -17,7 +17,7 @@ This means:
17
17
  - **Multiple subagents can run in parallel** — emit multiple `delegate` calls in one turn; pi executes them concurrently
18
18
  - **Subagents can nest subagents** — a `worker` can delegate exploration to `explorer` without returning to the main model
19
19
 
20
- > This design intentionally excludes chain pipelines and context-forking — those patterns are better suited when subagents act as advisors (planner, oracle), not executors.
20
+ > This design currently focuses on single-task delegation rather than chain pipelines or context-forking — those patterns fit better when subagents act as advisors (planner, oracle) rather than executors.
21
21
 
22
22
  ## How it works
23
23
 
@@ -61,65 +61,44 @@ pi install @d3ara1n/pi-subagent
61
61
 
62
62
  Edit `~/.pi/agent/settings.json`:
63
63
 
64
- ```jsonc
64
+ ```json
65
65
  {
66
66
  "subagent": {
67
- // Default timeout per subagent (10 minutes)
68
- "timeoutMs": 600000,
69
-
70
- // Max subagents running at once; extras queue with a "queued" TUI hint
67
+ "timeout": 600,
71
68
  "maxConcurrency": 4,
72
-
73
- // Max subagent nesting depth (the main session is depth 0).
74
- // Default 3 covers worker → researcher → explorer chains.
75
69
  "maxDepth": 3,
76
-
77
- // Turn / cost budgets (0 = unlimited). A run is killed once either is hit;
78
- // partial output is returned with stopReason "budget_exceeded".
79
70
  "maxTurns": 0,
80
71
  "maxCost": 0,
81
-
82
- // Audit log: one JSON per delegate run under
83
- // ~/.pi/subagent/history/{sessionId}/{toolCallId}.json
84
72
  "history": {
85
73
  "enabled": true
86
74
  },
87
-
88
- // Summary generation — uses a lightweight model to create
89
- // a one-line summary for the TUI display.
90
- // Outputs ≤ 150 chars skip the API call and reuse the text directly.
91
75
  "summary": {
92
- "role": "utility", // pi-model-roles role for summarization
93
- "enabled": true // set false to disable
76
+ "role": "utility",
77
+ "enabled": true
94
78
  }
95
79
  }
96
80
  }
97
81
  ```
98
82
 
99
- All fields are optional. Defaults: `timeoutMs: 600000` (10 min; roles that can `delegate` get 2× automatically when no per-role timeout is set), `maxConcurrency: 4`, `maxDepth: 3`, `maxTurns: 0` (unlimited), `maxCost: 0` (unlimited), `history.enabled: true`, `summary.role: "utility"`, `summary.enabled: true`.
83
+ All fields are optional. Defaults: `timeout: 600` (seconds; 10 min; roles that can `delegate` get 2× automatically when no per-role timeout is set), `maxConcurrency: 4`, `maxDepth: 3`, `maxTurns: 0` (unlimited), `maxCost: 0` (unlimited), `history.enabled: true`, `summary.role: "utility"`, `summary.enabled: true`.
100
84
 
101
85
  ### Agent Overrides
102
86
 
103
87
  Override, disable, or add subagent roles via `agentOverrides`. Built-in and custom roles are treated equally — all descriptions, examples, and decision triggers feed into the LLM's prompt dynamically.
104
88
 
105
- ```jsonc
89
+ ```json
106
90
  {
107
91
  "subagent": {
108
92
  "agentOverrides": {
109
- // ── Override a built-in role (only specify changed fields) ──
110
93
  "worker": {
111
- "role": "heavy", // use a stronger model
112
- "timeoutMs": 600000, // per-role timeout (overrides global)
113
- "maxTurns": 50, // per-role turn budget (0 = unlimited)
114
- "maxCost": 1.0 // per-role cost ceiling in USD (0 = unlimited)
94
+ "role": "heavy",
95
+ "timeout": 600,
96
+ "maxTurns": 50,
97
+ "maxCost": 1.0
115
98
  },
116
-
117
- // ── Disable a built-in role ──
118
99
  "reviewer": {
119
100
  "disabled": true
120
101
  },
121
-
122
- // ── Add a custom role (all required fields must be provided) ──
123
102
  "tester": {
124
103
  "role": "default",
125
104
  "description": "Test automation & QA — write and run tests, validate fixes. Tools: read, bash, edit, write, grep. Can delegate to explorer.",
@@ -138,7 +117,7 @@ Override, disable, or add subagent roles via `agentOverrides`. Built-in and cust
138
117
 
139
118
  **Required fields for custom roles:** `role`, `description`, `examples`, `decisionTrigger`, `tools`, `systemPrompt`.
140
119
 
141
- **Optional fields:** `subagentRoles` (roles this role can spawn via delegate), `timeoutMs` (per-role timeout override; when unset, delegate-capable roles get 2× the global default automatically), `maxTurns` / `maxCost` (per-role budget overrides; 0 = unlimited), `fallbackRole` (backup pi-model-roles role on provider errors).
120
+ **Optional fields:** `subagentRoles` (roles this role can spawn via delegate), `timeout` (per-role timeout override in seconds; when unset, delegate-capable roles get 2× the global default automatically), `maxTurns` / `maxCost` (per-role budget overrides; 0 = unlimited), `fallbackRole` (backup pi-model-roles role on provider errors).
142
121
 
143
122
  Invalid custom roles (missing required fields) are silently skipped with an error notification at session start.
144
123
 
@@ -171,9 +150,13 @@ Delegate tasks that would generate many tool calls or verbose output to keep you
171
150
  ]
172
151
  ```
173
152
 
174
- ### Passing extra context
153
+ ### Passing context and reference files
175
154
 
176
- The optional `context` field lets you hand a subagent precise context selected code, a prior delegate's result, a file list, a git diff without inflating the `task` string. It's prepended before the task:
155
+ pi-subagent delivers context to the child as **independent channels**, never fused into the task string. This keeps the task an unambiguous directive and lets each channel be sized independently.
156
+
157
+ #### `context` (inline text)
158
+
159
+ Hand the subagent precise context — selected code, a prior delegate's result, a file list, a git diff — without inflating the `task` string. It's delivered as a separate channel:
177
160
 
178
161
  ```json
179
162
  {
@@ -183,7 +166,19 @@ The optional `context` field lets you hand a subagent precise context — select
183
166
  }
184
167
  ```
185
168
 
186
- The stored/displayed task stays as the original `task`; the `context` is merged into the prompt the subagent receives.
169
+ The stored/displayed task stays as the original `task`. When small, `context` inlines as a `<context>` block; when large (over 8,000 chars) it spills to a temp file injected via `@file`, so a large context never drags a short task into a spill.
170
+
171
+ #### `files` (reference paths)
172
+
173
+ ```json
174
+ {
175
+ "role": "explorer",
176
+ "task": "Report the public API of the auth module",
177
+ "files": ["src/auth.ts", "src/auth.types.ts"]
178
+ }
179
+ ```
180
+
181
+ Each path is injected as an independent `@file` attachment the subagent reads directly. **File contents stay out of your context window** — you pass only the paths. Prefer this over pasting file contents into `context`, since the child receives the content on its first turn without spending a tool call to read it.
187
182
 
188
183
  ### Budget enforcement
189
184
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-subagent",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Role-based subagent orchestration for pi — delegates tasks to specialized pi child processes with configurable model roles",
6
6
  "main": "src/index.ts",
package/src/config.ts CHANGED
@@ -54,7 +54,7 @@ export function loadSubagentConfig(cwd?: string): SubagentConfig {
54
54
  const rawSummary = raw?.summary;
55
55
  const rawHistory = raw?.history;
56
56
  return {
57
- timeoutMs: raw.timeoutMs ?? DEFAULT_CONFIG.timeoutMs,
57
+ timeout: raw.timeout ?? DEFAULT_CONFIG.timeout,
58
58
  maxConcurrency: raw.maxConcurrency ?? DEFAULT_CONFIG.maxConcurrency,
59
59
  maxDepth: raw.maxDepth ?? DEFAULT_CONFIG.maxDepth,
60
60
  maxTurns: raw.maxTurns ?? DEFAULT_CONFIG.maxTurns,
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
- import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
12
+ import { getMarkdownTheme, type ThemeColor } from "@earendil-works/pi-coding-agent";
13
13
  import { complete } from "@earendil-works/pi-ai";
14
14
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
15
15
  import { Type } from "typebox";
@@ -27,6 +27,7 @@ import {
27
27
  AsyncSemaphore,
28
28
  buildDisplayItems,
29
29
  formatUsageStats,
30
+ elapsedSeconds,
30
31
  formatToolCall,
31
32
  statusStyle,
32
33
  formatThinking,
@@ -34,7 +35,7 @@ import {
34
35
  isFailedResult,
35
36
  sanitizeFilename,
36
37
  isProviderError,
37
- effectiveTimeoutMs,
38
+ effectiveTimeout,
38
39
  type DisplayItem,
39
40
  } from "./utils.ts";
40
41
  import * as os from "node:os";
@@ -127,10 +128,10 @@ async function compressOutput(
127
128
  );
128
129
 
129
130
  const compressed =
130
- result.content
131
- ?.filter((block: any) => block.type === "text")
132
- ?.map((block: any) => block.text)
133
- ?.join("") ?? "";
131
+ (result.content as Array<{ type: string; text?: string }> | undefined)
132
+ ?.filter((block) => block.type === "text")
133
+ .map((block) => block.text ?? "")
134
+ .join("") || "";
134
135
 
135
136
  if (!compressed.trim()) return { text: truncateOutput(text), method: "truncated" };
136
137
  // Model may not compress enough — fall back to truncation so we stay within budget
@@ -183,11 +184,11 @@ async function generateSummary(
183
184
  },
184
185
  );
185
186
 
186
- const text = result.content
187
- ?.filter((block: any) => block.type === "text")
188
- ?.map((block: any) => block.text)
189
- ?.join("")
190
- ?.trim();
187
+ const text = (result.content as Array<{ type: string; text?: string }> | undefined)
188
+ ?.filter((block) => block.type === "text")
189
+ .map((block) => block.text ?? "")
190
+ .join("")
191
+ .trim();
191
192
 
192
193
  return text || undefined;
193
194
  } catch {
@@ -269,11 +270,12 @@ export default function subagentExtension(pi: ExtensionAPI) {
269
270
  "",
270
271
  "For multiple independent substantial tasks, emit multiple delegate calls in one turn — they run in parallel.",
271
272
  "Include ALL necessary context — subagents have no access to this conversation.",
273
+ "Pass reference files via the `files` parameter (e.g. files: [\"src/auth.ts\"]) instead of pasting their contents into `context` — the subagent reads them directly without consuming your context window.",
272
274
  );
273
275
  }
274
276
 
275
277
  // Apply agent overrides on top of built-in roles
276
- function applyAgentOverrides(roles: Record<string, SubagentRole>, overrides: Record<string, any>): void {
278
+ function applyAgentOverrides(roles: Record<string, SubagentRole>, overrides: Record<string, Partial<SubagentRole> & { disabled?: boolean }>): void {
277
279
  for (const [name, override] of Object.entries(overrides)) {
278
280
  if (override.disabled) {
279
281
  delete roles[name];
@@ -292,6 +294,16 @@ export default function subagentExtension(pi: ExtensionAPI) {
292
294
  pi.on("session_start", async (_event, ctx) => {
293
295
  config = loadSubagentConfig(ctx.cwd);
294
296
  concurrencyGate = new AsyncSemaphore(config.maxConcurrency);
297
+
298
+ // Rebuild from BUILTIN_ROLES (respecting ALLOWLIST) so repeated
299
+ // session_start is idempotent — overrides from prior sessions don't accumulate.
300
+ for (const key of Object.keys(availableRoles)) delete availableRoles[key];
301
+ for (const [name, role] of Object.entries(BUILTIN_ROLES)) {
302
+ if (!ALLOWLIST || ALLOWLIST.includes(name)) {
303
+ availableRoles[name] = role;
304
+ }
305
+ }
306
+
295
307
  applyAgentOverrides(availableRoles, config.agentOverrides);
296
308
 
297
309
  // Validate custom roles (skip built-in roles — they already have all fields)
@@ -321,11 +333,13 @@ export default function subagentExtension(pi: ExtensionAPI) {
321
333
  parameters: Type.Object({
322
334
  role: Type.String({ description: "Subagent role to use" }),
323
335
  task: Type.String({ description: "Specific task for the subagent" }),
324
- context: Type.Optional(Type.String({ description: "Extra context to give the subagent (selected code, prior results, file list, etc.). Prepended before the task. Omit if the task alone is enough." })),
336
+ context: Type.Optional(Type.String({ description: "Extra context to give the subagent (selected code, prior results, file list, etc.). Delivered as a separate channel from the task. Omit if the task alone is enough." })),
337
+ files: Type.Optional(Type.Array(Type.String(), { description: "Reference file paths for the subagent to read directly (e.g. [\"src/auth.ts\", \"docs/api.md\"]). Injected as @file attachments — content stays out of your context window. Prefer this over pasting file contents into context." })),
325
338
  cwd: Type.Optional(Type.String({ description: "Working directory (defaults to current)" })),
326
339
  }),
327
340
 
328
341
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
342
+ const gate = concurrencyGate;
329
343
  const roleDef = availableRoles[params.role];
330
344
  if (!roleDef) {
331
345
  return {
@@ -353,12 +367,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
353
367
  };
354
368
  }
355
369
 
356
- // #12: prepend optional extra context so the subagent gets precise info
357
- // without cramming everything into the task string.
358
- const effectiveTask = params.context
359
- ? `## Context\n\n${params.context}\n\n---\n\n## Task\n\n${params.task}`
360
- : params.task;
361
-
362
370
  // Throttle state hoisted to the execute scope so the finally block can clear it.
363
371
  // (try-body `let` is invisible to catch/finally — JS gives each its own block scope.)
364
372
  let pendingPartial: Partial<SubagentResult> | undefined;
@@ -393,6 +401,8 @@ export default function subagentExtension(pi: ExtensionAPI) {
393
401
  stderr: "",
394
402
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
395
403
  activityLog: [],
404
+ files: params.files,
405
+ context: params.context,
396
406
  };
397
407
  onUpdate({
398
408
  content: [{ type: "text", text: `${params.role}: queued...` }],
@@ -402,7 +412,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
402
412
 
403
413
  // Acquire a concurrency slot (abortable while queued)
404
414
  try {
405
- await concurrencyGate.acquire(signal);
415
+ await gate.acquire(signal);
406
416
  } catch {
407
417
  return {
408
418
  content: [{ type: "text", text: `Subagent (${params.role}) was cancelled while queued.` }],
@@ -457,6 +467,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
457
467
  model: partial.model,
458
468
  stopReason: partial.stopReason,
459
469
  activityLog: partial.activityLog ?? [],
470
+ startTime,
471
+ files: params.files,
472
+ context: params.context,
460
473
  };
461
474
  const statusText = `${params.role} ${elapsed}s ${liveResult.usage.turns} turn${liveResult.usage.turns !== 1 ? "s" : ""}`;
462
475
  onUpdate!({
@@ -487,6 +500,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
487
500
  stderr: "",
488
501
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
489
502
  activityLog: [],
503
+ startTime,
504
+ files: params.files,
505
+ context: params.context,
490
506
  };
491
507
  onUpdate({
492
508
  content: [{ type: "text", text: `${params.role}: running...` }],
@@ -494,12 +510,14 @@ export default function subagentExtension(pi: ExtensionAPI) {
494
510
  });
495
511
  }
496
512
 
497
- let result = await spawnSubagent(modelRef, effectiveTask, {
513
+ let result = await spawnSubagent(modelRef, params.task, {
498
514
  cwd: params.cwd ?? ctx.cwd,
499
515
  tools: roleDef.tools,
500
516
  systemPrompt: roleDef.systemPrompt,
517
+ context: params.context,
518
+ contextFiles: params.files,
501
519
  subagentRoles: roleDef.subagentRoles,
502
- timeoutMs: effectiveTimeoutMs(roleDef, config.timeoutMs),
520
+ timeoutMs: effectiveTimeout(roleDef, config.timeout) * 1000,
503
521
  maxTurns: roleDef.maxTurns ?? config.maxTurns,
504
522
  maxCost: roleDef.maxCost ?? config.maxCost,
505
523
  depth: CURRENT_DEPTH + 1,
@@ -514,12 +532,14 @@ export default function subagentExtension(pi: ExtensionAPI) {
514
532
  const fallback = await rolesApi.resolveRoleAsync(roleDef.fallbackRole);
515
533
  if (fallback.model) {
516
534
  const fbRef = `${fallback.model.provider}/${fallback.model.id}`;
517
- result = await spawnSubagent(fbRef, effectiveTask, {
535
+ result = await spawnSubagent(fbRef, params.task, {
518
536
  cwd: params.cwd ?? ctx.cwd,
519
537
  tools: roleDef.tools,
520
538
  systemPrompt: roleDef.systemPrompt,
539
+ context: params.context,
540
+ contextFiles: params.files,
521
541
  subagentRoles: roleDef.subagentRoles,
522
- timeoutMs: effectiveTimeoutMs(roleDef, config.timeoutMs),
542
+ timeoutMs: effectiveTimeout(roleDef, config.timeout) * 1000,
523
543
  maxTurns: roleDef.maxTurns ?? config.maxTurns,
524
544
  maxCost: roleDef.maxCost ?? config.maxCost,
525
545
  depth: CURRENT_DEPTH + 1,
@@ -530,6 +550,12 @@ export default function subagentExtension(pi: ExtensionAPI) {
530
550
  }
531
551
  }
532
552
 
553
+ // Stamp terminal fields once, after any fallback retry: elapsedMs covers
554
+ // the whole delegate span (incl. retry); files/context mirror params for the TUI.
555
+ result.files = params.files;
556
+ result.context = params.context;
557
+ result.elapsedMs = Date.now() - startTime;
558
+
533
559
  // Compress/truncate oversized output before it reaches the main model or TUI.
534
560
  // Keep the raw original for the history file (audit), feed the prepared text to LLM + expanded view.
535
561
  const rawOutput = result.output;
@@ -595,25 +621,21 @@ export default function subagentExtension(pi: ExtensionAPI) {
595
621
  // delegates (worker → explorer): the inner crash surfaces as TUI escapes.
596
622
  if (throttleHandle !== undefined) clearTimeout(throttleHandle);
597
623
  pendingPartial = undefined;
598
- concurrencyGate.release();
624
+ gate.release();
599
625
  }
600
626
  },
601
627
 
602
- // ── renderCall: what the user sees when the tool is invoked ──────
628
+ // ── renderCall: what the user sees when the tool is invoked ─────
603
629
 
604
630
  renderCall(args, theme, _context) {
605
631
  const roleName = (args as any).role || "...";
606
- const task = (args as any).task || "";
607
- const preview = task.length > 60 ? `${task.slice(0, 60)}...` : task;
608
632
  const text =
609
- theme.fg("toolTitle", theme.bold("subagent ")) +
610
- theme.fg("accent", roleName) +
611
- "\n " +
612
- theme.fg("dim", preview);
633
+ theme.fg("toolTitle", theme.bold("delegate ")) +
634
+ theme.fg("accent", roleName);
613
635
  return new Text(text, 0, 0);
614
636
  },
615
637
 
616
- // ── renderResult: TUI display when the tool finishes ─────────────
638
+ // ── renderResult: TUI display when the tool finishes ────────
617
639
 
618
640
  renderResult(result, { expanded }, theme, _context) {
619
641
  const details = result.details as SubagentDetails | undefined;
@@ -627,52 +649,90 @@ export default function subagentExtension(pi: ExtensionAPI) {
627
649
  const isError = !isRunning && isFailedResult(r);
628
650
  const isTimeout = !isRunning && r.stopReason === "timeout";
629
651
  const isBudget = !isRunning && r.stopReason === "budget_exceeded";
652
+ const isFailedState = isError || isTimeout || isBudget;
653
+
654
+ // Status icon. ⏳ running / ⏸ queued (pause) / ⏱ timeout / ⏲ budget / ✗ error / ✓ ok
630
655
  let icon: string;
631
656
  if (isRunning) {
632
- icon = theme.fg("warning", "\u23F3"); // hourglass — time flowing
657
+ icon = r.queued ? theme.fg("warning", "\u23F8") : theme.fg("warning", "\u23F3");
633
658
  } else if (isTimeout) {
634
- icon = theme.fg("warning", "\u23F1"); // ⏱ stopwatch — time ran out
659
+ icon = theme.fg("warning", "\u23F1");
635
660
  } else if (isBudget) {
636
- icon = theme.fg("warning", "\u23F2"); // ⏲ timer — budget exhausted
661
+ icon = theme.fg("warning", "\u23F2");
637
662
  } else if (isError) {
638
- icon = theme.fg("error", "\u2717"); // ✗
663
+ icon = theme.fg("error", "\u2717");
639
664
  } else {
640
665
  icon = theme.fg("success", "\u2713");
641
666
  }
667
+
642
668
  const displayItems = buildDisplayItems(r.activityLog);
643
669
  const mdTheme = getMarkdownTheme();
670
+ const fg = theme.fg.bind(theme) as (color: string, text: string) => string;
671
+
672
+ // Task preview: first line, truncated to one row (always-visible anchor).
673
+ const firstLine = r.task.split("\n")[0];
674
+ const taskPreview = firstLine.length > 70 ? `${firstLine.slice(0, 70)}...` : firstLine;
675
+ // taskline: indicator prefix while running/queued; bare text once finished.
676
+ let taskline: string;
677
+ if (isRunning) {
678
+ const label = r.queued ? "(queued)" : "(running)";
679
+ taskline = `${icon} ${theme.fg("dim", label)} ${theme.fg("text", taskPreview)}`;
680
+ } else {
681
+ taskline = theme.fg("text", taskPreview);
682
+ }
683
+
684
+ // usage line: elapsed/live prefix + existing stats.
685
+ const secs = elapsedSeconds(r);
686
+ const stats = formatUsageStats(r.usage, r.model);
687
+ const usageLine = [secs != null ? `${secs}s` : null, stats].filter(Boolean).join(" \u00b7 ");
688
+
689
+ // Result line content (shared between collapsed and expanded).
690
+ let resultContent: string | undefined;
691
+ let resultCol: ThemeColor | undefined;
692
+ if (!isRunning) {
693
+ if (isFailedState) {
694
+ resultContent = r.errorMessage || (isTimeout ? "Timed out" : isBudget ? "Budget exceeded" : "failed");
695
+ resultCol = isTimeout || isBudget ? "warning" : "error";
696
+ } else {
697
+ resultContent = r.summary || undefined;
698
+ resultCol = "text";
699
+ }
700
+ }
644
701
 
645
702
  if (expanded) {
646
703
  const container = new Container();
647
704
 
648
- // Header
649
- let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.role))}`;
650
- if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
651
- container.addChild(new Text(header, 0, 0));
652
- if (isTimeout && r.errorMessage)
653
- container.addChild(new Text(theme.fg("warning", `\u23F1 ${r.errorMessage}`), 0, 0));
654
- else if (isBudget && r.errorMessage)
655
- container.addChild(new Text(theme.fg("warning", `\u23F2 ${r.errorMessage}`), 0, 0));
656
- else if (isError && r.errorMessage)
657
- container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
658
-
659
- if (!isRunning) {
660
- container.addChild(new Spacer(1));
661
- container.addChild(new Text(theme.fg("muted", "\u2500\u2500\u2500 Task \u2500\u2500\u2500"), 0, 0));
662
- container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
705
+ // Header: taskline (+ error line on failure; success has no summary line here
706
+ // full output below makes it redundant).
707
+ container.addChild(new Text(taskline, 0, 0));
708
+ if (resultContent) {
709
+ container.addChild(new Text(`${icon} ${theme.fg(resultCol!, resultContent)}`, 0, 0));
710
+ }
711
+
712
+ // Input block: reference files + context char count + task full text,
713
+ // grouped without inner spacing (they are all subagent input).
714
+ container.addChild(new Spacer(1));
715
+ if (r.files) {
716
+ for (const f of r.files) {
717
+ container.addChild(new Text(theme.fg("dim", `@${f}`), 0, 0));
718
+ }
719
+ }
720
+ if (r.context) {
721
+ container.addChild(new Text(theme.fg("dim", `ctx ${r.context.length} chars`), 0, 0));
663
722
  }
723
+ container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
664
724
 
725
+ // Activity stream (shown while running and after completion).
665
726
  container.addChild(new Spacer(1));
666
727
  const activity = displayItems.filter((item) => item.type === "toolCall" || item.type === "thinking");
667
728
  if (activity.length === 0) {
668
729
  const runningLabel = isRunning
669
730
  ? r.queued
670
- ? "(queued waiting for a concurrency slot...)"
731
+ ? "(queued \u2014 waiting for a concurrency slot...)"
671
732
  : "(waiting for first event...)"
672
733
  : "(none)";
673
734
  container.addChild(new Text(theme.fg("muted", runningLabel), 0, 0));
674
735
  } else {
675
- const fg = theme.fg.bind(theme) as (color: string, text: string) => string;
676
736
  for (const item of activity) {
677
737
  if (item.type === "thinking") {
678
738
  container.addChild(new Text(formatThinking(item.status, fg), 0, 0));
@@ -685,9 +745,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
685
745
  }
686
746
  }
687
747
 
748
+ // Full output (terminal runs only).
688
749
  if (!isRunning && r.output.trim()) {
689
750
  container.addChild(new Spacer(1));
690
- container.addChild(new Text(theme.fg("muted", "\u2500\u2500\u2500 Output \u2500\u2500\u2500"), 0, 0));
691
751
  container.addChild(new Markdown(r.output.trim(), 0, 0, mdTheme));
692
752
  if (r.outputMethod === "compressed") {
693
753
  container.addChild(new Text(theme.fg("muted", "(output compressed by summary model \u2014 full text in history)"), 0, 0));
@@ -696,53 +756,34 @@ export default function subagentExtension(pi: ExtensionAPI) {
696
756
  }
697
757
  }
698
758
 
699
- const usageStr = formatUsageStats(r.usage, r.model);
700
- if (usageStr) {
759
+ // Usage (with elapsed).
760
+ if (usageLine) {
701
761
  container.addChild(new Spacer(1));
702
- container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
762
+ container.addChild(new Text(theme.fg("dim", usageLine), 0, 0));
703
763
  }
704
764
 
705
765
  return container;
706
766
  }
707
767
 
708
- // Collapsed view
709
- let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.role))}`;
710
-
711
- if (isRunning) {
712
- if (r.queued) {
713
- text += `\n${theme.fg("muted", "(queued — waiting for a concurrency slot...)")}`;
768
+ // Collapsed view.
769
+ let text = taskline;
770
+ if (!isRunning) {
771
+ // Result line (shared computation above).
772
+ if (resultContent) text += `\n${icon} ${theme.fg(resultCol!, resultContent)}`;
773
+ } else if (!r.queued) {
774
+ // Running (not queued): show recent activity only.
775
+ const activity = displayItems.filter((item) => item.type === "toolCall" || item.type === "thinking");
776
+ if (activity.length === 0) {
777
+ text += `\n${theme.fg("muted", "(running...)")}`;
714
778
  } else {
715
- // Running: show recent tool calls only
716
- const activity = displayItems.filter((item) => item.type === "toolCall" || item.type === "thinking");
717
- if (activity.length === 0) {
718
- text += `\n${theme.fg("muted", "(running...)")}`;
719
- } else {
720
- const rendered = renderDisplayItems(activity, 5, theme.fg.bind(theme) as (color: string, text: string) => string);
721
- if (rendered) text += `\n${rendered}`;
722
- }
779
+ const rendered = renderDisplayItems(activity, 5, fg);
780
+ if (rendered) text += `\n${rendered}`;
723
781
  }
724
- } else {
725
- // Finished: summary + usage, no tool calls
726
- if (r.summary) {
727
- text += ` ${theme.fg("dim", "\u00b7")} ${theme.fg("text", r.summary)}`;
728
- }
729
- if (isTimeout) {
730
- const msg = r.errorMessage || "Timed out";
731
- text += `\n${theme.fg("warning", `\u23F1 ${msg}`)}`;
732
- } else if (isBudget) {
733
- const msg = r.errorMessage || "Budget exceeded";
734
- text += `\n${theme.fg("warning", `\u23F2 ${msg}`)}`;
735
- } else if (isError) {
736
- const errMsg = r.errorMessage || (r.stderr ? r.stderr.trim().split("\n")[0].slice(0, 80) : r.stopReason);
737
- if (errMsg) text += `\n${theme.fg("error", `Error: ${errMsg}`)}`;
738
- }
739
- const usageStr = formatUsageStats(r.usage, r.model);
740
- if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
741
782
  }
783
+ if (usageLine) text += `\n${theme.fg("dim", usageLine)}`;
742
784
  return new Text(text, 0, 0);
743
785
  },
744
786
  });
745
-
746
787
  pi.registerCommand("subagent:doctor", {
747
788
  description: "Diagnose pi-subagent configuration and dependencies",
748
789
  handler: async (_args, ctx) => {
@@ -761,7 +802,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
761
802
  // 3. config
762
803
  try {
763
804
  const cfg = loadSubagentConfig(ctx.cwd);
764
- lines.push(`[\u2713] config: timeout=${cfg.timeoutMs}ms concurrency=${cfg.maxConcurrency} depth=${cfg.maxDepth} turns=${cfg.maxTurns || "∞"} cost=$${cfg.maxCost || "∞"} summary=${cfg.summary.enabled ? cfg.summary.role : "off"} history=${cfg.history.enabled}`);
805
+ lines.push(`[\u2713] config: timeout=${cfg.timeout}s concurrency=${cfg.maxConcurrency} depth=${cfg.maxDepth} turns=${cfg.maxTurns || "∞"} cost=$${cfg.maxCost || "∞"} summary=${cfg.summary.enabled ? cfg.summary.role : "off"} history=${cfg.history.enabled}`);
765
806
  } catch {
766
807
  lines.push("[\u2717] config: failed to load");
767
808
  allOk = false;
package/src/spawn.ts CHANGED
@@ -6,15 +6,15 @@
6
6
  * Fires onProgress on each event for streaming updates.
7
7
  */
8
8
 
9
- import { spawn, type ChildProcess } from "node:child_process";
9
+ import { spawn, type ChildProcess } from "node:child_process";
10
10
  import * as fs from "node:fs";
11
11
  import * as os from "node:os";
12
12
  import * as path from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import type { SubagentMessage, SubagentResult } from "./types.ts";
15
15
 
16
- /** Maximum task length before writing to a temp file (avoids CLI arg limits). */
17
- const TASK_CHAR_LIMIT = 8000;
16
+ /** Max chars for an inline channel block (context or task) before it spills to a temp @file. */
17
+ const INLINE_LIMIT = 8000;
18
18
 
19
19
 
20
20
  const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
@@ -124,6 +124,10 @@ export async function spawnSubagent(
124
124
  cwd?: string;
125
125
  tools?: string[];
126
126
  systemPrompt?: string;
127
+ /** Extra context delivered as a separate channel from the task. */
128
+ context?: string;
129
+ /** Reference file paths injected as independent @file args (child reads them directly). */
130
+ contextFiles?: string[];
127
131
  subagentRoles?: string[];
128
132
  timeoutMs?: number;
129
133
  depth?: number;
@@ -145,7 +149,6 @@ export async function spawnSubagent(
145
149
  };
146
150
 
147
151
  let tmpDir: string | null = null;
148
- let tmpFile: string | null = null;
149
152
 
150
153
  try {
151
154
  // Build CLI args
@@ -155,24 +158,72 @@ export async function spawnSubagent(
155
158
  args.push("--tools", options.tools.join(","));
156
159
  }
157
160
 
158
- // Always create temp dir — used for prompt file, long task file, and as PI_SUBAGENT_TMPDIR for subagent work (e.g. git clone)
161
+ // Temp dir for: large-context/task spill files, and as PI_SUBAGENT_TMPDIR
162
+ // for subagent bash work (e.g. git clone). The system prompt no longer uses it.
159
163
  tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
160
164
 
161
- const promptContent = options.systemPrompt?.trim()
162
- ? options.systemPrompt + `\n\nPI_SUBAGENT_TMPDIR=${tmpDir}`
163
- : `PI_SUBAGENT_TMPDIR=${tmpDir}`;
164
- tmpFile = path.join(tmpDir, "prompt.md");
165
- await fs.promises.writeFile(tmpFile, promptContent, { encoding: "utf-8", mode: 0o600 });
166
- args.push("--append-system-prompt", tmpFile);
165
+ // ── System prompt channel: inline text via --append-system-prompt ──
166
+ // pi's resolvePromptInput treats an existing path as a file to read and any
167
+ // non-path string as literal text, so we pass structured blocks directly —
168
+ // no temp file, zero disk I/O. Multiple flags are joined with "\n\n".
169
+ if (options.systemPrompt?.trim()) {
170
+ args.push(
171
+ "--append-system-prompt",
172
+ `<subagent_role>\n${options.systemPrompt.trim()}\n</subagent_role>`,
173
+ );
174
+ }
175
+ args.push(
176
+ "--append-system-prompt",
177
+ `<subagent_env>\nPI_SUBAGENT_TMPDIR=${tmpDir}\nAvailable as $PI_SUBAGENT_TMPDIR in bash. Use for git clone and scratch files.\n</subagent_env>`,
178
+ );
179
+
180
+ // ── Context channel: independent size gate ──
181
+ // Large context spills to @ctx.md (pi auto-wraps in <file>); small context
182
+ // inlines as a structured <context> tag. Decoupled from the task gate so a
183
+ // large context never drags a short task into a spill file.
184
+ let contextInline = false;
185
+ if (options.context && options.context.trim()) {
186
+ if (options.context.length > INLINE_LIMIT) {
187
+ const ctxPath = path.join(tmpDir, "context.md");
188
+ await fs.promises.writeFile(ctxPath, options.context, { encoding: "utf-8", mode: 0o600 });
189
+ args.push(`@${ctxPath}`);
190
+ } else {
191
+ contextInline = true;
192
+ }
193
+ }
194
+
195
+ // ── Reference files channel: each as an independent @ argument ──
196
+ // pi reads each and wraps in <file name="...">. Content never enters the
197
+ // parent model's context — the child reads it directly.
198
+ if (options.contextFiles) {
199
+ for (const f of options.contextFiles) {
200
+ args.push(`@${f}`);
201
+ }
202
+ }
167
203
 
168
- if (task.length > TASK_CHAR_LIMIT) {
204
+ // ── Task channel: always inline, always the final block ──
205
+ // The task is an instruction, not reference material — it stays inline so the
206
+ // child sees it as the primary directive. A pathologically long task spills.
207
+ let taskInline = true;
208
+ if (task.length > INLINE_LIMIT) {
169
209
  const taskPath = path.join(tmpDir, "task.md");
170
210
  await fs.promises.writeFile(taskPath, task, { encoding: "utf-8", mode: 0o600 });
171
211
  args.push(`@${taskPath}`);
172
- } else {
173
- args.push(`Task: ${task}`);
212
+ taskInline = false;
174
213
  }
175
214
 
215
+ // ── Compose the message body (inline context + task) ──
216
+ // @file args are injected by pi BEFORE this message (buildInitialMessage),
217
+ // so the final shape the child sees is:
218
+ // [<file>...spilled context / reference files...</file>]
219
+ // [<context>...inline context...</context>]
220
+ // [<task>...task...</task>]
221
+ const messageParts: string[] = [];
222
+ if (contextInline) messageParts.push(`<context>\n${options.context}\n</context>`);
223
+ if (taskInline) messageParts.push(`<task>\n${task}\n</task>`);
224
+ const message = messageParts.join("\n\n");
225
+ if (message) args.push(message);
226
+
176
227
  // Spawn process
177
228
  const invocation = getPiInvocation(args);
178
229
  let wasAborted = false;
@@ -361,14 +412,24 @@ export async function spawnSubagent(
361
412
  result.stderr += data.toString();
362
413
  });
363
414
 
364
- p.on("close", (code) => {
415
+ p.on("close", (code, signal) => {
365
416
  if (timeoutHandle) clearTimeout(timeoutHandle);
366
417
  for (const t of escalationTimers) clearTimeout(t);
367
418
  if (onAbort && options.signal) options.signal.removeEventListener("abort", onAbort);
368
419
  if (buffer.trim()) processLine(buffer);
369
- // Budget stops are intentional (success); timeouts are failures (exit 124, Unix convention);
370
- // otherwise use the real exit code (signal kills yield null → 0).
371
- resolve(budgetExceeded ? 0 : (wasTimeout ? 124 : (code ?? 0)));
420
+
421
+ // External signal death (OOM killer, segfault, kill -9 from elsewhere)
422
+ // that we didn't trigger. Distinguish from our own budget/timeout/abort kills
423
+ // which set the flags before we send the signal.
424
+ const externalKill = signal !== null && !budgetExceeded && !wasTimeout && !wasAborted;
425
+ if (externalKill) {
426
+ result.errorMessage = result.errorMessage || `Subagent killed by signal ${signal}`;
427
+ result.stopReason = "error";
428
+ }
429
+
430
+ // Budget stops are intentional (success); timeouts and external kills
431
+ // are failures (non-zero); otherwise use the real exit code.
432
+ resolve(budgetExceeded ? 0 : (wasTimeout || externalKill ? (code ?? 128) : (code ?? 0)));
372
433
  });
373
434
 
374
435
  p.on("error", (err) => {
package/src/types.ts CHANGED
@@ -4,7 +4,8 @@
4
4
 
5
5
  /** Configuration for the subagent extension. */
6
6
  export interface SubagentConfig {
7
- timeoutMs: number;
7
+ /** Per-subagent timeout in seconds. Roles that can `delegate` get 2× automatically when no per-role timeout is set. */
8
+ timeout: number;
8
9
  /** Max number of subagents allowed to run concurrently. Extras queue with a TUI hint. */
9
10
  maxConcurrency: number;
10
11
  /** Max subagent nesting depth (the top-level session is depth 0). */
@@ -34,7 +35,7 @@ export interface SubagentSummaryConfig {
34
35
  }
35
36
 
36
37
  export const DEFAULT_CONFIG: SubagentConfig = {
37
- timeoutMs: 600_000,
38
+ timeout: 600,
38
39
  maxConcurrency: 4,
39
40
  maxDepth: 3,
40
41
  maxTurns: 0,
@@ -60,8 +61,8 @@ export interface SubagentRole {
60
61
  tools: string[];
61
62
  /** If this role has `delegate`, restrict which roles it may spawn. undefined = no restriction. */
62
63
  subagentRoles?: string[];
63
- /** Per-role timeout override (ms). Falls back to config.timeoutMs when unset. */
64
- timeoutMs?: number;
64
+ /** Per-role timeout override in seconds. Falls back to config.timeout when unset. */
65
+ timeout?: number;
65
66
  /** Max assistant turns before the run is killed (0 = use config default; unset = unlimited). */
66
67
  maxTurns?: number;
67
68
  /** Max cumulative cost (USD) before the run is killed (0 = use config default; unset = unlimited). */
@@ -149,6 +150,16 @@ export interface SubagentResult {
149
150
  errorMessage?: string;
150
151
  /** Real-time activity log: thinking blocks and tool calls in arrival order. */
151
152
  activityLog: ActivityEntry[];
153
+
154
+ // ── TUI 渲染辅助字段(非子进程产出,由 execute 层填入)──────────
155
+ /** 运行开始墙钟时间;仅 queued/running 帧存在,供 TUI 实时算耗时。终态帧无此字段。 */
156
+ startTime?: number;
157
+ /** 终态总耗时(ms),由 execute 在结束时写入;覆盖整个 delegate 区间(含 fallback 重试)。 */
158
+ elapsedMs?: number;
159
+ /** delegate 传入的引用文件路径(params.files),展开视图渲染用。 */
160
+ files?: string[];
161
+ /** delegate 传入的额外上下文(params.context),展开视图渲染用。 */
162
+ context?: string;
152
163
  }
153
164
 
154
165
  /** TUI details structure passed via tool result details. */
package/src/utils.test.ts CHANGED
@@ -19,7 +19,8 @@ import {
19
19
  previewArgs,
20
20
  truncateOutput,
21
21
  formatTokens,
22
- effectiveTimeoutMs,
22
+ effectiveTimeout,
23
+ elapsedSeconds,
23
24
  } from "./utils.ts";
24
25
  import type { SubagentResult, SubagentRole } from "./types.ts";
25
26
 
@@ -192,22 +193,22 @@ describe("previewArgs", () => {
192
193
  });
193
194
  });
194
195
 
195
- // ── effectiveTimeoutMs: guards delegate-role auto-widening ──
196
- describe("effectiveTimeoutMs", () => {
197
- const role = (tools: string[], timeoutMs?: number): SubagentRole =>
198
- ({ role: "default", description: "", examples: [], decisionTrigger: "", tools, systemPrompt: "", timeoutMs }) as unknown as SubagentRole;
196
+ // ── effectiveTimeout: guards delegate-role auto-widening (seconds) ──
197
+ describe("effectiveTimeout", () => {
198
+ const role = (tools: string[], timeout?: number): SubagentRole =>
199
+ ({ role: "default", description: "", examples: [], decisionTrigger: "", tools, systemPrompt: "", timeout }) as unknown as SubagentRole;
199
200
 
200
201
  test("non-delegate role uses base timeout", () => {
201
- assert.equal(effectiveTimeoutMs(role(["read", "grep"]), 600000), 600000);
202
+ assert.equal(effectiveTimeout(role(["read", "grep"]), 600), 600);
202
203
  });
203
204
  test("delegate role doubles base when no explicit timeout", () => {
204
- assert.equal(effectiveTimeoutMs(role(["read", "delegate"]), 600000), 1200000);
205
+ assert.equal(effectiveTimeout(role(["read", "delegate"]), 600), 1200);
205
206
  });
206
- test("explicit roleDef.timeoutMs is always honored (no widening)", () => {
207
- assert.equal(effectiveTimeoutMs(role(["read", "delegate"], 300000), 600000), 300000);
207
+ test("explicit roleDef.timeout is always honored (no widening)", () => {
208
+ assert.equal(effectiveTimeout(role(["read", "delegate"], 300), 600), 300);
208
209
  });
209
210
  test("explicit timeout on non-delegate also honored", () => {
210
- assert.equal(effectiveTimeoutMs(role(["read"]), 600000), 600000);
211
+ assert.equal(effectiveTimeout(role(["read"]), 600), 600);
211
212
  });
212
213
  });
213
214
 
@@ -250,3 +251,28 @@ describe("formatTokens", () => {
250
251
  assert.equal(formatTokens(1000000), "1.0M");
251
252
  });
252
253
  });
254
+
255
+ // ── elapsedSeconds: live/terminal time derivation ──
256
+ describe("elapsedSeconds", () => {
257
+ test("terminal state: rounds elapsedMs to whole seconds", () => {
258
+ assert.equal(elapsedSeconds({ exitCode: 0, elapsedMs: 12345 }), 12);
259
+ assert.equal(elapsedSeconds({ exitCode: 0, elapsedMs: 400 }), 0);
260
+ assert.equal(elapsedSeconds({ exitCode: 1, elapsedMs: 59999 }), 60);
261
+ });
262
+ test("terminal state without elapsedMs -> undefined", () => {
263
+ assert.equal(elapsedSeconds({ exitCode: 0 }), undefined);
264
+ });
265
+ test("queued (running sentinel, no startTime) -> undefined", () => {
266
+ assert.equal(elapsedSeconds({ exitCode: -1 }), undefined);
267
+ });
268
+ test("running: live seconds from startTime (within ~1s drift)", () => {
269
+ const start = Date.now() - 3500;
270
+ const s = elapsedSeconds({ exitCode: -1, startTime: start });
271
+ assert.ok(s !== undefined, "should be defined while running");
272
+ assert.ok(s >= 3 && s <= 4, `expected ~3s, got ${s}`);
273
+ });
274
+ test("running: clamps negative drift (future startTime) to 0", () => {
275
+ const start = Date.now() + 10000; // 10s in the future
276
+ assert.equal(elapsedSeconds({ exitCode: -1, startTime: start }), 0);
277
+ });
278
+ });
package/src/utils.ts CHANGED
@@ -31,6 +31,28 @@ export function formatUsageStats(usage: SubagentResult["usage"], model?: string)
31
31
  return parts.join(" ");
32
32
  }
33
33
 
34
+ /**
35
+ * 计算用于展示的运行耗时(秒)。
36
+ * - 运行中(exitCode === -1 且有 startTime):基于墙钟实时计算;
37
+ * - 终态(exitCode !== -1 且有 elapsedMs):冻结值;
38
+ * - queued 或字段缺失:undefined(调用方应跳过耗时展示)。
39
+ *
40
+ * 用结构子集而非 SubagentResult,便于在无需引入完整类型的纯工具上下文中复用与测试。
41
+ */
42
+ export function elapsedSeconds(r: {
43
+ exitCode: number;
44
+ startTime?: number;
45
+ elapsedMs?: number;
46
+ }): number | undefined {
47
+ if (r.exitCode === -1 && typeof r.startTime === "number") {
48
+ return Math.max(0, Math.round((Date.now() - r.startTime) / 1000));
49
+ }
50
+ if (r.exitCode !== -1 && typeof r.elapsedMs === "number") {
51
+ return Math.round(r.elapsedMs / 1000);
52
+ }
53
+ return undefined;
54
+ }
55
+
34
56
  export type DisplayItem =
35
57
  | { type: "toolCall"; name: string; args: Record<string, any>; status?: ToolStatus }
36
58
  | { type: "thinking"; status?: ToolStatus };
@@ -238,14 +260,15 @@ export class AsyncSemaphore {
238
260
  /**
239
261
  * Effective per-role timeout. Roles that can `delegate` need headroom for
240
262
  * nested runs to complete, so when no explicit per-role timeout is set we
241
- * double the base. An explicit roleDef.timeoutMs is always honored as-is.
263
+ * double the base. An explicit roleDef.timeout (seconds) is always honored as-is.
264
+ * All inputs/outputs are in SECONDS — convert to ms at the spawn boundary.
242
265
  */
243
- export function effectiveTimeoutMs(roleDef: SubagentRole, baseTimeoutMs: number): number {
266
+ export function effectiveTimeout(roleDef: SubagentRole, baseTimeoutSec: number): number {
244
267
  const canDelegate = (roleDef.tools ?? []).includes("delegate");
245
- if (canDelegate && roleDef.timeoutMs == null) {
246
- return baseTimeoutMs * 2;
268
+ if (canDelegate && roleDef.timeout == null) {
269
+ return baseTimeoutSec * 2;
247
270
  }
248
- return roleDef.timeoutMs ?? baseTimeoutMs;
271
+ return roleDef.timeout ?? baseTimeoutSec;
249
272
  }
250
273
 
251
274
  // ── Output truncation ────────────────────────────────────────