pi-subagents 0.23.0 → 0.24.0

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +17 -79
  3. package/agents/reviewer.md +2 -2
  4. package/package.json +1 -1
  5. package/prompts/parallel-cleanup.md +11 -1
  6. package/prompts/parallel-review.md +11 -1
  7. package/skills/pi-subagents/SKILL.md +29 -13
  8. package/src/agents/agent-serializer.ts +0 -42
  9. package/src/agents/agents.ts +1 -1
  10. package/src/extension/index.ts +14 -8
  11. package/src/extension/schemas.ts +1 -1
  12. package/src/intercom/intercom-bridge.ts +4 -1
  13. package/src/intercom/result-intercom.ts +8 -3
  14. package/src/runs/background/async-execution.ts +10 -5
  15. package/src/runs/background/async-resume.ts +57 -31
  16. package/src/runs/background/async-status.ts +16 -50
  17. package/src/runs/background/result-watcher.ts +3 -1
  18. package/src/runs/background/run-status.ts +28 -26
  19. package/src/runs/background/stale-run-reconciler.ts +3 -0
  20. package/src/runs/background/subagent-runner.ts +21 -7
  21. package/src/runs/foreground/chain-clarify.ts +183 -218
  22. package/src/runs/foreground/chain-execution.ts +55 -21
  23. package/src/runs/foreground/execution.ts +6 -3
  24. package/src/runs/foreground/subagent-executor.ts +152 -20
  25. package/src/runs/shared/single-output.ts +21 -6
  26. package/src/shared/settings.ts +19 -0
  27. package/src/shared/status-format.ts +49 -0
  28. package/src/shared/types.ts +18 -5
  29. package/src/slash/slash-commands.ts +1 -74
  30. package/src/tui/render.ts +37 -61
  31. package/src/agents/agent-templates.ts +0 -60
  32. package/src/manager-ui/agent-manager-chain-detail.ts +0 -164
  33. package/src/manager-ui/agent-manager-detail.ts +0 -235
  34. package/src/manager-ui/agent-manager-edit.ts +0 -456
  35. package/src/manager-ui/agent-manager-list.ts +0 -283
  36. package/src/manager-ui/agent-manager-parallel.ts +0 -302
  37. package/src/manager-ui/agent-manager.ts +0 -732
  38. package/src/tui/subagents-status.ts +0 -621
  39. package/src/tui/text-editor.ts +0 -286
@@ -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
  "",
@@ -1,7 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { formatDuration, formatTokens, shortenPath } from "../../shared/formatters.ts";
4
- import { type ActivityState, type AsyncParallelGroupStatus, type AsyncStatus, type SubagentRunMode, type TokenUsage } from "../../shared/types.ts";
4
+ import { formatActivityLabel, formatParallelOutcome } from "../../shared/status-format.ts";
5
+ import { type ActivityState, type AsyncJobStep, type AsyncParallelGroupStatus, type AsyncStatus, type SubagentRunMode, type TokenUsage } from "../../shared/types.ts";
5
6
  import { readStatus } from "../../shared/utils.ts";
6
7
  import { flatToLogicalStepIndex, normalizeParallelGroups } from "./parallel-groups.ts";
7
8
  import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
