pi-subagents 0.22.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.
@@ -38,7 +38,7 @@ export type ManagerResult =
38
38
  | { action: "launch-chain"; chain: ChainConfig; task: string; skipClarify?: boolean; fork?: boolean; background?: boolean; worktree?: boolean }
39
39
  | undefined;
40
40
 
41
- export interface AgentData { builtin: AgentConfig[]; user: AgentConfig[]; project: AgentConfig[]; chains: ChainConfig[]; userDir: string; projectDir: string | null; userSettingsPath: string; projectSettingsPath: string | null; cwd: string; }
41
+ export interface AgentData { builtin: AgentConfig[]; user: AgentConfig[]; project: AgentConfig[]; chains: ChainConfig[]; userDir: string; projectDir: string | null; userChainDir: string; projectChainDir: string | null; userSettingsPath: string; projectSettingsPath: string | null; cwd: string; }
42
42
  type ManagerScreen = "list" | "detail" | "chain-detail" | "edit" | "edit-field" | "edit-prompt" | "task-input" | "confirm-delete" | "name-input" | "chain-edit" | "template-select" | "parallel-builder" | "override-scope";
43
43
  interface AgentEntry { id: string; kind: "agent"; config: AgentConfig; isNew: boolean; }
44
44
  interface ChainEntry { id: string; kind: "chain"; config: ChainConfig; }
@@ -253,7 +253,8 @@ export class AgentManagerComponent implements Component {
253
253
  }
254
254
 
