pi-subagents 0.17.0 → 0.17.2

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,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.17.2] - 2026-04-21
6
+
7
+ ### Added
8
+ - Added `forceTopLevelAsync` so depth-0 delegated runs can be forced into background mode with `clarify: false`, while nested runs keep their existing behavior.
9
+
10
+ ### Fixed
11
+ - Background completion notifications now render `(no output)` instead of a blank body when a completion summary is empty or whitespace-only.
12
+ - 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.
13
+ - Async/background startup now fails fast for invalid resolved `cwd` values and spawn failures instead of reporting false launch success.
14
+ - 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.
15
+
16
+ ## [0.17.1] - 2026-04-20
17
+
18
+ ### Added
19
+ - Foreground subagent runs now make deeper live detail easier to discover. Running cards show an explicit `Ctrl+O` hint, lightweight live-state signals like recent activity, current-tool durations, and artifact output paths when available. Common array-heavy tool previews such as `web_search.queries` and `fetch_content.urls` are now summarized more clearly instead of collapsing into opaque fallback text.
20
+
21
+ ### Changed
22
+ - Forked delegated runs now use stronger prompt-side guidance for `pi-intercom` coordination instead of runtime policing. The default fork preamble and intercom bridge instructions now explicitly treat inherited fork history as reference-only context, tell children not to continue the parent conversation in normal assistant text, and steer upstream questions or handoffs through `intercom` when needed.
23
+ - Documented an opt-in custom agent pattern for forked chat-back workflows so users can make that coordination contract explicit without changing builtin agents.
24
+ - Slash-run status text and `/subagents-status` summary output now use the same more explicit observability language, including clearer live-detail hints and surfaced output/session paths in the async status overlay.
25
+ - Builtin agent defaults now prefer `openai-codex` models for `planner`, `scout`, `researcher`, `context-builder`, and `worker`.
26
+
27
+ ### Fixed
28
+ - Removed the short-lived foreground intercom enforcement/retry layer from delegated fork runs. Coordination behavior is now shaped by prompt and agent design only, avoiding hidden retries, heuristic output inspection, and failure paths based on guessed intent.
29
+
5
30
  ## [0.17.0] - 2026-04-16
6
31
 
7
32
  ### Added
package/README.md CHANGED
@@ -855,6 +855,33 @@ This aggregated output becomes `{previous}` for the next step.
855
855
 
856
856
  `pi-subagents` reads optional JSON config from `~/.pi/agent/extensions/subagent/config.json`.
857
857
 
858
+ ### `asyncByDefault`
859
+
860
+ `asyncByDefault` makes top-level subagent calls use background execution when the request does not explicitly set `async`.
861
+
862
+ ```json
863
+ {
864
+ "asyncByDefault": true
865
+ }
866
+ ```
867
+
868
+ This only changes the default. Callers can still force foreground execution by setting `async: false` unless `forceTopLevelAsync` is also enabled.
869
+
870
+ ### `forceTopLevelAsync`
871
+
872
+ `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.
873
+
874
+ ```json
875
+ {
876
+ "forceTopLevelAsync": true
877
+ }
878
+ ```
879
+
880
+ When enabled:
881
+ - top-level single, parallel, and chain runs are forced to `async: true`
882
+ - top-level clarify UI is bypassed by forcing `clarify: false`
883
+ - nested subagent calls still follow their own inherited depth and async settings
884
+
858
885
  ### `parallel`
859
886
 
860
887
  `parallel` controls top-level `tasks` mode defaults and limits.
@@ -925,12 +952,14 @@ Example `instructionFile`:
925
952
  ```md
926
953
  Intercom orchestration channel:
927
954
 
955
+ The inherited thread is reference-only. Do not continue that conversation or send questions, status updates, or completion handoffs to the orchestrator in normal assistant text.
956
+
928
957
  Use `intercom` only to coordinate with the orchestrator session `{orchestratorTarget}`.
929
958
 