@@ -9,7 +10,7 @@ import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
9
10
  interface AsyncRunStepSummary {
10
11
  index: number;
11
12
  agent: string;
12
- status: string;
13
+ status: AsyncJobStep["status"];
13
14
  activityState?: ActivityState;
14
15
  lastActivityAt?: number;
15
16
  currentTool?: string;
@@ -65,16 +66,6 @@ interface AsyncRunListOptions {
65
66
  reconcile?: boolean;
66
67
  }
67
68
 
68
- export interface AsyncRunOverlayData {
69
- active: AsyncRunSummary[];
70
- recent: AsyncRunSummary[];
71
- }
72
-
73
- export interface AsyncRunOverlayOptions {
74
- recentLimit?: number;
75
- sessionId?: string;
76
- }
77
-
78
69
  function getErrorMessage(error: unknown): string {
79
70
  return error instanceof Error ? error.message : String(error);
80
71
  }
@@ -185,9 +176,9 @@ function sortRuns(runs: AsyncRunSummary[]): AsyncRunSummary[] {
185
176
  switch (state) {
186
177
  case "running": return 0;
187
178
  case "queued": return 1;
188
- case "failed": return 2;
189
- case "paused": return 2;
190
- case "complete": return 3;
179
+ case "failed": return 2;
180
+ case "paused": return 2;
181
+ case "complete": return 3;
191
182
  }
192
183
  };
193
184
  return [...runs].sort((a, b) => {
@@ -229,35 +220,15 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
229
220
  return options.limit !== undefined ? sorted.slice(0, options.limit) : sorted;
230
221
  }
231
222
 
232
- export function listAsyncRunsForOverlay(asyncDirRoot: string, options: AsyncRunOverlayOptions = {}): AsyncRunOverlayData {
233
- const recentLimit = options.recentLimit ?? 5;
234
- const all = listAsyncRuns(asyncDirRoot, { sessionId: options.sessionId });
235
- const recent = all
236
- .filter((run) => run.state === "complete" || run.state === "failed" || run.state === "paused")
237
- .sort((a, b) => (b.lastUpdate ?? b.endedAt ?? b.startedAt) - (a.lastUpdate ?? a.endedAt ?? a.startedAt))
238
- .slice(0, recentLimit);
239
- return {
240
- active: all.filter((run) => run.state === "queued" || run.state === "running"),
241
- recent,
242
- };
243
- }
244
-
245
223
  function formatActivityFacts(input: { activityState?: ActivityState; lastActivityAt?: number; currentTool?: string; currentToolStartedAt?: number; currentPath?: string; turnCount?: number; toolCount?: number }): string | undefined {
246
224
  const facts: string[] = [];
247
- if (input.currentTool && input.currentToolStartedAt) facts.push(`tool ${input.currentTool} ${formatDuration(Math.max(0, Date.now() - input.currentToolStartedAt))}`);
225
+ if (input.currentTool && input.currentToolStartedAt !== undefined) facts.push(`tool ${input.currentTool} ${formatDuration(Math.max(0, Date.now() - input.currentToolStartedAt))}`);
248
226
  else if (input.currentTool) facts.push(`tool ${input.currentTool}`);
249
227
  if (input.currentPath) facts.push(shortenPath(input.currentPath));
250
228
  if (input.turnCount !== undefined) facts.push(`${input.turnCount} turns`);
251
229
  if (input.toolCount !== undefined) facts.push(`${input.toolCount} tools`);
252
- if (!input.lastActivityAt) {
253
- if (input.activityState === "needs_attention") return ["needs attention", ...facts].join(" | ");
254
- if (input.activityState === "active_long_running") return ["active but long-running", ...facts].join(" | ");
255
- return facts.length ? facts.join(" | ") : undefined;
256
- }
257
- const elapsed = formatDuration(Math.max(0, Date.now() - input.lastActivityAt));
258
- if (input.activityState === "needs_attention") return [`no activity for ${elapsed}`, ...facts].join(" | ");
259
- if (input.activityState === "active_long_running") return [`active but long-running; last activity ${elapsed} ago`, ...facts].join(" | ");
260
- return [`active ${elapsed} ago`, ...facts].join(" | ");
230
+ const activity = formatActivityLabel(input.lastActivityAt, input.activityState);
231
+ return activity || facts.length ? [activity, ...facts].filter(Boolean).join(" | ") : undefined;
261
232
  }
262
233
 
263
234
  function formatStepLine(step: AsyncRunStepSummary): string {
@@ -270,16 +241,9 @@ function formatStepLine(step: AsyncRunStepSummary): string {
270
241
  return parts.join(" | ");
271
242
  }
272
243
 
273
- function formatParallelProgress(steps: Pick<AsyncRunStepSummary, "status">[], total: number, showRunning: boolean): string {
274
- const running = steps.filter((step) => step.status === "running").length;
275
- const done = steps.filter((step) => step.status === "complete" || step.status === "completed").length;
276
- const failed = steps.filter((step) => step.status === "failed").length;
277
- const paused = steps.filter((step) => step.status === "paused").length;
278
- const parts = [`${done}/${total} done`];
279
- if (showRunning && running > 0) parts.unshift(running === 1 ? "1 agent running" : `${running} agents running`);
280
- if (failed > 0) parts.push(`${failed} failed`);
281
- if (paused > 0) parts.push(`${paused} paused`);
282
- return parts.join(" · ");
244
+ export function formatAsyncRunOutputPath(run: Pick<AsyncRunSummary, "asyncDir" | "outputFile">): string | undefined {
245
+ if (!run.outputFile) return undefined;
246
+ return path.isAbsolute(run.outputFile) ? run.outputFile : path.join(run.asyncDir, run.outputFile);
283
247
  }
284
248
 
285
249
  export function formatAsyncRunProgressLabel(run: Pick<AsyncRunSummary, "mode" | "state" | "currentStep" | "chainStepCount" | "parallelGroups" | "steps">): string {
@@ -291,11 +255,11 @@ export function formatAsyncRunProgressLabel(run: Pick<AsyncRunSummary, "mode" |
291
255
  : undefined;
292
256
  if (activeGroup) {
293
257
  const groupSteps = run.steps.slice(activeGroup.start, activeGroup.start + activeGroup.count);
294
- const groupLabel = formatParallelProgress(groupSteps, activeGroup.count, run.state === "running");
258
+ const groupLabel = formatParallelOutcome(groupSteps, activeGroup.count, { showRunning: run.state === "running" });
295
259
  if (run.mode === "parallel") return groupLabel;
296
260
  return `step ${activeGroup.stepIndex + 1}/${chainStepCount} · parallel group: ${groupLabel}`;
297
261
  }
298
- if (run.mode === "parallel") return formatParallelProgress(run.steps, stepCount, run.state === "running");
262
+ if (run.mode === "parallel") return formatParallelOutcome(run.steps, stepCount, { showRunning: run.state === "running" });
299
263
  if (run.mode === "chain" && run.currentStep !== undefined && groups.length > 0) {
300
264
  const logicalStep = flatToLogicalStepIndex(run.currentStep, chainStepCount, groups);
301
265
  return `step ${logicalStep + 1}/${chainStepCount}`;
@@ -319,6 +283,8 @@ export function formatAsyncRunList(runs: AsyncRunSummary[], heading = "Active as
319
283
  for (const step of run.steps) {
320
284
  lines.push(` ${formatStepLine(step)}`);
321
285
  }
286
+ const outputPath = formatAsyncRunOutputPath(run);
287
+ if (outputPath) lines.push(` output: ${shortenPath(outputPath)}`);
322
288
  if (run.sessionFile) lines.push(` session: ${shortenPath(run.sessionFile)}`);
323
289
  lines.push("");
324
290
  }
@@ -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
  }),
@@ -1,7 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentToolResult } from "@mariozechner/pi-agent-core";
4
- import { formatAsyncRunList, formatAsyncRunProgressLabel, listAsyncRuns } from "./async-status.ts";
4
+ import { formatAsyncRunList, formatAsyncRunOutputPath, formatAsyncRunProgressLabel, listAsyncRuns } from "./async-status.ts";
5
+ import { formatActivityLabel } from "../../shared/status-format.ts";
5
6
  import { ASYNC_DIR, RESULTS_DIR, type AsyncStatus, type Details } from "../../shared/types.ts";
6
7
  import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
7
8
  import { resolveAsyncRunLocation } from "./async-resume.ts";
@@ -22,14 +23,24 @@ interface RunStatusDeps {
22
23
  now?: () => number;
23
24
  }
24
25
 
25
- function activityText(activityState: unknown, lastActivityAt: unknown): string | undefined {
26
- if (typeof lastActivityAt !== "number") return undefined;
27
- const seconds = Math.floor(Math.max(0, Date.now() - lastActivityAt) / 1000);
28
- return activityState === "needs_attention" ? `no activity for ${seconds}s` : `active ${seconds}s ago`;
26
+ function hasExistingSessionFile(value: unknown): value is string {
27
+ return typeof value === "string" && fs.existsSync(value);
29
28
  }
30
29
 
31
- function canShowRevive(stepCount: number, sessionFile: unknown): sessionFile is string {
32
- return stepCount === 1 && typeof sessionFile === "string" && fs.existsSync(sessionFile);
30
+ function formatResumeGuidance(runId: string | undefined, children: Array<{ agent?: unknown; sessionFile?: unknown }>, fallbackSessionFile?: unknown): string {
31
+ const knownChildren = children
32
+ .map((child, index) => ({ child, index }))
33
+ .filter(({ child }) => typeof child.agent === "string");
34
+ if (!runId || knownChildren.length === 0) return "Resume: unavailable; no child session file was persisted.";
35
+ const singleSessionFile = knownChildren[0]?.child.sessionFile ?? fallbackSessionFile;
36
+ if (children.length === 1 && knownChildren.length === 1 && hasExistingSessionFile(singleSessionFile)) {
37
+ return `Revive: subagent({ action: "resume", id: "${runId}", message: "..." })`;
38
+ }
39
+ const childWithSession = knownChildren.find(({ child }) => hasExistingSessionFile(child.sessionFile));
40
+ if (childWithSession) {
41
+ return `Revive child: subagent({ action: "resume", id: "${runId}", index: ${childWithSession.index}, message: "..." })`;
42
+ }
43
+ return "Resume: unavailable; no child session file was persisted.";
33
44
  }
34
45
 
35
46
  function stepLineLabel(status: AsyncStatus, index: number): string {
@@ -103,6 +114,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
103
114
  const logPath = path.join(asyncDir, `subagent-log-${effectiveRunId}.md`);
104
115
  const eventsPath = path.join(asyncDir, "events.jsonl");
105
116
  if (status) {
117
+ const outputPath = formatAsyncRunOutputPath({ asyncDir, outputFile: status.outputFile });
106
118
  const progressLabel = formatAsyncRunProgressLabel({
107
119
  mode: status.mode,
108
120
  state: status.state,
@@ -113,7 +125,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
113
125
  });
114
126
  const started = new Date(status.startedAt).toISOString();
115
127
  const updated = status.lastUpdate ? new Date(status.lastUpdate).toISOString() : "n/a";
116
- const statusActivityText = status.state === "running" ? activityText(status.activityState, status.lastActivityAt) : undefined;
128
+ const statusActivityText = status.state === "running" ? formatActivityLabel(status.lastActivityAt, status.activityState) : undefined;
117
129
 
118
130
  const lines = [
119
131
  `Run: ${status.runId}`,
@@ -124,27 +136,23 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
124
136
  `Started: ${started}`,
125
137
  `Updated: ${updated}`,
126
138
  `Dir: ${asyncDir}`,
139
+ outputPath ? `Output: ${outputPath}` : undefined,
127
140
  reconciliation.message ? `Diagnosis: ${reconciliation.message}` : undefined,
128
141
  reconciliation.resultPath && fs.existsSync(reconciliation.resultPath) ? `Result: ${reconciliation.resultPath}` : undefined,
129
142
  ].filter((line): line is string => Boolean(line));
130
143
  for (const [index, step] of (status.steps ?? []).entries()) {
131
- const stepActivityText = step.status === "running" ? activityText(step.activityState, step.lastActivityAt) : undefined;
144
+ const stepActivityText = step.status === "running" ? formatActivityLabel(step.lastActivityAt, step.activityState) : undefined;
132
145
  const errorText = step.error ? `, error: ${step.error}` : "";
133
146
  lines.push(`${stepLineLabel(status, index)}: ${step.agent} ${step.status}${stepActivityText ? `, ${stepActivityText}` : ""}${errorText}`);
147
+ const stepOutputPath = path.join(asyncDir, `output-${index}.log`);
148
+ if (stepOutputPath !== outputPath && fs.existsSync(stepOutputPath)) lines.push(` Output: ${stepOutputPath}`);
134
149
  if (step.status === "running") {
135
150
  lines.push(` Intercom target: ${resolveSubagentIntercomTarget(status.runId, step.agent, index)} (if registered)`);
136
151
  }
137
152
  }
138
153
  if (status.sessionFile) lines.push(`Session: ${status.sessionFile}`);
139
154
  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
- }
155
+ lines.push(formatResumeGuidance(status.runId, status.steps ?? [], status.sessionFile));
148
156
  }
149
157
  if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
150
158
  if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
@@ -156,18 +164,12 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
156
164
  if (resultPath) {
157
165
  try {
158
166
  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 }> };
167
+ 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
168
  const status = data.success ? "complete" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
161
169
  const runId = data.runId ?? data.id ?? resolvedId;
162
170
  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
- }
171
+ const children = Array.isArray(data.results) ? data.results : data.agent ? [{ agent: data.agent, sessionFile: data.sessionFile }] : [];
172
+ lines.push(formatResumeGuidance(runId, children, data.sessionFile));
171
173
  if (data.summary) lines.push("", data.summary);
172
174
  return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [] } };
173
175
  } 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,
@@ -7,7 +7,7 @@ import type { Message } from "@mariozechner/pi-ai";
7
7
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
8
8
  import { appendJsonl, getArtifactPaths } from "../../shared/artifacts.ts";
9
9
  import { getPiSpawnCommand } from "../shared/pi-spawn.ts";
10
- import { captureSingleOutputSnapshot, finalizeSingleOutput, formatSavedOutputReference, resolveSingleOutput } from "../shared/single-output.ts";
10
+ import { captureSingleOutputSnapshot, finalizeSingleOutput, formatSavedOutputReference, resolveSingleOutput, type SingleOutputSnapshot } from "../shared/single-output.ts";
11
11
  import {
12
12
  type ActivityState,
13
13
  type ArtifactConfig,
@@ -100,6 +100,7 @@ interface StepResult {
100
100
  error?: string;
101
101
  success: boolean;
102
102
  skipped?: boolean;
103
+ sessionFile?: string;
103
104
  intercomTarget?: string;
104
105
  model?: string;
105
106
  attemptedModels?: string[];
@@ -575,6 +576,7 @@ async function runSingleStep(
575
576
  modelAttempts?: ModelAttempt[];
576
577
  artifactPaths?: ArtifactPaths;
577
578
  interrupted?: boolean;
579
+ sessionFile?: string;
578
580
  intercomTarget?: string;
579
581
  completionGuardTriggered?: boolean;
580
582
  }> {
@@ -582,7 +584,6 @@ async function runSingleStep(
582
584
  const task = step.task.replace(placeholderRegex, () => ctx.previousOutput);
583
585
  const sessionEnabled = Boolean(step.sessionFile) || ctx.sessionEnabled;
584
586
  const sessionDir = step.sessionFile ? undefined : ctx.sessionDir;
585
- const outputSnapshot = captureSingleOutputSnapshot(step.outputPath);
586
587
 
587
588
  let artifactPaths: ArtifactPaths | undefined;
588
589
  if (ctx.artifactsDir && ctx.artifactConfig?.enabled !== false) {
@@ -604,10 +605,12 @@ async function runSingleStep(
604
605
  const attemptNotes: string[] = [];
605
606
  const eventsPath = path.join(path.dirname(ctx.outputFile), "events.jsonl");
606
607
  let finalResult: RunPiStreamingResult | undefined;
608
+ let finalOutputSnapshot: SingleOutputSnapshot | undefined;
607
609
  let completionGuardTriggeredFinal = false;
608
610
 
609
611
  for (let index = 0; index < candidates.length; index++) {
610
612
  const candidate = candidates[index];
613
+ const outputSnapshot = captureSingleOutputSnapshot(step.outputPath);
611
614
  const { args, env, tempDir } = buildPiArgs({
612
615
  baseArgs: ["--mode", "json", "-p"],
613
616
  task,
@@ -659,7 +662,9 @@ async function runSingleStep(
659
662
  ? 1
660
663
  : hiddenError?.hasError
661
664
  ? (hiddenError.exitCode ?? 1)
662
- : run.exitCode;
665
+ : run.error && run.exitCode === 0
666
+ ? 1
667
+ : run.exitCode;
663
668
  const error = completionGuardError
664
669
  ?? (hiddenError?.hasError
665
670
  ? hiddenError.details
@@ -676,6 +681,7 @@ async function runSingleStep(
676
681
  modelAttempts.push(attempt);
677
682
  if (candidate) attemptedModels.push(candidate);
678
683
  completionGuardTriggeredFinal = completionGuardTriggered;
684
+ finalOutputSnapshot = outputSnapshot;
679
685
  finalResult = { ...run, exitCode: effectiveExitCode, model: candidate ?? run.model, error };
680
686
  if (attempt.success || completionGuardTriggered) break;
681
687
  if (!isRetryableModelFailure(error) || index === candidates.length - 1) break;
@@ -684,7 +690,7 @@ async function runSingleStep(
684
690
 
685
691
  const rawOutput = finalResult?.finalOutput ?? "";
686
692
  const resolvedOutput = step.outputPath && finalResult?.exitCode === 0
687
- ? resolveSingleOutput(step.outputPath, rawOutput, outputSnapshot)
693
+ ? resolveSingleOutput(step.outputPath, rawOutput, finalOutputSnapshot)
688
694
  : { fullOutput: rawOutput };
689
695
  const output = resolvedOutput.fullOutput;
690
696
  const outputReference = resolvedOutput.savedPath ? formatSavedOutputReference(resolvedOutput.savedPath, output) : undefined;
@@ -731,6 +737,7 @@ async function runSingleStep(
731
737
  output: outputForSummary,
732
738
  exitCode: finalResult?.exitCode ?? 1,
733
739
  error: finalResult?.error,
740
+ sessionFile: step.sessionFile,
734
741
  intercomTarget: ctx.childIntercomTarget,
735
742
  model: finalResult?.model,
736
743
  attemptedModels: attemptedModels.length > 0 ? attemptedModels : undefined,
@@ -780,7 +787,7 @@ function markParallelGroupSetupFailure(input: {
780
787
  input.statusPayload.steps[flatTaskIndex].endedAt = input.failedAt;
781
788
  input.statusPayload.steps[flatTaskIndex].durationMs = 0;
782
789
  input.statusPayload.steps[flatTaskIndex].exitCode = 1;
783
- input.results.push({ agent: input.group.parallel[taskIndex].agent, output: input.setupError, success: false });
790
+ input.results.push({ agent: input.group.parallel[taskIndex].agent, output: input.setupError, success: false, sessionFile: input.group.parallel[taskIndex].sessionFile });
784
791
  }
785
792
  input.statusPayload.currentStep = input.groupStartFlatIndex;
786
793
  input.statusPayload.lastUpdate = input.failedAt;
@@ -916,6 +923,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
916
923
  steps: flatSteps.map((step) => ({
917
924
  agent: step.agent,
918
925
  status: "pending",
926
+ ...(step.sessionFile ? { sessionFile: step.sessionFile } : {}),
919
927
  skills: step.skills,
920
928
  model: step.model,
921
929
  attemptedModels: step.modelCandidates && step.modelCandidates.length > 0 ? step.modelCandidates : step.model ? [step.model] : undefined,
@@ -1409,6 +1417,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1409
1417
  error: pr.error,
1410
1418
  success: pr.exitCode === 0,
1411
1419
  skipped: pr.skipped,
1420
+ sessionFile: pr.sessionFile,
1412
1421
  intercomTarget: pr.intercomTarget,
1413
1422
  model: pr.model,
1414
1423
  attemptedModels: pr.attemptedModels,
@@ -1492,6 +1501,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1492
1501
  output: singleResult.output,
1493
1502
  error: singleResult.error,
1494
1503
  success: singleResult.exitCode === 0,
1504
+ sessionFile: singleResult.sessionFile,
1495
1505
  intercomTarget: singleResult.intercomTarget,
1496
1506
  model: singleResult.model,
1497
1507
  attemptedModels: singleResult.attemptedModels,
@@ -1580,9 +1590,12 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1580
1590
  }
1581
1591
  }
1582
1592
 
1593
+ const resultMode = config.resultMode ?? statusPayload.mode;
1583
1594
  const agentName = flatSteps.length === 1
1584
1595
  ? flatSteps[0].agent
1585
- : `chain:${flatSteps.map((s) => s.agent).join("->")}`;
1596
+ : resultMode === "parallel"
1597
+ ? `parallel:${flatSteps.map((s) => s.agent).join("+")}`
1598
+ : `chain:${flatSteps.map((s) => s.agent).join("->")}`;
1586
1599
  let sessionFile: string | undefined;
1587
1600
  let shareUrl: string | undefined;
1588
1601
  let gistUrl: string | undefined;
@@ -1667,7 +1680,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1667
1680
  writeAtomicJson(resultPath, {
1668
1681
  id,
1669
1682
  agent: agentName,
1670
- mode: config.resultMode ?? statusPayload.mode,
1683
+ mode: resultMode,
1671
1684
  success: !interrupted && results.every((r) => r.success),
1672
1685
  state: interrupted ? "paused" : results.every((r) => r.success) ? "complete" : "failed",
1673
1686
  summary: interrupted ? "Paused after interrupt. Waiting for explicit next action." : summary,
@@ -1677,6 +1690,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1677
1690
  error: r.error,
1678
1691
  success: r.success,
1679
1692
  skipped: r.skipped || undefined,
1693
+ sessionFile: r.sessionFile,
1680
1694
  intercomTarget: r.intercomTarget,
1681
1695
  model: r.model,
1682
1696
  attemptedModels: r.attemptedModels,