@d3ara1n/pi-subagent 0.4.0 → 0.6.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": 1500,
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: 1500` (seconds; 25 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": 1500,
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.6.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,95 @@ 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
+ // resultline: fixed line on terminal frames — `<icon> <content>` colored by outcome.
690
+ // success → AI summary, else first line of output (truncated), else a placeholder — never blank.
691
+ // error/timeout/budget → errorMessage (or a default label).
692
+ let resultline: string | undefined;
693
+ if (!isRunning) {
694
+ if (isFailedState) {
695
+ const content = r.errorMessage || (isTimeout ? "Timed out" : isBudget ? "Budget exceeded" : "failed");
696
+ const col: ThemeColor = isTimeout || isBudget ? "warning" : "error";
697
+ resultline = `${icon} ${theme.fg(col, content)}`;
698
+ } else {
699
+ // success fallback chain: summary → output first line → placeholder.
700
+ const firstLine = r.output.trim().split("\n")[0] ?? "";
701
+ const preview = firstLine.length > 70 ? `${firstLine.slice(0, 70)}...` : firstLine;
702
+ const content = r.summary || preview;
703
+ const col: ThemeColor = content ? "text" : "muted";
704
+ resultline = `${icon} ${theme.fg(col, content || "(no output)")}`;
705
+ }
706
+ }
644
707
 
645
708
  if (expanded) {
646
709
  const container = new Container();
647
710
 
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));
711
+ // Header: taskline + resultline (summary on success, error message on failure).
712
+ container.addChild(new Text(taskline, 0, 0));
713
+ if (resultline) {
714
+ container.addChild(new Text(resultline, 0, 0));
715
+ }
658
716
 
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));
717
+ // Input block: reference files + context char count + task full text,
718
+ // grouped without inner spacing (they are all subagent input).
719
+ container.addChild(new Spacer(1));
720
+ if (r.files) {
721
+ for (const f of r.files) {
722
+ container.addChild(new Text(theme.fg("dim", `@${f}`), 0, 0));
723
+ }
663
724
  }
725
+ if (r.context) {
726
+ container.addChild(new Text(theme.fg("dim", `ctx ${r.context.length} chars`), 0, 0));
727
+ }
728
+ container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
664
729
 
730
+ // Activity stream (shown while running and after completion).
665
731
  container.addChild(new Spacer(1));
666
732
  const activity = displayItems.filter((item) => item.type === "toolCall" || item.type === "thinking");
667
733
  if (activity.length === 0) {
668
734
  const runningLabel = isRunning
669
735
  ? r.queued
670
- ? "(queued waiting for a concurrency slot...)"
736
+ ? "(queued \u2014 waiting for a concurrency slot...)"
671
737
  : "(waiting for first event...)"
672
738
  : "(none)";
673
739
  container.addChild(new Text(theme.fg("muted", runningLabel), 0, 0));
674
740
  } else {
675
- const fg = theme.fg.bind(theme) as (color: string, text: string) => string;
676
741
  for (const item of activity) {
677
742
  if (item.type === "thinking") {
678
743
  container.addChild(new Text(formatThinking(item.status, fg), 0, 0));
@@ -685,64 +750,50 @@ export default function subagentExtension(pi: ExtensionAPI) {
685
750
  }
686
751
  }
687
752
 
688
- if (!isRunning && r.output.trim()) {
753
+ // Full output (terminal runs only). Always render the slot — show a
754
+ // placeholder when empty so the user never thinks output was lost.
755
+ if (!isRunning) {
689
756
  container.addChild(new Spacer(1));
690
- container.addChild(new Text(theme.fg("muted", "\u2500\u2500\u2500 Output \u2500\u2500\u2500"), 0, 0));
691
- container.addChild(new Markdown(r.output.trim(), 0, 0, mdTheme));
692
- if (r.outputMethod === "compressed") {
693
- container.addChild(new Text(theme.fg("muted", "(output compressed by summary model \u2014 full text in history)"), 0, 0));
694
- } else if (r.outputMethod === "truncated") {
695
- container.addChild(new Text(theme.fg("muted", "(output truncated \u2014 full text in history)"), 0, 0));
757
+ if (r.output.trim()) {
758
+ container.addChild(new Markdown(r.output.trim(), 0, 0, mdTheme));
759
+ if (r.outputMethod === "compressed") {
760
+ container.addChild(new Text(theme.fg("muted", "(output compressed by summary model \u2014 full text in history)"), 0, 0));
761
+ } else if (r.outputMethod === "truncated") {
762
+ container.addChild(new Text(theme.fg("muted", "(output truncated \u2014 full text in history)"), 0, 0));
763
+ }
764
+ } else {
765
+ container.addChild(new Text(theme.fg("muted", "(no output \u2014 the run produced no text)"), 0, 0));
696
766
  }
697
767
  }
698
768
 
699
- const usageStr = formatUsageStats(r.usage, r.model);
700
- if (usageStr) {
769
+ // Usage (with elapsed).
770
+ if (usageLine) {
701
771
  container.addChild(new Spacer(1));
702
- container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
772
+ container.addChild(new Text(theme.fg("dim", usageLine), 0, 0));
703
773
  }
704
774
 
705
775
  return container;
706
776
  }
707
777
 
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...)")}`;
778
+ // Collapsed view.
779
+ let text = taskline;
780
+ if (!isRunning) {
781
+ // resultline (shared computation above).
782
+ if (resultline) text += `\n${resultline}`;
783
+ } else if (!r.queued) {
784
+ // Running (not queued): show recent activity only.
785
+ const activity = displayItems.filter((item) => item.type === "toolCall" || item.type === "thinking");
786
+ if (activity.length === 0) {
787
+ text += `\n${theme.fg("muted", "(running...)")}`;
714
788
  } 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
- }
789
+ const rendered = renderDisplayItems(activity, 5, fg);
790
+ if (rendered) text += `\n${rendered}`;
723
791
  }
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
792
  }
