pi-subagents 0.23.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.23.1] - 2026-05-02
6
+
7
+ ### Added
8
+ - Persist async per-child session metadata and remember recent foreground child session metadata so `resume` can revive multi-child async runs and foreground children by index.
9
+
10
+ ### Fixed
11
+ - Keep foreground children alive when they call `contact_supervisor` for a blocking decision by treating it as intercom coordination during parent detach, matching the generic `intercom` handoff path.
12
+ - Pause foreground parallel and chain flows when a child detaches for intercom coordination instead of counting the child as a successful completed result and continuing the workflow, and suppress grouped completion receipts for detached chains.
13
+ - Tighten resume/revive safety by rejecting pending async children, detached foreground children that may still be live, ambiguous foreground/async id prefixes, and exact invalid resume matches that would otherwise be masked by a prefix match in the other namespace.
14
+ - Preserve child session metadata in stale-run repaired results and avoid advertising revive from top-level-only or missing child session files.
15
+ - Stop builtin `reviewer` runs from writing progress by default, clarify that review-only/no-edit instructions win over progress-writing or artifact-writing instructions, and suppress automatic progress injection for explicit no-edit tasks even when chain templates use `{task}`.
16
+ - Treat parsed provider errors as failed foreground and async subagent attempts even when the child process exits successfully, and baseline saved output files per fallback attempt.
17
+ - Preserve output-file read and inspect errors instead of silently overwriting or falling back when a changed saved-output path cannot be read.
18
+ - Show each active async widget row's lifecycle status (`running`, `complete`, `failed`, or `paused`) alongside activity and usage stats.
19
+ - Start new direct, slash, prompt-template, foreground, and async subagent launches in compact view while keeping `Ctrl+O` available for live detail.
20
+ - Label top-level async parallel completion notifications as parallel runs instead of leaking the internal chain-shaped runner plan.
21
+
5
22
  ## [0.23.0] - 2026-05-02
6
23
 
7
24
  ### Fixed
package/README.md CHANGED
@@ -225,7 +225,7 @@ Ask oracle to review this plan. If it sees a decision I need to make, have it as
225
225
 
226
226
  The child can use one dedicated coordination tool:
227
227
 
228
- - `contact_supervisor`: the child contacts the parent/supervisor session that delegated the task. Use `reason: "need_decision"` for blocking decisions or clarification, and `reason: "progress_update"` for short non-blocking updates when a discovery changes the plan.
228
+ - `contact_supervisor`: the child contacts the parent/supervisor session that delegated the task. Use `reason: "need_decision"` for blocking decisions or clarification, and `reason: "progress_update"` for short non-blocking updates when a discovery changes the plan. Do not ask for clarification when the only conflict is review-only/no-edit versus progress-writing or artifact-writing instructions; no-edit wins.
229
229
 
230
230
  Child-side routine completion handoffs are still not expected. With the intercom bridge active, parent-side `pi-subagents` sends grouped completion results through `pi-intercom`: one grouped message per foreground parent `subagent` run and one per completed async result file. Acknowledged foreground delivery returns a compact receipt with artifact/session paths; if unacknowledged, the normal full output is preserved. Grouped messages include child intercom targets and full child summaries.
231
231
 
@@ -780,11 +780,12 @@ Status and control actions:
780
780
  subagent({ action: "status" })
781
781
  subagent({ action: "status", id: "<run-id>" })
782
782
  subagent({ action: "interrupt", id: "<run-id>" })
783
- subagent({ action: "resume", id: "<async-run-id>", message: "follow-up question" })
783
+ subagent({ action: "resume", id: "<run-id>", message: "follow-up question" })
784
+ subagent({ action: "resume", id: "<run-id>", index: 1, message: "follow-up for child 2" })
784
785
  subagent({ action: "doctor" })