930
959
  - Need a decision or you're blocked: `intercom({ action: "ask", to: "{orchestratorTarget}", message: "<question>" })`
931
- - Need to report progress or completion: `intercom({ action: "send", to: "{orchestratorTarget}", message: "DONE: <summary>" })`
960
+ - Need to report progress or a completion handoff: `intercom({ action: "send", to: "{orchestratorTarget}", message: "DONE: <summary>" })`
932
961
 
933
- If intercom is unavailable in this run, continue the task normally.
962
+ If no upstream coordination is needed, continue the task normally and return a focused task result.
934
963
  ```
935
964
 
936
965
  Bridge activation also requires all of the following:
@@ -941,6 +970,33 @@ Bridge activation also requires all of the following:
941
970
 
942
971
  When an unnamed session falls back to `subagent-chat-<id>`, that alias is used only for the live intercom broker. It is not persisted as the Pi session title, so `pi --resume` can still show the transcript snippet.
943
972
 
973
+ If you want a stronger prompt contract for forked chat-back runs without changing builtins, define a custom agent for it. Keeping that as an opt-in agent works better than teaching every delegated run to behave this way.
974
+
975
+ Example agent:
976
+
977
+ ```md
978
+ ---
979
+ name: fork-chatback
980
+ description: Forked worker that asks the orchestrator questions through intercom when needed
981
+ tools: read, bash, edit, write, intercom
982
+ systemPromptMode: replace
983
+ inheritProjectContext: true
984
+ inheritSkills: false
985
+ ---
986
+
987
+ You are a delegated worker running from a fork of the orchestrator session.
988
+
989
+ Treat the inherited conversation as reference-only context. Do not continue that conversation in normal assistant text.
990
+
991
+ Your job is to do the task. If you need a decision, clarification, or unblock from the orchestrator, use `intercom` to ask the orchestrator session named in the runtime bridge instructions.
992
+
993
+ If you need to send a progress update or completion handoff upstream, use `intercom` to send it to that same orchestrator session.
994
+
995
+ If no upstream coordination is needed, just complete the work and return a focused task result.
996
+ ```
997
+
998
+ Pair that with task wording that makes the contract explicit, like "Work from the forked context below. If you need anything from me, ask through `intercom`. Otherwise complete the task and return the result."
999
+
944
1000
  ### `worktreeSetupHook`
945
1001
 
946
1002
  `worktreeSetupHook` configures an optional setup hook for worktree-isolated parallel runs. The hook runs once per created worktree, after `git worktree add` succeeds and before the agent starts.
@@ -1005,7 +1061,7 @@ When fallback is used, metadata records both the ordered `attemptedModels` list
1005
1061
 
1006
1062
  Session files (JSONL) are stored under a per-run session directory. Directory selection follows the same precedence as session root resolution: explicit `sessionDir` > `config.defaultSessionDir` > parent-session-derived path. The session file path is shown in output.
1007
1063
 
1008
- When `context: "fork"` is used, each child run starts with `--session <branched-session-file>` produced from the parent's current leaf. This is a real session fork, not injected summary text.
1064
+ When `context: "fork"` is used, each child run starts with `--session <branched-session-file>` produced from the parent's current leaf. This is a real session fork, not injected summary text. The fork preamble explicitly tells the child to treat the inherited conversation as reference-only context rather than a live thread to continue.
1009
1065
 
1010
1066
  ## Session Sharing
1011
1067
 
@@ -1041,6 +1097,8 @@ During sync execution, the collapsed view shows real-time progress for single, c
1041
1097
  - Per-task step cards showing status icon, agent name, model, tool count, and duration
1042
1098
  - Current tool and recent output for each running task
1043
1099
 
1100
+ While a foreground run is active, the compact view also hints when richer detail is available and shows lightweight live-state signals like activity freshness and current-tool duration.
1101
+
1044
1102
  Press **Ctrl+O** to expand the full streaming view with complete output per step.
1045
1103
 
1046
1104
  > **Note:** Chain visualization (the `done scout → running planner` line) is only shown for sequential chains. Chains with parallel steps show per-step cards instead.
@@ -1095,7 +1153,7 @@ subagent_status({ id: "<id>" })
1095
1153
  subagent_status({ dir: "<tmpdir>/pi-subagents-<scope>/async-subagent-runs/<id>" })
1096
1154
  ```