793
+ if (usageLine) text += `\n${theme.fg("dim", usageLine)}`;
742
794
  return new Text(text, 0, 0);
743
795
  },
744
796
  });
745
-
746
797
  pi.registerCommand("subagent:doctor", {
747
798
  description: "Diagnose pi-subagent configuration and dependencies",
748
799
  handler: async (_args, ctx) => {
@@ -761,7 +812,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
761
812
  // 3. config
762
813
  try {
763
814
  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}`);
815
+ 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
816
  } catch {
766
817
  lines.push("[\u2717] config: failed to load");
767
818
  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: 1500,
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 rendering helpers (not produced by the child; filled in by the execute layer) ──
155
+ /** Wall-clock start time; present only on queued/running frames so the TUI can compute live elapsed time. Absent on terminal frames. */
156
+ startTime?: number;
157
+ /** Total elapsed time (ms) for terminal frames, written by execute when the run ends; spans the whole delegate interval (incl. fallback retries). */
158
+ elapsedMs?: number;
159
+ /** Reference file paths passed to delegate (params.files); used by the expanded view. */
160
+ files?: string[];
161
+ /** Extra context passed to delegate (params.context); used by the expanded view. */
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,29 @@ export function formatUsageStats(usage: SubagentResult["usage"], model?: string)
31
31
  return parts.join(" ");
32
32
  }
33
33
 
34
+ /**
35
+ * Display elapsed time in seconds.
36
+ * - Running (exitCode === -1 with startTime): live wall-clock value.
37
+ * - Terminal (exitCode !== -1 with elapsedMs): frozen value.
38
+ * - Queued or fields missing: undefined (caller should skip the elapsed display).
39
+ *
40
+ * Takes a structural subset rather than the full SubagentResult so it can be reused
41
+ * and tested in pure-helper contexts without importing the full type.
42
+ */
43
+ export function elapsedSeconds(r: {
44
+ exitCode: number;
45
+ startTime?: number;
46
+ elapsedMs?: number;
47
+ }): number | undefined {
48
+ if (r.exitCode === -1 && typeof r.startTime === "number") {
49
+ return Math.max(0, Math.round((Date.now() - r.startTime) / 1000));
50
+ }
51
+ if (r.exitCode !== -1 && typeof r.elapsedMs === "number") {
52
+ return Math.round(r.elapsedMs / 1000);
53
+ }
54
+ return undefined;
55
+ }
56
+
34
57
  export type DisplayItem =
35
58
  | { type: "toolCall"; name: string; args: Record<string, any>; status?: ToolStatus }
36
59
  | { type: "thinking"; status?: ToolStatus };
@@ -171,7 +194,10 @@ export function isProviderError(result: SubagentResult): boolean {
171
194
  return /429|quota|rate.?limit|auth|timeout|exhausted|unavailable|503|server error|temporary|declined|overloaded|econnreset|socket hang up|epipe|network|connection/i.test(haystack);
172
195
  }
173
196
 
174
- /** Shape-based preview for tools we don't have a dedicated formatter for. */
197
+ /**
198
+ * Shape-based preview for tools we don't have a dedicated formatter for.
199
+ * @internal — exported for testing; used internally by {@link formatToolCall}.
200
+ */
175
201
  export function previewArgs(args: Record<string, unknown>): string {
176
202
  const command = args.command as string | undefined;
177
203
  if (command) return `$ ${command.length > 60 ? command.slice(0, 60) + "..." : command}`;
@@ -238,14 +264,15 @@ export class AsyncSemaphore {
238
264
  /**
239
265
  * Effective per-role timeout. Roles that can `delegate` need headroom for
240
266
  * 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.
267
+ * double the base. An explicit roleDef.timeout (seconds) is always honored as-is.
268
+ * All inputs/outputs are in SECONDS — convert to ms at the spawn boundary.
242
269
  */
243
- export function effectiveTimeoutMs(roleDef: SubagentRole, baseTimeoutMs: number): number {
270
+ export function effectiveTimeout(roleDef: SubagentRole, baseTimeoutSec: number): number {
244
271
  const canDelegate = (roleDef.tools ?? []).includes("delegate");
245
- if (canDelegate && roleDef.timeoutMs == null) {
246
- return baseTimeoutMs * 2;
272
+ if (canDelegate && roleDef.timeout == null) {
273
+ return baseTimeoutSec * 2;
247
274
  }
248
- return roleDef.timeoutMs ?? baseTimeoutMs;
275
+ return roleDef.timeout ?? baseTimeoutSec;
249
276
  }
250
277
 
251
278
  // ── Output truncation ────────────────────────────────────────