@d3ara1n/pi-subagent 0.9.1 → 0.10.1
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 +20 -6
- package/package.json +4 -3
- package/src/config.test.ts +123 -0
- package/src/config.ts +6 -5
- package/src/history.ts +56 -0
- package/src/index.ts +29 -444
- package/src/output.ts +116 -0
- package/src/render.ts +277 -0
- package/src/roles.ts +6 -6
- package/src/spawn.ts +47 -24
- package/src/types.ts +9 -9
- package/src/utils.test.ts +36 -2
- package/src/utils.ts +27 -9
package/src/render.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI rendering for the delegate tool: the call row (`delegate <role>`) and the
|
|
3
|
+
* result view (collapsed and expanded), plus the render-side elapsed-time timer.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
getMarkdownTheme,
|
|
8
|
+
type ThemeColor,
|
|
9
|
+
type ToolDefinition,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
12
|
+
import type { SubagentDetails } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
// Contextual types derived from ToolDefinition so we don't depend on
|
|
15
|
+
// non-root-exported render types (ToolRenderContext is internal).
|
|
16
|
+
type RenderCallFn = NonNullable<ToolDefinition["renderCall"]>;
|
|
17
|
+
type RenderResultFn = NonNullable<ToolDefinition["renderResult"]>;
|
|
18
|
+
import {
|
|
19
|
+
buildDisplayItems,
|
|
20
|
+
formatUsageStats,
|
|
21
|
+
elapsedSeconds,
|
|
22
|
+
formatToolCall,
|
|
23
|
+
statusStyle,
|
|
24
|
+
formatThinking,
|
|
25
|
+
renderDisplayItems,
|
|
26
|
+
isFailedResult,
|
|
27
|
+
} from "./utils.ts";
|
|
28
|
+
|
|
29
|
+
// ── Elapsed-time animation (render-side timer) ───────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Per-row render state slot holding the elapsed-time animation timer.
|
|
33
|
+
* The handle lives in context.state so it is scoped to one tool row.
|
|
34
|
+
*/
|
|
35
|
+
interface DelegateRenderState {
|
|
36
|
+
elapsedTimer?: ReturnType<typeof setInterval>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* While a delegate is running, force a TUI repaint every second so the
|
|
41
|
+
* elapsed time ticks up even when the child process is idle. Uses
|
|
42
|
+
* context.invalidate() (pi's official re-render hook) rather than pushing
|
|
43
|
+
* data via onUpdate — the render recomputes elapsed time fresh from Date.now().
|
|
44
|
+
*/
|
|
45
|
+
function ensureElapsedTimer(context: {
|
|
46
|
+
state: Record<string, unknown>;
|
|
47
|
+
invalidate?: () => void;
|
|
48
|
+
}): void {
|
|
49
|
+
const state = context.state as DelegateRenderState;
|
|
50
|
+
if (state.elapsedTimer) return;
|
|
51
|
+
if (typeof context.invalidate !== "function") return;
|
|
52
|
+
state.elapsedTimer = setInterval(() => {
|
|
53
|
+
try {
|
|
54
|
+
context.invalidate?.();
|
|
55
|
+
} catch {
|
|
56
|
+
/* ignore — invalidate must never break rendering */
|
|
57
|
+
}
|
|
58
|
+
}, 1000);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Stop the elapsed-time animation once the run reaches a terminal state. */
|
|
62
|
+
function clearElapsedTimer(context: { state: Record<string, unknown> }): void {
|
|
63
|
+
const state = context.state as DelegateRenderState;
|
|
64
|
+
if (!state.elapsedTimer) return;
|
|
65
|
+
clearInterval(state.elapsedTimer);
|
|
66
|
+
state.elapsedTimer = undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── renderCall: what the user sees when the tool is invoked ─────
|
|
70
|
+
|
|
71
|
+
export const renderDelegateCall: RenderCallFn = (args, theme, _context) => {
|
|
72
|
+
const roleName = (args as any).role || "...";
|
|
73
|
+
const text = theme.fg("toolTitle", theme.bold("delegate ")) + theme.fg("accent", roleName);
|
|
74
|
+
return new Text(text, 0, 0);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ── renderResult: TUI display when the tool finishes ────────
|
|
78
|
+
|
|
79
|
+
export const renderDelegateResult: RenderResultFn = (result, { expanded }, theme, context) => {
|
|
80
|
+
const details = result.details as SubagentDetails | undefined;
|
|
81
|
+
const isRunning = !!details?.results[0] && details.results[0].exitCode === -1;
|
|
82
|
+
|
|
83
|
+
// Tick elapsed time every second while running; stop once terminal.
|
|
84
|
+
// Placed BEFORE the empty-results early return so every terminal path
|
|
85
|
+
// (abort, model-resolution failure, catch) still clears the timer —
|
|
86
|
+
// otherwise the interval leaks a permanent 1 Hz re-render per aborted run.
|
|
87
|
+
// The timer calls context.invalidate() so the render recomputes elapsed
|
|
88
|
+
// time fresh from Date.now() without dirtying the data layer.
|
|
89
|
+
if (isRunning) {
|
|
90
|
+
ensureElapsedTimer(context);
|
|
91
|
+
} else {
|
|
92
|
+
clearElapsedTimer(context);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!details || details.results.length === 0) {
|
|
96
|
+
const text = result.content[0];
|
|
97
|
+
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const r = details.results[0];
|
|
101
|
+
const isError = !isRunning && isFailedResult(r);
|
|
102
|
+
const isTimeout = !isRunning && r.stopReason === "timeout";
|
|
103
|
+
const isBudget = !isRunning && r.stopReason === "budget_exceeded";
|
|
104
|
+
const isFailedState = isError || isTimeout || isBudget;
|
|
105
|
+
|
|
106
|
+
// Status icon. ⏳ running / ⏸ queued (pause) / ⏱ timeout / ⏲ budget / ✗ error / ✓ ok
|
|
107
|
+
let icon: string;
|
|
108
|
+
if (isRunning) {
|
|
109
|
+
icon = r.queued ? theme.fg("warning", "\u23F8") : theme.fg("warning", "\u23F3");
|
|
110
|
+
} else if (isTimeout) {
|
|
111
|
+
icon = theme.fg("warning", "\u23F1");
|
|
112
|
+
} else if (isBudget) {
|
|
113
|
+
icon = theme.fg("warning", "\u23F2");
|
|
114
|
+
} else if (isError) {
|
|
115
|
+
icon = theme.fg("error", "\u2717");
|
|
116
|
+
} else {
|
|
117
|
+
icon = theme.fg("success", "\u2713");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const displayItems = buildDisplayItems(r.activityLog);
|
|
121
|
+
const mdTheme = getMarkdownTheme();
|
|
122
|
+
const fg = theme.fg.bind(theme) as (color: string, text: string) => string;
|
|
123
|
+
|
|
124
|
+
// Task preview: first line, truncated to one row (always-visible anchor).
|
|
125
|
+
const firstLine = r.task.split("\n")[0];
|
|
126
|
+
const taskPreview = firstLine.length > 70 ? `${firstLine.slice(0, 70)}...` : firstLine;
|
|
127
|
+
// taskline: indicator prefix while running/queued; bare text once finished.
|
|
128
|
+
let taskline: string;
|
|
129
|
+
if (isRunning) {
|
|
130
|
+
const label = r.queued ? "(queued)" : "(running)";
|
|
131
|
+
taskline = `${icon} ${theme.fg("dim", label)} ${theme.fg("text", taskPreview)}`;
|
|
132
|
+
} else {
|
|
133
|
+
taskline = theme.fg("text", taskPreview);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// usage line: elapsed/budget(+grace) prefix + existing stats.
|
|
137
|
+
const secs = elapsedSeconds(r);
|
|
138
|
+
const stats = formatUsageStats(r.usage, r.model);
|
|
139
|
+
const budgetSec = r.budgetMs ? Math.round(r.budgetMs / 1000) : 0;
|
|
140
|
+
const liveGraceMs = (r.graceMs ?? 0) + (r.pauseStart ? Date.now() - r.pauseStart : 0);
|
|
141
|
+
const graceSec = Math.round(liveGraceMs / 1000);
|
|
142
|
+
let timePart: string | null = null;
|
|
143
|
+
if (secs != null) {
|
|
144
|
+
timePart =
|
|
145
|
+
budgetSec > 0
|
|
146
|
+
? graceSec > 0
|
|
147
|
+
? `${secs}s/${budgetSec}s(+${graceSec}s)`
|
|
148
|
+
: `${secs}s/${budgetSec}s`
|
|
149
|
+
: `${secs}s`;
|
|
150
|
+
}
|
|
151
|
+
const usageLine = [timePart, stats].filter(Boolean).join(" \u00b7 ");
|
|
152
|
+
|
|
153
|
+
// resultline: fixed line on terminal frames — `<icon> <content>` colored by outcome.
|
|
154
|
+
// success → AI summary, else first line of output (truncated), else a placeholder — never blank.
|
|
155
|
+
// error/timeout/budget → errorMessage (or a default label).
|
|
156
|
+
let resultline: string | undefined;
|
|
157
|
+
if (!isRunning) {
|
|
158
|
+
if (isFailedState) {
|
|
159
|
+
const content =
|
|
160
|
+
r.errorMessage || (isTimeout ? "Timed out" : isBudget ? "Budget exceeded" : "failed");
|
|
161
|
+
const col: ThemeColor = isTimeout || isBudget ? "warning" : "error";
|
|
162
|
+
resultline = `${icon} ${theme.fg(col, content)}`;
|
|
163
|
+
} else {
|
|
164
|
+
// success fallback chain: summary → output first line → placeholder.
|
|
165
|
+
const firstLine = r.output.trim().split("\n")[0] ?? "";
|
|
166
|
+
const preview = firstLine.length > 70 ? `${firstLine.slice(0, 70)}...` : firstLine;
|
|
167
|
+
const content = r.summary || preview;
|
|
168
|
+
const col: ThemeColor = content ? "text" : "muted";
|
|
169
|
+
resultline = `${icon} ${theme.fg(col, content || "(no output)")}`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (expanded) {
|
|
174
|
+
const container = new Container();
|
|
175
|
+
|
|
176
|
+
// Header: taskline + resultline (summary on success, error message on failure).
|
|
177
|
+
container.addChild(new Text(taskline, 0, 0));
|
|
178
|
+
if (resultline) {
|
|
179
|
+
container.addChild(new Text(resultline, 0, 0));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Input block: reference files + context char count + task full text,
|
|
183
|
+
// grouped without inner spacing (they are all subagent input).
|
|
184
|
+
container.addChild(new Spacer(1));
|
|
185
|
+
if (r.files) {
|
|
186
|
+
for (const f of r.files) {
|
|
187
|
+
container.addChild(new Text(theme.fg("dim", `@${f}`), 0, 0));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (r.context) {
|
|
191
|
+
container.addChild(new Text(theme.fg("dim", `ctx ${r.context.length} chars`), 0, 0));
|
|
192
|
+
}
|
|
193
|
+
container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
|
|
194
|
+
|
|
195
|
+
// Activity stream (shown while running and after completion).
|
|
196
|
+
container.addChild(new Spacer(1));
|
|
197
|
+
const activity = displayItems.filter(
|
|
198
|
+
(item) => item.type === "toolCall" || item.type === "thinking",
|
|
199
|
+
);
|
|
200
|
+
if (activity.length === 0) {
|
|
201
|
+
const runningLabel = isRunning
|
|
202
|
+
? r.queued
|
|
203
|
+
? "(queued \u2014 waiting for a concurrency slot...)"
|
|
204
|
+
: "(waiting for first event...)"
|
|
205
|
+
: "(none)";
|
|
206
|
+
container.addChild(new Text(theme.fg("muted", runningLabel), 0, 0));
|
|
207
|
+
} else {
|
|
208
|
+
for (const item of activity) {
|
|
209
|
+
if (item.type === "thinking") {
|
|
210
|
+
container.addChild(new Text(formatThinking(item.status, fg), 0, 0));
|
|
211
|
+
} else {
|
|
212
|
+
const { prefix, color } = statusStyle(item.status, fg);
|
|
213
|
+
container.addChild(
|
|
214
|
+
new Text(prefix + formatToolCall(item.name, item.args, color), 0, 0),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Full output (terminal runs only). Always render the slot — show a
|
|
221
|
+
// placeholder when empty so the user never thinks output was lost.
|
|
222
|
+
if (!isRunning) {
|
|
223
|
+
container.addChild(new Spacer(1));
|
|
224
|
+
if (r.output.trim()) {
|
|
225
|
+
container.addChild(new Markdown(r.output.trim(), 0, 0, mdTheme));
|
|
226
|
+
if (r.outputMethod === "compressed") {
|
|
227
|
+
container.addChild(
|
|
228
|
+
new Text(
|
|
229
|
+
theme.fg(
|
|
230
|
+
"muted",
|
|
231
|
+
"(output compressed by summary model \u2014 full text in history)",
|
|
232
|
+
),
|
|
233
|
+
0,
|
|
234
|
+
0,
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
} else if (r.outputMethod === "truncated") {
|
|
238
|
+
container.addChild(
|
|
239
|
+
new Text(theme.fg("muted", "(output truncated \u2014 full text in history)"), 0, 0),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
container.addChild(
|
|
244
|
+
new Text(theme.fg("muted", "(no output \u2014 the run produced no text)"), 0, 0),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Usage (with elapsed).
|
|
250
|
+
if (usageLine) {
|
|
251
|
+
container.addChild(new Spacer(1));
|
|
252
|
+
container.addChild(new Text(theme.fg("dim", usageLine), 0, 0));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return container;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Collapsed view.
|
|
259
|
+
let text = taskline;
|
|
260
|
+
if (!isRunning) {
|
|
261
|
+
// resultline (shared computation above).
|
|
262
|
+
if (resultline) text += `\n${resultline}`;
|
|
263
|
+
} else if (!r.queued) {
|
|
264
|
+
// Running (not queued): show recent activity only.
|
|
265
|
+
const activity = displayItems.filter(
|
|
266
|
+
(item) => item.type === "toolCall" || item.type === "thinking",
|
|
267
|
+
);
|
|
268
|
+
if (activity.length === 0) {
|
|
269
|
+
text += `\n${theme.fg("muted", "(running...)")}`;
|
|
270
|
+
} else {
|
|
271
|
+
const rendered = renderDisplayItems(activity, 5, fg);
|
|
272
|
+
if (rendered) text += `\n${rendered}`;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (usageLine) text += `\n${theme.fg("dim", usageLine)}`;
|
|
276
|
+
return new Text(text, 0, 0);
|
|
277
|
+
};
|
package/src/roles.ts
CHANGED
|
@@ -13,10 +13,10 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
|
|
|
13
13
|
role: "fast",
|
|
14
14
|
fallbackRole: "default",
|
|
15
15
|
description:
|
|
16
|
-
"READ-ONLY codebase exploration — locate files, grep symbols, trace imports, explain structures. Tools: read, find, grep
|
|
16
|
+
"READ-ONLY codebase exploration — locate files, grep symbols, trace imports, explain structures. Tools: read, find, grep. NO bash, NO edits, NO web access.",
|
|
17
17
|
examples: ["Find where auth middleware is implemented", "Map the routing structure"],
|
|
18
18
|
decisionTrigger: "Task finds or maps code without touch?",
|
|
19
|
-
tools: ["read", "find", "grep"
|
|
19
|
+
tools: ["read", "find", "grep"],
|
|
20
20
|
systemPrompt: [
|
|
21
21
|
"Fast code explorer. You have READ-ONLY tools only — no commands, no edits.",
|
|
22
22
|
"Grep/find to locate → read key sections only → identify types, interfaces, functions.",
|
|
@@ -32,13 +32,13 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
|
|
|
32
32
|
role: "heavy",
|
|
33
33
|
fallbackRole: "default",
|
|
34
34
|
description:
|
|
35
|
-
"READ-ONLY code review & analysis — audit code, assess architecture, review diffs. Tools: read, bash, grep,
|
|
35
|
+
"READ-ONLY code review & analysis — audit code, assess architecture, review diffs. Tools: read, bash, grep, find. Has bash (git diff/log, test runs). NO edits, NO web access.",
|
|
36
36
|
examples: [
|
|
37
37
|
"Review the error handling in src/api/ for security issues",
|
|
38
38
|
"Audit this PR diff for performance regressions",
|
|
39
39
|
],
|
|
40
40
|
decisionTrigger: "Task audits or reviews code quality?",
|
|
41
|
-
tools: ["read", "bash", "grep", "
|
|
41
|
+
tools: ["read", "bash", "grep", "find"],
|
|
42
42
|
systemPrompt: [
|
|
43
43
|
"Senior code reviewer. READ-ONLY — you must NOT modify any file.",
|
|
44
44
|
"bash is for read-only commands only (git diff/log/show, test runs). Never use sed, tee, echo >, or any write command.",
|
|
@@ -53,10 +53,10 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
|
|
|
53
53
|
worker: {
|
|
54
54
|
role: "default",
|
|
55
55
|
description:
|
|
56
|
-
"the ONLY role that can MODIFY files — edit, write, refactor, fix, implement. Tools: read, bash, edit, write, grep,
|
|
56
|
+
"the ONLY role that can MODIFY files — edit, write, refactor, fix, implement. Tools: read, bash, edit, write, grep, find, delegate. Can delegate to explorer/researcher.",
|
|
57
57
|
examples: ["Rename all snake_case fields to camelCase", "Add input validation to POST /login"],
|
|
58
58
|
decisionTrigger: "Task modifies files?",
|
|
59
|
-
tools: ["read", "bash", "edit", "write", "grep", "
|
|
59
|
+
tools: ["read", "bash", "edit", "write", "grep", "find", "delegate"],
|
|
60
60
|
subagentRoles: ["explorer", "researcher"],
|
|
61
61
|
systemPrompt: [
|
|
62
62
|
"Implementation worker. Work autonomously — all context is in the task description.",
|
package/src/spawn.ts
CHANGED
|
@@ -123,6 +123,8 @@ export async function spawnSubagent(
|
|
|
123
123
|
task: string,
|
|
124
124
|
options: {
|
|
125
125
|
cwd?: string;
|
|
126
|
+
/** Thinking level passed to the child pi process when the role defines one. */
|
|
127
|
+
thinking?: string;
|
|
126
128
|
tools?: string[];
|
|
127
129
|
systemPrompt?: string;
|
|
128
130
|
/** Extra context delivered as a separate channel from the task. */
|
|
@@ -163,7 +165,7 @@ export async function spawnSubagent(
|
|
|
163
165
|
// budget instead of racing the parent's wall clock. `graceMs` is the
|
|
164
166
|
// accumulated paused time — display only; the verdict is always
|
|
165
167
|
// "active elapsed >= budget" (pausing grants no extra active time).
|
|
166
|
-
const budgetMs = options.timeoutMs ?? 0;
|
|
168
|
+
const budgetMs = Number.isFinite(options.timeoutMs) ? Math.max(0, options.timeoutMs ?? 0) : 0;
|
|
167
169
|
let activeElapsedAccum = 0; // settled active ms (excludes suspended spans)
|
|
168
170
|
let segmentStart = 0; // wall-clock start of the current active segment; 0 = no active segment
|
|
169
171
|
let isSuspended = false; // true while a child `delegate` call is in flight
|
|
@@ -178,12 +180,16 @@ export async function spawnSubagent(
|
|
|
178
180
|
// Build CLI args
|
|
179
181
|
const args: string[] = ["--mode", "json", "--no-session", "--model", modelRef];
|
|
180
182
|
|
|
183
|
+
if (options.thinking) {
|
|
184
|
+
args.push("--thinking", options.thinking);
|
|
185
|
+
}
|
|
186
|
+
|
|
181
187
|
if (options.tools && options.tools.length > 0) {
|
|
182
188
|
args.push("--tools", options.tools.join(","));
|
|
183
189
|
}
|
|
184
190
|
|
|
185
191
|
// Temp dir for: large-context/task spill files, and as PI_SUBAGENT_TMPDIR
|
|
186
|
-
// for subagent bash work (e.g. git clone).
|
|
192
|
+
// for subagent bash work (e.g. git clone).
|
|
187
193
|
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
|
|
188
194
|
|
|
189
195
|
// ── System prompt channel: inline text via --append-system-prompt ──
|
|
@@ -269,14 +275,14 @@ export async function spawnSubagent(
|
|
|
269
275
|
};
|
|
270
276
|
|
|
271
277
|
let thinkingCounter = 0;
|
|
272
|
-
// O(1) lookup from toolCallId → activityLog index
|
|
278
|
+
// O(1) lookup from toolCallId → activityLog index.
|
|
273
279
|
const toolCallIndex = new Map<string, number>();
|
|
274
280
|
|
|
275
281
|
// Kill the child when the configured turn/cost budget is exceeded.
|
|
276
282
|
// Called after each assistant message_end (usage already accumulated).
|
|
277
283
|
const checkBudget = () => {
|
|
278
|
-
const mt = options.maxTurns ?? 0;
|
|
279
|
-
const mc = options.maxCost ?? 0;
|
|
284
|
+
const mt = Number.isFinite(options.maxTurns) ? Math.max(0, options.maxTurns ?? 0) : 0;
|
|
285
|
+
const mc = Number.isFinite(options.maxCost) ? Math.max(0, options.maxCost ?? 0) : 0;
|
|
280
286
|
if (budgetExceeded || wasTimeout) return;
|
|
281
287
|
if ((mt > 0 && result.usage.turns >= mt) || (mc > 0 && result.usage.cost >= mc)) {
|
|
282
288
|
budgetExceeded = true;
|
|
@@ -400,45 +406,55 @@ export async function spawnSubagent(
|
|
|
400
406
|
childEnv.PI_SUBAGENT_DEPTH = String(options.depth ?? 0);
|
|
401
407
|
|
|
402
408
|
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
409
|
+
let escalationTimer: ReturnType<typeof setTimeout> | undefined;
|
|
403
410
|
let proc: ChildProcess | undefined;
|
|
411
|
+
let processExited = false;
|
|
412
|
+
let terminationRequested = false;
|
|
413
|
+
|
|
414
|
+
const clearEscalationTimer = () => {
|
|
415
|
+
if (!escalationTimer) return;
|
|
416
|
+
clearTimeout(escalationTimer);
|
|
417
|
+
escalationTimer = undefined;
|
|
418
|
+
};
|
|
404
419
|
|
|
405
420
|
// Shared kill helper used by abort, budget, and timeout paths.
|
|
406
|
-
//
|
|
407
|
-
|
|
421
|
+
// A single termination request sends SIGTERM once. SIGKILL is sent only if
|
|
422
|
+
// the process has not emitted exit/close after the grace period.
|
|
408
423
|
const killProc = (reason: "abort" | "budget" | "timeout") => {
|
|
424
|
+
if (terminationRequested || processExited) return;
|
|
425
|
+
terminationRequested = true;
|
|
409
426
|
if (reason === "abort") wasAborted = true;
|
|
410
427
|
else if (reason === "budget") {
|
|
411
428
|
result.stopReason = "budget_exceeded";
|
|
412
429
|
// Human-readable so the caller/TUI never falls back to raw stderr noise.
|
|
413
|
-
const mt = options.maxTurns ?? 0;
|
|
414
|
-
const mc = options.maxCost ?? 0;
|
|
430
|
+
const mt = Number.isFinite(options.maxTurns) ? Math.max(0, options.maxTurns ?? 0) : 0;
|
|
415
431
|
const why =
|
|
416
432
|
mt > 0 && result.usage.turns >= mt
|
|
417
433
|
? `${result.usage.turns} turns`
|
|
418
434
|
: `$${result.usage.cost.toFixed(4)}`;
|
|
419
435
|
result.errorMessage = `Budget exceeded (${why}; partial output returned)`;
|
|
420
|
-
} else
|
|
436
|
+
} else {
|
|
421
437
|
result.stopReason = "timeout";
|
|
422
438
|
wasTimeout = true;
|
|
423
439
|
// Human-readable message so the caller/TUI never falls back to the
|
|
424
440
|
// raw stderr (which is full of TUI teardown escape sequences).
|
|
425
|
-
const secs = Math.round(
|
|
441
|
+
const secs = Math.round(budgetMs / 1000);
|
|
426
442
|
result.errorMessage = `Timed out after ${secs}s (completed ${result.usage.turns} turn${result.usage.turns === 1 ? "" : "s"})`;
|
|
427
443
|
}
|
|
444
|
+
|
|
428
445
|
try {
|
|
429
|
-
proc
|
|
446
|
+
if (!proc || !proc.kill("SIGTERM")) return;
|
|
430
447
|
} catch {
|
|
431
|
-
|
|
448
|
+
return;
|
|
432
449
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
);
|
|
450
|
+
escalationTimer = setTimeout(() => {
|
|
451
|
+
if (processExited) return;
|
|
452
|
+
try {
|
|
453
|
+
proc?.kill("SIGKILL");
|
|
454
|
+
} catch {
|
|
455
|
+
/* ignore */
|
|
456
|
+
}
|
|
457
|
+
}, 5000);
|
|
442
458
|
};
|
|
443
459
|
|
|
444
460
|
/** Pause the active-time clock (called on child `delegate` start). */
|
|
@@ -503,9 +519,15 @@ export async function spawnSubagent(
|
|
|
503
519
|
result.stderr += data.toString();
|
|
504
520
|
});
|
|
505
521
|
|
|
522
|
+
p.on("exit", () => {
|
|
523
|
+
processExited = true;
|
|
524
|
+
clearEscalationTimer();
|
|
525
|
+
});
|
|
526
|
+
|
|
506
527
|
p.on("close", (code, signal) => {
|
|
528
|
+
processExited = true;
|
|
507
529
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
508
|
-
|
|
530
|
+
clearEscalationTimer();
|
|
509
531
|
if (onAbort && options.signal) options.signal.removeEventListener("abort", onAbort);
|
|
510
532
|
if (buffer.trim()) processLine(buffer);
|
|
511
533
|
|
|
@@ -524,8 +546,9 @@ export async function spawnSubagent(
|
|
|
524
546
|
});
|
|
525
547
|
|
|
526
548
|
p.on("error", (err) => {
|
|
549
|
+
processExited = true;
|
|
527
550
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
528
|
-
|
|
551
|
+
clearEscalationTimer();
|
|
529
552
|
if (onAbort && options.signal) options.signal.removeEventListener("abort", onAbort);
|
|
530
553
|
// Surface the real cause (e.g. ENOENT when pi is not in PATH) instead of "unknown error".
|
|
531
554
|
result.errorMessage = err?.message || String(err);
|
package/src/types.ts
CHANGED
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
/** Configuration for the subagent extension. */
|
|
6
6
|
export interface SubagentConfig {
|
|
7
|
-
/** Per-subagent timeout in seconds
|
|
7
|
+
/** Per-subagent active-time timeout in seconds. `0` means unlimited; negative values are normalized to `0`. The clock pauses while the child is inside a nested `delegate` call, so no widening is needed for delegate-capable roles. */
|
|
8
8
|
timeout: number;
|
|
9
|
-
/** Max
|
|
9
|
+
/** Max concurrent subagents. `0` means unlimited; negative values are normalized to `0`. Extras queue with a TUI hint when this is positive. */
|
|
10
10
|
maxConcurrency: number;
|
|
11
|
-
/** Max subagent nesting depth (the top-level session is depth 0). */
|
|
11
|
+
/** Max subagent nesting depth (the top-level session is depth 0). `0` means unlimited; negative values are normalized to `0`. */
|
|
12
12
|
maxDepth: number;
|
|
13
|
-
/** Default turn budget
|
|
13
|
+
/** Default assistant-turn budget. `0` means unlimited; negative values are normalized to `0`. Per-role maxTurns overrides this. */
|
|
14
14
|
maxTurns: number;
|
|
15
|
-
/** Default cost budget in USD
|
|
15
|
+
/** Default cumulative cost budget in USD. `0` means unlimited; negative values are normalized to `0`. Per-role maxCost overrides this. */
|
|
16
16
|
maxCost: number;
|
|
17
|
-
/** Persist each delegate run to
|
|
17
|
+
/** Persist each delegate run to ~/.pi/subagent/history/{sessionId}/{id}.json for auditing. */
|
|
18
18
|
history: SubagentHistoryConfig;
|
|
19
19
|
summary: SubagentSummaryConfig;
|
|
20
20
|
/**
|
|
@@ -61,11 +61,11 @@ export interface SubagentRole {
|
|
|
61
61
|
tools: string[];
|
|
62
62
|
/** If this role has `delegate`, restrict which roles it may spawn. undefined = no restriction. */
|
|
63
63
|
subagentRoles?: string[];
|
|
64
|
-
/** Per-role timeout override in seconds. Falls back to config.timeout when unset. */
|
|
64
|
+
/** Per-role active-time timeout override in seconds. `0` means unlimited; negative values are normalized to `0`. Falls back to config.timeout when unset. */
|
|
65
65
|
timeout?: number;
|
|
66
|
-
/** Max assistant turns before the run is killed
|
|
66
|
+
/** Max assistant turns before the run is killed. `0` means unlimited; negative values are normalized to `0`. */
|
|
67
67
|
maxTurns?: number;
|
|
68
|
-
/** Max cumulative cost
|
|
68
|
+
/** Max cumulative cost in USD. `0` means unlimited; negative values are normalized to `0`. */
|
|
69
69
|
maxCost?: number;
|
|
70
70
|
/** Fallback pi-model-roles role name when this role's model is unavailable (provider error). Defaults to "default". */
|
|
71
71
|
fallbackRole?: string;
|
package/src/utils.test.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* node --test packages/pi-subagent/src/utils.test.ts
|
|
6
6
|
*
|
|
7
7
|
* These guard the bug fixes introduced during the improvement rounds:
|
|
8
|
-
* path-injection (sanitizeFilename), concurrency/abort/negative-active
|
|
9
|
-
* (AsyncSemaphore), provider-error word list (isProviderError), unknown-tool
|
|
8
|
+
* path-injection (sanitizeFilename), concurrency/abort/negative-active/unlimited
|
|
9
|
+
* semantics (AsyncSemaphore), provider-error word list (isProviderError), unknown-tool
|
|
10
10
|
* formatting (previewArgs), output truncation fallback (truncateOutput).
|
|
11
11
|
*/
|
|
12
12
|
|
|
@@ -146,6 +146,40 @@ describe("AsyncSemaphore", () => {
|
|
|
146
146
|
await assert.rejects(p);
|
|
147
147
|
assert.equal((s as any).waiters.length, 0);
|
|
148
148
|
});
|
|
149
|
+
test("unlimited max (0) never queues or reports capacity", async () => {
|
|
150
|
+
const s = new AsyncSemaphore(0);
|
|
151
|
+
assert.equal(s.isLimited, false);
|
|
152
|
+
assert.equal(s.isAtCapacity, false);
|
|
153
|
+
|
|
154
|
+
let acquired = 0;
|
|
155
|
+
await Promise.all(
|
|
156
|
+
Array.from({ length: 10 }, () =>
|
|
157
|
+
s.acquire().then(() => {
|
|
158
|
+
acquired++;
|
|
159
|
+
}),
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
assert.equal(acquired, 10);
|
|
164
|
+
assert.equal((s as any).waiters.length, 0);
|
|
165
|
+
assert.equal(s.isAtCapacity, false);
|
|
166
|
+
});
|
|
167
|
+
test("positive max reports capacity and retains FIFO queueing", async () => {
|
|
168
|
+
const s = new AsyncSemaphore(1);
|
|
169
|
+
await s.acquire();
|
|
170
|
+
assert.equal(s.isAtCapacity, true);
|
|
171
|
+
|
|
172
|
+
const order: number[] = [];
|
|
173
|
+
const p1 = s.acquire().then(() => order.push(1));
|
|
174
|
+
const p2 = s.acquire().then(() => order.push(2));
|
|
175
|
+
assert.equal((s as any).waiters.length, 2);
|
|
176
|
+
|
|
177
|
+
s.release();
|
|
178
|
+
await p1;
|
|
179
|
+
s.release();
|
|
180
|
+
await p2;
|
|
181
|
+
assert.deepEqual(order, [1, 2]);
|
|
182
|
+
});
|
|
149
183
|
test("releases queued waiters in FIFO order", async () => {
|
|
150
184
|
const s = new AsyncSemaphore(1);
|
|
151
185
|
await s.acquire();
|
package/src/utils.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure helpers for pi-subagent: formatting, sanitization,
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Extracted from index.ts so these can be exercised directly. index.ts imports
|
|
6
|
-
* them; behavior is unchanged.
|
|
2
|
+
* Pure helpers for pi-subagent: formatting, sanitization, and the concurrency
|
|
3
|
+
* semaphore. No pi-API or I/O dependencies — safe to unit-test.
|
|
7
4
|
*/
|
|
8
5
|
|
|
9
6
|
import * as os from "node:os";
|
|
@@ -222,11 +219,24 @@ export function previewArgs(args: Record<string, unknown>): string {
|
|
|
222
219
|
return argsStr.length > 50 ? argsStr.slice(0, 50) + "..." : argsStr;
|
|
223
220
|
}
|
|
224
221
|
|
|
222
|
+
// ── Numeric configuration ─────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/** Normalize a finite numeric limit: invalid values use the default; negatives become 0 (unlimited). */
|
|
225
|
+
export function normalizeNonNegativeNumber(value: unknown, fallback: number): number {
|
|
226
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
227
|
+
return Math.max(0, value);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Normalize a count limit to a non-negative integer. */
|
|
231
|
+
export function normalizeNonNegativeInteger(value: unknown, fallback: number): number {
|
|
232
|
+
return Math.floor(normalizeNonNegativeNumber(value, fallback));
|
|
233
|
+
}
|
|
234
|
+
|
|
225
235
|
// ── Concurrency gate ───────────────────────────────────────────────
|
|
226
236
|
|
|
227
237
|
/**
|
|
228
238
|
* Promise-based semaphore capping concurrent subagent spawns.
|
|
229
|
-
*
|
|
239
|
+
* A max of 0 means unlimited concurrency, so acquire() never queues.
|
|
230
240
|
* Pass an AbortSignal to cancel while waiting (rejects and removes the waiter).
|
|
231
241
|
*/
|
|
232
242
|
export class AsyncSemaphore {
|
|
@@ -234,10 +244,16 @@ export class AsyncSemaphore {
|
|
|
234
244
|
private waiters: Array<() => void> = [];
|
|
235
245
|
private max: number;
|
|
236
246
|
constructor(max: number) {
|
|
237
|
-
this.max = max;
|
|
247
|
+
this.max = normalizeNonNegativeInteger(max, 0);
|
|
248
|
+
}
|
|
249
|
+
get isLimited(): boolean {
|
|
250
|
+
return this.max > 0;
|
|
251
|
+
}
|
|
252
|
+
get isAtCapacity(): boolean {
|
|
253
|
+
return this.isLimited && this.active >= this.max;
|
|
238
254
|
}
|
|
239
255
|
async acquire(signal?: AbortSignal): Promise<void> {
|
|
240
|
-
if (this.
|
|
256
|
+
if (!this.isAtCapacity) {
|
|
241
257
|
this.active++;
|
|
242
258
|
return;
|
|
243
259
|
}
|
|
@@ -265,6 +281,7 @@ export class AsyncSemaphore {
|
|
|
265
281
|
}
|
|
266
282
|
release(): void {
|
|
267
283
|
this.active = Math.max(0, this.active - 1);
|
|
284
|
+
if (!this.isLimited) return;
|
|
268
285
|
const next = this.waiters.shift();
|
|
269
286
|
if (next) next();
|
|
270
287
|
}
|
|
@@ -277,9 +294,10 @@ export class AsyncSemaphore {
|
|
|
277
294
|
* No widening for delegate-capable roles: the parent's active-time clock
|
|
278
295
|
* pauses while the child is inside a nested `delegate` call, so the base
|
|
279
296
|
* budget is already enough. An explicit roleDef.timeout always wins.
|
|
297
|
+
* Non-finite values fall back to the base timeout; negative values become 0 (unlimited).
|
|
280
298
|
*/
|
|
281
299
|
export function effectiveTimeout(roleDef: SubagentRole, baseTimeoutSec: number): number {
|
|
282
|
-
return roleDef.timeout
|
|
300
|
+
return normalizeNonNegativeNumber(roleDef.timeout, normalizeNonNegativeNumber(baseTimeoutSec, 0));
|
|
283
301
|
}
|
|
284
302
|
|
|
285
303
|
// ── Output truncation ────────────────────────────────────────
|