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.
@@ -9,10 +9,13 @@ const DEFAULT_INTERCOM_CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "in
9
9
  const DEFAULT_SUBAGENT_CONFIG_DIR = path.join(os.homedir(), ".pi", "agent", "extensions", "subagent");
10
10
  const DEFAULT_INTERCOM_TARGET_PREFIX = "subagent-chat";
11
11
  const INTERCOM_BRIDGE_MARKER = "Intercom orchestration channel:";
12
- const DEFAULT_INTERCOM_BRIDGE_TEMPLATE = `Use intercom only for coordination with the orchestrator session:
12
+ const DEFAULT_INTERCOM_BRIDGE_TEMPLATE = `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.
13
+
14
+ Use intercom only for coordination with the orchestrator session "{orchestratorTarget}".
13
15
  - Need a decision or blocked: intercom({ action: "ask", to: "{orchestratorTarget}", message: "<question>" })
14
- - Completion/update: intercom({ action: "send", to: "{orchestratorTarget}", message: "DONE: <summary>" })
15
- If intercom is unavailable in this run, continue the task normally.`;
16
+ - Need to report progress or a completion handoff: intercom({ action: "send", to: "{orchestratorTarget}", message: "DONE: <summary>" })
17
+
18
+ If no upstream coordination is needed, continue the task normally and return a focused task result.`;
16
19
 
