@d3ara1n/pi-subagent 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/package.json +1 -1
- package/src/index.ts +21 -1
- package/src/roles.ts +25 -11
- package/src/run.test.ts +57 -0
- package/src/run.ts +33 -6
- package/src/spawn.ts +27 -0
- package/src/types.ts +5 -2
package/README.md
CHANGED
|
@@ -34,8 +34,10 @@ This means:
|
|
|
34
34
|
|------|-----------|---------|-------|-----------------|-------------|
|
|
35
35
|
| `explorer` | fast | 900s | read, find, grep | — | Fast code search (read-only, no bash) |
|
|
36
36
|
| `reviewer` | heavy | 3600s | read, bash, grep, find | — | Deep code review (read-only, bash for git/log) |
|
|
37
|
-
| `worker` | default | 2400s |
|
|
38
|
-
| `researcher` | fast | 2400s | web_search, fetch_content, read, bash, delegate | explorer | Web research + GitHub repo analysis |
|
|
37
|
+
| `worker` | default | 2400s | all (no whitelist) | explorer, researcher | Implementation — the only role that can modify files; full tool access (web, MCP, everything) |
|
|
38
|
+
| `researcher` | fast | 2400s | web_search, fetch_content, source_check, get_search_content, read, bash, edit, write, delegate | explorer | Web research + GitHub repo analysis; writes artifacts only inside its temp dir |
|
|
39
|
+
|
|
40
|
+
**Web tool naming**: `researcher`'s web tools use the community-standard names (`web_search`, `fetch_content`, `source_check`, `get_search_content`) shared by the most popular Pi web extensions — [pi-web-access](https://github.com/nicobailon/pi-web-access), `pi-web-tools`, `pi-browse`, and others. Install any of those and the researcher gets web access out of the box. If your web extension uses different tool names (e.g. `websearch`/`webfetch`) or you renamed the tools via a `toolNames` config, override `researcher.tools` in `agentOverrides` to match.
|
|
39
41
|
|
|
40
42
|
**Nested delegation**: `worker` and `researcher` can spawn their own subagents. This keeps the main model's context clean — a worker can explore unfamiliar code via an `explorer` subagent without returning intermediate results to the main model.
|
|
41
43
|
|
|
@@ -138,7 +140,7 @@ Override, disable, or add subagent roles via `agentOverrides`. Built-in and cust
|
|
|
138
140
|
}
|
|
139
141
|
```
|
|
140
142
|
|
|
141
|
-
**Required fields for custom roles:** `role`, `description`, `examples`, `decisionTrigger`, `tools
|
|
143
|
+
**Required fields for custom roles:** `role`, `description`, `examples`, `decisionTrigger`, `systemPrompt`. `tools` is optional — absent means all tools, a list restricts to those exact tool names.
|
|
142
144
|
|
|
143
145
|
**Optional fields:** `subagentRoles` (roles this role can spawn via delegate), `timeout` (per-role active-time timeout in seconds; unset or `0` is unlimited, negative values normalize to `0`), `maxTurns` / `maxCost` (per-role budget overrides; unset uses the top-level `maxTurns` / `maxCost` setting, `0` is unlimited, negative values normalize to `0`), `fallbackRole` (backup pi-model-roles role the whole run is retried on after a provider error; unset means no retry — see [Fallback observability](#fallback-observability)).
|
|
144
146
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d3ara1n/pi-subagent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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/index.ts
CHANGED
|
@@ -104,6 +104,16 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
104
104
|
const collectedRuns = new Map<string, CollectedRun>();
|
|
105
105
|
let runCounter = 0;
|
|
106
106
|
|
|
107
|
+
// ── Live-run reaping ─────────────────────────────────────────
|
|
108
|
+
// Every in-flight run (foreground and background alike), removed once
|
|
109
|
+
// settled. session_shutdown aborts whatever is still here so no child
|
|
110
|
+
// process outlives the parent — quit, reload, or session replacement.
|
|
111
|
+
const liveRuns = new Set<RunHandle>();
|
|
112
|
+
function trackRun(run: RunHandle): void {
|
|
113
|
+
liveRuns.add(run);
|
|
114
|
+
void run.promise.then(() => liveRuns.delete(run));
|
|
115
|
+
}
|
|
116
|
+
|
|
107
117
|
// Mutable guidelines array — rebuilt in session_start to reflect agentOverrides
|
|
108
118
|
const guidelines: string[] = [];
|
|
109
119
|
|
|
@@ -178,12 +188,12 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
178
188
|
applyAgentOverrides(availableRoles, config.agentOverrides);
|
|
179
189
|
|
|
180
190
|
// Validate custom roles (skip built-in roles — they already have all fields)
|
|
191
|
+
// `tools` is optional — absent means the role gets all tools.
|
|
181
192
|
const REQUIRED_FIELDS = [
|
|
182
193
|
"role",
|
|
183
194
|
"description",
|
|
184
195
|
"examples",
|
|
185
196
|
"decisionTrigger",
|
|
186
|
-
"tools",
|
|
187
197
|
"systemPrompt",
|
|
188
198
|
] as const;
|
|
189
199
|
for (const [name, role] of Object.entries(availableRoles)) {
|
|
@@ -219,6 +229,15 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
219
229
|
}
|
|
220
230
|
});
|
|
221
231
|
|
|
232
|
+
// Fires before the extension runtime is torn down (quit, reload, or
|
|
233
|
+
// session replacement). Aborting funnels through the standard abort path:
|
|
234
|
+
// children get SIGTERM → their own handlers kill grandchildren, aborted
|
|
235
|
+
// runs are audited to history, gates release. Without this, background
|
|
236
|
+
// children would burn tokens as unwaitable orphans after /reload or /new.
|
|
237
|
+
pi.on("session_shutdown", () => {
|
|
238
|
+
for (const run of liveRuns) run.abort("session shutdown");
|
|
239
|
+
});
|
|
240
|
+
|
|
222
241
|
pi.registerTool({
|
|
223
242
|
name: "subagent_delegate",
|
|
224
243
|
label: "Delegate to subagent",
|
|
@@ -307,6 +326,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
307
326
|
getRolesApi: getModelRolesAPI,
|
|
308
327
|
getSessionId: () => ctx.sessionManager?.getSessionId(),
|
|
309
328
|
});
|
|
329
|
+
trackRun(run);
|
|
310
330
|
|
|
311
331
|
// ── Background: return the id immediately; the pipeline keeps running. ──
|
|
312
332
|
if (params.background) {
|
package/src/roles.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Built-in subagent role definitions.
|
|
3
3
|
*
|
|
4
4
|
* Each maps to a pi-model-roles role and has a tailored system prompt
|
|
5
|
-
* and tool
|
|
5
|
+
* and tool policy (an explicit allowlist, or all tools when unset).
|
|
6
|
+
* Prompts are in English — concise, efficient, task-focused.
|
|
6
7
|
* Final output should be accurate and concise, stating conclusions directly.
|
|
7
8
|
*/
|
|
8
9
|
|
|
@@ -56,10 +57,9 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
|
|
|
56
57
|
role: "default",
|
|
57
58
|
timeout: 2400,
|
|
58
59
|
description:
|
|
59
|
-
"the ONLY role that can MODIFY files
|
|
60
|
+
"Full tool access — the ONLY role that can MODIFY files (edit, write, refactor, fix, implement). Can delegate to explorer/researcher.",
|
|
60
61
|
examples: ["Rename all snake_case fields to camelCase", "Add input validation to POST /login"],
|
|
61
62
|
decisionTrigger: "Task modifies files?",
|
|
62
|
-
tools: ["read", "bash", "edit", "write", "grep", "find", "subagent_delegate"],
|
|
63
63
|
subagentRoles: ["explorer", "researcher"],
|
|
64
64
|
systemPrompt: [
|
|
65
65
|
"Implementation worker. Work autonomously — all context is in the task description.",
|
|
@@ -67,10 +67,12 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
|
|
|
67
67
|
"After each change, validate: run tests, check syntax, verify behavior.",
|
|
68
68
|
"",
|
|
69
69
|
"## Protecting your context",
|
|
70
|
-
"You have
|
|
70
|
+
"You have full tool access (web_search, fetch_content, MCP, ...) plus a `subagent_delegate` tool.",
|
|
71
|
+
"Use direct tools for quick lookups — e.g. check library docs/APIs with web_search or context7 before writing third-party code.",
|
|
72
|
+
"Delegate only when the work is substantial:",
|
|
71
73
|
"- subagent_delegate(role=explorer) when you need to map unfamiliar code before editing",
|
|
72
|
-
"- subagent_delegate(role=researcher) when
|
|
73
|
-
"Don't delegate tasks you can do with a single read or
|
|
74
|
+
"- subagent_delegate(role=researcher) when the research itself is a multi-step investigation",
|
|
75
|
+
"Don't delegate tasks you can do with a single read, grep, or web search.",
|
|
74
76
|
"",
|
|
75
77
|
"Output format (be brief — summarize, don't paste full diffs):",
|
|
76
78
|
"## Changes: list each file touched and what changed",
|
|
@@ -82,23 +84,35 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
|
|
|
82
84
|
fallbackRole: "default",
|
|
83
85
|
timeout: 2400,
|
|
84
86
|
description:
|
|
85
|
-
"the ONLY role with WEB ACCESS — search docs, fetch pages, analyze GitHub repos.
|
|
87
|
+
"the ONLY role with WEB ACCESS — search docs, fetch pages, verify claims, analyze GitHub repos. Can clone repos & delegate to explorer.",
|
|
86
88
|
examples: ["Find the React 19 migration guide", "Check GitHub issue #1234 for context"],
|
|
87
89
|
decisionTrigger: "Task searches web or GitHub?",
|
|
88
|
-
tools: [
|
|
90
|
+
tools: [
|
|
91
|
+
"web_search",
|
|
92
|
+
"fetch_content",
|
|
93
|
+
"source_check",
|
|
94
|
+
"get_search_content",
|
|
95
|
+
"read",
|
|
96
|
+
"bash",
|
|
97
|
+
"edit",
|
|
98
|
+
"write",
|
|
99
|
+
"subagent_delegate",
|
|
100
|
+
],
|
|
89
101
|
subagentRoles: ["explorer"],
|
|
90
102
|
systemPrompt: [
|
|
91
103
|
"Web researcher. Search with varied angles, prefer official docs over blogs.",
|
|
92
104
|
"If first results are insufficient, refine queries and search again.",
|
|
93
105
|
"",
|
|
106
|
+
"## Research artifacts",
|
|
107
|
+
"You may write files (downloaded docs, notes, intermediate results) — but ONLY under $PI_SUBAGENT_TMPDIR.",
|
|
108
|
+
"Never write anywhere else: project files and other directories are strictly off-limits.",
|
|
109
|
+
"",
|
|
94
110
|
"## GitHub repo analysis",
|
|
95
111
|
"When the task requires analyzing a GitHub repo:",
|
|
96
|
-
"1.
|
|
112
|
+
"1. Clone the repo into $PI_SUBAGENT_TMPDIR",
|
|
97
113
|
"2. Use `subagent_delegate` with role=explorer to investigate the cloned codebase — pass the repo path and the research question",
|
|
98
114
|
"3. Combine explorer findings with any web search results",
|
|
99
115
|
"",
|
|
100
|
-
"bash is for git clone and read-only commands only. Never modify files.",
|
|
101
|
-
"",
|
|
102
116
|
"Output format:",
|
|
103
117
|
"## Answer: direct answer to the question (2-3 sentences)",
|
|
104
118
|
"## Sources: list of URLs used",
|
package/src/run.test.ts
CHANGED
|
@@ -256,6 +256,63 @@ test("abort while queued fails the run and exposes thrown for the foreground pat
|
|
|
256
256
|
gate.release();
|
|
257
257
|
});
|
|
258
258
|
|
|
259
|
+
test("handle.abort() reaps a queued background run (no caller signal)", async () => {
|
|
260
|
+
const gate = new AsyncSemaphore(1);
|
|
261
|
+
await gate.acquire();
|
|
262
|
+
const spawnImpl: SpawnImpl = async () => makeResult({ output: "never" });
|
|
263
|
+
|
|
264
|
+
const run = startSubagentRun(makeDeps({ gate, spawnImpl }));
|
|
265
|
+
run.abort("session shutdown");
|
|
266
|
+
const result = await run.promise;
|
|
267
|
+
|
|
268
|
+
assert.strictEqual(run.state, "failed");
|
|
269
|
+
assert.ok(run.thrown instanceof Error);
|
|
270
|
+
assert.match(result.errorMessage!, /cancelled while queued/);
|
|
271
|
+
assert.match(result.errorMessage!, /session shutdown/);
|
|
272
|
+
gate.release();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("handle.abort(reason) fails a running run with the reason in the error message", async () => {
|
|
276
|
+
const signals: AbortSignal[] = [];
|
|
277
|
+
// Mirrors real spawn's abort handling: pre-aborted signals settle immediately
|
|
278
|
+
// (an "abort" listener alone would never fire — the event already happened).
|
|
279
|
+
const honoringSpawn: SpawnImpl = (_m, _t, options) =>
|
|
280
|
+
new Promise((_resolve, reject) => {
|
|
281
|
+
signals.push(options.signal!);
|
|
282
|
+
const die = () => reject(new Error("Subagent was aborted"));
|
|
283
|
+
if (options.signal?.aborted) die();
|
|
284
|
+
else options.signal?.addEventListener("abort", die, { once: true });
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const run = startSubagentRun(makeDeps({ spawnImpl: honoringSpawn }));
|
|
288
|
+
run.abort("session shutdown");
|
|
289
|
+
const result = await run.promise;
|
|
290
|
+
|
|
291
|
+
assert.strictEqual(run.state, "failed");
|
|
292
|
+
assert.match(result.errorMessage!, /Subagent was aborted \(session shutdown\)/);
|
|
293
|
+
assert.ok(run.thrown instanceof Error);
|
|
294
|
+
// The internal controller the spawn honored is the same channel abort() used.
|
|
295
|
+
assert.ok(signals[0].aborted);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("a pre-aborted caller signal chains into the run before spawn", async () => {
|
|
299
|
+
const controller = new AbortController();
|
|
300
|
+
controller.abort();
|
|
301
|
+
const spawnImpl: SpawnImpl = async (_m, _t, options) => {
|
|
302
|
+
if (options.signal?.aborted) throw new Error("Subagent was aborted");
|
|
303
|
+
return makeResult({ output: "done" });
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const run = startSubagentRun(makeDeps({ signal: controller.signal, spawnImpl }));
|
|
307
|
+
const result = await run.promise;
|
|
308
|
+
|
|
309
|
+
assert.strictEqual(run.state, "failed");
|
|
310
|
+
assert.strictEqual(result.errorMessage, "Subagent was aborted");
|
|
311
|
+
// abort() after settle is a no-op — the terminal state never flips.
|
|
312
|
+
run.abort("session shutdown");
|
|
313
|
+
assert.strictEqual(run.state, "failed");
|
|
314
|
+
});
|
|
315
|
+
|
|
259
316
|
test("subscribers are notified on progress and terminal frames", async () => {
|
|
260
317
|
let notifications = 0;
|
|
261
318
|
const spawnImpl: SpawnImpl = async (_m, _t, options) => {
|
package/src/run.ts
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* exposed via `thrown`), and a subscriber list the `wait` tool uses to mirror
|
|
10
10
|
* live progress into its own tool row.
|
|
11
11
|
*
|
|
12
|
+
* Every run owns an AbortController. The foreground tool signal chains into
|
|
13
|
+
* it; runs started without a caller signal (background) are still abortable
|
|
14
|
+
* via handle.abort() — session_shutdown reaps every live run that way, so no
|
|
15
|
+
* child process outlives the parent.
|
|
16
|
+
*
|
|
12
17
|
* All post-processing (fallback retry, output compression, summary
|
|
13
18
|
* generation, history persistence) runs inside the pipeline, so background
|
|
14
19
|
* runs finish exactly like foreground ones.
|
|
@@ -52,6 +57,8 @@ export interface RunHandle {
|
|
|
52
57
|
readonly thrown: Error | undefined;
|
|
53
58
|
/** Resolves with the terminal result once the run finishes (always succeeds). */
|
|
54
59
|
readonly promise: Promise<SubagentResult>;
|
|
60
|
+
/** Abort the run — no-op after settle. Tool-cancellation and session-shutdown reaping both funnel here. */
|
|
61
|
+
abort(reason?: string): void;
|
|
55
62
|
/** Get notified on every frame change. Returns an unsubscribe function. */
|
|
56
63
|
subscribe(fn: () => void): () => void;
|
|
57
64
|
}
|
|
@@ -69,7 +76,7 @@ export interface StartRunOptions {
|
|
|
69
76
|
cwd: string;
|
|
70
77
|
/** Nesting depth for the child (CURRENT_DEPTH + 1). */
|
|
71
78
|
depth: number;
|
|
72
|
-
/** Foreground callers
|
|
79
|
+
/** Foreground callers chain the tool's AbortSignal in; background runs pass none and are aborted via handle.abort() instead. */
|
|
73
80
|
signal?: AbortSignal;
|
|
74
81
|
/** Per-call model override ('provider/model-id'), bypassing the role's configured model. */
|
|
75
82
|
modelOverride?: string;
|
|
@@ -106,6 +113,14 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
106
113
|
let snapshot: SubagentResult = inputFrame(-1, true);
|
|
107
114
|
let result: SubagentResult | undefined;
|
|
108
115
|
let thrown: Error | undefined;
|
|
116
|
+
let settled = false;
|
|
117
|
+
let abortReason: string | undefined;
|
|
118
|
+
const controller = new AbortController();
|
|
119
|
+
const onCallerAbort = () => controller.abort();
|
|
120
|
+
if (opts.signal) {
|
|
121
|
+
if (opts.signal.aborted) controller.abort();
|
|
122
|
+
else opts.signal.addEventListener("abort", onCallerAbort, { once: true });
|
|
123
|
+
}
|
|
109
124
|
let resolvePromise!: (r: SubagentResult) => void;
|
|
110
125
|
const promise = new Promise<SubagentResult>((resolve) => {
|
|
111
126
|
resolvePromise = resolve;
|
|
@@ -126,11 +141,14 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
126
141
|
notify();
|
|
127
142
|
};
|
|
128
143
|
const finish = (terminal: SubagentResult, error?: Error) => {
|
|
144
|
+
if (settled) return;
|
|
145
|
+
settled = true;
|
|
129
146
|
result = terminal;
|
|
130
147
|
snapshot = terminal;
|
|
131
148
|
thrown = error;
|
|
132
149
|
currentState = isFailedResult(terminal) ? "failed" : "finished";
|
|
133
150
|
notify();
|
|
151
|
+
opts.signal?.removeEventListener("abort", onCallerAbort);
|
|
134
152
|
resolvePromise(terminal);
|
|
135
153
|
};
|
|
136
154
|
|
|
@@ -158,15 +176,22 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
158
176
|
listeners.delete(fn);
|
|
159
177
|
};
|
|
160
178
|
},
|
|
179
|
+
abort(reason?: string) {
|
|
180
|
+
if (settled) return;
|
|
181
|
+
if (reason) abortReason = reason;
|
|
182
|
+
controller.abort();
|
|
183
|
+
},
|
|
161
184
|
promise,
|
|
162
185
|
};
|
|
163
186
|
|
|
164
187
|
(async () => {
|
|
165
188
|
// ── Concurrency gate (abortable while queued) ──
|
|
166
189
|
try {
|
|
167
|
-
await opts.gate.acquire(
|
|
190
|
+
await opts.gate.acquire(controller.signal);
|
|
168
191
|
} catch {
|
|
169
|
-
const msg =
|
|
192
|
+
const msg =
|
|
193
|
+
"cancelled while queued for a concurrency slot" +
|
|
194
|
+
(abortReason ? ` (${abortReason})` : "");
|
|
170
195
|
finish({ ...inputFrame(1, false), errorMessage: msg }, new Error(msg));
|
|
171
196
|
return;
|
|
172
197
|
}
|
|
@@ -262,7 +287,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
262
287
|
maxTurns,
|
|
263
288
|
maxCost,
|
|
264
289
|
depth: opts.depth,
|
|
265
|
-
signal:
|
|
290
|
+
signal: controller.signal,
|
|
266
291
|
onProgress: emitProgress,
|
|
267
292
|
});
|
|
268
293
|
|
|
@@ -294,7 +319,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
294
319
|
maxTurns,
|
|
295
320
|
maxCost,
|
|
296
321
|
depth: opts.depth,
|
|
297
|
-
signal:
|
|
322
|
+
signal: controller.signal,
|
|
298
323
|
onProgress: emitProgress,
|
|
299
324
|
});
|
|
300
325
|
runResult.fallbackFrom = fallbackFrom;
|
|
@@ -348,7 +373,9 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
348
373
|
activityLog: partial.activityLog,
|
|
349
374
|
budgetMs: partial.budgetMs,
|
|
350
375
|
elapsedMs: partial.startTime ? Date.now() - partial.startTime : undefined,
|
|
351
|
-
errorMessage:
|
|
376
|
+
errorMessage: abortReason
|
|
377
|
+
? `Subagent was aborted (${abortReason})`
|
|
378
|
+
: err?.message || String(err),
|
|
352
379
|
};
|
|
353
380
|
// The run spawned before throwing — audit it like any terminal state.
|
|
354
381
|
// The partial output is raw (compression never ran on it).
|
package/src/spawn.ts
CHANGED
|
@@ -19,6 +19,29 @@ const INLINE_LIMIT = 8000;
|
|
|
19
19
|
|
|
20
20
|
const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
21
21
|
|
|
22
|
+
// ── Parent-exit safety net ─────────────────────────────────────
|
|
23
|
+
// process.on("exit") fires synchronously on every terminal path that goes
|
|
24
|
+
// through process.exit — normal quit, signal-triggered graceful shutdown,
|
|
25
|
+
// emergency terminal exit, uncaught crash. SIGTERM the live children so each
|
|
26
|
+
// pi child runs its own cleanup (killing ITS tracked grandchildren) instead
|
|
27
|
+
// of burning tokens as an orphan. This covers the paths where the graceful
|
|
28
|
+
// session_shutdown reaping never fires; a SIGKILL'd parent is beyond help.
|
|
29
|
+
const liveChildren = new Set<ChildProcess>();
|
|
30
|
+
let exitHookInstalled = false;
|
|
31
|
+
function reapChildrenOnExit(): void {
|
|
32
|
+
if (exitHookInstalled) return;
|
|
33
|
+
exitHookInstalled = true;
|
|
34
|
+
process.on("exit", () => {
|
|
35
|
+
for (const child of liveChildren) {
|
|
36
|
+
try {
|
|
37
|
+
child.kill("SIGTERM");
|
|
38
|
+
} catch {
|
|
39
|
+
/* already dead */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
22
45
|
function isRunnableScript(filePath: string): boolean {
|
|
23
46
|
try {
|
|
24
47
|
if (!fs.existsSync(filePath)) return false;
|
|
@@ -508,6 +531,8 @@ export async function spawnSubagent(
|
|
|
508
531
|
stdio: ["ignore", "pipe", "pipe"],
|
|
509
532
|
});
|
|
510
533
|
proc = p;
|
|
534
|
+
liveChildren.add(p);
|
|
535
|
+
reapChildrenOnExit();
|
|
511
536
|
|
|
512
537
|
p.stdout.on("data", (data: Buffer) => {
|
|
513
538
|
buffer += data.toString();
|
|
@@ -522,6 +547,7 @@ export async function spawnSubagent(
|
|
|
522
547
|
|
|
523
548
|
p.on("exit", () => {
|
|
524
549
|
processExited = true;
|
|
550
|
+
liveChildren.delete(p);
|
|
525
551
|
clearEscalationTimer();
|
|
526
552
|
});
|
|
527
553
|
|
|
@@ -548,6 +574,7 @@ export async function spawnSubagent(
|
|
|
548
574
|
|
|
549
575
|
p.on("error", (err) => {
|
|
550
576
|
processExited = true;
|
|
577
|
+
liveChildren.delete(p);
|
|
551
578
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
552
579
|
clearEscalationTimer();
|
|
553
580
|
if (onAbort && options.signal) options.signal.removeEventListener("abort", onAbort);
|
package/src/types.ts
CHANGED
|
@@ -54,8 +54,11 @@ export interface SubagentRole {
|
|
|
54
54
|
decisionTrigger: string;
|
|
55
55
|
/** System prompt for the subagent */
|
|
56
56
|
systemPrompt: string;
|
|
57
|
-
/**
|
|
58
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Tools available to this subagent. Empty or absent = all tools (no restriction).
|
|
59
|
+
* When set, only the listed tool names are exposed to the child (exact-name allowlist).
|
|
60
|
+
*/
|
|
61
|
+
tools?: string[];
|
|
59
62
|
/** If this role has `delegate`, restrict which roles it may spawn. undefined = no restriction. */
|
|
60
63
|
subagentRoles?: string[];
|
|
61
64
|
/** Per-role active-time timeout in seconds. `0` or unset means unlimited; negative values are normalized to `0`. */
|