pi-subagents 0.17.1 → 0.17.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.17.3] - 2026-04-22
6
+
7
+ ### Added
8
+ - Added builtin `oracle` and `oracle-executor` agents for the `main -> oracle -> main decision -> oracle-executor` workflow, plus README guidance for invoking the oracle pair with forked context.
9
+
10
+ ### Fixed
11
+ - Migrated extension tool schemas from `@sinclair/typebox` to `typebox` 1.x so packaged installs follow Pi's current extension runtime contract.
12
+
13
+ ### Changed
14
+ - Moved TypeBox from `peerDependencies` to a real `dependencies` entry so `pi install` production installs keep the schema package available at runtime.
15
+
16
+ ## [0.17.2] - 2026-04-21
17
+
18
+ ### Added
19
+ - Added `forceTopLevelAsync` so depth-0 delegated runs can be forced into background mode with `clarify: false`, while nested runs keep their existing behavior.
20
+
21
+ ### Fixed
22
+ - Background completion notifications now render `(no output)` instead of a blank body when a completion summary is empty or whitespace-only.
23
+ - Async status and token reporting now rerender more reliably when cleanup state changes, read token usage from `message.usage`, and prefer the newest session file when multiple async session files exist.
24
+ - Async/background startup now fails fast for invalid resolved `cwd` values and spawn failures instead of reporting false launch success.
25
+ - Sync and async runner paths now drain stuck child processes in bounded time, covering both post-exit stdio holders and children that emit a final message but never exit.
26
+
5
27
  ## [0.17.1] - 2026-04-20
6
28
 
7
29
  ### Added
package/README.md CHANGED
@@ -46,7 +46,10 @@ Project discovery also reads legacy `.agents/{name}.md` files. If both `.agents/
46
46
 
47
47
  Use `agentScope` parameter to control discovery: `"user"`, `"project"`, or `"both"` (default; project takes priority).
48
48
 
49
- **Builtin agents:** The extension ships with ready-to-use agents — `scout`, `planner`, `worker`, `reviewer`, `context-builder`, `researcher`, and `delegate`. They load at lowest priority so any user or project agent with the same name overrides them.
49
+ **Builtin agents:** The extension ships with ready-to-use agents — `scout`, `planner`, `worker`, `reviewer`, `context-builder`, `researcher`, `delegate`, `oracle`, and `oracle-executor`. They load at lowest priority so any user or project agent with the same name overrides them.
50
+
51
+ - `oracle` is a high-context advisory reviewer on `openai-codex/gpt-5.4:high`. It critiques direction, surfaces hidden risks, and proposes a concrete execution prompt, but it does not edit files directly.
52
+ - `oracle-executor` is a high-context implementation escalator on `openai-codex/gpt-5.3-codex:high`. It is intended to run only after the main agent explicitly approves a course of action.
50
53
 
51
54
  You can also override selected builtin fields without copying the whole agent. Builtin overrides are stored in settings under `subagents.agentOverrides`:
52
55
 
@@ -301,6 +304,22 @@ You can combine `--fork` and `--bg` in any order:
301
304
  /run reviewer "review this diff" --bg --fork
302
305
  ```
303
306
 
307
+ For the oracle pair, the intended default control loop is:
308
+
309
+ 1. main agent invokes `oracle` with forked context
310
+ 2. `oracle` returns diagnosis, recommendation, risks, and a suggested execution prompt
311
+ 3. main agent decides whether to accept that direction
312
+ 4. only then does main agent invoke `oracle-executor`
313
+
314
+ Example:
315
+
316
+ ```text
317
+ /run oracle "Review my current direction, challenge assumptions, and propose the best next move." --fork
318
+ /run oracle-executor "Implement the approved approach: ..." --fork
319
+ ```
320
+
321
+ This keeps decision authority in the main thread while still giving you a stronger review/escalation path.
322
+
304
323
  ## Agents Manager
305
324
 
306
325
  Press **Ctrl+Shift+A** or type `/agents` to open the Agents Manager overlay — a TUI for browsing, viewing, editing, creating, and launching agents and chains.
@@ -855,6 +874,33 @@ This aggregated output becomes `{previous}` for the next step.
855
874
 
856
875
  `pi-subagents` reads optional JSON config from `~/.pi/agent/extensions/subagent/config.json`.
857
876
 
877
+ ### `asyncByDefault`
878
+
879
+ `asyncByDefault` makes top-level subagent calls use background execution when the request does not explicitly set `async`.
880
+
881
+ ```json
882
+ {
883
+ "asyncByDefault": true
884
+ }
885
+ ```
886
+
887
+ This only changes the default. Callers can still force foreground execution by setting `async: false` unless `forceTopLevelAsync` is also enabled.
888
+
889
+ ### `forceTopLevelAsync`
890
+
891
+ `forceTopLevelAsync` forces depth-0 subagent execution into background mode. This is useful for automation setups that never want the top-level orchestrator to block on child runs.
892
+
893
+ ```json
894
+ {
895
+ "forceTopLevelAsync": true
896
+ }
897
+ ```
898
+
899
+ When enabled:
900
+ - top-level single, parallel, and chain runs are forced to `async: true`
901
+ - top-level clarify UI is bypassed by forcing `clarify: false`
902
+ - nested subagent calls still follow their own inherited depth and async settings
903
+
858
904
  ### `parallel`
859
905
 
860
906
  `parallel` controls top-level `tasks` mode defaults and limits.