785
786
  ```
786
787
 
787
- `resume` sends the follow-up directly when the async child is still reachable over intercom. After completion, it starts a new async child from the stored single-child session file.
788
+ `resume` sends the follow-up directly when an async child is still reachable over intercom. After completion, it revives the child by starting a new async child from the stored child session file. Multi-child async runs and remembered foreground single, parallel, or chain runs can be revived by passing `index` to choose the child. Revive starts a new child process from the old session context; it does not restart the same OS process, and it requires the chosen child to have a persisted `.jsonl` session file.
788
789
 
789
790
  ## Worktree isolation
790
791
 
@@ -7,7 +7,6 @@ systemPromptMode: replace
7
7
  inheritProjectContext: true
8
8
  inheritSkills: false
9
9
  defaultReads: plan.md, progress.md
10
- defaultProgress: true
11
10
  ---
12
11
 
13
12
  You are a disciplined review subagent. Your job is to inspect, evaluate, and report findings with evidence. You do not guess; you verify from the code, tests, docs, or requirements.
@@ -59,9 +58,10 @@ Review a PR or issue by understanding the context, then verifying:
59
58
  - Prefer small corrective edits over broad rewrites.
60
59
  - If everything looks good, say so plainly.
61
60
  - If you are asked to maintain progress, record what you checked and what you found.
61
+ - If review-only or no-edit instructions conflict with progress-writing instructions, review-only/no-edit wins. Do not write `progress.md`; mention the conflict in your final review only if it matters.
62
62
 
63
63
  ## Supervisor coordination
64
- If runtime bridge instructions identify a safe supervisor target and you are blocked or need a decision, use `contact_supervisor` with `reason: "need_decision"` and wait for the reply. Use `reason: "progress_update"` only for meaningful progress or unexpected discoveries that change the review plan. Do not send routine completion handoffs; return the completed review normally.
64
+ If runtime bridge instructions identify a safe supervisor target and you are blocked or need a decision, use `contact_supervisor` with `reason: "need_decision"` and wait for the reply. Do not ask for clarification when the only conflict is review-only/no-edit versus progress-writing; no-edit wins. Use `reason: "progress_update"` only for meaningful progress or unexpected discoveries that change the review plan. Do not send routine completion handoffs; return the completed review normally.
65
65
 
66
66
  Fall back to generic `intercom` only if `contact_supervisor` is unavailable and the runtime bridge instructions identify a safe target. If no safe target is discoverable, do not guess.
67
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.23.0",
3
+ "version": "0.23.1",
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",
@@ -105,7 +105,7 @@ Use this at the start of non-trivial work. Launch `scout` for local context and
105
105
 
106
106
  ### Parallel cleanup technique
107
107
 
108
- Use this after implementation when the user wants cleanup review or when a final pass would reduce AI-slop. Launch two fresh-context `reviewer` tasks with `output: false`: one deslop pass and one verbosity pass. If the `deslop` or `verbosity-cleaner` skills are available, pass the relevant skill to that reviewer; otherwise inline the criteria. Both reviewers are review-only and should flag concrete issues with severity, file/line references, and smallest safe fixes. The parent decides what to apply and asks before making changes unless cleanup was already authorized.
108
+ Use this after implementation when the user wants cleanup review or when a final pass would reduce AI-slop. Launch two fresh-context `reviewer` tasks with `output: false` and `progress: false`: one deslop pass and one verbosity pass. If the `deslop` or `verbosity-cleaner` skills are available, pass the relevant skill to that reviewer; otherwise inline the criteria. Both reviewers are review-only and should flag concrete issues with severity, file/line references, and smallest safe fixes. Review-only/no-edit beats progress-writing or artifact-writing instructions. The parent decides what to apply and asks before making changes unless cleanup was already authorized.
109
109
 
110
110
  ## Builtin Agents
111
111
 
@@ -290,6 +290,21 @@ const run = subagent({
290
290
 
291
291
  Inspect async runs with `subagent({ action: "status", id: "..." })`, `subagent({ action: "status" })` for active runs, or the `/subagents-status` slash command.
292
292
 
293
+ Use `resume` for follow-up work after a delegated run:
294
+
295
+ ```typescript
296
+ subagent({ action: "resume", id: "run-id", message: "Follow up on this point." })
297
+ subagent({ action: "resume", id: "run-id", index: 1, message: "Continue reviewer 2." })
298
+ ```
299
+
300
+ Resume behavior:
301
+ - If an async child is still running and reachable, `resume` sends the follow-up to that live child over intercom.
302
+ - If an async child has completed, `resume` revives it by starting a new async child from the persisted child session file.
303
+ - Multi-child async runs require `index` unless only one running child is selectable.
304
+ - Completed foreground single, parallel, and chain runs can also be revived by `index` while their run metadata remains in extension state.
305
+ - Revive starts a new child process from the old session context; it does not restart the same OS process.
306
+ - If the chosen child has no persisted `.jsonl` session file, resume fails and reports that directly.
307
+
293
308
  Use diagnostics when setup or child startup looks wrong:
294
309
 
295
310
  ```typescript
@@ -409,6 +424,8 @@ Use `contact_supervisor` with `reason: "need_decision"` when:
409
424
  - a child needs clarification instead of guessing
410
425
  - an approval, product, API, or scope choice is required before continuing safely
411
426
 
427
+ Do not use `contact_supervisor` just to resolve review-only/no-edit versus progress-writing or artifact-writing instructions. No-edit wins, and the child should return review findings without touching files.
428
+
412
429
  Use `contact_supervisor` with `reason: "progress_update"` when:
413
430
  - a child is explicitly asked for progress
414
431
  - a meaningful discovery changes the plan
