@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 +31 -36
- package/package.json +1 -1
- package/src/config.ts +1 -1
- package/src/index.ts +147 -96
- package/src/spawn.ts +79 -18
- package/src/types.ts +15 -4
- package/src/utils.test.ts +36 -10
- package/src/utils.ts +33 -6
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
|
|
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
|
-
```
|
|
64
|
+
```json
|
|
65
65
|
{
|
|
66
66
|
"subagent": {
|
|
67
|
-
|
|
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",
|
|
93
|
-
"enabled": true
|
|
76
|
+
"role": "utility",
|
|
77
|
+
"enabled": true
|
|
94
78
|
}
|
|
95
79
|
}
|
|
96
80
|
}
|
|
97
81
|
```
|
|
98
82
|
|
|
99
|
-
All fields are optional. Defaults: `
|
|
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
|
-
```
|
|
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",
|
|
112
|
-
"
|
|
113
|
-
"maxTurns": 50,
|
|
114
|
-
"maxCost": 1.0
|
|
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), `
|
|
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
|
|
153
|
+
### Passing context and reference files
|
|
175
154
|
|
|
176
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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,
|
|
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.).
|
|
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
|
|
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,
|
|
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:
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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("
|
|
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", "\
|
|
657
|
+
icon = r.queued ? theme.fg("warning", "\u23F8") : theme.fg("warning", "\u23F3");
|
|
633
658
|
} else if (isTimeout) {
|
|
634
|
-
icon = theme.fg("warning", "\u23F1");
|
|
659
|
+
icon = theme.fg("warning", "\u23F1");
|
|
635
660
|
} else if (isBudget) {
|
|
636
|
-
icon = theme.fg("warning", "\u23F2");
|
|
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
|
-
|
|
650
|
-
if (
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
700
|
-
if (
|
|
769
|
+
// Usage (with elapsed).
|
|
770
|
+
if (usageLine) {
|
|
701
771
|
container.addChild(new Spacer(1));
|
|
702
|
-
container.addChild(new Text(theme.fg("dim",
|
|
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 =
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
if (
|
|
713
|
-
|
|
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
|
-
|
|
716
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
17
|
-
const
|
|
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
|
-
//
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
//
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
196
|
-
describe("
|
|
197
|
-
const role = (tools: string[],
|
|
198
|
-
({ role: "default", description: "", examples: [], decisionTrigger: "", tools, systemPrompt: "",
|
|
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(
|
|
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(
|
|
205
|
+
assert.equal(effectiveTimeout(role(["read", "delegate"]), 600), 1200);
|
|
205
206
|
});
|
|
206
|
-
test("explicit roleDef.
|
|
207
|
-
assert.equal(
|
|
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(
|
|
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
|
-
/**
|
|
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.
|
|
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
|
|
270
|
+
export function effectiveTimeout(roleDef: SubagentRole, baseTimeoutSec: number): number {
|
|
244
271
|
const canDelegate = (roleDef.tools ?? []).includes("delegate");
|
|
245
|
-
if (canDelegate && roleDef.
|
|
246
|
-
return
|
|
272
|
+
if (canDelegate && roleDef.timeout == null) {
|
|
273
|
+
return baseTimeoutSec * 2;
|
|
247
274
|
}
|
|
248
|
-
return roleDef.
|
|
275
|
+
return roleDef.timeout ?? baseTimeoutSec;
|
|
249
276
|
}
|
|
250
277
|
|
|
251
278
|
// ── Output truncation ────────────────────────────────────────
|