@@ -0,0 +1,46 @@
1
+ ---
2
+ name: oracle-executor
3
+ description: High-context implementation agent that executes only after main-agent approval
4
+ tools: read, grep, find, ls, bash, edit, write, intercom
5
+ model: openai-codex/gpt-5.3-codex
6
+ thinking: high
7
+ systemPromptMode: replace
8
+ inheritProjectContext: true
9
+ inheritSkills: false
10
+ defaultProgress: true
11
+ ---
12
+
13
+ You are `oracle-executor`: a high-context implementation subagent.
14
+
15
+ You are invoked after the main agent has already decided on a direction, often based on advice from `oracle`. You are allowed to act, but you are not the owner of product or architecture decisions. The main agent remains the final decision authority.
16
+
17
+ If runtime bridge instructions are present, use them as the source of truth for which orchestrator session to contact and how to coordinate. Use `intercom({ action: "ask", ... })` when a new decision is needed to continue safely. Use `intercom({ action: "send", ... })` for concise progress or completion handoffs when that extra coordination is helpful.
18
+
19
+ First understand the inherited context and the explicit task. Then execute carefully and minimally.
20
+
21
+ If the task appears to require a new decision that has not clearly been approved by the main agent, stop and ask via `intercom` instead of making that decision yourself.
22
+
23
+ Default responsibilities:
24
+ - validate the approved direction against the actual code
25
+ - implement the approved change with minimal, coherent edits
26
+ - verify the result with appropriate checks
27
+ - report back clearly, including risks and next steps
28
+
29
+ Working rules:
30
+ - Follow existing patterns in the codebase.
31
+ - Prefer narrow, correct changes over broad rewrites.
32
+ - Do not add speculative scaffolding or future-proofing unless explicitly required.
33
+ - Use `bash` for inspection, validation, and relevant tests.
34
+ - Escalate uncertainty to the main agent with `intercom` when needed.
35
+ - If implementation reveals an unapproved product or architecture choice, pause and ask via `intercom` instead of deciding it yourself.
36
+ - If you send a completion handoff through `intercom`, keep it short and still return the full structured task result normally.
37
+ - Keep `progress.md` accurate when asked to maintain it.
38
+ - Do not silently change the scope of the task.
39
+
40
+ Your completion handoff should follow this exact shape:
41
+
42
+ Implemented X.
43
+ Changed files: Y.
44
+ Validation: Z.
45
+ Open risks/questions: R.
46
+ Recommended next step: N.
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: oracle
3
+ description: High-context decision-consistency oracle that protects inherited state and prevents drift
4
+ tools: read, grep, find, ls, bash, intercom
5
+ model: openai-codex/gpt-5.4
6
+ thinking: high
7
+ systemPromptMode: replace
8
+ inheritProjectContext: true
9
+ inheritSkills: false
10
+ ---
11
+
12
+ You are the oracle: a high-context decision-consistency subagent.
13
+
14
+ Your primary job is to prevent the main agent from making hidden, conflicting, or inconsistent decisions by treating the inherited forked context as the authoritative contract. You are not the primary executor. You do not silently become a second decision-maker.
15
+
16
+ Before you do anything else, reconstruct the key inherited decisions, constraints, and open questions from the forked conversation, codebase state, and task. Those decisions form your baseline contract. Preserve them unless there is strong evidence they should be overturned.
17
+
18
+ If you need clarification from the main agent, use `intercom`. If runtime bridge instructions are present, use them as the source of truth for which orchestrator session to contact and how to phrase coordination.
19
+
20
+ Use `intercom({ action: "ask", ... })` when you need a real decision or clarification. Use `intercom({ action: "send", ... })` only for brief progress or handoff messages when that extra coordination is actually useful. Keep intercom traffic tight and purposeful. Do not narrate your whole review through intercom.
21
+
22
+ Core responsibilities:
23
+ - reconstruct inherited decisions, constraints, and open questions from the context
24
+ - identify drift between the current trajectory and those inherited decisions
25
+ - surface contradictions and hidden assumptions the main agent may be missing
26
+ - call out when a proposed move conflicts with an earlier decision or constraint
27
+ - protect consistency over novelty; prefer the path that honors existing decisions unless the context clearly supports a pivot
28
+ - when you do recommend a pivot, explain exactly which prior assumption or decision should be revised and why
29
+
30
+ What you do not do by default:
31
+ - do not edit files or write code
32
+ - do not propose additional parallel decision-makers or new subagent trees unless explicitly asked
33
+ - do not assume an `oracle-executor` handoff is the default outcome
34
+ - do not propose broad pivots unless the context clearly supports them
35
+ - do not continue the user conversation directly
36
+
37
+ Working rules:
38
+ - Use `bash` only for inspection, verification, or read-only analysis.
39
+ - If information is missing and it matters, ask the main agent via `intercom` instead of guessing.
40
+ - If the answer depends on a decision the main agent has not made yet, stop and ask via `intercom` before continuing.
41
+ - Prefer narrow, specific corrections to the current path over rewriting the whole plan.
42
+
43
+ Your output should follow this shape. If no executor handoff is warranted, say so plainly.
44
+
45
+ Inherited decisions:
46
+ - the key decisions, constraints, and assumptions already in play
47
+
48
+ Diagnosis:
49
+ - what is actually going on
50
+ - what the main agent may be missing
51
+
52
+ Drift / contradiction check:
53
+ - where the current trajectory conflicts with inherited decisions or constraints
54
+ - what assumptions have quietly changed
55
+
56
+ Recommendation:
57
+ - the best next move
58
+ - why it is the best move
59
+ - if recommending a pivot, which inherited decision is being revised and why
60
+
61
+ Risks:
62
+ - what could still go wrong
63
+ - what assumptions remain uncertain
64
+
65
+ Need from main agent:
66
+ - specific question or decision required before continuing, if any
67
+
68
+ Suggested execution prompt:
69
+ - a concrete prompt for `oracle-executor`, only if an executor handoff is actually warranted
70
+ - if no handoff is warranted, say so explicitly
@@ -114,22 +114,39 @@ export function isAsyncAvailable(): boolean {
114
114
  /**
115
115
  * Spawn the async runner process
116
116
  */
117
- function spawnRunner(cfg: object, suffix: string, cwd: string): number | undefined {
118
- if (!jitiCliPath) return undefined;
119
-
117
+ function spawnRunner(cfg: object, suffix: string, cwd: string): { pid?: number; error?: string } {
118
+ if (!jitiCliPath) {
119
+ return { error: "jiti for TypeScript execution could not be found" };
120
+ }
121
+
122
+ try {
123
+ const cwdStats = fs.statSync(cwd);
124
+ if (!cwdStats.isDirectory()) {
125
+ return { error: `cwd is not a directory: ${cwd}` };
126
+ }
127
+ } catch {
128
+ return { error: `cwd does not exist: ${cwd}` };
129
+ }
130
+
120
131
  fs.mkdirSync(TEMP_ROOT_DIR, { recursive: true });
121
132
  const cfgPath = getAsyncConfigPath(suffix);
122
133
  fs.writeFileSync(cfgPath, JSON.stringify(cfg));
123
134
  const runner = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-runner.ts");
124
-
135
+
125
136
  const proc = spawn(process.execPath, [jitiCliPath, runner, cfgPath], {
126
137
  cwd,
127
138
  detached: true,
128
139
  stdio: "ignore",
129
140
  windowsHide: true,
130
141
  });
142
+ proc.on("error", (error) => {
143
+ console.error(`[pi-subagents] async spawn failed: ${error.message}`);
144
+ });
145
+ if (typeof proc.pid !== "number") {
146
+ return { error: `async runner did not produce a pid for cwd: ${cwd}` };
147
+ }
131
148
  proc.unref();
132
- return proc.pid;
149
+ return { pid: proc.pid };
133
150
  }
134
151
 
135
152
  function formatAsyncStartError(mode: "single" | "chain", message: string): AsyncExecutionResult {
@@ -260,9 +277,9 @@ export function executeAsyncChain(
260
277
  return buildSeqStep(s as SequentialStep, nextSessionFile());
261
278
  });
262
279
 
263
- let pid: number | undefined;
280
+ let spawnResult: { pid?: number; error?: string } = {};
264
281
  try {
265
- pid = spawnRunner(
282
+ spawnResult = spawnRunner(
266
283
  {
267
284
  id,
268
285
  steps,
@@ -289,14 +306,18 @@ export function executeAsyncChain(
289
306
  return formatAsyncStartError("chain", `Failed to start async chain '${id}': ${message}`);
290
307
  }
291
308
 
292
- if (pid) {
309
+ if (spawnResult.error) {
310
+ return formatAsyncStartError("chain", `Failed to start async chain '${id}': ${spawnResult.error}`);
311
+ }
312
+
313
+ if (spawnResult.pid) {
293
314
  const firstStep = chain[0];
294
315
  const firstAgents = isParallelStep(firstStep)
295
316
  ? firstStep.parallel.map((t) => t.agent)
296
317
  : [(firstStep as SequentialStep).agent];
297
318
  ctx.pi.events.emit("subagent:started", {
298
319
  id,
299
- pid,
320
+ pid: spawnResult.pid,
300
321
  agent: firstAgents[0],
301
322
  task: isParallelStep(firstStep)
302
323
  ? firstStep.parallel[0]?.task?.slice(0, 50)
@@ -368,9 +389,9 @@ export function executeAsyncSingle(
368
389
 
369
390
  const outputPath = resolveSingleOutputPath(params.output, ctx.cwd, runnerCwd);
370
391
  const taskWithOutputInstruction = injectSingleOutputInstruction(task, outputPath);
371
- let pid: number | undefined;
392
+ let spawnResult: { pid?: number; error?: string } = {};
372
393
  try {
373
- pid = spawnRunner(
394
+ spawnResult = spawnRunner(
374
395
  {
375
396
  id,
376
397
  steps: [
@@ -418,10 +439,14 @@ export function executeAsyncSingle(
418
439
  return formatAsyncStartError("single", `Failed to start async run '${id}': ${message}`);
419
440
  }
420
441
 
421
- if (pid) {
442
+ if (spawnResult.error) {
443
+ return formatAsyncStartError("single", `Failed to start async run '${id}': ${spawnResult.error}`);
444
+ }
445
+
446
+ if (spawnResult.pid) {
422
447
  ctx.pi.events.emit("subagent:started", {
423
448
  id,
424
- pid,
449
+ pid: spawnResult.pid,
425
450
  agent,
426
451
  task: task?.slice(0, 50),
427
452
  cwd: runnerCwd,
@@ -7,18 +7,42 @@ import {
7
7
  } from "./types.ts";
8
8
  import { readStatus } from "./utils.ts";
9
9
 
10
- export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string): {
10
+ interface AsyncJobTrackerOptions {
11
+ completionRetentionMs?: number;
12
+ pollIntervalMs?: number;
13
+ }
14
+
15
+ export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string, options: AsyncJobTrackerOptions = {}): {
11
16
  ensurePoller: () => void;
12
17
  handleStarted: (data: unknown) => void;
13
18
  handleComplete: (data: unknown) => void;
14
19
  resetJobs: (ctx?: ExtensionContext) => void;
15
20
  } {
21
+ const completionRetentionMs = options.completionRetentionMs ?? 10000;
22
+ const pollIntervalMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
23
+ const rerenderWidget = (ctx: ExtensionContext, jobs = Array.from(state.asyncJobs.values())) => {
24
+ renderWidget(ctx, jobs);
25
+ ctx.ui.requestRender?.();
26
+ };
27
+ const scheduleCleanup = (asyncId: string) => {
28
+ const existingTimer = state.cleanupTimers.get(asyncId);
29
+ if (existingTimer) clearTimeout(existingTimer);
30
+ const timer = setTimeout(() => {
31
+ state.cleanupTimers.delete(asyncId);
32
+ state.asyncJobs.delete(asyncId);
33
+ if (state.lastUiContext) {
34
+ rerenderWidget(state.lastUiContext);
35
+ }
36
+ }, completionRetentionMs);
37
+ state.cleanupTimers.set(asyncId, timer);
38
+ };
39
+
16
40
  const ensurePoller = () => {
17
41
  if (state.poller) return;
18
42
  state.poller = setInterval(() => {
19
43
  if (!state.lastUiContext || !state.lastUiContext.hasUI) return;
20
44
  if (state.asyncJobs.size === 0) {
21
- renderWidget(state.lastUiContext, []);
45
+ rerenderWidget(state.lastUiContext, []);
22
46
  if (state.poller) {
23
47
  clearInterval(state.poller);
24
48
  state.poller = null;
@@ -27,12 +51,10 @@ export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string
27
51
  }
28
52
 
29
53
  for (const job of state.asyncJobs.values()) {
30
- if (job.status === "complete" || job.status === "failed") {
31
- continue;
32
- }
33
54
  try {
34
55
  const status = readStatus(job.asyncDir);
35
56
  if (status) {
57
+ const previousStatus = job.status;
36
58
  job.status = status.state;
37
59
  job.mode = status.mode;
38
60
  job.currentStep = status.currentStep ?? job.currentStep;
@@ -46,6 +68,9 @@ export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string
46
68
  job.outputFile = status.outputFile ?? job.outputFile;
47
69
  job.totalTokens = status.totalTokens ?? job.totalTokens;
48
70
  job.sessionFile = status.sessionFile ?? job.sessionFile;
71
+ if ((job.status === "complete" || job.status === "failed") && previousStatus !== job.status) {
72
+ scheduleCleanup(job.asyncId);
73
+ }
49
74
  continue;
50
75
  }
51
76
  job.status = job.status === "queued" ? "running" : job.status;
@@ -57,8 +82,8 @@ export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string
57
82
  }
58
83
  }
59
84
 
60
- renderWidget(state.lastUiContext, Array.from(state.asyncJobs.values()));
61
- }, POLL_INTERVAL_MS);
85
+ rerenderWidget(state.lastUiContext);
86
+ }, pollIntervalMs);
62
87
  state.poller.unref?.();
63
88
  };
64
89
 
@@ -84,7 +109,7 @@ export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string
84
109
  updatedAt: now,
85
110
  });
86
111
  if (state.lastUiContext) {
87
- renderWidget(state.lastUiContext, Array.from(state.asyncJobs.values()));
112
+ rerenderWidget(state.lastUiContext);
88
113
  ensurePoller();
89
114
  }
90
115
  };
@@ -100,16 +125,9 @@ export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string
100
125
  if (result.asyncDir) job.asyncDir = result.asyncDir;
101
126
  }
102
127
  if (state.lastUiContext) {
103
- renderWidget(state.lastUiContext, Array.from(state.asyncJobs.values()));
128
+ rerenderWidget(state.lastUiContext);
104
129
  }
105
- const timer = setTimeout(() => {
106
- state.cleanupTimers.delete(asyncId);
107
- state.asyncJobs.delete(asyncId);
108
- if (state.lastUiContext) {
109
- renderWidget(state.lastUiContext, Array.from(state.asyncJobs.values()));
110
- }
111
- }, 10000);
112
- state.cleanupTimers.set(asyncId, timer);
130
+ scheduleCleanup(asyncId);
113
131
  };
114
132
 
115
133
  const resetJobs = (ctx?: ExtensionContext) => {
@@ -121,7 +139,7 @@ export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string
121
139
  state.resultFileCoalescer.clear();
122
140
  if (ctx?.hasUI) {
123
141
  state.lastUiContext = ctx;
124
- renderWidget(ctx, []);
142
+ rerenderWidget(ctx, []);
125
143
  }
126
144
  };
127
145
 
package/execution.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  import { buildSkillInjection, resolveSkillsWithFallback } from "./skills.ts";
35
35
  import { getPiSpawnCommand } from "./pi-spawn.ts";
36
36
  import { createJsonlWriter } from "./jsonl-writer.ts";
37
+ import { attachPostExitStdioGuard, trySignalChild } from "./post-exit-stdio-guard.ts";
37
38
  import { applyThinkingSuffix, buildPiArgs, cleanupTempDir } from "./pi-args.ts";
38
39
  import { captureSingleOutputSnapshot, resolveSingleOutput, type SingleOutputSnapshot } from "./single-output.ts";
39
40
  import {
@@ -188,6 +189,42 @@ async function runSingleAttempt(
188
189
  finish(-2);
189
190
  };
190
191
 
192
+ // If the child emits its final assistant message but never exits,
193
+ // start a bounded drain window and force termination if needed.
194
+ const FINAL_DRAIN_MS = 5000;
195
+ const HARD_KILL_MS = 3000;
196
+ let childExited = false;
197
+ let forcedTerminationSignal = false;
198
+ let finalDrainTimer: NodeJS.Timeout | undefined;
199
+ let finalHardKillTimer: NodeJS.Timeout | undefined;
200
+ const clearFinalDrainTimers = () => {
201
+ if (finalDrainTimer) {
202
+ clearTimeout(finalDrainTimer);
203
+ finalDrainTimer = undefined;
204
+ }
205
+ if (finalHardKillTimer) {
206
+ clearTimeout(finalHardKillTimer);
207
+ finalHardKillTimer = undefined;
208
+ }
209
+ };
210
+ const startFinalDrain = () => {
211
+ if (childExited || finalDrainTimer || settled || processClosed || detached) return;
212
+ finalDrainTimer = setTimeout(() => {
213
+ if (settled || processClosed || detached) return;
214
+ const termSent = trySignalChild(proc, "SIGTERM");
215
+ if (!termSent) return;
216
+ forcedTerminationSignal = true;
217
+ result.error = result.error
218
+ ?? `Subagent process did not exit within ${FINAL_DRAIN_MS}ms after its final message. Forcing termination.`;
219
+ finalHardKillTimer = setTimeout(() => {
220
+ if (settled || processClosed || detached) return;
221
+ forcedTerminationSignal = trySignalChild(proc, "SIGKILL") || forcedTerminationSignal;
222
+ }, HARD_KILL_MS);
223
+ finalHardKillTimer.unref?.();
224
+ }, FINAL_DRAIN_MS);
225
+ finalDrainTimer.unref?.();
226
+ };
227
+
191
228
  const unsubscribeIntercomDetach = options.intercomEvents?.on?.(INTERCOM_DETACH_REQUEST_EVENT, (payload) => {
192
229
  if (!options.allowIntercomDetach || detached || processClosed) return;
193
230
  if (!payload || typeof payload !== "object") return;
@@ -202,6 +239,8 @@ async function runSingleAttempt(
202
239
  const finish = (code: number) => {
203
240
  if (settled) return;
204
241
  settled = true;
242
+ clearFinalDrainTimers();
243
+ clearStdioGuard();
205
244
  unsubscribeIntercomDetach?.();
206
245
  removeAbortListener?.();
207
246
  resolve(code);
@@ -279,6 +318,13 @@ async function runSingleAttempt(
279
318
  if (!result.model && evt.message.model) result.model = evt.message.model;
280
319
  if (evt.message.errorMessage) result.error = evt.message.errorMessage;
281
320
  appendRecentOutput(progress, extractTextFromContent(evt.message.content).split("\n").slice(-10));
321
+ // Final assistant message: start the exit drain window.
322
+ const stopReason = (evt.message as { stopReason?: string }).stopReason;
323
+ const hasToolCall = Array.isArray(evt.message.content)
324
+ && evt.message.content.some((part) => (part as { type?: string }).type === "toolCall");
325
+ if (stopReason === "stop" && !hasToolCall) {
326
+ startFinalDrain();
327
+ }
282
328
  }
283
329
  fireUpdate();
284
330
  }
@@ -292,6 +338,7 @@ async function runSingleAttempt(
292
338
 
293
339
  let stderrBuf = "";
294
340
 
341
+ const clearStdioGuard = attachPostExitStdioGuard(proc, { idleMs: 2000, hardMs: 8000 });
295
342
  proc.stdout.on("data", (d) => {
296
343
  buf += d.toString();
297
344
  const lines = buf.split("\n");
@@ -301,7 +348,13 @@ async function runSingleAttempt(
301
348
  proc.stderr.on("data", (d) => {
302
349
  stderrBuf += d.toString();
303
350
  });
304
- proc.on("close", (code) => {
351
+ proc.on("exit", () => {
352
+ childExited = true;
353
+ clearFinalDrainTimers();
354
+ });
355
+ proc.on("close", (code, signal) => {
356
+ clearFinalDrainTimers();
357
+ clearStdioGuard();
305
358
  void jsonlWriter.close().catch(() => {
306
359
  // JSONL artifact flush is best effort.
307
360
  });
@@ -315,9 +368,12 @@ async function runSingleAttempt(
315
368
  if (code !== 0 && stderrBuf.trim() && !result.error) {
316
369
  result.error = stderrBuf.trim();
317
370
  }
318
- finish(code ?? 0);
371
+ const finalCode = forcedTerminationSignal || signal ? (code ?? 1) : (code ?? 0);
372
+ finish(finalCode);
319
373
  });
320
374
  proc.on("error", (error) => {
375
+ clearFinalDrainTimers();
376
+ clearStdioGuard();
321
377
  void jsonlWriter.close().catch(() => {
322
378
  // JSONL artifact flush is best effort.
323
379
  });
package/index.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * Toggle: async parameter (default: false, configurable via config.json)
10
10
  *
11
11
  * Config file: ~/.pi/agent/extensions/subagent/config.json
12
- * { "asyncByDefault": true, "maxSubagentDepth": 1, "intercomBridge": { "mode": "always", "instructionFile": "./intercom-bridge.md" }, "worktreeSetupHook": "./scripts/setup-worktree.mjs" }
12
+ * { "asyncByDefault": true, "forceTopLevelAsync": true, "maxSubagentDepth": 1, "intercomBridge": { "mode": "always", "instructionFile": "./intercom-bridge.md" }, "worktreeSetupHook": "./scripts/setup-worktree.mjs" }
13
13
  */
14
14
 
15
15
  import * as fs from "node:fs";
package/notify.ts CHANGED
@@ -54,10 +54,11 @@ export default function registerSubagentNotify(pi: ExtensionAPI): void {
54
54
  extra.push(`Session file: ${result.sessionFile}`);
55
55
  }
56
56
 
57
+ const summary = result.summary.trim() ? result.summary : "(no output)";
57
58
  const content = [
58
59
  `Background task ${status}: **${agent}**${taskInfo}`,
59
60
  "",
60
- result.summary,
61
+ summary,
61
62
  extra.length ? "" : undefined,
62
63
  extra.length ? extra.join("\n") : undefined,
63
64
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.17.1",
3
+ "version": "0.17.3",
4
4
  "description": "Pi extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
@@ -50,8 +50,10 @@
50
50
  "@mariozechner/pi-agent-core": "*",
51
51
  "@mariozechner/pi-ai": "*",
52
52
  "@mariozechner/pi-coding-agent": "*",
53
- "@mariozechner/pi-tui": "*",
54
- "@sinclair/typebox": "*"
53
+ "@mariozechner/pi-tui": "*"
54
+ },
55
+ "dependencies": {
56
+ "typebox": "^1.1.24"
55
57
  },
56
58
  "devDependencies": {
57
59
  "@marcfargas/pi-test-harness": "^0.5.0",
@@ -0,0 +1,85 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+
3
+ interface PostExitStdioGuardOptions {
4
+ idleMs: number;
5
+ hardMs: number;
6
+ }
7
+
8
+ interface ChildWithPipedStdio {
9
+ stdout: ChildProcess["stdout"];
10
+ stderr: ChildProcess["stderr"];
11
+ on: ChildProcess["on"];
12
+ }
13
+
14
+ interface ChildWithKill {
15
+ kill(signal?: NodeJS.Signals | number): boolean;
16
+ }
17
+
18
+ export function trySignalChild(child: ChildWithKill, signal: NodeJS.Signals): boolean {
19
+ try {
20
+ return child.kill(signal);
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ export function attachPostExitStdioGuard(
27
+ child: ChildWithPipedStdio,
28
+ options: PostExitStdioGuardOptions,
29
+ ): () => void {
30
+ const { idleMs, hardMs } = options;
31
+ let exited = false;
32
+ let stdoutEnded = false;
33
+ let stderrEnded = false;
34
+ let idleTimer: NodeJS.Timeout | undefined;
35
+ let hardTimer: NodeJS.Timeout | undefined;
36
+
37
+ const destroyUnendedStdio = () => {
38
+ if (!stdoutEnded) {
39
+ try { child.stdout?.destroy(); } catch {}
40
+ }
41
+ if (!stderrEnded) {
42
+ try { child.stderr?.destroy(); } catch {}
43
+ }
44
+ };
45
+
46
+ const clearTimers = () => {
47
+ if (idleTimer) {
48
+ clearTimeout(idleTimer);
49
+ idleTimer = undefined;
50
+ }
51
+ if (hardTimer) {
52
+ clearTimeout(hardTimer);
53
+ hardTimer = undefined;
54
+ }
55
+ };
56
+
57
+ const armIdleTimer = () => {
58
+ if (!exited) return;
59
+ if (idleTimer) clearTimeout(idleTimer);
60
+ idleTimer = setTimeout(destroyUnendedStdio, idleMs);
61
+ idleTimer.unref?.();
62
+ };
63
+
64
+ child.stdout?.on("data", armIdleTimer);
65
+ child.stderr?.on("data", armIdleTimer);
66
+ child.stdout?.on("end", () => {
67
+ stdoutEnded = true;
68
+ if (stdoutEnded && stderrEnded) clearTimers();
69
+ });
70
+ child.stderr?.on("end", () => {
71
+ stderrEnded = true;
72
+ if (stdoutEnded && stderrEnded) clearTimers();
73
+ });
74
+ child.on("exit", () => {
75
+ exited = true;
76
+ armIdleTimer();
77
+ if (hardTimer) return;
78
+ hardTimer = setTimeout(destroyUnendedStdio, hardMs);
79
+ hardTimer.unref?.();
80
+ });
81
+ child.on("close", clearTimers);
82
+ child.on("error", clearTimers);
83
+
84
+ return clearTimers;
85
+ }
package/schemas.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * TypeBox schemas for subagent tool parameters
3
3
  */
4
4
 
5
- import { Type } from "@sinclair/typebox";
5
+ import { Type } from "typebox";
6
6
 
7
7
  // Note: Using Type.Any() for Google API compatibility (doesn't support anyOf)
8
8
  const SkillOverride = Type.Any({ description: "Skill name(s) to inject (comma-separated), array of strings, or boolean (false disables, true uses default)" });
@@ -0,0 +1,48 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export interface TokenUsage {
5
+ input: number;
6
+ output: number;
7
+ total: number;
8
+ }
9
+
10
+ function findLatestSessionFile(sessionDir: string): string | null {
11
+ try {
12
+ const files = fs.readdirSync(sessionDir)
13
+ .filter((f) => f.endsWith(".jsonl"))
14
+ .map((f) => path.join(sessionDir, f));
15
+ if (files.length === 0) return null;
16
+ files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
17
+ return files[0] ?? null;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ export function parseSessionTokens(sessionDir: string): TokenUsage | null {
24
+ const sessionFile = findLatestSessionFile(sessionDir);
25
+ if (!sessionFile) return null;
26
+ try {
27
+ const content = fs.readFileSync(sessionFile, "utf-8");
28
+ let input = 0;
29
+ let output = 0;
30
+ for (const line of content.split("\n")) {
31
+ if (!line.trim()) continue;
32
+ try {
33
+ const entry = JSON.parse(line);
34
+ const usage = entry.usage ?? entry.message?.usage;
35
+ if (usage) {
36
+ input += usage.inputTokens ?? usage.input ?? 0;
37
+ output += usage.outputTokens ?? usage.output ?? 0;
38
+ }
39
+ } catch {
40
+ // Ignore malformed lines while scanning usage entries.
41
+ }
42
+ }
43
+ return { input, output, total: input + output };
44
+ } catch {
45
+ // Usage extraction should not fail the run.
46
+ return null;
47
+ }
48
+ }
@@ -26,6 +26,7 @@ import { createForkContextResolver } from "./fork-context.ts";
26
26
  import { applyIntercomBridgeToAgent, resolveIntercomBridge, resolveIntercomSessionTarget } from "./intercom-bridge.ts";
27
27
  import { finalizeSingleOutput, injectSingleOutputInstruction, resolveSingleOutputPath } from "./single-output.ts";
28
28
  import { compactForegroundDetails, getSingleResultOutput, mapConcurrent, resolveChildCwd } from "./utils.ts";
29
+ import { applyForceTopLevelAsyncOverride } from "./top-level-async.ts";
29
30
  import {
30
31
  cleanupWorktrees,
31
32
  createWorktrees,
@@ -1209,32 +1210,38 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
1209
1210
  if (normalized.error) return normalized.error;
1210
1211
  const normalizedParams = normalized.params!;
1211
1212
 
1212
- const scope: AgentScope = resolveExecutionAgentScope(normalizedParams.agentScope);
1213
- const effectiveCwd = normalizedParams.cwd ?? ctx.cwd;
1213
+ const effectiveParams = applyForceTopLevelAsyncOverride(
1214
+ normalizedParams,
1215
+ depth,
1216
+ deps.config.forceTopLevelAsync === true,
1217
+ );
1218
+
1219
+ const scope: AgentScope = resolveExecutionAgentScope(effectiveParams.agentScope);
1220
+ const effectiveCwd = effectiveParams.cwd ?? ctx.cwd;
1214
1221
  const parentSessionFile = ctx.sessionManager.getSessionFile() ?? null;
1215
1222
  deps.state.currentSessionId = parentSessionFile ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1216
1223
  const discoveredAgents = deps.discoverAgents(effectiveCwd, scope).agents;
1217
1224
  const sessionName = resolveIntercomSessionTarget(deps.pi.getSessionName(), ctx.sessionManager.getSessionId());
1218
1225
  const intercomBridge = resolveIntercomBridge({
1219
1226
  config: deps.config.intercomBridge,
1220
- context: normalizedParams.context,
1227
+ context: effectiveParams.context,
1221
1228
  orchestratorTarget: sessionName,
1222
1229
  });
1223
1230
  const agents = intercomBridge.active
1224
1231
  ? discoveredAgents.map((agent) => applyIntercomBridgeToAgent(agent, intercomBridge))
1225
1232
  : discoveredAgents;
1226
1233
  const runId = randomUUID().slice(0, 8);
1227
- const shareEnabled = normalizedParams.share === true;
1228
- const hasChain = (normalizedParams.chain?.length ?? 0) > 0;
1229
- const hasTasks = (normalizedParams.tasks?.length ?? 0) > 0;
1230
- const hasSingle = Boolean(normalizedParams.agent && normalizedParams.task);
1234
+ const shareEnabled = effectiveParams.share === true;
1235
+ const hasChain = (effectiveParams.chain?.length ?? 0) > 0;
1236
+ const hasTasks = (effectiveParams.tasks?.length ?? 0) > 0;
1237
+ const hasSingle = Boolean(effectiveParams.agent && effectiveParams.task);
1231
1238
  const allowClarifyTaskPrompt = hasChain
1232
- && normalizedParams.clarify === true
1239
+ && effectiveParams.clarify === true
1233
1240
  && ctx.hasUI
1234
- && !(normalizedParams.chain?.some(isParallelStep) ?? false);
1241
+ && !(effectiveParams.chain?.some(isParallelStep) ?? false);
1235
1242
 
1236
1243
  const validationError = validateExecutionInput(
1237
- normalizedParams,
1244
+ effectiveParams,
1238
1245
  agents,
1239
1246
  hasChain,
1240
1247
  hasTasks,
@@ -1245,25 +1252,24 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
1245
1252
 
1246
1253
  let sessionFileForIndex: (idx?: number) => string | undefined = () => undefined;
1247
1254
  try {
1248
- sessionFileForIndex = createForkContextResolver(ctx.sessionManager, normalizedParams.context).sessionFileForIndex;
1255
+ sessionFileForIndex = createForkContextResolver(ctx.sessionManager, effectiveParams.context).sessionFileForIndex;
1249
1256
  } catch (error) {
1250
- return toExecutionErrorResult(normalizedParams, error);
1257
+ return toExecutionErrorResult(effectiveParams, error);
1251
1258
  }
1252
-
1253
- const requestedAsync = normalizedParams.async ?? deps.asyncByDefault;
1254
- const backgroundRequestedWhileClarifying = hasTasks && requestedAsync && normalizedParams.clarify === true;
1259
+ const requestedAsync = effectiveParams.async ?? deps.asyncByDefault;
1260
+ const backgroundRequestedWhileClarifying = hasTasks && requestedAsync && effectiveParams.clarify === true;
1255
1261
  const effectiveAsync = requestedAsync
1256
- && (hasChain ? normalizedParams.clarify === false : normalizedParams.clarify !== true);
1262
+ && (hasChain ? effectiveParams.clarify === false : effectiveParams.clarify !== true);
1257
1263
 
1258
1264
  const artifactConfig: ArtifactConfig = {
1259
1265
  ...DEFAULT_ARTIFACT_CONFIG,
1260
- enabled: normalizedParams.artifacts !== false,
1266
+ enabled: effectiveParams.artifacts !== false,
1261
1267
  };
1262
1268
  const artifactsDir = effectiveAsync ? deps.tempArtifactsDir : getArtifactsDir(parentSessionFile);
1263
1269
 
1264
1270
  let sessionRoot: string;
1265
- if (normalizedParams.sessionDir) {
1266
- sessionRoot = path.resolve(deps.expandTilde(normalizedParams.sessionDir));
1271
+ if (effectiveParams.sessionDir) {
1272
+ sessionRoot = path.resolve(deps.expandTilde(effectiveParams.sessionDir));
1267
1273
  } else {
1268
1274
  const baseSessionRoot = deps.config.defaultSessionDir
1269
1275
  ? path.resolve(deps.expandTilde(deps.config.defaultSessionDir))
@@ -1275,7 +1281,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
1275
1281
  } catch (error) {
1276
1282
  const message = error instanceof Error ? error.message : String(error);
1277
1283
  return toExecutionErrorResult(
1278
- normalizedParams,
1284
+ effectiveParams,
1279
1285
  new Error(`Failed to create session directory '${sessionRoot}': ${message}`),
1280
1286
  );
1281
1287
  }
@@ -1283,11 +1289,11 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
1283
1289
  path.join(sessionRoot, `run-${idx ?? 0}`);
1284
1290
 
1285
1291
  const onUpdateWithContext = onUpdate
1286
- ? (r: AgentToolResult<Details>) => onUpdate(withForkContext(r, normalizedParams.context))
1292
+ ? (r: AgentToolResult<Details>) => onUpdate(withForkContext(r, effectiveParams.context))
1287
1293
  : undefined;
1288
1294
 
1289
1295
  const execData: ExecutionContextData = {
1290
- params: normalizedParams,
1296
+ params: effectiveParams,
1291
1297
  effectiveCwd,
1292
1298
  ctx,
1293
1299
  signal,
@@ -1306,18 +1312,18 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
1306
1312
 
1307
1313
  try {
1308
1314
  const asyncResult = runAsyncPath(execData, deps);
1309
- if (asyncResult) return withForkContext(asyncResult, normalizedParams.context);
1315
+ if (asyncResult) return withForkContext(asyncResult, effectiveParams.context);
1310
1316
 
1311
- if (hasChain && normalizedParams.chain) {
1312
- return withForkContext(await runChainPath(execData, deps), normalizedParams.context);
1317
+ if (hasChain && effectiveParams.chain) {
1318
+ return withForkContext(await runChainPath(execData, deps), effectiveParams.context);
1313
1319
  }
1314
1320
 
1315
- if (hasTasks && normalizedParams.tasks) {
1316
- return withForkContext(await runParallelPath(execData, deps), normalizedParams.context);
1321
+ if (hasTasks && effectiveParams.tasks) {
1322
+ return withForkContext(await runParallelPath(execData, deps), effectiveParams.context);
1317
1323
  }
1318
1324
 
1319
1325
  if (hasSingle) {
1320
- return withForkContext(await runSinglePath(execData, deps), normalizedParams.context);
1326
+ return withForkContext(await runSinglePath(execData, deps), effectiveParams.context);
1321
1327
  }
1322
1328
  } catch (error) {
1323
1329
  return toExecutionErrorResult(normalizedParams, error);
@@ -1327,7 +1333,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
1327
1333
  content: [{ type: "text", text: "Invalid params" }],
1328
1334
  isError: true,
1329
1335
  details: { mode: "single" as const, results: [] },
1330
- }, normalizedParams.context);
1336
+ }, effectiveParams.context);
1331
1337
  };
1332
1338
 
1333
1339
  return { execute };
@@ -28,7 +28,9 @@ import {
28
28
  } from "./parallel-utils.ts";
29
29
  import { buildPiArgs, cleanupTempDir } from "./pi-args.ts";
30
30
  import { formatModelAttemptNote, isRetryableModelFailure } from "./model-fallback.ts";
31
+ import { attachPostExitStdioGuard, trySignalChild } from "./post-exit-stdio-guard.ts";
31
32
  import { detectSubagentError, extractTextFromContent, extractToolArgsPreview, getFinalOutput } from "./utils.ts";
33
+ import { parseSessionTokens, type TokenUsage } from "./session-tokens.ts";
32
34
  import {
33
35
  cleanupWorktrees,
34
36
  createWorktrees,
@@ -89,38 +91,6 @@ function findLatestSessionFile(sessionDir: string): string | null {
89
91
  }
90
92
  }
91
93
 
92
- interface TokenUsage {
93
- input: number;
94
- output: number;
95
- total: number;
96
- }
97
-
98
- function parseSessionTokens(sessionDir: string): TokenUsage | null {
99
- const sessionFile = findLatestSessionFile(sessionDir);
100
- if (!sessionFile) return null;
101
- try {
102
- const content = fs.readFileSync(sessionFile, "utf-8");
103
- let input = 0;
104
- let output = 0;
105
- for (const line of content.split("\n")) {
106
- if (!line.trim()) continue;
107
- try {
108
- const entry = JSON.parse(line);
109
- if (entry.usage) {
110
- input += entry.usage.inputTokens ?? entry.usage.input ?? 0;
111
- output += entry.usage.outputTokens ?? entry.usage.output ?? 0;
112
- }
113
- } catch {
114
- // Ignore malformed lines while scanning usage entries.
115
- }
116
- }
117
- return { input, output, total: input + output };
118
- } catch {
119
- // Usage extraction should not fail the run.
120
- return null;
121
- }
122
- }
123
-
124
94
  function emptyUsage(): Usage {
125
95
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
126
96
  }
@@ -248,13 +218,18 @@ function runPiStreaming(
248
218
  if (event.message.model) model = event.message.model;
249
219
  if (event.message.errorMessage) error = event.message.errorMessage;
250
220
  const eventUsage = event.message.usage;
251
- if (!eventUsage) return;
252
- usage.turns++;
253
- usage.input += eventUsage.input ?? eventUsage.inputTokens ?? 0;
254
- usage.output += eventUsage.output ?? eventUsage.outputTokens ?? 0;
255
- usage.cacheRead += eventUsage.cacheRead ?? 0;
256
- usage.cacheWrite += eventUsage.cacheWrite ?? 0;
257
- usage.cost += eventUsage.cost?.total ?? 0;
221
+ if (eventUsage) {
222
+ usage.turns++;
223
+ usage.input += eventUsage.input ?? eventUsage.inputTokens ?? 0;
224
+ usage.output += eventUsage.output ?? eventUsage.outputTokens ?? 0;
225
+ usage.cacheRead += eventUsage.cacheRead ?? 0;
226
+ usage.cacheWrite += eventUsage.cacheWrite ?? 0;
227
+ usage.cost += eventUsage.cost?.total ?? 0;
228
+ }
229
+ const stopReason = (event.message as { stopReason?: string }).stopReason;
230
+ const hasToolCall = Array.isArray(event.message.content)
231
+ && event.message.content.some((part) => (part as { type?: string }).type === "toolCall");
232
+ if (stopReason === "stop" && !hasToolCall) startFinalDrain();
258
233
  }
259
234
  };
260
235
 
@@ -271,6 +246,16 @@ function runPiStreaming(
271
246
  }
272
247
  };
273
248
 
249
+ // Guard both cases that can leave the parent waiting on `close` forever:
250
+ // a lingering stdio holder after `exit`, or a child that never exits.
251
+ const FINAL_DRAIN_MS = 5000;
252
+ const HARD_KILL_MS = 3000;
253
+ let childExited = false;
254
+ let forcedTerminationSignal = false;
255
+ let finalDrainTimer: NodeJS.Timeout | undefined;
256
+ let finalHardKillTimer: NodeJS.Timeout | undefined;
257
+ let settled = false;
258
+ const clearStdioGuard = attachPostExitStdioGuard(child, { idleMs: 2000, hardMs: 8000 });
274
259
  child.stdout.on("data", (chunk: Buffer) => {
275
260
  const text = chunk.toString();
276
261
  stdoutBuf += text;
@@ -282,16 +267,61 @@ function runPiStreaming(
282
267
  child.stderr.on("data", (chunk: Buffer) => {
283
268
  processStderrText(chunk.toString());
284
269
  });
285
-
286
- child.on("close", (exitCode) => {
270
+ const clearDrainTimers = () => {
271
+ if (finalDrainTimer) {
272
+ clearTimeout(finalDrainTimer);
273
+ finalDrainTimer = undefined;
274
+ }
275
+ if (finalHardKillTimer) {
276
+ clearTimeout(finalHardKillTimer);
277
+ finalHardKillTimer = undefined;
278
+ }
279
+ };
280
+ function startFinalDrain(): void {
281
+ if (childExited || finalDrainTimer || settled) return;
282
+ finalDrainTimer = setTimeout(() => {
283
+ if (settled) return;
284
+ const termSent = trySignalChild(child, "SIGTERM");
285
+ if (!termSent) return;
286
+ forcedTerminationSignal = true;
287
+ if (!error) {
288
+ error = `Subagent process did not exit within ${FINAL_DRAIN_MS}ms after its final message. Forcing termination.`;
289
+ }
290
+ finalHardKillTimer = setTimeout(() => {
291
+ if (settled) return;
292
+ forcedTerminationSignal = trySignalChild(child, "SIGKILL") || forcedTerminationSignal;
293
+ }, HARD_KILL_MS);
294
+ finalHardKillTimer.unref?.();
295
+ }, FINAL_DRAIN_MS);
296
+ finalDrainTimer.unref?.();
297
+ }
298
+ child.on("exit", () => {
299
+ childExited = true;
300
+ clearDrainTimers();
301
+ });
302
+ child.on("close", (exitCode, signal) => {
303
+ settled = true;
304
+ clearDrainTimers();
305
+ clearStdioGuard();
287
306
  if (stdoutBuf.trim()) processStdoutLine(stdoutBuf);
288
307
  if (stderrBuf.trim()) appendChildLine("subagent.child.stderr", stderrBuf);
289
308
  outputStream.end();
290
309
  const finalOutput = getFinalOutput(messages) || rawStdoutLines.join("\n").trim();
291
- resolve({ stderr, exitCode, messages, usage, model, error, finalOutput });
310
+ resolve({
311
+ stderr,
312
+ exitCode: forcedTerminationSignal || signal ? (exitCode ?? 1) : exitCode,
313
+ messages,
314
+ usage,
315
+ model,
316
+ error,
317
+ finalOutput,
318
+ });
292
319
  });
293
320
 
294
321
  child.on("error", (spawnError) => {
322
+ settled = true;
323
+ clearDrainTimers();
324
+ clearStdioGuard();
295
325
  outputStream.end();
296
326
  const finalOutput = getFinalOutput(messages) || rawStdoutLines.join("\n").trim();
297
327
  const spawnErrorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
@@ -0,0 +1,13 @@
1
+ export interface AsyncOverrideParams {
2
+ async?: boolean;
3
+ clarify?: boolean;
4
+ }
5
+
6
+ export function applyForceTopLevelAsyncOverride<T extends AsyncOverrideParams>(
7
+ params: T,
8
+ depth: number,
9
+ forceTopLevelAsync: boolean,
10
+ ): T {
11
+ if (!(depth === 0 && forceTopLevelAsync)) return params;
12
+ return { ...params, async: true, clarify: false };
13
+ }
package/types.ts CHANGED
@@ -288,6 +288,7 @@ export interface TopLevelParallelConfig {
288
288
 
289
289
  export interface ExtensionConfig {
290
290
  asyncByDefault?: boolean;
291
+ forceTopLevelAsync?: boolean;
291
292
  defaultSessionDir?: string;
292
293
  maxSubagentDepth?: number;
293
294
  parallel?: TopLevelParallelConfig;