17
20
  export interface IntercomBridgeState {
18
21
  active: boolean;
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.0",
3
+ "version": "0.17.2",
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",
@@ -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/render.ts CHANGED
@@ -6,6 +6,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
6
6
  import { getMarkdownTheme, type ExtensionContext } from "@mariozechner/pi-coding-agent";
7
7
  import { Container, Markdown, Spacer, Text, visibleWidth, type Component } from "@mariozechner/pi-tui";
8
8
  import {
9
+ type AgentProgress,
9
10
  type AsyncJobState,
10
11
  type Details,
11
12
  MAX_WIDGET_JOBS,
@@ -113,6 +114,34 @@ function getToolCallLines(
113
114
  return result.toolCalls?.map((toolCall) => expanded ? toolCall.expandedText : toolCall.text) ?? [];
114
115
  }
115
116
 
117
+ function formatActivityLabel(lastActivityAt: number | undefined, now = Date.now()): string | undefined {
118
+ if (lastActivityAt === undefined) return undefined;
119
+ const ago = Math.max(0, now - lastActivityAt);
120
+ if (ago < 1000) return "active now";
121
+ if (ago < 60000) return `active ${Math.floor(ago / 1000)}s ago`;
122
+ return `active ${Math.floor(ago / 60000)}m ago`;
123
+ }
124
+
125
+ function formatCurrentToolLine(progress: Pick<AgentProgress, "currentTool" | "currentToolArgs" | "currentToolStartedAt">, availableWidth: number, expanded: boolean): string | undefined {
126
+ if (!progress.currentTool) return undefined;
127
+ const maxToolArgsLen = Math.max(50, availableWidth - 20);
128
+ const toolArgsPreview = progress.currentToolArgs
129
+ ? (expanded || progress.currentToolArgs.length <= maxToolArgsLen
130
+ ? progress.currentToolArgs
131
+ : `${progress.currentToolArgs.slice(0, maxToolArgsLen)}...`)
132
+ : "";
133
+ const durationSuffix = progress.currentToolStartedAt !== undefined
134
+ ? ` | ${formatDuration(Math.max(0, Date.now() - progress.currentToolStartedAt))}`
135
+ : "";
136
+ return toolArgsPreview
137
+ ? `${progress.currentTool}: ${toolArgsPreview}${durationSuffix}`
138
+ : `${progress.currentTool}${durationSuffix}`;
139
+ }
140
+
141
+ function buildLiveStatusLine(progress: Pick<AgentProgress, "lastActivityAt">): string | undefined {
142
+ return formatActivityLabel(progress.lastActivityAt);
143
+ }
144
+
116
145
  /**
117
146
  * Render the async jobs widget
118
147
  */
@@ -226,18 +255,18 @@ export function renderSubagentResult(
226
255
  c.addChild(new Spacer(1));
227
256
 
228
257
  if (isRunning && r.progress) {
229
- if (r.progress.currentTool) {
230
- const maxToolArgsLen = Math.max(50, w - 20);
231
- const toolArgsPreview = r.progress.currentToolArgs
232
- ? (expanded || r.progress.currentToolArgs.length <= maxToolArgsLen
233
- ? r.progress.currentToolArgs
234
- : `${r.progress.currentToolArgs.slice(0, maxToolArgsLen)}...`)
235
- : "";
236
- const toolLine = toolArgsPreview
237
- ? `${r.progress.currentTool}: ${toolArgsPreview}`
238
- : r.progress.currentTool;
258
+ const toolLine = formatCurrentToolLine(r.progress, w, expanded);
259
+ if (toolLine) {
239
260
  c.addChild(new Text(fit(theme.fg("warning", `> ${toolLine}`)), 0, 0));
240
261
  }
262
+ const liveStatusLine = buildLiveStatusLine(r.progress);
263
+ if (liveStatusLine) {
264
+ c.addChild(new Text(fit(theme.fg("accent", liveStatusLine)), 0, 0));
265
+ }
266
+ c.addChild(new Text(fit(theme.fg("accent", "Press Ctrl+O for live detail")), 0, 0));
267
+ if (r.artifactPaths) {
268
+ c.addChild(new Text(fit(theme.fg("dim", `Artifacts: ${shortenPath(r.artifactPaths.outputPath)}`)), 0, 0));
269
+ }
241
270
  if (r.progress.recentTools?.length) {
242
271
  for (const t of r.progress.recentTools.slice(-3)) {
243
272
  const maxArgsLen = Math.max(40, w - 24);
@@ -250,7 +279,7 @@ export function renderSubagentResult(
250
279
  for (const line of (r.progress.recentOutput ?? []).slice(-5)) {
251
280
  c.addChild(new Text(fit(theme.fg("dim", ` ${line}`)), 0, 0));
252
281
  }
253
- if (r.progress.currentTool || r.progress.recentTools?.length || r.progress.recentOutput?.length) {
282
+ if (toolLine || liveStatusLine || r.progress.recentTools?.length || r.progress.recentOutput?.length || r.artifactPaths) {
254
283
  c.addChild(new Spacer(1));
255
284
  }
256
285
  }
@@ -278,7 +307,7 @@ export function renderSubagentResult(
278
307
  c.addChild(new Text(fit(theme.fg("dim", `Session: ${shortenPath(r.sessionFile)}`)), 0, 0));
279
308
  }
280
309
 
281
- if (r.artifactPaths) {
310
+ if (!isRunning && r.artifactPaths) {
282
311
  c.addChild(new Spacer(1));
283
312
  c.addChild(new Text(fit(theme.fg("dim", `Artifacts: ${shortenPath(r.artifactPaths.outputPath)}`)), 0, 0));
284
313
  }
@@ -391,6 +420,7 @@ export function renderSubagentResult(
391
420
  || d.progress?.find((p) => p.agent === r.agent && p.status === "running");
392
421
  const rProg = r.progress || progressFromArray || r.progressSummary;
393
422
  const rRunning = rProg?.status === "running";
423
+ const stepNumber = typeof rProg?.index === "number" ? rProg.index + 1 : i + 1;
394
424
 
395
425
  const resultOutput = getSingleResultOutput(r);
396
426
  const statusIcon = rRunning
@@ -403,8 +433,8 @@ export function renderSubagentResult(
403
433
  const stats = rProg ? ` | ${rProg.toolCount} tools, ${formatDuration(rProg.durationMs)}` : "";
404
434
  const modelDisplay = r.model ? theme.fg("dim", ` (${r.model})`) : "";
405
435
  const stepHeader = rRunning
406
- ? `${statusIcon} Step ${i + 1}: ${theme.bold(theme.fg("warning", r.agent))}${modelDisplay}${stats}`
407
- : `${statusIcon} Step ${i + 1}: ${theme.bold(r.agent)}${modelDisplay}${stats}`;
436
+ ? `${statusIcon} Step ${stepNumber}: ${theme.bold(theme.fg("warning", r.agent))}${modelDisplay}${stats}`
437
+ : `${statusIcon} Step ${stepNumber}: ${theme.bold(r.agent)}${modelDisplay}${stats}`;
408
438
  const toolCallLines = getToolCallLines(r, expanded);
409
439
  c.addChild(new Text(fit(stepHeader), 0, 0));
410
440
 
@@ -433,18 +463,18 @@ export function renderSubagentResult(
433
463
  if (rProg.skills?.length) {
434
464
  c.addChild(new Text(fit(theme.fg("accent", ` skills: ${rProg.skills.join(", ")}`)), 0, 0));
435
465
  }
436
- if (rProg.currentTool) {
437
- const maxToolArgsLen = Math.max(50, w - 20);
438
- const toolArgsPreview = rProg.currentToolArgs
439
- ? (expanded || rProg.currentToolArgs.length <= maxToolArgsLen
440
- ? rProg.currentToolArgs
441
- : `${rProg.currentToolArgs.slice(0, maxToolArgsLen)}...`)
442
- : "";
443
- const toolLine = toolArgsPreview
444
- ? `${rProg.currentTool}: ${toolArgsPreview}`
445
- : rProg.currentTool;
466
+ const toolLine = formatCurrentToolLine(rProg, w, expanded);
467
+ if (toolLine) {
446
468
  c.addChild(new Text(fit(theme.fg("warning", ` > ${toolLine}`)), 0, 0));
447
469
  }
470
+ const liveStatusLine = buildLiveStatusLine(rProg);
471
+ if (liveStatusLine) {
472
+ c.addChild(new Text(fit(theme.fg("accent", ` ${liveStatusLine}`)), 0, 0));
473
+ }
474
+ c.addChild(new Text(fit(theme.fg("accent", " Press Ctrl+O for live detail")), 0, 0));
475
+ if (r.artifactPaths) {
476
+ c.addChild(new Text(fit(theme.fg("dim", ` artifacts: ${shortenPath(r.artifactPaths.outputPath)}`)), 0, 0));
477
+ }
448
478
  if (rProg.recentTools?.length) {
449
479
  for (const t of rProg.recentTools.slice(-3)) {
450
480
  const maxArgsLen = Math.max(40, w - 30);
@@ -460,6 +490,10 @@ export function renderSubagentResult(
460
490
  }
461
491
  }
462
492
 
493
+ if (!rRunning && r.artifactPaths) {
494
+ c.addChild(new Text(fit(theme.fg("dim", ` artifacts: ${shortenPath(r.artifactPaths.outputPath)}`)), 0, 0));
495
+ }
496
+
463
497
  if (expanded && !rRunning) {
464
498
  for (const line of toolCallLines) {
465
499
  c.addChild(new Text(fit(theme.fg("muted", ` ${line}`)), 0, 0));
@@ -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
+ }
package/slash-commands.ts CHANGED
@@ -145,7 +145,7 @@ async function requestSlashRun(
145
145
  if (!ctx.hasUI) return;
146
146
  const tool = update.currentTool ? ` ${update.currentTool}` : "";
147
147
  const count = update.toolCount ?? 0;
148
- ctx.ui.setStatus("subagent-slash", `${count} tools${tool}`);
148
+ ctx.ui.setStatus("subagent-slash", `${count} tools${tool} | Ctrl+O live detail`);
149
149
  };
150
150
 
151
151
  const onTerminalInput = ctx.hasUI
@@ -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);
@@ -157,6 +157,12 @@ export class SubagentsStatusComponent implements Component {
157
157
  const lines = [
158
158
  row(`cwd: ${truncateToWidth(shortenPath(run.cwd ?? run.asyncDir), innerW - 5)}`, width, this.theme),
159
159
  ];
160
+ if (run.outputFile) {
161
+ lines.push(row(`output: ${truncateToWidth(shortenPath(run.outputFile), innerW - 8)}`, width, this.theme));
162
+ }
163
+ if (run.sessionFile) {
164
+ lines.push(row(`session: ${truncateToWidth(shortenPath(run.sessionFile), innerW - 9)}`, width, this.theme));
165
+ }
160
166
  for (const step of run.steps) {
161
167
  const model = step.model ? ` | ${step.model}` : "";
162
168
  const attempts = step.attemptedModels && step.attemptedModels.length > 1
@@ -226,7 +232,7 @@ export class SubagentsStatusComponent implements Component {
226
232
  lines.push(row(this.theme.fg("dim", "No runs selected."), w, this.theme));
227
233
  }
228
234
 
229
- const footer = `↑↓ select esc close ${this.active.length} active / ${this.recent.length} recent`;
235
+ const footer = `↑↓ select esc close summary view ${this.active.length} active / ${this.recent.length} recent`;
230
236
  lines.push(renderFooter(truncateToWidth(footer, innerW), w, this.theme));
231
237
  return lines;
232
238
  }
@@ -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
+ }