255
255
  private enterNameInput(mode: NameInputState["mode"], sourceId?: string, template?: AgentTemplate): void {
256
- const allowProject = Boolean(this.agentData.projectDir); let initial = ""; let scope: "user" | "project" = "user";
256
+ const isChain = mode === "new-chain" || mode === "clone-chain";
257
+ const allowProject = Boolean(isChain ? this.agentData.projectChainDir : this.agentData.projectDir); let initial = ""; let scope: "user" | "project" = "user";
257
258
  if (mode === "clone-agent" && sourceId) { const entry = this.getAgentEntry(sourceId); if (entry) { initial = `${frontmatterNameForConfig(entry.config)}-copy`; scope = entry.config.source === "project" ? "project" : "user"; } }
258
259
  if (mode === "clone-chain" && sourceId) { const entry = this.getChainEntry(sourceId); if (entry) { initial = `${frontmatterNameForConfig(entry.config)}-copy`; scope = entry.config.source === "project" ? "project" : "user"; } }
259
260
  if (mode === "new-agent" && template && template.name !== "Blank") initial = slugTemplateName(template.name);
@@ -425,14 +426,14 @@ export class AgentManagerComponent implements Component {
425
426
 
426
427
  if (state.mode === "clone-chain" && state.sourceId) {
427
428
  const sourceEntry = this.getChainEntry(state.sourceId); if (!sourceEntry) { this.screen = "list"; this.tui.requestRender(); return; }
428
- const dir = state.scope === "project" ? this.agentData.projectDir : this.agentData.userDir;
429
- if (!dir) { state.error = "Project agents directory not found."; this.tui.requestRender(); return; }
429
+ const dir = state.scope === "project" ? this.agentData.projectChainDir : this.agentData.userChainDir;
430
+ if (!dir) { state.error = "Project chains directory not found."; this.tui.requestRender(); return; }
430
431
  const filePath = path.join(dir, `${name}.chain.md`); if (fs.existsSync(filePath) || this.runtimeNameExistsInScope("chain", state.scope, name)) { state.error = "A chain with that name already exists."; this.tui.requestRender(); return; }
431
432
  try { const cloned = cloneChainConfig({ ...sourceEntry.config, name, localName: name, packageName: undefined, source: state.scope, filePath }); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(filePath, serializeChain(cloned), "utf-8"); const added: ChainEntry = { id: `c${this.nextId++}`, kind: "chain", config: cloned }; this.chains.push(added); this.nameInputState = null; this.enterChainDetail(added); this.tui.requestRender(); return; }
432
433
  catch (err) { state.error = err instanceof Error ? err.message : "Failed to clone chain."; this.tui.requestRender(); return; }
433
434
  }
434
435
  if (state.mode === "new-chain") {
435
- const dir = state.scope === "project" ? this.agentData.projectDir : this.agentData.userDir;
436
+ const dir = state.scope === "project" ? this.agentData.projectChainDir : this.agentData.userChainDir;
436
437
  if (!dir) { state.error = "Directory not found."; this.tui.requestRender(); return; }
437
438
  const filePath = path.join(dir, `${name}.chain.md`); if (fs.existsSync(filePath) || this.runtimeNameExistsInScope("chain", state.scope, name)) { state.error = "A chain with that name already exists."; this.tui.requestRender(); return; }
438
439
  const config: ChainConfig = { name, localName: name, description: "Describe this chain", source: state.scope, filePath, steps: [{ agent: "agent-name", task: "{task}" }] };
@@ -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;
@@ -116,6 +117,16 @@ interface AsyncExecutionResult {
116
117
  isError?: boolean;
117
118
  }
118
119
 
120
+ export function formatAsyncStartedMessage(headline: string): string {
121
+ return [
122
+ headline,
123
+ "",
124
+ "The async run is detached. Do not run sleep timers or polling loops just to wait for it.",
125
+ "If you have independent work, continue that work. If you have nothing else to do until the async result arrives, end your turn now; Pi will deliver the completion when the run finishes.",
126
+ "Use subagent({ action: \"status\", id: \"...\" }) when you need the current status/result, or to inspect a blocked/stale run. Do not poll just to wait.",
127
+ ].join("\n");
128
+ }
129
+
119
130
  /**
120
131
  * Check if jiti is available for async execution
121
132
  */
@@ -203,6 +214,10 @@ export function executeAsyncChain(
203
214
  const chainSkills = params.chainSkills ?? [];
204
215
  const availableModels = params.availableModels;
205
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);
206
221
 
207
222
  for (const s of chain) {
208
223
  const stepAgents = isParallelStep(s)
@@ -247,7 +262,7 @@ export function executeAsyncChain(
247
262
  const a = agents.find((x) => x.name === s.agent)!;
248
263
  const stepCwd = resolveChildCwd(runnerCwd, s.cwd);
249
264
  const instructionCwd = behaviorCwd ?? stepCwd;
250
- const behavior = resolvedBehavior ?? resolveStepBehavior(a, buildStepOverrides(s), chainSkills);
265
+ const behavior = suppressProgressForReadOnlyTask(resolvedBehavior ?? resolveStepBehavior(a, buildStepOverrides(s), chainSkills), s.task, originalTask);
251
266
  const skillNames = behavior.skills === false ? [] : behavior.skills;
252
267
  const { resolved: resolvedSkills, missing: missingSkills } = resolveSkillsWithFallback(skillNames, stepCwd, ctx.cwd);
253
268
  if (missingSkills.includes("pi-subagents")) throw new UnavailableSubagentSkillError(UNAVAILABLE_SUBAGENT_SKILL_ERROR);
@@ -304,7 +319,7 @@ export function executeAsyncChain(
304
319
  if (isParallelStep(s)) {
305
320
  const parallelBehaviors = s.parallel.map((task) => {
306
321
  const agent = agents.find((candidate) => candidate.name === task.agent)!;
307
- return resolveStepBehavior(agent, buildStepOverrides(task), chainSkills);
322
+ return suppressProgressForReadOnlyTask(resolveStepBehavior(agent, buildStepOverrides(task), chainSkills), task.task, originalTask);
308
323
  });
309
324
  const progressPrecreated = parallelBehaviors.some((behavior) => behavior.progress);
310
325
  if (progressPrecreated) {
@@ -425,8 +440,8 @@ export function executeAsyncChain(
425
440
  .join(" -> ");
426
441
 
427
442
  return {
428
- content: [{ type: "text", text: `Async ${resultMode}: ${chainDesc} [${id}]` }],
429
- details: { mode: resultMode, results: [], asyncId: id, asyncDir },
443
+ content: [{ type: "text", text: formatAsyncStartedMessage(`Async ${resultMode}: ${chainDesc} [${id}]`) }],
444
+ details: { mode: resultMode, runId: id, results: [], asyncId: id, asyncDir },
430
445
  };
431
446
  }
432
447
 
@@ -557,7 +572,7 @@ export function executeAsyncSingle(
557
572
  }
558
573
 
559
574
  return {
560
- content: [{ type: "text", text: `Async: ${agent} [${id}]` }],
561
- details: { mode: "single", results: [], asyncId: id, asyncDir },
575
+ content: [{ type: "text", text: formatAsyncStartedMessage(`Async: ${agent} [${id}]`) }],
576
+ details: { mode: "single", runId: id, results: [], asyncId: id, asyncDir },
562
577
  };
563
578
  }
@@ -162,8 +162,8 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
162
162
  ? groups.find((group) => status.currentStep! >= group.start && status.currentStep! < group.start + group.count)
163
163
  : undefined;
164
164
  const visibleSteps = activeGroup
165
- ? status.steps.slice(activeGroup.start, activeGroup.start + activeGroup.count)
166
- : status.steps;
165
+ ? status.steps.slice(activeGroup.start, activeGroup.start + activeGroup.count).map((step, index) => ({ ...step, index: activeGroup.start + index }))
166
+ : status.steps.map((step, index) => ({ ...step, index }));
167
167
  job.activeParallelGroup = Boolean(activeGroup);
168
168
  job.agents = visibleSteps.map((step) => step.agent);
169
169
  job.steps = visibleSteps;
@@ -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
  "",
@@ -13,8 +13,11 @@ interface AsyncRunStepSummary {
13
13
  activityState?: ActivityState;
14
14
  lastActivityAt?: number;
15
15
  currentTool?: string;
16
+ currentToolArgs?: string;
16
17
  currentToolStartedAt?: number;
17
18
  currentPath?: string;
19
+ recentTools?: Array<{ tool: string; args: string; endMs: number }>;
20
+ recentOutput?: string[];
18
21
  turnCount?: number;
19
22
  toolCount?: number;
20
23
  durationMs?: number;
@@ -155,8 +158,11 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
155
158
  ...(stepActivityState ? { activityState: stepActivityState } : {}),
156
159
  ...(stepLastActivityAt ? { lastActivityAt: stepLastActivityAt } : {}),
157
160
  ...(step.currentTool ? { currentTool: step.currentTool } : {}),
161
+ ...(step.currentToolArgs ? { currentToolArgs: step.currentToolArgs } : {}),
158
162
  ...(step.currentToolStartedAt ? { currentToolStartedAt: step.currentToolStartedAt } : {}),
159
163
  ...(step.currentPath ? { currentPath: step.currentPath } : {}),
164
+ ...(step.recentTools ? { recentTools: step.recentTools.map((tool) => ({ ...tool })) } : {}),
165
+ ...(step.recentOutput ? { recentOutput: [...step.recentOutput] } : {}),
160
166
  ...(step.turnCount !== undefined ? { turnCount: step.turnCount } : {}),
161
167
  ...(step.toolCount !== undefined ? { toolCount: step.toolCount } : {}),
162
168
  ...(step.durationMs !== undefined ? { durationMs: step.durationMs } : {}),
@@ -270,7 +276,7 @@ function formatParallelProgress(steps: Pick<AsyncRunStepSummary, "status">[], to
270
276
  const failed = steps.filter((step) => step.status === "failed").length;
271
277
  const paused = steps.filter((step) => step.status === "paused").length;
272
278
  const parts = [`${done}/${total} done`];
273
- if (showRunning) parts.unshift(running === 1 ? "1 agent running" : `${running} agents running`);
279
+ if (showRunning && running > 0) parts.unshift(running === 1 ? "1 agent running" : `${running} agents running`);
274
280
  if (failed > 0) parts.push(`${failed} failed`);
275
281
  if (paused > 0) parts.push(`${paused} paused`);
276
282
  return parts.join(" · ");
@@ -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,
@@ -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[];
@@ -142,6 +143,25 @@ function tokenUsageFromAttempts(attempts: ModelAttempt[] | undefined): TokenUsag
142
143
  return total > 0 ? { input, output, total } : null;
143
144
  }
144
145
 
146
+ function appendRecentStepOutput(step: RunnerStatusStep, lines: string[]): void {
147
+ const nonEmpty = lines.filter((line) => line.trim());
148
+ if (nonEmpty.length === 0) return;
149
+ step.recentOutput ??= [];
150
+ step.recentOutput.push(...nonEmpty);
151
+ if (step.recentOutput.length > 50) {
152
+ step.recentOutput.splice(0, step.recentOutput.length - 50);
153
+ }
154
+ }
155
+
156
+ function resetStepLiveDetail(step: RunnerStatusStep): void {
157
+ step.currentTool = undefined;
158
+ step.currentToolArgs = undefined;
159
+ step.currentToolStartedAt = undefined;
160
+ step.currentPath = undefined;
161
+ step.recentTools = [];
162
+ step.recentOutput = [];
163
+ }
164
+
145
165
  interface ChildEventContext {
146
166
  eventsPath: string;
147
167
  runId: string;
@@ -556,6 +576,7 @@ async function runSingleStep(
556
576
  modelAttempts?: ModelAttempt[];
557
577
  artifactPaths?: ArtifactPaths;
558
578
  interrupted?: boolean;
579
+ sessionFile?: string;
559
580
  intercomTarget?: string;
560
581
  completionGuardTriggered?: boolean;
561
582
  }> {
@@ -563,7 +584,6 @@ async function runSingleStep(
563
584
  const task = step.task.replace(placeholderRegex, () => ctx.previousOutput);
564
585
  const sessionEnabled = Boolean(step.sessionFile) || ctx.sessionEnabled;
565
586
  const sessionDir = step.sessionFile ? undefined : ctx.sessionDir;
566
- const outputSnapshot = captureSingleOutputSnapshot(step.outputPath);
567
587
 
568
588
  let artifactPaths: ArtifactPaths | undefined;
569
589
  if (ctx.artifactsDir && ctx.artifactConfig?.enabled !== false) {
@@ -585,10 +605,12 @@ async function runSingleStep(
585
605
  const attemptNotes: string[] = [];
586
606
  const eventsPath = path.join(path.dirname(ctx.outputFile), "events.jsonl");
587
607
  let finalResult: RunPiStreamingResult | undefined;
608
+ let finalOutputSnapshot: SingleOutputSnapshot | undefined;
588
609
  let completionGuardTriggeredFinal = false;
589
610
 
590
611
  for (let index = 0; index < candidates.length; index++) {
591
612
  const candidate = candidates[index];
613
+ const outputSnapshot = captureSingleOutputSnapshot(step.outputPath);
592
614
  const { args, env, tempDir } = buildPiArgs({
593
615
  baseArgs: ["--mode", "json", "-p"],
594
616
  task,
@@ -640,7 +662,9 @@ async function runSingleStep(
640
662
  ? 1
641
663
  : hiddenError?.hasError
642
664
  ? (hiddenError.exitCode ?? 1)
643
- : run.exitCode;
665
+ : run.error && run.exitCode === 0
666
+ ? 1
667
+ : run.exitCode;
644
668
  const error = completionGuardError
645
669
  ?? (hiddenError?.hasError
646
670
  ? hiddenError.details
@@ -657,6 +681,7 @@ async function runSingleStep(
657
681
  modelAttempts.push(attempt);
658
682
  if (candidate) attemptedModels.push(candidate);
659
683
  completionGuardTriggeredFinal = completionGuardTriggered;
684
+ finalOutputSnapshot = outputSnapshot;
660
685
  finalResult = { ...run, exitCode: effectiveExitCode, model: candidate ?? run.model, error };
661
686
  if (attempt.success || completionGuardTriggered) break;
662
687
  if (!isRetryableModelFailure(error) || index === candidates.length - 1) break;
@@ -665,7 +690,7 @@ async function runSingleStep(
665
690
 
666
691
  const rawOutput = finalResult?.finalOutput ?? "";
667
692
  const resolvedOutput = step.outputPath && finalResult?.exitCode === 0
668
- ? resolveSingleOutput(step.outputPath, rawOutput, outputSnapshot)
693
+ ? resolveSingleOutput(step.outputPath, rawOutput, finalOutputSnapshot)
669
694
  : { fullOutput: rawOutput };
670
695
  const output = resolvedOutput.fullOutput;
671
696
  const outputReference = resolvedOutput.savedPath ? formatSavedOutputReference(resolvedOutput.savedPath, output) : undefined;
@@ -712,6 +737,7 @@ async function runSingleStep(
712
737
  output: outputForSummary,
713
738
  exitCode: finalResult?.exitCode ?? 1,
714
739
  error: finalResult?.error,
740
+ sessionFile: step.sessionFile,
715
741
  intercomTarget: ctx.childIntercomTarget,
716
742
  model: finalResult?.model,
717
743
  attemptedModels: attemptedModels.length > 0 ? attemptedModels : undefined,
@@ -761,7 +787,7 @@ function markParallelGroupSetupFailure(input: {
761
787
  input.statusPayload.steps[flatTaskIndex].endedAt = input.failedAt;
762
788
  input.statusPayload.steps[flatTaskIndex].durationMs = 0;
763
789
  input.statusPayload.steps[flatTaskIndex].exitCode = 1;
764
- 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 });
765
791
  }
766
792
  input.statusPayload.currentStep = input.groupStartFlatIndex;
767
793
  input.statusPayload.lastUpdate = input.failedAt;
@@ -897,9 +923,12 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
897
923
  steps: flatSteps.map((step) => ({
898
924
  agent: step.agent,
899
925
  status: "pending",
926
+ ...(step.sessionFile ? { sessionFile: step.sessionFile } : {}),
900
927
  skills: step.skills,
901
928
  model: step.model,
902
929
  attemptedModels: step.modelCandidates && step.modelCandidates.length > 0 ? step.modelCandidates : step.model ? [step.model] : undefined,
930
+ recentTools: [],
931
+ recentOutput: [],
903
932
  })),
904
933
  artifactsDir,
905
934
  sessionDir: config.sessionDir,
@@ -1002,13 +1031,19 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1002
1031
  const currentPath = resolveCurrentPath(event.toolName, event.args);
1003
1032
  step.toolCount = (step.toolCount ?? 0) + 1;
1004
1033
  step.currentTool = event.toolName;
1034
+ step.currentToolArgs = extractToolArgsPreview(event.args ?? {});
1005
1035
  step.currentToolStartedAt = now;
1006
1036
  step.currentPath = currentPath;
1007
1037
  pendingToolResults[flatIndex] = { tool: event.toolName, path: currentPath, mutates, startedAt: now };
1008
1038
  statusPayload.toolCount = (statusPayload.toolCount ?? 0) + 1;
1009
1039
  syncTopLevelCurrentTool();
1010
1040
  } else if (event.type === "tool_execution_end") {
1041
+ if (step.currentTool) {
1042
+ step.recentTools ??= [];
1043
+ step.recentTools.push({ tool: step.currentTool, args: step.currentToolArgs || "", endMs: now });
1044
+ }
1011
1045
  step.currentTool = undefined;
1046
+ step.currentToolArgs = undefined;
1012
1047
  step.currentToolStartedAt = undefined;
1013
1048
  step.currentPath = undefined;
1014
1049
  syncTopLevelCurrentTool();
@@ -1016,6 +1051,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1016
1051
  const toolSnapshot = pendingToolResults[flatIndex];
1017
1052
  pendingToolResults[flatIndex] = undefined;
1018
1053
  const resultText = extractTextFromContent(event.message.content);
1054
+ appendRecentStepOutput(step, resultText.split("\n").slice(-10));
1019
1055
  if (toolSnapshot?.mutates && didMutatingToolFail(resultText)) {
1020
1056
  const state = mutatingFailureStates[flatIndex]!;
1021
1057
  recordMutatingFailure(state, {
@@ -1051,6 +1087,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1051
1087
  resetMutatingFailureState(mutatingFailureStates[flatIndex]!);
1052
1088
  }
1053
1089
  } else if (event.type === "message_end" && event.message?.role === "assistant") {
1090
+ appendRecentStepOutput(step, extractTextFromContent(event.message.content).split("\n").slice(-10));
1054
1091
  step.turnCount = (step.turnCount ?? 0) + 1;
1055
1092
  const usage = event.message.usage;
1056
1093
  if (usage) {
@@ -1277,6 +1314,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1277
1314
  statusPayload.steps[fi].status = "running";
1278
1315
  statusPayload.steps[fi].error = undefined;
1279
1316
  statusPayload.steps[fi].activityState = undefined;
1317
+ resetStepLiveDetail(statusPayload.steps[fi]);
1280
1318
  statusPayload.steps[fi].startedAt = taskStartTime;
1281
1319
  statusPayload.steps[fi].endedAt = undefined;
1282
1320
  statusPayload.steps[fi].durationMs = undefined;
@@ -1379,6 +1417,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1379
1417
  error: pr.error,
1380
1418
  success: pr.exitCode === 0,
1381
1419
  skipped: pr.skipped,
1420
+ sessionFile: pr.sessionFile,
1382
1421
  intercomTarget: pr.intercomTarget,
1383
1422
  model: pr.model,
1384
1423
  attemptedModels: pr.attemptedModels,
@@ -1420,6 +1459,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1420
1459
  statusPayload.steps[flatIndex].status = "running";
1421
1460
  statusPayload.steps[flatIndex].activityState = undefined;
1422
1461
  statusPayload.activityState = undefined;
1462
+ resetStepLiveDetail(statusPayload.steps[flatIndex]);
1423
1463
  statusPayload.steps[flatIndex].skills = seqStep.skills;
1424
1464
  statusPayload.steps[flatIndex].startedAt = stepStartTime;
1425
1465
  statusPayload.steps[flatIndex].lastActivityAt = stepStartTime;
@@ -1461,6 +1501,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1461
1501
  output: singleResult.output,
1462
1502
  error: singleResult.error,
1463
1503
  success: singleResult.exitCode === 0,
1504
+ sessionFile: singleResult.sessionFile,
1464
1505
  intercomTarget: singleResult.intercomTarget,
1465
1506
  model: singleResult.model,
1466
1507
  attemptedModels: singleResult.attemptedModels,
@@ -1549,9 +1590,12 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1549
1590
  }
1550
1591
  }
1551
1592
 
1593
+ const resultMode = config.resultMode ?? statusPayload.mode;
1552
1594
  const agentName = flatSteps.length === 1
1553
1595
  ? flatSteps[0].agent
1554
- : `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("->")}`;
1555
1599
  let sessionFile: string | undefined;
1556
1600
  let shareUrl: string | undefined;
1557
1601
  let gistUrl: string | undefined;
@@ -1636,7 +1680,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1636
1680
  writeAtomicJson(resultPath, {
1637
1681
  id,
1638
1682
  agent: agentName,
1639
- mode: config.resultMode ?? statusPayload.mode,
1683
+ mode: resultMode,
1640
1684
  success: !interrupted && results.every((r) => r.success),
1641
1685
  state: interrupted ? "paused" : results.every((r) => r.success) ? "complete" : "failed",
1642
1686
  summary: interrupted ? "Paused after interrupt. Waiting for explicit next action." : summary,
@@ -1646,6 +1690,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1646
1690
  error: r.error,
1647
1691
  success: r.success,
1648
1692
  skipped: r.skipped || undefined,
1693
+ sessionFile: r.sessionFile,
1649
1694
  intercomTarget: r.intercomTarget,
1650
1695
  model: r.model,
1651
1696
  attemptedModels: r.attemptedModels,