@@ -24,7 +24,7 @@ import { resolveCurrentSessionId } from "../shared/session-identity.ts";
24
24
  import { cleanupOldChainDirs } from "../shared/settings.ts";
25
25
  import { renderWidget, renderSubagentResult, stopResultAnimations, stopWidgetAnimation, syncResultAnimation } from "../tui/render.ts";
26
26
  import { SubagentParams } from "./schemas.ts";
27
- import { createSubagentExecutor } from "../runs/foreground/subagent-executor.ts";
27
+ import { createSubagentExecutor, type SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
28
28
  import { createAsyncJobTracker } from "../runs/background/async-job-tracker.ts";
29
29
  import { createResultWatcher } from "../runs/background/result-watcher.ts";
30
30
  import { registerSlashCommands } from "../slash/slash-commands.ts";
@@ -245,6 +245,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
245
245
  baseCwd: process.cwd(),
246
246
  currentSessionId: null,
247
247
  asyncJobs: new Map(),
248
+ foregroundRuns: new Map(),
248
249
  foregroundControls: new Map(),
249
250
  lastForegroundControlId: null,
250
251
  pendingForegroundControlNotices: new Map(),
@@ -336,11 +337,16 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
336
337
  return new SubagentControlNoticeComponent({ ...details, noticeText: formatSubagentControlNotice(details, content) }, theme);
337
338
  });
338
339
 
340
+ const executeSubagentCollapsed = (id: string, params: SubagentParamsLike, signal: AbortSignal, onUpdate: ((result: AgentToolResult<Details>) => void) | undefined, ctx: ExtensionContext) => {
341
+ if (ctx.hasUI) ctx.ui.setToolsExpanded(false);
342
+ return executor.execute(id, params, signal, onUpdate, ctx);
343
+ };
344
+
339
345
  const slashBridge = registerSlashSubagentBridge({
340
346
  events: pi.events,
341
347
  getContext: () => state.lastUiContext,
342
348
  execute: (id, params, signal, onUpdate, ctx) =>
343
- executor.execute(id, params, signal, onUpdate, ctx),
349
+ executeSubagentCollapsed(id, params, signal, onUpdate, ctx),
344
350
  });
345
351
 
