pi-subagents 0.24.3 → 0.25.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 (40) hide show
  1. package/CHANGELOG.md +26 -5
  2. package/README.md +19 -11
  3. package/package.json +4 -8
  4. package/prompts/review-loop.md +1 -1
  5. package/skills/pi-subagents/SKILL.md +46 -10
  6. package/src/agents/agent-management.ts +5 -0
  7. package/src/agents/agent-serializer.ts +2 -0
  8. package/src/agents/agents.ts +30 -6
  9. package/src/agents/skills.ts +25 -23
  10. package/src/extension/config.ts +16 -0
  11. package/src/extension/fanout-child.ts +170 -0
  12. package/src/extension/index.ts +13 -25
  13. package/src/intercom/intercom-bridge.ts +2 -1
  14. package/src/intercom/result-intercom.ts +108 -0
  15. package/src/runs/background/async-execution.ts +107 -7
  16. package/src/runs/background/async-job-tracker.ts +57 -14
  17. package/src/runs/background/async-resume.ts +28 -15
  18. package/src/runs/background/async-status.ts +60 -30
  19. package/src/runs/background/result-watcher.ts +111 -54
  20. package/src/runs/background/run-id-resolver.ts +83 -0
  21. package/src/runs/background/run-status.ts +79 -3
  22. package/src/runs/background/stale-run-reconciler.ts +46 -1
  23. package/src/runs/background/subagent-runner.ts +66 -18
  24. package/src/runs/foreground/chain-execution.ts +6 -0
  25. package/src/runs/foreground/execution.ts +21 -5
  26. package/src/runs/foreground/subagent-executor.ts +314 -18
  27. package/src/runs/shared/completion-guard.ts +23 -1
  28. package/src/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
  29. package/src/runs/shared/nested-events.ts +819 -0
  30. package/src/runs/shared/nested-path.ts +52 -0
  31. package/src/runs/shared/nested-render.ts +115 -0
  32. package/src/runs/shared/parallel-utils.ts +1 -0
  33. package/src/runs/shared/pi-args.ts +67 -5
  34. package/src/runs/shared/run-history.ts +12 -7
  35. package/src/runs/shared/single-output.ts +12 -2
  36. package/src/runs/shared/subagent-prompt-runtime.ts +25 -5
  37. package/src/shared/artifacts.ts +2 -2
  38. package/src/shared/types.ts +95 -0
  39. package/src/shared/utils.ts +11 -1
  40. package/src/tui/render.ts +254 -153
@@ -11,7 +11,7 @@ import { createRequire } from "node:module";
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import type { AgentConfig } from "../../agents/agents.ts";
13
13
  import { applyThinkingSuffix } from "../shared/pi-args.ts";
