@d3ara1n/pi-subagent 1.1.0 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-subagent",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
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
 
@@ -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/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 pass the tool's AbortSignal; background runs pass none and outlive the turn. */
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(opts.signal);
190
+ await opts.gate.acquire(controller.signal);
168
191
  } catch {
169
- const msg = "cancelled while queued for a concurrency slot";
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: opts.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: opts.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: err?.message || String(err),
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);