346
352
  const promptTemplateBridge = registerPromptTemplateDelegationBridge({
@@ -348,7 +354,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
348
354
  getContext: () => state.lastUiContext,
349
355
  execute: async (requestId, request, signal, ctx, onUpdate) => {
350
356
  if (request.tasks && request.tasks.length > 0) {
351
- return executor.execute(
357
+ return executeSubagentCollapsed(
352
358
  requestId,
353
359
  {
354
360
  tasks: request.tasks,
@@ -363,7 +369,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
363
369
  ctx,
364
370
  );
365
371
  }
366
- return executor.execute(
372
+ return executeSubagentCollapsed(
367
373
  requestId,
368
374
  {
369
375
  agent: request.agent,
@@ -419,14 +425,14 @@ MANAGEMENT (use action field, omit agent/task/chain/tasks):
419
425
  CONTROL:
420
426
  • { action: "status", id: "..." } - inspect an async/background run by id or prefix
421
427
  • { action: "interrupt", id?: "..." } - soft-interrupt the current child turn and leave the run paused
422
- • { action: "resume", id: "...", message: "..." } - follow up with a live async child or revive a completed single-child async run from its session
428
+ • { action: "resume", id: "...", message: "...", index?: 0 } - follow up with a live async child or revive a completed async/foreground child from its session
423
429
 
424
430
  DIAGNOSTICS:
425
431
  • { action: "doctor" } - read-only report for runtime paths, discovery, sessions, and intercom`,
426
432
  parameters: SubagentParams,
427
433
 
428
434
  execute(id, params, signal, onUpdate, ctx) {
429
- return executor.execute(id, params, signal, onUpdate, ctx);
435
+ return executeSubagentCollapsed(id, params, signal, onUpdate, ctx);
430
436
  },
431
437
 
432
438
  renderCall(args, theme) {
@@ -116,7 +116,7 @@ export const SubagentParams = Type.Object({
116
116
  description: "Async run directory for action='status' or action='resume'."
117
117
  })),
118
118
  index: Type.Optional(Type.Integer({ minimum: 0, description: "Zero-based child index for actions that target a specific child." })),
119
- message: Type.Optional(Type.String({ description: "Follow-up message for action='resume'." })),
119
+ message: Type.Optional(Type.String({ description: "Follow-up message for action='resume'. Use index to choose a child from multi-child runs." })),
120
120
  // Chain identifier for management (can't reuse 'chain' — that's the execution array)
121
121
  chainName: Type.Optional(Type.String({
122
122
  description: "Chain name for get/update/delete management actions"
@@ -31,6 +31,7 @@ const DEFAULT_INTERCOM_BRIDGE_TEMPLATE = `The inherited thread is reference-only
31
31
  Use contact_supervisor first. It resolves the supervisor session "{orchestratorTarget}" and run metadata automatically.
32
32
  - Need a decision, blocked, approval, or product/API/scope ambiguity: contact_supervisor({ reason: "need_decision", message: "<question>" })
33
33
  - After contact_supervisor with reason "need_decision", stay alive and continue only after the reply arrives. Do not finish your final response with a choose-one question.
34
+ - Do not ask for clarification when the only conflict is review-only/no-edit versus progress-writing or artifact-writing instructions. Review-only/no-edit wins; leave files unchanged and mention the conflict in your final result only if it matters.
34
35
  - Meaningful progress or unexpected discoveries that change the plan: contact_supervisor({ reason: "progress_update", message: "UPDATE: <summary>" })
35
36
  - Generic intercom is lower-level plumbing/fallback only: intercom({ action: "ask", to: "{orchestratorTarget}", message: "<question>" })
36
37
 
@@ -116,7 +117,9 @@ function intercomConfigStatus(configPath: string): { enabled: boolean; error?: u
116
117
  function readJsonBestEffort(filePath: string): unknown {
117
118
  try {
118
119
  return JSON.parse(fs.readFileSync(filePath, "utf-8"));
119
- } catch {
120
+ } catch (error) {
121
+ const code = error && typeof error === "object" && "code" in error ? (error as NodeJS.ErrnoException).code : undefined;
122
+ if (code !== "ENOENT") console.warn(`Failed to read JSON from '${filePath}'.`, error);
120
123
  return null;
121
124
  }
122
125
  }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs";
2
3
  import {
3
4
  type Details,
4
5
  type IntercomEventBus,
@@ -76,11 +77,15 @@ function asyncResumeGuidance(input: {
76
77
  asyncId?: string;
77
78
  }): string | undefined {
78
79
  if (input.source !== "async" || !input.asyncId) return undefined;
79
- if (input.children.length === 1 && typeof input.children[0]?.sessionPath === "string") {
80
+ const resumable = input.children.filter((child) => typeof child.sessionPath === "string" && fs.existsSync(child.sessionPath));
81
+ if (input.children.length === 1 && resumable.length === 1) {
80
82
  return `Revive: subagent({ action: "resume", id: "${input.asyncId}", message: "..." })`;
81
83
  }
82
- if (input.children.length > 1) return "Resume: unsupported for multi-child async runs until per-child session files are persisted.";
83
- return "Resume: unavailable; no single child session file was persisted.";
84
+ if (resumable.length > 0) {
85
+ const firstIndex = resumable[0]?.index ?? input.children.indexOf(resumable[0]!);
86
+ return `Revive child: subagent({ action: "resume", id: "${input.asyncId}", index: ${firstIndex}, message: "..." })`;
87
+ }
88
+ return "Resume: unavailable; no child session file was persisted.";
84
89
  }
85
90
 
86
91
  function formatSubagentResultIntercomMessage(input: {
@@ -12,7 +12,7 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
12
12
  import type { AgentConfig } from "../../agents/agents.ts";
13
13
  import { applyThinkingSuffix } from "../shared/pi-args.ts";
14
14
  import { injectSingleOutputInstruction, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
15
- import { buildChainInstructions, isParallelStep, resolveStepBehavior, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
15
+ import { buildChainInstructions, isParallelStep, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
16
16
  import type { RunnerStep } from "../shared/parallel-utils.ts";
17
17
  import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
18
18
  import { buildSkillInjection, normalizeSkillInput, resolveSkillsWithFallback } from "../../agents/skills.ts";
@@ -65,6 +65,7 @@ interface AsyncExecutionContext {
65
65
 
66
66
  interface AsyncChainParams {
67
67
  chain: ChainStep[];
68
+ task?: string;
68
69
  resultMode?: Exclude<SubagentRunMode, "single">;
69
70
  agents: AgentConfig[];
70
71
  ctx: AsyncExecutionContext;
@@ -213,6 +214,10 @@ export function executeAsyncChain(
213
214
  const chainSkills = params.chainSkills ?? [];
214
215
  const availableModels = params.availableModels;
215
216
  const runnerCwd = resolveChildCwd(ctx.cwd, cwd);
217
+ const firstStep = chain[0];
218
+ const originalTask = params.task ?? (firstStep
219
+ ? (isParallelStep(firstStep) ? firstStep.parallel[0]?.task : (firstStep as SequentialStep).task)
220
+ : undefined);
216
221
 
217
222
  for (const s of chain) {
218
223
  const stepAgents = isParallelStep(s)
@@ -257,7 +262,7 @@ export function executeAsyncChain(
257
262
  const a = agents.find((x) => x.name === s.agent)!;
258
263
  const stepCwd = resolveChildCwd(runnerCwd, s.cwd);
259
264
  const instructionCwd = behaviorCwd ?? stepCwd;
260
- const behavior = resolvedBehavior ?? resolveStepBehavior(a, buildStepOverrides(s), chainSkills);
265
+ const behavior = suppressProgressForReadOnlyTask(resolvedBehavior ?? resolveStepBehavior(a, buildStepOverrides(s), chainSkills), s.task, originalTask);
261
266
  const skillNames = behavior.skills === false ? [] : behavior.skills;
262
267
  const { resolved: resolvedSkills, missing: missingSkills } = resolveSkillsWithFallback(skillNames, stepCwd, ctx.cwd);
263
268
  if (missingSkills.includes("pi-subagents")) throw new UnavailableSubagentSkillError(UNAVAILABLE_SUBAGENT_SKILL_ERROR);
@@ -314,7 +319,7 @@ export function executeAsyncChain(
314
319
  if (isParallelStep(s)) {
315
320
  const parallelBehaviors = s.parallel.map((task) => {
316
321
  const agent = agents.find((candidate) => candidate.name === task.agent)!;
317
- return resolveStepBehavior(agent, buildStepOverrides(task), chainSkills);
322
+ return suppressProgressForReadOnlyTask(resolveStepBehavior(agent, buildStepOverrides(task), chainSkills), task.task, originalTask);
318
323
  });
319
324
  const progressPrecreated = parallelBehaviors.some((behavior) => behavior.progress);
320
325
  if (progressPrecreated) {
@@ -436,7 +441,7 @@ export function executeAsyncChain(
436
441
 
437
442
  return {
438
443
  content: [{ type: "text", text: formatAsyncStartedMessage(`Async ${resultMode}: ${chainDesc} [${id}]`) }],
439
- details: { mode: resultMode, results: [], asyncId: id, asyncDir },
444
+ details: { mode: resultMode, runId: id, results: [], asyncId: id, asyncDir },
440
445
  };
441
446
  }
442
447
 
@@ -568,6 +573,6 @@ export function executeAsyncSingle(
568
573
 
569
574
  return {
570
575
  content: [{ type: "text", text: formatAsyncStartedMessage(`Async: ${agent} [${id}]`) }],
571
- details: { mode: "single", results: [], asyncId: id, asyncDir },
576
+ details: { mode: "single", runId: id, results: [], asyncId: id, asyncDir },
572
577
  };
573
578
  }
@@ -39,7 +39,7 @@ interface AsyncResultFile {
39
39
  success?: boolean;
40
40
  cwd?: string;
41
41
  sessionFile?: string;
42
- results?: Array<{ agent?: string; success?: boolean; intercomTarget?: string }>;
42
+ results?: Array<{ agent?: string; success?: boolean; sessionFile?: string; intercomTarget?: string }>;
43
43
  }
44
44
 
45
45
  export interface AsyncRunLocation {
@@ -59,10 +59,10 @@ function ensureObject(value: unknown, source: string): Record<string, unknown> {
59
59
  return value as Record<string, unknown>;
60
60
  }
61
61
 
62
- function validateOptionalString(value: Record<string, unknown>, field: string, source: string): string | undefined {
62
+ function validateOptionalString(value: Record<string, unknown>, field: string, source: string, displayField = field): string | undefined {
63
63
  const fieldValue = value[field];
64
64
  if (fieldValue === undefined) return undefined;
65
- if (typeof fieldValue !== "string") throw new Error(`Invalid async result file '${source}': ${field} must be a string.`);
65
+ if (typeof fieldValue !== "string") throw new Error(`Invalid async result file '${source}': ${displayField} must be a string.`);
66
66
  return fieldValue;
67
67
  }
68
68
 
@@ -74,11 +74,12 @@ function validateResultFile(value: unknown, resultPath: string): AsyncResultFile
74
74
  if (!Array.isArray(resultsValue)) throw new Error(`Invalid async result file '${resultPath}': results must be an array.`);
75
75
  results = resultsValue.map((entry, index) => {
76
76
  const child = ensureObject(entry, `${resultPath} results[${index}]`);
77
- const agent = validateOptionalString(child, "agent", `${resultPath} results[${index}]`);
78
- const intercomTarget = validateOptionalString(child, "intercomTarget", `${resultPath} results[${index}]`);
77
+ const agent = validateOptionalString(child, "agent", resultPath, `results[${index}].agent`);
78
+ const sessionFile = validateOptionalString(child, "sessionFile", resultPath, `results[${index}].sessionFile`);
79
+ const intercomTarget = validateOptionalString(child, "intercomTarget", resultPath, `results[${index}].intercomTarget`);
79
80
  const success = child.success;
80
81
  if (success !== undefined && typeof success !== "boolean") throw new Error(`Invalid async result file '${resultPath}': results[${index}].success must be a boolean.`);
81
- return { agent, intercomTarget, ...(typeof success === "boolean" ? { success } : {}) };
82
+ return { agent, sessionFile, intercomTarget, ...(typeof success === "boolean" ? { success } : {}) };
82
83
  });
83
84
  }
84
85
  const success = data.success;
@@ -210,6 +211,7 @@ function validateStatusForResume(status: AsyncStatus | null, source: string): vo
210
211
  status.steps.forEach((step, index) => {
211
212
  if (!step || typeof step !== "object" || Array.isArray(step)) throw new Error(`Invalid async status '${source}': steps[${index}] must be an object.`);
212
213
  if (typeof step.agent !== "string") throw new Error(`Invalid async status '${source}': steps[${index}].agent must be a string.`);
214
+ if (step.sessionFile !== undefined && typeof step.sessionFile !== "string") throw new Error(`Invalid async status '${source}': steps[${index}].sessionFile must be a string.`);
213
215
  });
214
216
  }
215
217
  }
@@ -243,38 +245,62 @@ export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncR
243
245
  const resultSteps = result?.results ?? [];
244
246
  const stepCount = statusSteps.length || resultSteps.length || (result?.agent ? 1 : 0);
245
247
  const requestedIndex = params.index;
248
+ if (requestedIndex !== undefined && !Number.isInteger(requestedIndex)) throw new Error(`Async run '${runId}' index must be an integer.`);
249
+ const terminalStepStatuses = new Set(["complete", "completed", "failed", "paused"]);
246
250
 
247
251
  if (state === "running") {
248
- const running = statusSteps
249
- .map((step, index) => ({ step, index }))
250
- .filter(({ step }) => step.status === "running");
251
- const selected = requestedIndex !== undefined
252
- ? running.find(({ index }) => index === requestedIndex)
253
- : running.length === 1 ? running[0] : undefined;
254
- if (!selected) {
255
- throw new Error(`Async run '${runId}' has ${running.length} running children. Provide index to choose one.`);
252
+ if (requestedIndex !== undefined) {
253
+ if (requestedIndex < 0 || requestedIndex >= stepCount) throw new Error(`Async run '${runId}' has ${stepCount} children. Index ${requestedIndex} is out of range.`);
254
+ const selectedStep = statusSteps[requestedIndex];
255
+ if (selectedStep?.status === "running") {
256
+ return {
257
+ kind: "live",
258
+ runId,
259
+ asyncDir: location.asyncDir ?? undefined,
260
+ state,
261
+ agent: selectedStep.agent,
262
+ index: requestedIndex,
263
+ intercomTarget: resolveSubagentIntercomTarget(runId, selectedStep.agent, requestedIndex),
264
+ cwd: status?.cwd ?? result?.cwd,
265
+ sessionFile: selectedStep.sessionFile ?? status?.sessionFile ?? result?.sessionFile,
266
+ };
267
+ }
268
+ if (selectedStep?.status === "pending") throw new Error(`Async run '${runId}' child ${requestedIndex} is pending and has not started yet. Wait for it to run or complete before resuming.`);
269
+ if (selectedStep && !terminalStepStatuses.has(selectedStep.status)) throw new Error(`Async run '${runId}' child ${requestedIndex} is ${selectedStep.status} and cannot be revived yet.`);
270
+ } else {
271
+ const running = statusSteps
272
+ .map((step, index) => ({ step, index }))
273
+ .filter(({ step }) => step.status === "running");
274
+ const selected = running.length === 1 ? running[0] : undefined;
275
+ if (!selected) {
276
+ throw new Error(`Async run '${runId}' has ${running.length} running children. Provide index to choose one.`);
277
+ }
278
+ return {
279
+ kind: "live",
280
+ runId,
281
+ asyncDir: location.asyncDir ?? undefined,
282
+ state,
283
+ agent: selected.step.agent,
284
+ index: selected.index,
285
+ intercomTarget: resolveSubagentIntercomTarget(runId, selected.step.agent, selected.index),
286
+ cwd: status?.cwd ?? result?.cwd,
287
+ sessionFile: selected.step.sessionFile ?? status?.sessionFile ?? result?.sessionFile,
288
+ };
256
289
  }
257
- return {
258
- kind: "live",
259
- runId,
260
- asyncDir: location.asyncDir ?? undefined,
261
- state,
262
- agent: selected.step.agent,
263
- index: selected.index,
264
- intercomTarget: resolveSubagentIntercomTarget(runId, selected.step.agent, selected.index),
265
- cwd: status?.cwd ?? result?.cwd,
266
- sessionFile: status?.sessionFile ?? result?.sessionFile,
267
- };
268
290
  }
269
291
 
270
- if (stepCount !== 1) {
271
- throw new Error(`Async run '${runId}' has ${stepCount} children. Resume currently supports single-child async runs because per-child session files are not persisted.`);
292
+ if (stepCount > 1 && requestedIndex === undefined) {
293
+ throw new Error(`Async run '${runId}' has ${stepCount} children. Provide index to choose one.`);
272
294
  }
273
295
  const index = requestedIndex ?? 0;
296
+ if (!Number.isInteger(index)) throw new Error(`Async run '${runId}' index must be an integer.`);
297
+ if (index < 0 || index >= stepCount) throw new Error(`Async run '${runId}' has ${stepCount} children. Index ${index} is out of range.`);
274
298
  const agent = statusSteps[index]?.agent ?? resultSteps[index]?.agent ?? result?.agent;
275
299
  if (!agent) throw new Error(`Could not determine child agent for async run '${runId}'.`);
276
- const sessionFile = status?.sessionFile ?? result?.sessionFile;
277
- if (!sessionFile) throw new Error(`Async run '${runId}' does not have a persisted child session file to resume from.`);
300
+ const sessionFile = statusSteps[index]?.sessionFile
301
+ ?? resultSteps[index]?.sessionFile
302
+ ?? (stepCount === 1 ? status?.sessionFile ?? result?.sessionFile : undefined);
303
+ if (!sessionFile) throw new Error(`Async run '${runId}' child ${index} does not have a persisted session file to resume from.`);
278
304
  const resolvedSessionFile = validateResumeSessionFile(runId, sessionFile);
279
305
 
280
306
  return {
@@ -292,9 +318,9 @@ export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncR
292
318
 
293
319
  export function buildRevivedAsyncTask(target: AsyncResumeTarget, message: string): string {
294
320
  return [
295
- "You are reviving a completed async subagent conversation.",
321
+ "You are reviving a previous subagent conversation.",
296
322
  "",
297
- `Original async run: ${target.runId}`,
323
+ `Original run: ${target.runId}`,
298
324
  `Original agent: ${target.agent}`,
299
325
  target.sessionFile ? `Original session file: ${target.sessionFile}` : undefined,
300
326
  "",
@@ -76,6 +76,7 @@ export function createResultWatcher(
76
76
  output?: string;
77
77
  error?: string;
78
78
  success?: boolean;
79
+ sessionFile?: string;
79
80
  artifactPaths?: { outputPath?: string };
80
81
  intercomTarget?: string;
81
82
  }>;
@@ -120,6 +121,7 @@ export function createResultWatcher(
120
121
  const summary = result.success === false && result.error
121
122
  ? `${result.error}${hasRealOutput ? `\n\nOutput:\n${baseOutput}` : ""}`
122
123
  : output;
124
+ const sessionPath = result.sessionFile ?? (childResults.length === 1 ? data.sessionFile : undefined);
123
125
  return {
124
126
  agent: result.agent ?? data.agent ?? `step-${index + 1}`,
125
127
  status: resolveSubagentResultStatus({
@@ -129,7 +131,7 @@ export function createResultWatcher(
129
131
  summary,
130
132
  index,
131
133
  artifactPath: result.artifactPaths?.outputPath,
132
- sessionPath: data.sessionFile,
134
+ ...(typeof sessionPath === "string" && fsApi.existsSync(sessionPath) ? { sessionPath } : {}),
133
135
  intercomTarget: result.intercomTarget,
134
136
  };
135
137
  }),
@@ -28,8 +28,24 @@ function activityText(activityState: unknown, lastActivityAt: unknown): string |
28
28
  return activityState === "needs_attention" ? `no activity for ${seconds}s` : `active ${seconds}s ago`;
29
29
  }
30
30
 
31
- function canShowRevive(stepCount: number, sessionFile: unknown): sessionFile is string {
32
- return stepCount === 1 && typeof sessionFile === "string" && fs.existsSync(sessionFile);
31
+ function hasExistingSessionFile(value: unknown): value is string {
32
+ return typeof value === "string" && fs.existsSync(value);
33
+ }
34
+
35
+ function formatResumeGuidance(runId: string | undefined, children: Array<{ agent?: unknown; sessionFile?: unknown }>, fallbackSessionFile?: unknown): string {
36
+ const knownChildren = children
37
+ .map((child, index) => ({ child, index }))
38
+ .filter(({ child }) => typeof child.agent === "string");
39
+ if (!runId || knownChildren.length === 0) return "Resume: unavailable; no child session file was persisted.";
40
+ const singleSessionFile = knownChildren[0]?.child.sessionFile ?? fallbackSessionFile;
41
+ if (children.length === 1 && knownChildren.length === 1 && hasExistingSessionFile(singleSessionFile)) {
42
+ return `Revive: subagent({ action: "resume", id: "${runId}", message: "..." })`;
43
+ }
44
+ const childWithSession = knownChildren.find(({ child }) => hasExistingSessionFile(child.sessionFile));
45
+ if (childWithSession) {
46
+ return `Revive child: subagent({ action: "resume", id: "${runId}", index: ${childWithSession.index}, message: "..." })`;
47
+ }
48
+ return "Resume: unavailable; no child session file was persisted.";
33
49
  }
34
50
 
35
51
  function stepLineLabel(status: AsyncStatus, index: number): string {
@@ -137,14 +153,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
137
153
  }
138
154
  if (status.sessionFile) lines.push(`Session: ${status.sessionFile}`);
139
155
  if (status.state !== "running") {
140
- const stepCount = status.steps?.length ?? 0;
141
- if (canShowRevive(stepCount, status.sessionFile)) {
142
- lines.push(`Revive: subagent({ action: "resume", id: "${status.runId}", message: "..." })`);
143
- } else if (stepCount > 1) {
144
- lines.push("Resume: unsupported for multi-child async runs until per-child session files are persisted.");
145
- } else {
146
- lines.push("Resume: unavailable; no single child session file was persisted.");
147
- }
156
+ lines.push(formatResumeGuidance(status.runId, status.steps ?? [], status.sessionFile));
148
157
  }
149
158
  if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
150
159
  if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
@@ -156,18 +165,12 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
156
165
  if (resultPath) {
157
166
  try {
158
167
  const raw = fs.readFileSync(resultPath, "utf-8");
159
- const data = JSON.parse(raw) as { id?: string; runId?: string; agent?: string; success?: boolean; summary?: string; exitCode?: number; state?: string; sessionFile?: string; results?: Array<{ agent?: string }> };
168
+ const data = JSON.parse(raw) as { id?: string; runId?: string; agent?: string; success?: boolean; summary?: string; exitCode?: number; state?: string; sessionFile?: string; results?: Array<{ agent?: string; sessionFile?: string }> };
160
169
  const status = data.success ? "complete" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
161
170
  const runId = data.runId ?? data.id ?? resolvedId;
162
171
  const lines = [`Run: ${runId}`, `State: ${status}`, `Result: ${resultPath}`];
163
- const stepCount = Array.isArray(data.results) ? data.results.length : data.agent ? 1 : 0;
164
- if (runId && canShowRevive(stepCount, data.sessionFile)) {
165
- lines.push(`Revive: subagent({ action: "resume", id: "${runId}", message: "..." })`);
166
- } else if (stepCount > 1) {
167
- lines.push("Resume: unsupported for multi-child async runs until per-child session files are persisted.");
168
- } else {
169
- lines.push("Resume: unavailable; no single child session file was persisted.");
170
- }
172
+ const children = Array.isArray(data.results) ? data.results : data.agent ? [{ agent: data.agent, sessionFile: data.sessionFile }] : [];
173
+ lines.push(formatResumeGuidance(runId, children, data.sessionFile));
171
174
  if (data.summary) lines.push("", data.summary);
172
175
  return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [] } };
173
176
  } catch (error) {
@@ -76,6 +76,7 @@ interface ResultChildOutcome {
76
76
  agent?: string;
77
77
  success?: boolean;
78
78
  error?: string;
79
+ sessionFile?: string;
79
80
  model?: string;
80
81
  attemptedModels?: string[];
81
82
  modelAttempts?: NonNullable<AsyncStatus["steps"]>[number]["modelAttempts"];
@@ -119,6 +120,7 @@ function terminalStatusFromResult(status: AsyncStatus, resultPath: string, now:
119
120
  durationMs: step.startedAt !== undefined && step.durationMs === undefined ? Math.max(0, now - step.startedAt) : step.durationMs,
120
121
  exitCode: step.exitCode ?? (state === "complete" || state === "paused" ? 0 : 1),
121
122
  error: state === "failed" ? step.error ?? child?.error : step.error,
123
+ sessionFile: step.sessionFile ?? child?.sessionFile,
122
124
  model: step.model ?? child?.model,
123
125
  attemptedModels: step.attemptedModels ?? child?.attemptedModels,
124
126
  modelAttempts: step.modelAttempts ?? child?.modelAttempts,
@@ -204,6 +206,7 @@ function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, r
204
206
  model: step.model,
205
207
  attemptedModels: step.attemptedModels,
206
208
  modelAttempts: step.modelAttempts,
209
+ sessionFile: step.sessionFile,
207
210
  })),
208
211
  exitCode: 1,
209
212
  timestamp: now,