14
- import { injectSingleOutputInstruction, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
14
+ import { injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
15
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";
@@ -24,6 +24,7 @@ import {
24
24
  type ArtifactConfig,
25
25
  type Details,
26
26
  type MaxOutputConfig,
27
+ type NestedRouteInfo,
27
28
  type ResolvedControlConfig,
28
29
  type SubagentRunMode,
29
30
  ASYNC_DIR,
@@ -33,6 +34,7 @@ import {
33
34
  getAsyncConfigPath,
34
35
  resolveChildMaxSubagentDepth,
35
36
  } from "../../shared/types.ts";
37
+ import { nestedResultsPath, resolveInheritedNestedRouteFromEnv, resolveNestedParentAddressFromEnv, writeNestedEvent } from "../shared/nested-events.ts";
36
38
 
37
39
  const require = createRequire(import.meta.url);
38
40
  const piPackageRoot = resolvePiPackageRoot();
@@ -111,6 +113,7 @@ interface AsyncChainParams {
111
113
  controlConfig?: ResolvedControlConfig;
112
114
  controlIntercomTarget?: string;
113
115
  childIntercomTarget?: (agent: string, index: number) => string | undefined;
116
+ nestedRoute?: NestedRouteInfo;
114
117
  }
115
118
 
116
119
  interface AsyncSingleParams {
@@ -126,7 +129,7 @@ interface AsyncSingleParams {
126
129
  sessionRoot?: string;
127
130
  sessionFile?: string;
128
131
  skills?: string[];
129
- output?: string | false;
132
+ output?: string | boolean;
130
133
  outputMode?: "inline" | "file-only";
131
134
  modelOverride?: string;
132
135
  availableModels?: AvailableModelInfo[];
@@ -136,6 +139,7 @@ interface AsyncSingleParams {
136
139
  controlConfig?: ResolvedControlConfig;
137
140
  controlIntercomTarget?: string;
138
141
  childIntercomTarget?: (agent: string, index: number) => string | undefined;
142
+ nestedRoute?: NestedRouteInfo;
139
143
  }
140
144
 
141
145
  interface AsyncExecutionResult {
@@ -236,6 +240,7 @@ export function executeAsyncChain(
236
240
  controlConfig,
237
241
  controlIntercomTarget,
238
242
  childIntercomTarget,
243
+ nestedRoute,
239
244
  } = params;
240
245
  const resultMode = params.resultMode ?? "chain";
241
246
  const chainSkills = params.chainSkills ?? [];
@@ -261,7 +266,11 @@ export function executeAsyncChain(
261
266
  }
262
267
  }
263
268
 
264
- const asyncDir = path.join(ASYNC_DIR, id);
269
+ const inheritedNestedRoute = resolveInheritedNestedRouteFromEnv();
270
+ const nestedAddress = inheritedNestedRoute ? resolveNestedParentAddressFromEnv() : undefined;
271
+ const asyncDir = inheritedNestedRoute
272
+ ? path.join(TEMP_ROOT_DIR, "nested-subagent-runs", inheritedNestedRoute.rootRunId, id)
273
+ : path.join(ASYNC_DIR, id);
265
274
  try {
266
275
  fs.mkdirSync(asyncDir, { recursive: true });
267
276
  } catch (error) {
@@ -323,6 +332,7 @@ export function executeAsyncChain(
323
332
  tools: a.tools,
324
333
  extensions: a.extensions,
325
334
  mcpDirectTools: a.mcpDirectTools,
335
+ completionGuard: a.completionGuard,
326
336
  systemPrompt,
327
337
  systemPromptMode: a.systemPromptMode,
328
338
  inheritProjectContext: a.inheritProjectContext,
@@ -392,7 +402,7 @@ export function executeAsyncChain(
392
402
  {
393
403
  id,
394
404
  steps,
395
- resultPath: path.join(RESULTS_DIR, `${id}.json`),
405
+ resultPath: inheritedNestedRoute ? nestedResultsPath(inheritedNestedRoute.rootRunId, id) : path.join(RESULTS_DIR, `${id}.json`),
396
406
  cwd: runnerCwd,
397
407
  placeholder: "{previous}",
398
408
  maxOutput,
@@ -410,6 +420,13 @@ export function executeAsyncChain(
410
420
  controlIntercomTarget,
411
421
  childIntercomTargets,
412
422
  resultMode,
423
+ nestedRoute: nestedRoute ?? inheritedNestedRoute,
424
+ nestedSelf: inheritedNestedRoute && nestedAddress ? {
425
+ parentRunId: nestedAddress.parentRunId,
426
+ parentStepIndex: nestedAddress.parentStepIndex,
427
+ depth: nestedAddress.depth,
428
+ path: nestedAddress.path,
429
+ } : undefined,
413
430
  },
414
431
  id,
415
432
  runnerCwd,
@@ -442,6 +459,40 @@ export function executeAsyncChain(
442
459
  flatStepStart++;
443
460
  }
444
461
  }
462
+ if (inheritedNestedRoute && nestedAddress) {
463
+ const now = Date.now();
464
+ try {
465
+ writeNestedEvent(inheritedNestedRoute, {
466
+ type: "subagent.nested.started",
467
+ ts: now,
468
+ parentRunId: nestedAddress.parentRunId,
469
+ parentStepIndex: nestedAddress.parentStepIndex,
470
+ child: {
471
+ id,
472
+ parentRunId: nestedAddress.parentRunId,
473
+ parentStepIndex: nestedAddress.parentStepIndex,
474
+ depth: nestedAddress.depth,
475
+ path: nestedAddress.path,
476
+ asyncDir,
477
+ pid: spawnResult.pid,
478
+ ownerIntercomTarget: process.env.PI_SUBAGENT_INTERCOM_SESSION_NAME,
479
+ leafIntercomTarget: childIntercomTargets?.[0],
480
+ intercomTarget: childIntercomTargets?.[0],
481
+ ownerState: "live",
482
+ mode: resultMode,
483
+ state: "running",
484
+ agent: firstAgents[0],
485
+ agents: flatAgents,
486
+ chainStepCount: chain.length,
487
+ parallelGroups,
488
+ startedAt: now,
489
+ lastUpdate: now,
490
+ },
491
+ });
492
+ } catch (error) {
493
+ console.error("Failed to emit nested async start event:", error);
494
+ }
495
+ }
445
496
  ctx.pi.events.emit(SUBAGENT_ASYNC_STARTED_EVENT, {
446
497
  id,
447
498
  pid: spawnResult.pid,
@@ -459,6 +510,7 @@ export function executeAsyncChain(
459
510
  parallelGroups,
460
511
  cwd: runnerCwd,
461
512
  asyncDir,
513
+ nestedRoute,
462
514
  });
463
515
  }
464
516
 
@@ -498,6 +550,7 @@ export function executeAsyncSingle(
498
550
  controlConfig,
499
551
  controlIntercomTarget,
500
552
  childIntercomTarget,
553
+ nestedRoute,
501
554
  } = params;
502
555
  const task = params.task ?? "";
503
556
  const runnerCwd = resolveChildCwd(ctx.cwd, cwd);
@@ -511,7 +564,11 @@ export function executeAsyncSingle(
511
564
  systemPrompt = systemPrompt ? `${systemPrompt}\n\n${injection}` : injection;
512
565
  }
513
566
 
514
- const asyncDir = path.join(ASYNC_DIR, id);
567
+ const inheritedNestedRoute = resolveInheritedNestedRouteFromEnv();
568
+ const nestedAddress = inheritedNestedRoute ? resolveNestedParentAddressFromEnv() : undefined;
569
+ const asyncDir = inheritedNestedRoute
570
+ ? path.join(TEMP_ROOT_DIR, "nested-subagent-runs", inheritedNestedRoute.rootRunId, id)
571
+ : path.join(ASYNC_DIR, id);
515
572
  try {
516
573
  fs.mkdirSync(asyncDir, { recursive: true });
517
574
  } catch (error) {
@@ -523,7 +580,8 @@ export function executeAsyncSingle(
523
580
  };
524
581
  }
525
582
 
526
- const outputPath = resolveSingleOutputPath(params.output, ctx.cwd, runnerCwd);
583
+ const effectiveOutput = normalizeSingleOutputOverride(params.output, agentConfig.output);
584
+ const outputPath = resolveSingleOutputPath(effectiveOutput, ctx.cwd, runnerCwd);
527
585
  const outputMode = params.outputMode ?? "inline";
528
586
  const validationError = validateFileOnlyOutputMode(outputMode, outputPath, `Async single run (${agent})`);
529
587
  if (validationError) return formatAsyncStartError("single", validationError);
@@ -550,6 +608,7 @@ export function executeAsyncSingle(
550
608
  tools: agentConfig.tools,
551
609
  extensions: agentConfig.extensions,
552
610
  mcpDirectTools: agentConfig.mcpDirectTools,
611
+ completionGuard: agentConfig.completionGuard,
553
612
  systemPrompt,
554
613
  systemPromptMode: agentConfig.systemPromptMode,
555
614
  inheritProjectContext: agentConfig.inheritProjectContext,
@@ -561,7 +620,7 @@ export function executeAsyncSingle(
561
620
  maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, agentConfig.maxSubagentDepth),
562
621
  },
563
622
  ],
564
- resultPath: path.join(RESULTS_DIR, `${id}.json`),
623
+ resultPath: inheritedNestedRoute ? nestedResultsPath(inheritedNestedRoute.rootRunId, id) : path.join(RESULTS_DIR, `${id}.json`),
565
624
  cwd: runnerCwd,
566
625
  placeholder: "{previous}",
567
626
  maxOutput,
@@ -579,6 +638,13 @@ export function executeAsyncSingle(
579
638
  controlIntercomTarget,
580
639
  childIntercomTargets: childIntercomTarget ? [childIntercomTarget(agent, 0)] : undefined,
581
640
  resultMode: "single",
641
+ nestedRoute: nestedRoute ?? inheritedNestedRoute,
642
+ nestedSelf: inheritedNestedRoute && nestedAddress ? {
643
+ parentRunId: nestedAddress.parentRunId,
644
+ parentStepIndex: nestedAddress.parentStepIndex,
645
+ depth: nestedAddress.depth,
646
+ path: nestedAddress.path,
647
+ } : undefined,
582
648
  },
583
649
  id,
584
650
  runnerCwd,
@@ -593,6 +659,39 @@ export function executeAsyncSingle(
593
659
  }
594
660
 
595
661
  if (spawnResult.pid) {
662
+ if (inheritedNestedRoute && nestedAddress) {
663
+ const now = Date.now();
664
+ try {
665
+ writeNestedEvent(inheritedNestedRoute, {
666
+ type: "subagent.nested.started",
667
+ ts: now,
668
+ parentRunId: nestedAddress.parentRunId,
669
+ parentStepIndex: nestedAddress.parentStepIndex,
670
+ child: {
671
+ id,
672
+ parentRunId: nestedAddress.parentRunId,
673
+ parentStepIndex: nestedAddress.parentStepIndex,
674
+ depth: nestedAddress.depth,
675
+ path: nestedAddress.path,
676
+ asyncDir,
677
+ pid: spawnResult.pid,
678
+ ownerIntercomTarget: process.env.PI_SUBAGENT_INTERCOM_SESSION_NAME,
679
+ leafIntercomTarget: childIntercomTarget?.(agent, 0),
680
+ intercomTarget: childIntercomTarget?.(agent, 0),
681
+ ownerState: "live",
682
+ mode: "single",
683
+ state: "running",
684
+ agent,
685
+ agents: [agent],
686
+ chainStepCount: 1,
687
+ startedAt: now,
688
+ lastUpdate: now,
689
+ },
690
+ });
691
+ } catch (error) {
692
+ console.error("Failed to emit nested async start event:", error);
693
+ }
694
+ }
596
695
  ctx.pi.events.emit(SUBAGENT_ASYNC_STARTED_EVENT, {
597
696
  id,
598
697
  pid: spawnResult.pid,
@@ -602,6 +701,7 @@ export function executeAsyncSingle(
602
701
  task: task?.slice(0, 50),
603
702
  cwd: runnerCwd,
604
703
  asyncDir,
704
+ nestedRoute,
605
705
  });
606
706
  }
607
707
 
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import { renderWidget } from "../../tui/render.ts";
4
+ import { renderWidget, widgetRenderKey } from "../../tui/render.ts";
5
5
  import { formatControlNoticeMessage } from "../shared/subagent-control.ts";
6
6
  import {
7
7
  type AsyncJobState,
@@ -15,7 +15,8 @@ import {
15
15
  } from "../../shared/types.ts";
16
16
  import { readStatus } from "../../shared/utils.ts";
17
17
  import { normalizeParallelGroups } from "./parallel-groups.ts";
18
- import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
18
+ import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
19
+ import { hasLiveNestedDescendants, updateAsyncJobNestedProjection } from "../shared/nested-events.ts";
19
20
 
20
21
  interface AsyncJobTrackerOptions {
21
22
  completionRetentionMs?: number;
@@ -38,9 +39,14 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
38
39
  renderWidget(ctx, jobs);
39
40
  ctx.ui.requestRender?.();
40
41
  };
41
- const scheduleCleanup = (asyncId: string) => {
42
+ const cancelCleanup = (asyncId: string) => {
42
43
  const existingTimer = state.cleanupTimers.get(asyncId);
43
- if (existingTimer) clearTimeout(existingTimer);
44
+ if (!existingTimer) return;
45
+ clearTimeout(existingTimer);
46
+ state.cleanupTimers.delete(asyncId);
47
+ };
48
+ const scheduleCleanup = (asyncId: string) => {
49
+ cancelCleanup(asyncId);
44
50
  const timer = setTimeout(() => {
45
51
  state.cleanupTimers.delete(asyncId);
46
52
  state.asyncJobs.delete(asyncId);
@@ -118,9 +124,30 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
118
124
  return;
119
125
  }
120
126
 
127
+ let widgetChanged = false;
121
128
  for (const job of state.asyncJobs.values()) {
129
+ const widgetStateBefore = widgetRenderKey(job);
130
+ let nestedRefreshFailed = false;
131
+ const refreshNestedProjection = () => {
132
+ try {
133
+ updateAsyncJobNestedProjection(job);
134
+ } catch (error) {
135
+ nestedRefreshFailed = true;
136
+ console.error(`Failed to refresh nested async descendants for '${job.asyncDir}':`, error);
137
+ }
138
+ };
139
+ const reconcileNestedDescendants = () => {
140
+ try {
141
+ if (job.nestedRoute) reconcileNestedAsyncDescendants(job.nestedRoute, { resultsDir, kill: options.kill, now: options.now });
142
+ } catch (error) {
143
+ nestedRefreshFailed = true;
144
+ console.error(`Failed to refresh nested async descendants for '${job.asyncDir}':`, error);
145
+ }
146
+ refreshNestedProjection();
147
+ };
122
148
  try {
123
149
  emitNewControlEvents(job);
150
+ reconcileNestedDescendants();
124
151
  const reconciliation = reconcileAsyncRun(job.asyncDir, {
125
152
  resultsDir,
126
153
  kill: options.kill,
@@ -141,6 +168,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
141
168
  if (status) {
142
169
  const previousStatus = job.status;
143
170
  job.status = status.state;
171
+ if (job.status !== "complete" && job.status !== "failed" && job.status !== "paused") cancelCleanup(job.asyncId);
144
172
  job.sessionId = status.sessionId ?? job.sessionId;
145
173
  job.activityState = status.activityState;
146
174
  job.lastActivityAt = status.lastActivityAt ?? job.lastActivityAt;
@@ -153,7 +181,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
153
181
  job.currentStep = status.currentStep ?? job.currentStep;
154
182
  job.chainStepCount = status.chainStepCount ?? job.chainStepCount;
155
183
  job.startedAt = status.startedAt ?? job.startedAt;
156
- job.updatedAt = status.lastUpdate ?? Date.now();
184
+ if (status.lastUpdate !== undefined) job.updatedAt = status.lastUpdate;
157
185
  if (status.steps?.length) {
158
186
  const groups = normalizeParallelGroups(status.parallelGroups, status.steps.length, status.chainStepCount ?? status.steps.length);
159
187
  job.parallelGroups = groups.length ? groups : job.parallelGroups;
@@ -167,6 +195,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
167
195
  job.activeParallelGroup = Boolean(activeGroup);
168
196
  job.agents = visibleSteps.map((step) => step.agent);
169
197
  job.steps = visibleSteps;
198
+ refreshNestedProjection();
170
199
  job.stepsTotal = visibleSteps.length;
171
200
  job.runningSteps = visibleSteps.filter((step) => step.status === "running").length;
172
201
  job.completedSteps = visibleSteps.filter((step) => step.status === "complete" || step.status === "completed").length;
@@ -176,24 +205,30 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
176
205
  job.outputFile = status.outputFile ?? job.outputFile;
177
206
  job.totalTokens = status.totalTokens ?? job.totalTokens;
178
207
  job.sessionFile = status.sessionFile ?? job.sessionFile;
179
- if ((job.status === "complete" || job.status === "failed" || job.status === "paused") && (previousStatus !== job.status || !state.cleanupTimers.has(job.asyncId))) {
208
+ if ((job.status === "complete" || job.status === "failed" || job.status === "paused") && !nestedRefreshFailed && !hasLiveNestedDescendants(job.nestedChildren) && (previousStatus !== job.status || !state.cleanupTimers.has(job.asyncId))) {
180
209
  scheduleCleanup(job.asyncId);
181
210
  }
211
+ if (widgetRenderKey(job) !== widgetStateBefore) widgetChanged = true;
182
212
  continue;
183
213
  }
184
- job.status = job.status === "queued" ? "running" : job.status;
185
- job.updatedAt = Date.now();
214
+ if (job.status === "queued") {
215
+ job.status = "running";
216
+ job.updatedAt = Date.now();
217
+ }
186
218
  } catch (error) {
187
- console.error(`Failed to read async status for '${job.asyncDir}':`, error);
188
- job.status = "failed";
189
- job.updatedAt = Date.now();
190
- if (!state.cleanupTimers.has(job.asyncId)) {
219
+ if (job.status !== "failed") {
220
+ console.error(`Failed to read async status for '${job.asyncDir}':`, error);
221
+ job.status = "failed";
222
+ job.updatedAt = Date.now();
223
+ }
224
+ if (!hasLiveNestedDescendants(job.nestedChildren) && !state.cleanupTimers.has(job.asyncId)) {
191
225
  scheduleCleanup(job.asyncId);
192
226
  }
193
227
  }
228
+ if (widgetRenderKey(job) !== widgetStateBefore) widgetChanged = true;
194
229
  }
195
230
 
196
- if (state.lastUiContext?.hasUI) rerenderWidget(state.lastUiContext);
231
+ if (widgetChanged && state.lastUiContext?.hasUI) rerenderWidget(state.lastUiContext);
197
232
  }, pollIntervalMs);
198
233
  state.poller.unref?.();
199
234
  };
@@ -220,6 +255,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
220
255
  agents,
221
256
  chainStepCount: info.chainStepCount,
222
257
  parallelGroups: validParallelGroups,
258
+ nestedRoute: info.nestedRoute,
223
259
  stepsTotal: firstGroupCount ?? agents?.length,
224
260
  hasParallelGroups: validParallelGroups.length > 0,
225
261
  activeParallelGroup: Boolean(firstGroupCount && firstGroupCount > 0),
@@ -237,15 +273,22 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
237
273
  const asyncId = result.id;
238
274
  if (!asyncId) return;
239
275
  const job = state.asyncJobs.get(asyncId);
276
+ let nestedRefreshFailed = false;
240
277
  if (job) {
241
278
  job.status = result.success ? "complete" : "failed";
242
279
  job.updatedAt = Date.now();
243
280
  if (result.asyncDir) job.asyncDir = result.asyncDir;
281
+ try {
282
+ updateAsyncJobNestedProjection(job);
283
+ } catch (error) {
284
+ nestedRefreshFailed = true;
285
+ console.error(`Failed to refresh nested async descendants for '${job.asyncDir}':`, error);
286
+ }
244
287
  }
245
288
  if (state.lastUiContext) {
246
289
  rerenderWidget(state.lastUiContext);
247
290
  }
248
- scheduleCleanup(asyncId);
291
+ if (!nestedRefreshFailed && !hasLiveNestedDescendants(job?.nestedChildren)) scheduleCleanup(asyncId);
249
292
  };
250
293
 
251
294
  const resetJobs = (ctx?: ExtensionContext) => {
@@ -149,6 +149,29 @@ function exactResultPath(resultsDir: string, runId: string): string | null {
149
149
  return fs.existsSync(resultPath) ? resultPath : null;
150
150
  }
151
151
 
152
+ export function findAsyncRunPrefixMatches(prefix: string, asyncDirRoot: string, resultsDir: string): Array<{ id: string; location: AsyncRunLocation }> {
153
+ const requestedId = assertRunId(prefix, "id");
154
+ if (!requestedId) return [];
155
+ const asyncRoot = path.resolve(asyncDirRoot);
156
+ const resultRoot = path.resolve(resultsDir);
157
+ const matchingIds = [...new Set([
158
+ ...prefixedRunIds(asyncRoot, requestedId),
159
+ ...prefixedRunIds(resultRoot, requestedId, ".json"),
160
+ ])].sort();
161
+ return matchingIds.map((id) => {
162
+ const asyncDir = path.join(asyncRoot, id);
163
+ assertInsideRoot(asyncRoot, asyncDir, "Async run directory");
164
+ return {
165
+ id,
166
+ location: {
167
+ asyncDir: fs.existsSync(asyncDir) ? asyncDir : null,
168
+ resultPath: exactResultPath(resultRoot, id),
169
+ resolvedId: id,
170
+ },
171
+ };
172
+ });
173
+ }
174
+
152
175
  export function resolveAsyncRunLocation(params: AsyncResumeParams, asyncDirRoot: string, resultsDir: string): AsyncRunLocation {
153
176
  const asyncRoot = path.resolve(asyncDirRoot);
154
177
  const resultRoot = path.resolve(resultsDir);
@@ -175,22 +198,12 @@ export function resolveAsyncRunLocation(params: AsyncResumeParams, asyncDirRoot:
175
198
  };
176
199
  }
177
200
 
178
- const matchingIds = [...new Set([
179
- ...prefixedRunIds(asyncRoot, requestedId),
180
- ...prefixedRunIds(resultRoot, requestedId, ".json"),
181
- ])].sort();
182
- if (matchingIds.length === 0) return { asyncDir: null, resultPath: null, resolvedId: requestedId };
183
- if (matchingIds.length > 1) {
184
- throw new Error(`Ambiguous async run id prefix '${requestedId}' matched: ${matchingIds.join(", ")}. Provide a longer id.`);
201
+ const matching = findAsyncRunPrefixMatches(requestedId, asyncRoot, resultRoot);
202
+ if (matching.length === 0) return { asyncDir: null, resultPath: null, resolvedId: requestedId };
203
+ if (matching.length > 1) {
204
+ throw new Error(`Ambiguous async run id prefix '${requestedId}' matched: ${matching.map((match) => match.id).join(", ")}. Provide a longer id.`);
185
205
  }
186
- const resolvedId = matchingIds[0]!;
187
- const asyncDir = path.join(asyncRoot, resolvedId);
188
- assertInsideRoot(asyncRoot, asyncDir, "Async run directory");
189
- return {
190
- asyncDir: fs.existsSync(asyncDir) ? asyncDir : null,
191
- resultPath: exactResultPath(resultRoot, resolvedId),
192
- resolvedId,
193
- };
206
+ return matching[0]!.location;
194
207
  }
195
208
 
196
209
  function resultState(result: AsyncResultFile): AsyncStatus["state"] {
@@ -2,10 +2,12 @@ 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;
@@ -28,6 +30,7 @@ interface AsyncRunStepSummary {
28
30
  thinking?: string;
29
31
  attemptedModels?: string[];
30
32
  error?: string;
33
+ children?: NestedRunSummary[];
31
34
  }
32
35
 
33
36
  export interface AsyncRunSummary {
@@ -55,6 +58,8 @@ export interface AsyncRunSummary {
55
58
  outputFile?: string;
56
59
  totalTokens?: TokenUsage;
57
60
  sessionFile?: string;
61
+ nestedChildren?: NestedRunSummary[];
62
+ nestedWarnings?: string[];
58
63
  }
59
64
 
60
65
  interface AsyncRunListOptions {
@@ -112,7 +117,7 @@ function deriveAsyncActivityState(asyncDir: string, status: AsyncStatus): { acti
112
117
  };
113
118
  }
114
119
 
115
- function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string }): AsyncRunSummary {
120
+ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string }, nestedWarnings: string[] = []): AsyncRunSummary {
116
121
  if (status.sessionId !== undefined && typeof status.sessionId !== "string") {
117
122
  throw new Error(`Invalid async status '${path.join(asyncDir, "status.json")}': sessionId must be a string.`);
118
123
  }
@@ -120,6 +125,42 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
120
125
  const steps = status.steps ?? [];
121
126
  const chainStepCount = status.chainStepCount ?? steps.length;
122
127
  const parallelGroups = normalizeParallelGroups(status.parallelGroups, steps.length, chainStepCount);
128
+ let nestedChildren: NestedRunSummary[] = [];
129
+ if (nestedWarnings.length === 0) {
130
+ try {
131
+ nestedChildren = projectNestedRegistryForRoot(status.runId || path.basename(asyncDir))?.children ?? [];
132
+ } catch (error) {
133
+ nestedWarnings.push(`Nested status unavailable: ${getErrorMessage(error)}`);
134
+ }
135
+ }
136
+ const summarizedSteps = steps.map((step, index) => {
137
+ const stepActivityState = step.activityState;
138
+ const stepLastActivityAt = step.lastActivityAt;
139
+ return {
140
+ index,
141
+ agent: step.agent,
142
+ status: step.status,
143
+ ...(stepActivityState ? { activityState: stepActivityState } : {}),
144
+ ...(stepLastActivityAt ? { lastActivityAt: stepLastActivityAt } : {}),
145
+ ...(step.currentTool ? { currentTool: step.currentTool } : {}),
146
+ ...(step.currentToolArgs ? { currentToolArgs: step.currentToolArgs } : {}),
147
+ ...(step.currentToolStartedAt ? { currentToolStartedAt: step.currentToolStartedAt } : {}),
148
+ ...(step.currentPath ? { currentPath: step.currentPath } : {}),
149
+ ...(step.recentTools ? { recentTools: step.recentTools.map((tool) => ({ ...tool })) } : {}),
150
+ ...(step.recentOutput ? { recentOutput: [...step.recentOutput] } : {}),
151
+ ...(step.turnCount !== undefined ? { turnCount: step.turnCount } : {}),
152
+ ...(step.toolCount !== undefined ? { toolCount: step.toolCount } : {}),
153
+ ...(step.durationMs !== undefined ? { durationMs: step.durationMs } : {}),
154
+ ...(step.tokens ? { tokens: step.tokens } : {}),
155
+ ...(step.skills ? { skills: step.skills } : {}),
156
+ ...(step.model ? { model: step.model } : {}),
157
+ ...(step.thinking ? { thinking: step.thinking } : {}),
158
+ ...(step.attemptedModels ? { attemptedModels: step.attemptedModels } : {}),
159
+ ...(step.error ? { error: step.error } : {}),
160
+ ...(step.children?.length ? { children: step.children } : {}),
161
+ };
162
+ });
163
+ attachRootChildrenToSteps(status.runId || path.basename(asyncDir), summarizedSteps, nestedChildren);
123
164
  return {
124
165
  id: status.runId || path.basename(asyncDir),
125
166
  asyncDir,
@@ -140,32 +181,9 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
140
181
  currentStep: status.currentStep,
141
182
  ...(status.chainStepCount !== undefined ? { chainStepCount: status.chainStepCount } : {}),
142
183
  ...(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
- }),
184
+ steps: summarizedSteps,
185
+ ...(nestedChildren.length ? { nestedChildren } : {}),
186
+ ...(nestedWarnings.length ? { nestedWarnings } : {}),
169
187
  ...(status.sessionDir ? { sessionDir: status.sessionDir } : {}),
170
188
  ...(status.outputFile ? { outputFile: status.outputFile } : {}),
171
189
  ...(status.totalTokens ? { totalTokens: status.totalTokens } : {}),
@@ -212,7 +230,14 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
212
230
  : reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
213
231
  const status = (reconciliation?.status ?? readStatus(asyncDir)) as (AsyncStatus & { cwd?: string }) | null;
214
232
  if (!status) continue;
215
- const summary = statusToSummary(asyncDir, status);
233
+ const nestedWarnings: string[] = [];
234
+ try {
235
+ const nestedRoute = findNestedRouteForRootId(status.runId || path.basename(asyncDir));
236
+ if (nestedRoute) reconcileNestedAsyncDescendants(nestedRoute, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
237
+ } catch (error) {
238
+ nestedWarnings.push(`Nested status unavailable: ${getErrorMessage(error)}`);
239
+ }
240
+ const summary = statusToSummary(asyncDir, status, nestedWarnings);
216
241
  if (allowedStates && !allowedStates.has(summary.state)) continue;
217
242
  if (options.sessionId && summary.sessionId !== options.sessionId) continue;
218
243
  runs.push(summary);
@@ -285,7 +310,12 @@ export function formatAsyncRunList(runs: AsyncRunSummary[], heading = "Active as
285
310
  lines.push(`- ${formatRunHeader(run)}`);
286
311
  for (const step of run.steps) {
287
312
  lines.push(` ${formatStepLine(step)}`);
313
+ lines.push(...formatNestedRunStatusLines(step.children, { indent: " ", maxLines: 12 }));
288
314
  }
315
+ const attached = new Set(run.steps.flatMap((step) => step.children?.map((child) => child.id) ?? []));
316
+ const unattached = run.nestedChildren?.filter((child) => !attached.has(child.id)) ?? [];
317
+ lines.push(...formatNestedRunStatusLines(unattached, { indent: " ", maxLines: 12 }));
318
+ for (const warning of run.nestedWarnings ?? []) lines.push(` Warning: ${warning}`);
289
319
  const outputPath = formatAsyncRunOutputPath(run);
290
320
  if (outputPath) lines.push(` output: ${shortenPath(outputPath)}`);
291
321
  if (run.sessionFile) lines.push(` session: ${shortenPath(run.sessionFile)}`);