pi-subagents 0.24.4 → 0.27.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 (48) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +145 -27
  3. package/package.json +1 -1
  4. package/prompts/parallel-context-build.md +3 -1
  5. package/prompts/parallel-handoff-plan.md +3 -1
  6. package/prompts/review-loop.md +1 -1
  7. package/skills/pi-subagents/SKILL.md +71 -20
  8. package/src/agents/agent-management.ts +57 -15
  9. package/src/agents/agent-serializer.ts +3 -2
  10. package/src/agents/agents.ts +47 -16
  11. package/src/agents/chain-serializer.ts +120 -0
  12. package/src/extension/fanout-child.ts +171 -0
  13. package/src/extension/index.ts +7 -2
  14. package/src/extension/schemas.ts +138 -5
  15. package/src/intercom/result-intercom.ts +108 -0
  16. package/src/runs/background/async-execution.ts +185 -10
  17. package/src/runs/background/async-job-tracker.ts +41 -6
  18. package/src/runs/background/async-resume.ts +28 -15
  19. package/src/runs/background/async-status.ts +71 -31
  20. package/src/runs/background/result-watcher.ts +111 -54
  21. package/src/runs/background/run-id-resolver.ts +83 -0
  22. package/src/runs/background/run-status.ts +89 -4
  23. package/src/runs/background/stale-run-reconciler.ts +46 -1
  24. package/src/runs/background/subagent-runner.ts +648 -42
  25. package/src/runs/foreground/chain-execution.ts +331 -118
  26. package/src/runs/foreground/execution.ts +226 -10
  27. package/src/runs/foreground/subagent-executor.ts +377 -14
  28. package/src/runs/shared/acceptance-contract.ts +291 -0
  29. package/src/runs/shared/acceptance-evaluation.ts +221 -0
  30. package/src/runs/shared/acceptance-finalization.ts +161 -0
  31. package/src/runs/shared/acceptance-reports.ts +127 -0
  32. package/src/runs/shared/acceptance.ts +22 -0
  33. package/src/runs/shared/chain-outputs.ts +101 -0
  34. package/src/runs/shared/completion-guard.ts +26 -3
  35. package/src/runs/shared/dynamic-fanout.ts +293 -0
  36. package/src/runs/shared/nested-events.ts +819 -0
  37. package/src/runs/shared/nested-path.ts +52 -0
  38. package/src/runs/shared/nested-render.ts +115 -0
  39. package/src/runs/shared/parallel-utils.ts +31 -1
  40. package/src/runs/shared/pi-args.ts +73 -5
  41. package/src/runs/shared/structured-output.ts +77 -0
  42. package/src/runs/shared/subagent-prompt-runtime.ts +77 -7
  43. package/src/runs/shared/workflow-graph.ts +206 -0
  44. package/src/shared/formatters.ts +2 -2
  45. package/src/shared/settings.ts +53 -4
  46. package/src/shared/types.ts +345 -0
  47. package/src/slash/slash-commands.ts +41 -3
  48. package/src/tui/render.ts +268 -43
@@ -2,14 +2,20 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { formatDuration, formatModelThinking, formatTokens, shortenPath } from "../../shared/formatters.ts";
4
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
+ import { type ActivityState, type AsyncJobStep, type AsyncParallelGroupStatus, type AsyncStatus, type NestedRunSummary, type SubagentRunMode, type TokenUsage } from "../../shared/types.ts";
6
6
  import { readStatus } from "../../shared/utils.ts";
7
+ import { attachRootChildrenToSteps, findNestedRouteForRootId, projectNestedRegistryForRoot } from "../shared/nested-events.ts";
8
+ import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
7
9
  import { flatToLogicalStepIndex, normalizeParallelGroups } from "./parallel-groups.ts";
8
- import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
10
+ import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
9
11
 