1097
1155
 
1098
- For an interactive overview, run the `/subagents-status` slash command to open the overlay listing active runs and recent completed/failed runs. The overlay auto-refreshes every 2 seconds while it is open.
1156
+ For an interactive overview, run the `/subagents-status` slash command to open the overlay listing active runs and recent completed/failed runs. The overlay auto-refreshes every 2 seconds while it is open and focuses on summary/status information, including the current output/session paths when available.
1099
1157
 
1100
1158
  ## Events
1101
1159
 
@@ -2,7 +2,7 @@
2
2
  name: context-builder
3
3
  description: Analyzes requirements and codebase, generates context and meta-prompt
4
4
  tools: read, grep, find, ls, bash, write, web_search
5
- model: claude-sonnet-4-6
5
+ model: openai-codex/gpt-5.4
6
6
  systemPromptMode: replace
7
7
  inheritProjectContext: true
8
8
  inheritSkills: false
package/agents/planner.md CHANGED
@@ -2,7 +2,7 @@
2
2
  name: planner
3
3
  description: Creates implementation plans from context and requirements
4
4
  tools: read, grep, find, ls, write
5
- model: claude-opus-4-6
5
+ model: openai-codex/gpt-5.4
6
6
  thinking: high
7
7
  systemPromptMode: replace
8
8
  inheritProjectContext: true
@@ -2,7 +2,7 @@
2
2
  name: researcher
3
3
  description: Autonomous web researcher — searches, evaluates, and synthesizes a focused research brief
4
4
  tools: read, write, web_search, fetch_content, get_search_content
5
- model: anthropic/claude-sonnet-4-6
5
+ model: openai-codex/gpt-5.4
6
6
  systemPromptMode: replace
7
7
  inheritProjectContext: true
8
8
  inheritSkills: false
package/agents/scout.md CHANGED
@@ -2,7 +2,7 @@
2
2
  name: scout
3
3
  description: Fast codebase recon that returns compressed context for handoff
4
4
  tools: read, grep, find, ls, bash, write
5
- model: anthropic/claude-haiku-4-5
5
+ model: openai-codex/gpt-5.4-mini
6
6
  systemPromptMode: replace
7
7
  inheritProjectContext: true
8
8
  inheritSkills: false
package/agents/worker.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: worker
3
3
  description: General-purpose subagent with full capabilities
4
- model: claude-sonnet-4-6
4
+ model: openai-codex/gpt-5.4
5
5
  systemPromptMode: replace
6
6
  inheritProjectContext: true
7
7
  inheritSkills: false
@@ -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 {
@@ -63,6 +64,35 @@ function appendRecentOutput(progress: AgentProgress, lines: string[]): void {
63
64
  }
64
65
  }
65
66
 
67
+ function snapshotProgress(progress: AgentProgress): AgentProgress {
68
+ return {
69
+ ...progress,
70
+ skills: progress.skills ? [...progress.skills] : undefined,
71
+ recentTools: progress.recentTools.map((tool) => ({ ...tool })),
72
+ recentOutput: [...progress.recentOutput],
73
+ };
74
+ }
75
+
76
+ function snapshotResult(result: SingleResult, progress: AgentProgress): SingleResult {
77
+ return {
78
+ ...result,
79
+ messages: result.messages ? [...result.messages] : undefined,
80
+ usage: { ...result.usage },
81
+ skills: result.skills ? [...result.skills] : undefined,
82
+ attemptedModels: result.attemptedModels ? [...result.attemptedModels] : undefined,
83
+ modelAttempts: result.modelAttempts
84
+ ? result.modelAttempts.map((attempt) => ({
85
+ ...attempt,
86
+ usage: attempt.usage ? { ...attempt.usage } : undefined,
87
+ }))
88
+ : undefined,
89
+ progress,
90
+ progressSummary: result.progressSummary ? { ...result.progressSummary } : undefined,
91
+ artifactPaths: result.artifactPaths ? { ...result.artifactPaths } : undefined,
92
+ truncation: result.truncation ? { ...result.truncation } : undefined,
93
+ };
94
+ }
95
+
66
96
  async function runSingleAttempt(
67
97
  runtimeCwd: string,
68
98
  agent: AgentConfig,
@@ -75,6 +105,7 @@ async function runSingleAttempt(
75
105
  resolvedSkillNames?: string[];
76
106
  skillsWarning?: string;
77
107
  jsonlPath?: string;
108
+ artifactPaths?: ArtifactPaths;
78
109
  attemptNotes: string[];
79
110
  outputSnapshot?: SingleOutputSnapshot;
80
111
  },
@@ -105,6 +136,7 @@ async function runSingleAttempt(
105
136
  messages: [],
106
137
  usage: emptyUsage(),
107
138
  model: modelArg,
139
+ artifactPaths: shared.artifactPaths,
108
140
  skills: shared.resolvedSkillNames,
109
141
  skillsWarning: shared.skillsWarning,
110
142
  };
@@ -120,6 +152,7 @@ async function runSingleAttempt(
120
152
  toolCount: 0,
121
153
  tokens: 0,
122
154
  durationMs: 0,
155
+ lastActivityAt: Date.now(),
123
156
  };
124
157
  result.progress = progress;
125
158
 
@@ -156,6 +189,42 @@ async function runSingleAttempt(
156
189
  finish(-2);
157
190
  };
158
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
+
159
228
  const unsubscribeIntercomDetach = options.intercomEvents?.on?.(INTERCOM_DETACH_REQUEST_EVENT, (payload) => {
160
229
  if (!options.allowIntercomDetach || detached || processClosed) return;
161
230
  if (!payload || typeof payload !== "object") return;
@@ -170,20 +239,29 @@ async function runSingleAttempt(
170
239
  const finish = (code: number) => {
171
240
  if (settled) return;
172
241
  settled = true;
242
+ clearFinalDrainTimers();
243
+ clearStdioGuard();
173
244
  unsubscribeIntercomDetach?.();
174
245
  removeAbortListener?.();
175
246
  resolve(code);
176
247
  };
177
248
 
178
- const fireUpdate = () => {
249
+ const emitUpdateSnapshot = (text: string) => {
179
250
  if (!options.onUpdate || processClosed) return;
180
- progress.durationMs = Date.now() - startTime;
251
+ const progressSnapshot = snapshotProgress(progress);
252
+ const resultSnapshot = snapshotResult(result, progressSnapshot);
181
253
  options.onUpdate({
182
- content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
183
- details: { mode: "single", results: [result], progress: [progress] },
254
+ content: [{ type: "text", text }],
255
+ details: { mode: "single", results: [resultSnapshot], progress: [progressSnapshot] },
184
256
  });
185
257
  };
186
258
 
259
+ const fireUpdate = () => {
260
+ if (!options.onUpdate || processClosed) return;
261
+ progress.durationMs = Date.now() - startTime;
262
+ emitUpdateSnapshot(getFinalOutput(result.messages) || "(running...)");
263
+ };
264
+
187
265
  const processLine = (line: string) => {
188
266
  if (!line.trim()) return;
189
267
  jsonlWriter.writeLine(line);
@@ -197,6 +275,7 @@ async function runSingleAttempt(
197
275
 
198
276
  const now = Date.now();
199
277
  progress.durationMs = now - startTime;
278
+ progress.lastActivityAt = now;
200
279
 
201
280
  if (evt.type === "tool_execution_start") {
202
281
  if (options.allowIntercomDetach && evt.toolName === "intercom") {
@@ -205,6 +284,7 @@ async function runSingleAttempt(
205
284
  progress.toolCount++;
206
285
  progress.currentTool = evt.toolName;
207
286
  progress.currentToolArgs = extractToolArgsPreview((evt.args || {}) as Record<string, unknown>);
287
+ progress.currentToolStartedAt = now;
208
288
  fireUpdate();
209
289
  }
210
290
 
@@ -218,6 +298,7 @@ async function runSingleAttempt(
218
298
  }
219
299
  progress.currentTool = undefined;
220
300
  progress.currentToolArgs = undefined;
301
+ progress.currentToolStartedAt = undefined;
221
302
  fireUpdate();
222
303
  }
223
304
 
@@ -237,6 +318,13 @@ async function runSingleAttempt(
237
318
  if (!result.model && evt.message.model) result.model = evt.message.model;
238
319
  if (evt.message.errorMessage) result.error = evt.message.errorMessage;
239
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
+ }
240
328
  }
241
329
  fireUpdate();
242
330
  }
@@ -250,6 +338,7 @@ async function runSingleAttempt(
250
338
 
251
339
  let stderrBuf = "";
252
340
 
341
+ const clearStdioGuard = attachPostExitStdioGuard(proc, { idleMs: 2000, hardMs: 8000 });
253
342
  proc.stdout.on("data", (d) => {
254
343
  buf += d.toString();
255
344
  const lines = buf.split("\n");
@@ -259,7 +348,13 @@ async function runSingleAttempt(
259
348
  proc.stderr.on("data", (d) => {
260
349
  stderrBuf += d.toString();
261
350
  });
262
- proc.on("close", (code) => {
351
+ proc.on("exit", () => {
352
+ childExited = true;
353
+ clearFinalDrainTimers();
354
+ });
355
+ proc.on("close", (code, signal) => {
356
+ clearFinalDrainTimers();
357
+ clearStdioGuard();
263
358
  void jsonlWriter.close().catch(() => {
264
359
  // JSONL artifact flush is best effort.
265
360
  });
@@ -273,9 +368,12 @@ async function runSingleAttempt(
273
368
  if (code !== 0 && stderrBuf.trim() && !result.error) {
274
369
  result.error = stderrBuf.trim();
275
370
  }
276
- finish(code ?? 0);
371
+ const finalCode = forcedTerminationSignal || signal ? (code ?? 1) : (code ?? 0);
372
+ finish(finalCode);
277
373
  });
278
374
  proc.on("error", (error) => {
375
+ clearFinalDrainTimers();
376
+ clearStdioGuard();
279
377
  void jsonlWriter.close().catch(() => {
280
378
  // JSONL artifact flush is best effort.
281
379
  });
@@ -343,6 +441,15 @@ async function runSingleAttempt(
343
441
  result.outputSaveError = resolvedOutput.saveError;
344
442
  }
345
443
  result.finalOutput = fullOutput;
444
+ if (options.onUpdate) {
445
+ const finalText = result.finalOutput || result.error || "(no output)";
446
+ const progressSnapshot = snapshotProgress(progress);
447
+ const resultSnapshot = snapshotResult(result, progressSnapshot);
448
+ options.onUpdate({
449
+ content: [{ type: "text", text: finalText }],
450
+ details: { mode: "single", results: [resultSnapshot], progress: [progressSnapshot] },
451
+ });
452
+ }
346
453
  return result;
347
454
  }
348
455
 
@@ -417,6 +524,7 @@ export async function runSync(
417
524
  resolvedSkillNames: resolvedSkills.length > 0 ? resolvedSkills.map((skill) => skill.name) : undefined,
418
525
  skillsWarning: missingSkills.length > 0 ? `Skills not found: ${missingSkills.join(", ")}` : undefined,
419
526
  jsonlPath,
527
+ artifactPaths: artifactPathsResult,
420
528
  attemptNotes,
421
529
  outputSnapshot,
422
530
  });
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";