10
12
  interface AsyncRunStepSummary {
11
13
  index: number;
12
14
  agent: string;
15
+ label?: string;
16
+ phase?: string;
17
+ outputName?: string;
18
+ structured?: boolean;
13
19
  status: AsyncJobStep["status"];
14
20
  activityState?: ActivityState;
15
21
  lastActivityAt?: number;
@@ -28,6 +34,7 @@ interface AsyncRunStepSummary {
28
34
  thinking?: string;
29
35
  attemptedModels?: string[];
30
36
  error?: string;
37
+ children?: NestedRunSummary[];
31
38
  }
32
39
 
33
40
  export interface AsyncRunSummary {
@@ -55,6 +62,8 @@ export interface AsyncRunSummary {
55
62
  outputFile?: string;
56
63
  totalTokens?: TokenUsage;
57
64
  sessionFile?: string;
65
+ nestedChildren?: NestedRunSummary[];
66
+ nestedWarnings?: string[];
58
67
  }
59
68
 
60
69
  interface AsyncRunListOptions {
@@ -112,7 +121,7 @@ function deriveAsyncActivityState(asyncDir: string, status: AsyncStatus): { acti
112
121
  };
113
122
  }
114
123
 
115
- function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string }): AsyncRunSummary {
124
+ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string }, nestedWarnings: string[] = []): AsyncRunSummary {
116
125
  if (status.sessionId !== undefined && typeof status.sessionId !== "string") {
117
126
  throw new Error(`Invalid async status '${path.join(asyncDir, "status.json")}': sessionId must be a string.`);
118
127
  }
@@ -120,6 +129,46 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
120
129
  const steps = status.steps ?? [];
121
130
  const chainStepCount = status.chainStepCount ?? steps.length;
122
131
  const parallelGroups = normalizeParallelGroups(status.parallelGroups, steps.length, chainStepCount);
132
+ let nestedChildren: NestedRunSummary[] = [];
133
+ if (nestedWarnings.length === 0) {
134
+ try {
135
+ nestedChildren = projectNestedRegistryForRoot(status.runId || path.basename(asyncDir))?.children ?? [];
136
+ } catch (error) {
137
+ nestedWarnings.push(`Nested status unavailable: ${getErrorMessage(error)}`);
138
+ }
139
+ }
140
+ const summarizedSteps = steps.map((step, index) => {
141
+ const stepActivityState = step.activityState;
142
+ const stepLastActivityAt = step.lastActivityAt;
143
+ return {
144
+ index,
145
+ agent: step.agent,
146
+ ...(step.label ? { label: step.label } : {}),
147
+ ...(step.phase ? { phase: step.phase } : {}),
148
+ ...(step.outputName ? { outputName: step.outputName } : {}),
149
+ ...(step.structured ? { structured: step.structured } : {}),
150
+ status: step.status,
151
+ ...(stepActivityState ? { activityState: stepActivityState } : {}),
152
+ ...(stepLastActivityAt ? { lastActivityAt: stepLastActivityAt } : {}),
153
+ ...(step.currentTool ? { currentTool: step.currentTool } : {}),
154
+ ...(step.currentToolArgs ? { currentToolArgs: step.currentToolArgs } : {}),
155
+ ...(step.currentToolStartedAt ? { currentToolStartedAt: step.currentToolStartedAt } : {}),
156
+ ...(step.currentPath ? { currentPath: step.currentPath } : {}),
157
+ ...(step.recentTools ? { recentTools: step.recentTools.map((tool) => ({ ...tool })) } : {}),
158
+ ...(step.recentOutput ? { recentOutput: [...step.recentOutput] } : {}),
159
+ ...(step.turnCount !== undefined ? { turnCount: step.turnCount } : {}),
160
+ ...(step.toolCount !== undefined ? { toolCount: step.toolCount } : {}),
161
+ ...(step.durationMs !== undefined ? { durationMs: step.durationMs } : {}),
162
+ ...(step.tokens ? { tokens: step.tokens } : {}),
163
+ ...(step.skills ? { skills: step.skills } : {}),
164
+ ...(step.model ? { model: step.model } : {}),
165
+ ...(step.thinking ? { thinking: step.thinking } : {}),
166
+ ...(step.attemptedModels ? { attemptedModels: step.attemptedModels } : {}),
167
+ ...(step.error ? { error: step.error } : {}),
168
+ ...(step.children?.length ? { children: step.children } : {}),
169
+ };
170
+ });
171
+ attachRootChildrenToSteps(status.runId || path.basename(asyncDir), summarizedSteps, nestedChildren);
123
172
  return {
124
173
  id: status.runId || path.basename(asyncDir),
125
174
  asyncDir,
@@ -140,32 +189,9 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
140
189
  currentStep: status.currentStep,
141
190
  ...(status.chainStepCount !== undefined ? { chainStepCount: status.chainStepCount } : {}),
142
191
  ...(parallelGroups.length ? { parallelGroups } : {}),
143
- steps: steps.map((step, index) => {
144
- const stepActivityState = step.activityState;
145
- const stepLastActivityAt = step.lastActivityAt;
146
- return {
147
- index,
148
- agent: step.agent,
149
- status: step.status,
150
- ...(stepActivityState ? { activityState: stepActivityState } : {}),
151
- ...(stepLastActivityAt ? { lastActivityAt: stepLastActivityAt } : {}),
152
- ...(step.currentTool ? { currentTool: step.currentTool } : {}),
153
- ...(step.currentToolArgs ? { currentToolArgs: step.currentToolArgs } : {}),
154
- ...(step.currentToolStartedAt ? { currentToolStartedAt: step.currentToolStartedAt } : {}),
155
- ...(step.currentPath ? { currentPath: step.currentPath } : {}),
156
- ...(step.recentTools ? { recentTools: step.recentTools.map((tool) => ({ ...tool })) } : {}),
157
- ...(step.recentOutput ? { recentOutput: [...step.recentOutput] } : {}),
158
- ...(step.turnCount !== undefined ? { turnCount: step.turnCount } : {}),
159
- ...(step.toolCount !== undefined ? { toolCount: step.toolCount } : {}),
160
- ...(step.durationMs !== undefined ? { durationMs: step.durationMs } : {}),
161
- ...(step.tokens ? { tokens: step.tokens } : {}),
162
- ...(step.skills ? { skills: step.skills } : {}),
163
- ...(step.model ? { model: step.model } : {}),
164
- ...(step.thinking ? { thinking: step.thinking } : {}),
165
- ...(step.attemptedModels ? { attemptedModels: step.attemptedModels } : {}),
166
- ...(step.error ? { error: step.error } : {}),
167
- };
168
- }),
192
+ steps: summarizedSteps,
193
+ ...(nestedChildren.length ? { nestedChildren } : {}),
194
+ ...(nestedWarnings.length ? { nestedWarnings } : {}),
169
195
  ...(status.sessionDir ? { sessionDir: status.sessionDir } : {}),
170
196
  ...(status.outputFile ? { outputFile: status.outputFile } : {}),
171
197
  ...(status.totalTokens ? { totalTokens: status.totalTokens } : {}),
@@ -212,7 +238,14 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
212
238
  : reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
213
239
  const status = (reconciliation?.status ?? readStatus(asyncDir)) as (AsyncStatus & { cwd?: string }) | null;
214
240
  if (!status) continue;
215
- const summary = statusToSummary(asyncDir, status);
241
+ const nestedWarnings: string[] = [];
242
+ try {
243
+ const nestedRoute = findNestedRouteForRootId(status.runId || path.basename(asyncDir));
244
+ if (nestedRoute) reconcileNestedAsyncDescendants(nestedRoute, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
245
+ } catch (error) {
246
+ nestedWarnings.push(`Nested status unavailable: ${getErrorMessage(error)}`);
247
+ }
248
+ const summary = statusToSummary(asyncDir, status, nestedWarnings);
216
249
  if (allowedStates && !allowedStates.has(summary.state)) continue;
217
250
  if (options.sessionId && summary.sessionId !== options.sessionId) continue;
218
251
  runs.push(summary);
@@ -234,7 +267,9 @@ function formatActivityFacts(input: { activityState?: ActivityState; lastActivit
234
267
  }
235
268
 
236
269
  function formatStepLine(step: AsyncRunStepSummary): string {
237
- const parts = [`${step.index + 1}. ${step.agent}`, step.status];
270
+ const display = step.label ? `${step.label} (${step.agent})` : step.agent;
271
+ const phase = step.phase ? `[${step.phase}] ` : "";
272
+ const parts = [`${step.index + 1}. ${phase}${display}`, step.status];
238
273
  const activity = formatActivityFacts(step);
239
274
  if (activity) parts.push(activity);
240
275
  const modelThinking = formatModelThinking(step.model, step.thinking);
@@ -285,7 +320,12 @@ export function formatAsyncRunList(runs: AsyncRunSummary[], heading = "Active as
285
320
  lines.push(`- ${formatRunHeader(run)}`);
286
321
  for (const step of run.steps) {
287
322
  lines.push(` ${formatStepLine(step)}`);
323
+ lines.push(...formatNestedRunStatusLines(step.children, { indent: " ", maxLines: 12 }));
288
324
  }
325
+ const attached = new Set(run.steps.flatMap((step) => step.children?.map((child) => child.id) ?? []));
326
+ const unattached = run.nestedChildren?.filter((child) => !attached.has(child.id)) ?? [];
327
+ lines.push(...formatNestedRunStatusLines(unattached, { indent: " ", maxLines: 12 }));
328
+ for (const warning of run.nestedWarnings ?? []) lines.push(` Warning: ${warning}`);
289
329
  const outputPath = formatAsyncRunOutputPath(run);
290
330
  if (outputPath) lines.push(` output: ${shortenPath(outputPath)}`);
291
331
  if (run.sessionFile) lines.push(` session: ${shortenPath(run.sessionFile)}`);
@@ -5,13 +5,18 @@ import { createFileCoalescer } from "../../shared/file-coalescer.ts";
5
5
  import {
6
6
  SUBAGENT_ASYNC_COMPLETE_EVENT,
7
7
  type IntercomEventBus,
8
+ type NestedRunSummary,
9
+ type SubagentResultIntercomChild,
8
10
  type SubagentState,
9
11
  } from "../../shared/types.ts";
10
12
  import {
13
+ attachNestedChildrenToResultChildren,
11
14
  buildSubagentResultIntercomPayload,
15
+ compactNestedResultChildren,
12
16
  deliverSubagentResultIntercomEvent,
13
17
  resolveSubagentResultStatus,
14
18
  } from "../../intercom/result-intercom.ts";
19
+ import { projectNestedRegistryForRoot, sanitizeSummary } from "../shared/nested-events.ts";
15
20
 
16
21
  const WATCHER_RESTART_DELAY_MS = 3000;
17
22
  const POLL_INTERVAL_MS = 3000;
@@ -30,6 +35,47 @@ type ResultWatcherDeps = {
30
35
  timers?: ResultWatcherTimers;
31
36
  };
32
37
 
38
+ type ResultFileChild = {
39
+ agent?: string;
40
+ output?: string;
41
+ error?: string;
42
+ success?: boolean;
43
+ sessionFile?: string;
44
+ artifactPaths?: { outputPath?: string };
45
+ intercomTarget?: string;
46
+ children?: unknown;
47
+ };
48
+
49
+ type ResultFileData = {
50
+ id?: string;
51
+ runId?: string;
52
+ agent?: string;
53
+ success?: boolean;
54
+ state?: string;
55
+ mode?: string;
56
+ summary?: string;
57
+ results?: ResultFileChild[];
58
+ nestedChildren?: unknown;
59
+ sessionId?: string;
60
+ cwd?: string;
61
+ sessionFile?: string;
62
+ asyncDir?: string;
63
+ intercomTarget?: string;
64
+ };
65
+
66
+ function sanitizeNestedResultChildren(value: unknown, resultPath: string, label: string): NestedRunSummary[] | undefined {
67
+ if (value === undefined) return undefined;
68
+ if (!Array.isArray(value)) {
69
+ console.error(`Ignoring invalid nested children in subagent result file '${resultPath}' at ${label}: expected an array.`);
70
+ return undefined;
71
+ }
72
+ const children = value.map((child) => sanitizeSummary(child)).filter((child): child is NestedRunSummary => Boolean(child));
73
+ if (children.length !== value.length) {
74
+ console.error(`Ignoring ${value.length - children.length} invalid nested child record(s) in subagent result file '${resultPath}' at ${label}.`);
75
+ }
76
+ return children.length ? children : undefined;
77
+ }
78
+
33
79
  function getErrorCode(error: unknown): string | undefined {
34
80
  return typeof error === "object" && error !== null && "code" in error
35
81
  ? (error as NodeJS.ErrnoException).code
@@ -63,32 +109,21 @@ export function createResultWatcher(
63
109
  const resultPath = path.join(resultsDir, file);
64
110
  if (!fsApi.existsSync(resultPath)) return;
65
111
  try {
66
- const data = JSON.parse(fsApi.readFileSync(resultPath, "utf-8")) as {
67
- id?: string;
68
- runId?: string;
69
- agent?: string;
70
- success?: boolean;
71
- state?: string;
72
- mode?: string;
73
- summary?: string;
74
- results?: Array<{
75
- agent?: string;
76
- output?: string;
77
- error?: string;
78
- success?: boolean;
79
- sessionFile?: string;
80
- artifactPaths?: { outputPath?: string };
81
- intercomTarget?: string;
82
- }>;
83
- sessionId?: string;
84
- cwd?: string;
85
- sessionFile?: string;
86
- asyncDir?: string;
87
- intercomTarget?: string;
88
- };
112
+ const data = JSON.parse(fsApi.readFileSync(resultPath, "utf-8")) as ResultFileData;
89
113
  if (data.sessionId && data.sessionId !== state.currentSessionId) return;
90
114
  if (!data.sessionId && data.cwd && (!state.baseCwd || data.cwd !== state.baseCwd)) return;
91
115
 
116
+ const runId = data.runId ?? data.id ?? file.replace(/\.json$/i, "");
117
+ const hasExplicitNestedChildren = data.nestedChildren !== undefined;
118
+ let nestedChildren = compactNestedResultChildren(sanitizeNestedResultChildren(data.nestedChildren, resultPath, "nestedChildren"));
119
+ if (!nestedChildren?.length && !hasExplicitNestedChildren) {
120
+ try {
121
+ nestedChildren = compactNestedResultChildren(projectNestedRegistryForRoot(runId)?.children);
122
+ } catch (error) {
123
+ console.error(`Failed to enrich subagent result file '${resultPath}' with nested registry children; will retry later:`, error);
124
+ return;
125
+ }
126
+ }
92
127
  const now = Date.now();
93
128
  const completionKey = buildCompletionKey(data, `result:${file}`);
94
129
  if (markSeenWithTtl(state.completionSeen, completionKey, now, completionTtlMs)) {
@@ -96,45 +131,49 @@ export function createResultWatcher(
96
131
  return;
97
132
  }
98
133
 
134
+ const hasResultChildren = Array.isArray(data.results) && data.results.length > 0;
135
+ const resultChildren = hasResultChildren
136
+ ? data.results!
137
+ : [{
138
+ agent: data.agent,
139
+ output: data.summary,
140
+ success: data.success,
141
+ }];
142
+ const normalizedChildren = attachNestedChildrenToResultChildren(runId, resultChildren.map((result = {}, index): SubagentResultIntercomChild => {
143
+ const baseOutput = result.output ?? data.summary;
144
+ const hasRealOutput = typeof baseOutput === "string" && baseOutput.trim().length > 0;
145
+ const output = hasRealOutput ? baseOutput : "(no output)";
146
+ const summary = result.success === false && result.error
147
+ ? `${result.error}${hasRealOutput ? `\n\nOutput:\n${baseOutput}` : ""}`
148
+ : output;
149
+ const sessionPath = result.sessionFile ?? (resultChildren.length === 1 ? data.sessionFile : undefined);
150
+ const childNestedChildren = sanitizeNestedResultChildren(result.children, resultPath, `results[${index}].children`);
151
+ return {
152
+ agent: result.agent ?? data.agent ?? `step-${index + 1}`,
153
+ status: resolveSubagentResultStatus({
154
+ success: result.success,
155
+ state: data.state === "paused" || typeof result.success !== "boolean" ? data.state : undefined,
156
+ }),
157
+ summary,
158
+ index,
159
+ artifactPath: result.artifactPaths?.outputPath,
160
+ ...(typeof sessionPath === "string" && fsApi.existsSync(sessionPath) ? { sessionPath } : {}),
161
+ ...(result.intercomTarget ? { intercomTarget: result.intercomTarget } : {}),
162
+ ...(childNestedChildren ? { children: childNestedChildren } : {}),
163
+ };
164
+ }), nestedChildren);
165
+
99
166
  const intercomTarget = data.intercomTarget?.trim();
100
167
  if (intercomTarget) {
101
- const childResults = Array.isArray(data.results) && data.results.length > 0
102
- ? data.results
103
- : [{
104
- agent: data.agent,
105
- output: data.summary,
106
- success: data.success,
107
- }];
108
- const runId = data.runId ?? data.id ?? file.replace(/\.json$/i, "");
109
168
  const mode = data.mode === "single" || data.mode === "parallel" || data.mode === "chain"
110
169
  ? data.mode
111
- : childResults.length > 1 ? "chain" : "single";
170
+ : resultChildren.length > 1 ? "chain" : "single";
112
171
  const payload = buildSubagentResultIntercomPayload({
113
172
  to: intercomTarget,
114
173
  runId,
115
174
  mode,
116
175
  source: "async",
117
- children: childResults.map((result = {}, index) => {
118
- const baseOutput = result.output ?? data.summary;
119
- const hasRealOutput = typeof baseOutput === "string" && baseOutput.trim().length > 0;
120
- const output = hasRealOutput ? baseOutput : "(no output)";
121
- const summary = result.success === false && result.error
122
- ? `${result.error}${hasRealOutput ? `\n\nOutput:\n${baseOutput}` : ""}`
123
- : output;
124
- const sessionPath = result.sessionFile ?? (childResults.length === 1 ? data.sessionFile : undefined);
125
- return {
126
- agent: result.agent ?? data.agent ?? `step-${index + 1}`,
127
- status: resolveSubagentResultStatus({
128
- success: result.success,
129
- state: data.state === "paused" || typeof result.success !== "boolean" ? data.state : undefined,
130
- }),
131
- summary,
132
- index,
133
- artifactPath: result.artifactPaths?.outputPath,
134
- ...(typeof sessionPath === "string" && fsApi.existsSync(sessionPath) ? { sessionPath } : {}),
135
- intercomTarget: result.intercomTarget,
136
- };
137
- }),
176
+ children: normalizedChildren,
138
177
  asyncId: data.id,
139
178
  asyncDir: data.asyncDir,
140
179
  });
@@ -144,7 +183,25 @@ export function createResultWatcher(
144
183
  }
145
184
  }
146
185
 
147
- pi.events.emit(SUBAGENT_ASYNC_COMPLETE_EVENT, data);
186
+ pi.events.emit(SUBAGENT_ASYNC_COMPLETE_EVENT, {
187
+ ...data,
188
+ runId,
189
+ ...(nestedChildren?.length ? { nestedChildren } : {}),
190
+ ...(Array.isArray(data.results) ? {
191
+ results: hasResultChildren
192
+ ? normalizedChildren.map((child, index) => ({
193
+ ...data.results![index],
194
+ agent: child.agent,
195
+ status: child.status,
196
+ summary: child.summary,
197
+ index: child.index,
198
+ artifactPath: child.artifactPath,
199
+ sessionPath: child.sessionPath,
200
+ children: child.children,
201
+ }))
202
+ : [],
203
+ } : {}),
204
+ });
148
205
  fsApi.unlinkSync(resultPath);
149
206
  } catch (error) {
150
207
  if (isNotFoundError(error)) return;
@@ -0,0 +1,83 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { ASYNC_DIR, RESULTS_DIR, type SubagentState } from "../../shared/types.ts";
4
+ import { findAsyncRunPrefixMatches, type AsyncRunLocation } from "./async-resume.ts";
5
+ import { assertSafeNestedId, findNestedRunMatchesById, type NestedRoute, type NestedRunMatch, type NestedRunResolutionScope } from "../shared/nested-events.ts";
6
+
7
+ export type ResolvedSubagentRunId =
8
+ | { kind: "foreground"; id: string }
9
+ | { kind: "async"; id: string; location: AsyncRunLocation }
10
+ | { kind: "nested"; id: string; match: NestedRunMatch };
11
+
12
+ export interface ResolveSubagentRunIdDeps {
13
+ state?: SubagentState;
14
+ asyncDirRoot?: string;
15
+ resultsDir?: string;
16
+ nested?: NestedRunResolutionScope;
17
+ }
18
+
19
+ function exactAsyncLocation(id: string, asyncDirRoot: string, resultsDir: string): AsyncRunLocation | undefined {
20
+ const asyncDir = path.join(asyncDirRoot, id);
21
+ const resultPath = path.join(resultsDir, `${id}.json`);
22
+ if (!fs.existsSync(asyncDir) && !fs.existsSync(resultPath)) return undefined;
23
+ return {
24
+ asyncDir: fs.existsSync(asyncDir) ? asyncDir : null,
25
+ resultPath: fs.existsSync(resultPath) ? resultPath : null,
26
+ resolvedId: id,
27
+ };
28
+ }
29
+
30
+ function foregroundIds(state: SubagentState | undefined): string[] {
31
+ return state ? [...state.foregroundControls.keys()] : [];
32
+ }
33
+
34
+ function nestedScopeFromState(state: SubagentState | undefined): NestedRunResolutionScope | undefined {
35
+ if (!state) return undefined;
36
+ const routes: NestedRoute[] = [];
37
+ const seen = new Set<string>();
38
+ const add = (route: NestedRoute | undefined) => {
39
+ if (!route) return;
40
+ const key = `${route.rootRunId}:${route.eventSink}:${route.controlInbox}`;
41
+ if (seen.has(key)) return;
42
+ seen.add(key);
43
+ routes.push(route);
44
+ };
45
+ for (const control of state.foregroundControls.values()) add(control.nestedRoute as NestedRoute | undefined);
46
+ for (const job of state.asyncJobs.values()) add(job.nestedRoute as NestedRoute | undefined);
47
+ return { routes };
48
+ }
49
+
50
+ function asyncPrefixMatches(prefix: string, asyncDirRoot: string, resultsDir: string): Array<{ id: string; location: AsyncRunLocation }> {
51
+ return findAsyncRunPrefixMatches(prefix, asyncDirRoot, resultsDir);
52
+ }
53
+
54
+ export function resolveSubagentRunId(id: string, deps: ResolveSubagentRunIdDeps = {}): ResolvedSubagentRunId | undefined {
55
+ assertSafeNestedId("id", id);
56
+ const asyncDirRoot = deps.asyncDirRoot ?? ASYNC_DIR;
57
+ const resultsDir = deps.resultsDir ?? RESULTS_DIR;
58
+
59
+ const nestedScope = deps.nested ?? nestedScopeFromState(deps.state);
60
+ if (deps.state?.foregroundControls.has(id)) return { kind: "foreground", id };
61
+ const exactAsync = exactAsyncLocation(id, asyncDirRoot, resultsDir);
62
+ if (exactAsync) return { kind: "async", id, location: exactAsync };
63
+ const exactNested = findNestedRunMatchesById(id, nestedScope ? { scope: nestedScope } : {});
64
+ if (exactNested.length > 1) throw new Error(`Nested run id '${id}' is ambiguous across authorized registries. Provide the full id after stale registries are cleaned up.`);
65
+ if (exactNested[0]) return { kind: "nested", id, match: exactNested[0] };
66
+
67
+ const matches: ResolvedSubagentRunId[] = [];
68
+ for (const foregroundId of foregroundIds(deps.state).filter((candidate) => candidate.startsWith(id))) {
69
+ matches.push({ kind: "foreground", id: foregroundId });
70
+ }
71
+ for (const match of asyncPrefixMatches(id, asyncDirRoot, resultsDir)) {
72
+ matches.push({ kind: "async", id: match.id, location: match.location });
73
+ }
74
+ for (const match of findNestedRunMatchesById(id, nestedScope ? { prefix: true, scope: nestedScope } : { prefix: true })) {
75
+ matches.push({ kind: "nested", id: match.run.id, match });
76
+ }
77
+ const unique = new Map(matches.map((match) => [`${match.kind}:${match.id}`, match]));
78
+ const values = [...unique.values()];
79
+ if (values.length > 1) {
80
+ throw new Error(`Ambiguous subagent run id prefix '${id}' matched: ${values.map((match) => `${match.kind}:${match.id}`).join(", ")}. Provide a longer id.`);
81
+ }
82
+ return values[0];
83
+ }
@@ -2,13 +2,16 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
4
4
  import { formatAsyncRunList, formatAsyncRunOutputPath, formatAsyncRunProgressLabel, listAsyncRuns } from "./async-status.ts";
5
+ import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
5
6
  import { formatModelThinking } from "../../shared/formatters.ts";
6
7
  import { formatActivityLabel } from "../../shared/status-format.ts";
7
- import { ASYNC_DIR, RESULTS_DIR, type AsyncStatus, type Details } from "../../shared/types.ts";
8
+ import { ASYNC_DIR, RESULTS_DIR, type AsyncStatus, type Details, type NestedRunSummary, type SubagentState } from "../../shared/types.ts";
8
9
  import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
9
10
  import { resolveAsyncRunLocation } from "./async-resume.ts";
11
+ import { resolveSubagentRunId } from "./run-id-resolver.ts";
10
12
  import { flatToLogicalStepIndex, normalizeParallelGroups } from "./parallel-groups.ts";
11
- import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
13
+ import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
14
+ import { attachRootChildrenToSteps, findNestedRouteForRootId, projectNestedRegistryForRoot, type NestedRunResolutionScope } from "../shared/nested-events.ts";
12
15
 
13
16
  interface RunStatusParams {
14
17
  action?: "status";
@@ -22,6 +25,8 @@ interface RunStatusDeps {
22
25
  resultsDir?: string;
23
26
  kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
24
27
  now?: () => number;
28
+ state?: SubagentState;
29
+ nested?: NestedRunResolutionScope;
25
30
  }
26
31
 
27
32
  function hasExistingSessionFile(value: unknown): value is string {
@@ -44,6 +49,11 @@ function formatResumeGuidance(runId: string | undefined, children: Array<{ agent
44
49
  return "Resume: unavailable; no child session file was persisted.";
45
50
  }
46
51
 
52
+ function formatAcceptanceFinalizationSummary(finalization: NonNullable<NonNullable<AsyncStatus["steps"]>[number]["acceptance"]>["finalization"] | undefined): string {
53
+ if (!finalization) return "";
54
+ return `, finalization: ${finalization.status} after ${finalization.turns.length}/${finalization.maxTurns} turns`;
55
+ }
56
+
47
57
  function stepLineLabel(status: AsyncStatus, index: number): string {
48
58
  const steps = status.steps ?? [];
49
59
  if (status.mode === "parallel") return `Agent ${index + 1}/${steps.length || 1}`;
@@ -57,10 +67,53 @@ function stepLineLabel(status: AsyncStatus, index: number): string {
57
67
  return `Step ${index + 1}`;
58
68
  }
59
69
 
70
+ function nestedRunDisplayName(run: NestedRunSummary): string {
71
+ if (run.agent) return run.agent;
72
+ if (run.agents?.length) return run.agents.join(", ");
73
+ return run.id;
74
+ }
75
+
76
+ function formatNestedExactStatus(rootRunId: string, run: NestedRunSummary): string {
77
+ const lines = [
78
+ `Nested run: ${run.id}`,
79
+ `Root: ${rootRunId}`,
80
+ `Parent: ${run.parentRunId}${run.parentStepIndex !== undefined ? ` step ${run.parentStepIndex + 1}` : ""}`,
81
+ `State: ${run.state}`,
82
+ run.activityState || run.lastActivityAt ? `Activity: ${formatActivityLabel(run.lastActivityAt, run.activityState)}` : undefined,
83
+ run.mode ? `Mode: ${run.mode}` : undefined,
84
+ `Agent: ${nestedRunDisplayName(run)}`,
85
+ run.currentStep !== undefined ? `Progress: step ${run.currentStep + 1}/${run.chainStepCount ?? run.steps?.length ?? 1}` : undefined,
86
+ run.asyncDir ? `Dir: ${run.asyncDir}` : undefined,
87
+ run.sessionFile ? `Session: ${run.sessionFile}` : undefined,
88
+ run.error ? `Error: ${run.error}` : undefined,
89
+ ].filter((line): line is string => Boolean(line));
90
+ if (run.path.length) {
91
+ lines.push(`Path: ${run.path.map((part) => `${part.runId}${part.stepIndex !== undefined ? `:${part.stepIndex + 1}` : ""}${part.agent ? `:${part.agent}` : ""}`).join(" > ")} > ${run.id}`);
92
+ }
93
+ if (run.steps?.length) {
94
+ lines.push("Steps:");
95
+ for (const [index, step] of run.steps.entries()) {
96
+ const activity = step.status === "running" ? formatActivityLabel(step.lastActivityAt, step.activityState) : undefined;
97
+ lines.push(` ${index + 1}. ${step.agent} ${step.status}${activity ? `, ${activity}` : ""}${step.error ? `, error: ${step.error}` : ""}`);
98
+ lines.push(...formatNestedRunStatusLines(step.children, { indent: " ", commandHints: true }));
99
+ }
100
+ }
101
+ lines.push(...formatNestedRunStatusLines(run.children, { indent: " ", commandHints: true }));
102
+ lines.push("Commands:", ` Status: subagent({ action: "status", id: "${run.id}" })`, ` Interrupt: subagent({ action: "interrupt", id: "${run.id}" })`, ` Resume: subagent({ action: "resume", id: "${run.id}", message: "..." })`, ` Root status: subagent({ action: "status", id: "${rootRunId}" })`);
103
+ return lines.join("\n");
104
+ }
105
+
60
106
  export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDeps = {}): AgentToolResult<Details> {
61
107
  const asyncDirRoot = deps.asyncDirRoot ?? ASYNC_DIR;
62
108
  const resultsDir = deps.resultsDir ?? RESULTS_DIR;
63
109
  if (!params.id && !params.runId && !params.dir) {
110
+ if (deps.nested) {
111
+ return {
112
+ content: [{ type: "text", text: "Child-safe subagent status requires an id when no foreground run is active." }],
113
+ isError: true,
114
+ details: { mode: "single", results: [] },
115
+ };
116
+ }
64
117
  try {
65
118
  const runs = listAsyncRuns(asyncDirRoot, { states: ["queued", "running"], resultsDir, kill: deps.kill, now: deps.now });
66
119
  return {
@@ -79,7 +132,20 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
79
132
 
80
133
  let location;
81
134
  try {
82
- location = resolveAsyncRunLocation(params, asyncDirRoot, resultsDir);
135
+ const requestedId = params.id ?? params.runId;
136
+ if (!params.dir && requestedId) {
137
+ const resolved = resolveSubagentRunId(requestedId, { asyncDirRoot, resultsDir, state: deps.state, nested: deps.nested });
138
+ if (resolved?.kind === "nested") {
139
+ reconcileNestedAsyncDescendants(resolved.match.route, { resultsDir, kill: deps.kill, now: deps.now });
140
+ const refreshed = resolveSubagentRunId(requestedId, { asyncDirRoot, resultsDir, state: deps.state, nested: deps.nested });
141
+ const nested = refreshed?.kind === "nested" ? refreshed : resolved;
142
+ return { content: [{ type: "text", text: formatNestedExactStatus(nested.match.rootRunId, nested.match.run) }], details: { mode: "single", results: [] } };
143
+ }
144
+ if (resolved?.kind === "async") location = resolved.location;
145
+ else location = { asyncDir: null, resultPath: null, resolvedId: requestedId };
146
+ } else {
147
+ location = resolveAsyncRunLocation(params, asyncDirRoot, resultsDir);
148
+ }
83
149
  } catch (error) {
84
150
  const message = error instanceof Error ? error.message : String(error);
85
151
  return {
@@ -115,6 +181,16 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
115
181
  const logPath = path.join(asyncDir, `subagent-log-${effectiveRunId}.md`);
116
182
  const eventsPath = path.join(asyncDir, "events.jsonl");
117
183
  if (status) {
184
+ let nestedChildren: NestedRunSummary[] = [];
185
+ let nestedWarning: string | undefined;
186
+ try {
187
+ const nestedRoute = findNestedRouteForRootId(status.runId);
188
+ if (nestedRoute) reconcileNestedAsyncDescendants(nestedRoute, { resultsDir, kill: deps.kill, now: deps.now });
189
+ nestedChildren = projectNestedRegistryForRoot(status.runId)?.children ?? [];
190
+ attachRootChildrenToSteps(status.runId, status.steps, nestedChildren);
191
+ } catch (error) {
192
+ nestedWarning = `Nested status unavailable: ${error instanceof Error ? error.message : String(error)}`;
193
+ }
118
194
  const outputPath = formatAsyncRunOutputPath({ asyncDir, outputFile: status.outputFile });
119
195
  const progressLabel = formatAsyncRunProgressLabel({
120
196
  mode: status.mode,
@@ -146,13 +222,22 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
146
222
  const modelThinking = formatModelThinking(step.model, step.thinking);
147
223
  const modelText = modelThinking ? ` (${modelThinking})` : "";
148
224
  const errorText = step.error ? `, error: ${step.error}` : "";
149
- lines.push(`${stepLineLabel(status, index)}: ${step.agent} ${step.status}${modelText}${stepActivityText ? `, ${stepActivityText}` : ""}${errorText}`);
225
+ const finalizationText = formatAcceptanceFinalizationSummary(step.acceptance?.finalization);
226
+ const acceptanceText = step.acceptance?.status ? `, acceptance: ${step.acceptance.status}${finalizationText}` : "";
227
+ const display = step.label ? `${step.label} (${step.agent})` : step.agent;
228
+ const phase = step.phase ? `[${step.phase}] ` : "";
229
+ lines.push(`${stepLineLabel(status, index)}: ${phase}${display} ${step.status}${modelText}${stepActivityText ? `, ${stepActivityText}` : ""}${acceptanceText}${errorText}`);
230
+ lines.push(...formatNestedRunStatusLines(step.children, { indent: " ", commandHints: true, maxLines: 20 }));
150
231
  const stepOutputPath = path.join(asyncDir, `output-${index}.log`);
151
232
  if (stepOutputPath !== outputPath && fs.existsSync(stepOutputPath)) lines.push(` Output: ${stepOutputPath}`);
152
233
  if (step.status === "running") {
153
234
  lines.push(` Intercom target: ${resolveSubagentIntercomTarget(status.runId, step.agent, index)} (if registered)`);
154
235
  }
155
236
  }
237
+ const attached = new Set((status.steps ?? []).flatMap((step) => step.children?.map((child) => child.id) ?? []));
238
+ const unattached = nestedChildren.filter((child) => !attached.has(child.id));
239
+ lines.push(...formatNestedRunStatusLines(unattached, { indent: "", commandHints: true, maxLines: 20 }));
240
+ if (nestedWarning) lines.push(`Warning: ${nestedWarning}`);
156
241
  if (status.sessionFile) lines.push(`Session: ${status.sessionFile}`);
157
242
  if (status.state !== "running") {
158
243
  lines.push(formatResumeGuidance(status.runId, status.steps ?? [], status.sessionFile));