pi-subagents 0.47.0 → 0.48.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 (57) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +2 -0
  3. package/agents/reviewer.md +3 -4
  4. package/docs/configuration.md +46 -2
  5. package/docs/observability.md +1 -1
  6. package/docs/tool-reference.md +1 -1
  7. package/package.json +1 -1
  8. package/src/agents/agents.ts +9 -3
  9. package/src/extension/config.ts +16 -0
  10. package/src/extension/doctor.ts +40 -0
  11. package/src/extension/index.ts +30 -11
  12. package/src/extension/public-execution.ts +5 -0
  13. package/src/extension/rpc.ts +7 -9
  14. package/src/extension/schemas.ts +4 -4
  15. package/src/intercom/intercom-bridge.ts +4 -1
  16. package/src/intercom/native-supervisor-channel.ts +102 -4
  17. package/src/missions/lifecycle.ts +4 -7
  18. package/src/missions/store.ts +4 -4
  19. package/src/missions/workflow-state.ts +6 -2
  20. package/src/runs/background/active-async-capacity.ts +374 -0
  21. package/src/runs/background/active-run-index.ts +46 -0
  22. package/src/runs/background/async-execution.ts +117 -29
  23. package/src/runs/background/async-job-tracker.ts +279 -134
  24. package/src/runs/background/async-resume.ts +7 -1
  25. package/src/runs/background/async-status.ts +33 -4
  26. package/src/runs/background/chain-append.ts +33 -15
  27. package/src/runs/background/control-channel.ts +55 -17
  28. package/src/runs/background/owned-process-tree.ts +104 -0
  29. package/src/runs/background/process-terminal.ts +17 -3
  30. package/src/runs/background/result-watcher.ts +87 -6
  31. package/src/runs/background/run-status.ts +23 -1
  32. package/src/runs/background/scheduled-runs.ts +23 -0
  33. package/src/runs/background/stale-run-reconciler.ts +10 -3
  34. package/src/runs/background/subagent-runner.ts +75 -33
  35. package/src/runs/foreground/async-dismiss-action.ts +85 -0
  36. package/src/runs/foreground/async-steering-action.ts +6 -7
  37. package/src/runs/foreground/chain-execution.ts +37 -2
  38. package/src/runs/foreground/execution.ts +90 -19
  39. package/src/runs/foreground/foreground-control.ts +12 -0
  40. package/src/runs/foreground/prompt-audit.ts +171 -0
  41. package/src/runs/foreground/subagent-executor.ts +648 -195
  42. package/src/runs/shared/acceptance.ts +13 -4
  43. package/src/runs/shared/llm-intent-arbiter.ts +286 -0
  44. package/src/runs/shared/parallel-utils.ts +2 -0
  45. package/src/runs/shared/pi-args.ts +44 -1
  46. package/src/runs/shared/run-fanout-budget.ts +280 -0
  47. package/src/runs/shared/single-output.ts +4 -2
  48. package/src/runs/shared/subagent-prompt-runtime.ts +24 -5
  49. package/src/runs/shared/task-intent.ts +19 -3
  50. package/src/runs/shared/worktree.ts +17 -5
  51. package/src/shared/artifacts.ts +1 -1
  52. package/src/shared/file-coalescer.ts +9 -0
  53. package/src/shared/types.ts +97 -1
  54. package/src/shared/utils.ts +3 -1
  55. package/src/tui/fleet-status.ts +7 -5
  56. package/src/tui/fleet.ts +225 -12
  57. package/src/workflows/scripted-workflow.ts +26 -6
@@ -15,14 +15,15 @@ import {
15
15
  SUBAGENT_CONTROL_INTERCOM_EVENT,
16
16
  SUBAGENT_STEERING_NOTICE_EVENT,
17
17
  } from "../../shared/types.ts";
18
- import { readStatus } from "../../shared/utils.ts";
18
+ import { readStatus, resolveWatchPath } from "../../shared/utils.ts";
19
19
  import { normalizeParallelGroups } from "./parallel-groups.ts";
20
20
  import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
21
- import { hasLiveNestedDescendants, updateAsyncJobNestedProjection } from "../shared/nested-events.ts";
21
+ import { findNestedRouteForRootId, hasLiveNestedDescendants, updateAsyncJobNestedProjection } from "../shared/nested-events.ts";
22
22
  import { listAsyncRuns, type AsyncRunSummary } from "./async-status.ts";
23
23
 
24
24
  interface AsyncJobTrackerOptions {
25
25
  completionRetentionMs?: number;
26
+ /** Slow safety sweep for liveness repair when filesystem events are missed. */
26
27
  pollIntervalMs?: number;
27
28
  resultsDir?: string;
28
29
  widgetEnabled?: boolean;
@@ -34,6 +35,9 @@ const CONTROL_EVENT_READ_CHUNK_BYTES = 64 * 1024;
34
35
  const MAX_CONTROL_EVENT_LINE_BYTES = 1024 * 1024;
35
36
  const CONTROL_EVENT_SCAN_WINDOW_BYTES = 2 * 1024 * 1024;
36
37
  const MAX_RECENT_FLEET_JOBS = 20;
38
+ const DEFAULT_LIVENESS_INTERVAL_MS = 5000;
39
+ const EVENT_REFRESH_DEBOUNCE_MS = 25;
40
+ const WATCH_ATTACHMENT_RETRY_MS = 100;
37
41
 
38
42
  function rememberFleetJob(state: SubagentState, job: AsyncJobState): void {
39
43
  state.fleetJobs ??= new Map();
@@ -51,11 +55,17 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
51
55
  handleComplete: (data: unknown) => void;
52
56
  resetJobs: (ctx?: ExtensionContext) => void;
53
57
  restoreActiveJobs: (ctx?: ExtensionContext) => void;
58
+ dispose: () => void;
54
59
  } {
55
60
  const completionRetentionMs = options.completionRetentionMs ?? 10000;
56
- const pollIntervalMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
61
+ const livenessIntervalMs = options.pollIntervalMs ?? DEFAULT_LIVENESS_INTERVAL_MS;
57
62
  const resultsDir = options.resultsDir ?? DIRS.results;
58
63
  const steeringNoticeSeen = new Map<string, number>();
64
+ const jobWatchers = new Map<string, { watchers: Map<string, fs.FSWatcher>; retryTimer?: ReturnType<typeof setTimeout> }>();
65
+ const refreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
66
+ const runningJobIds = new Set<string>();
67
+ let rootWatcher: fs.FSWatcher | undefined;
68
+ let nextLivenessAt = Date.now() + livenessIntervalMs;
59
69
  const rerenderWidget = (ctx: ExtensionContext, jobs = Array.from(state.asyncJobs.values())) => {
60
70
  if (state.widgetsSuspended) return;
61
71
  renderWidget(ctx, options.widgetEnabled === false ? [] : jobs);
@@ -96,6 +106,14 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
96
106
  throw error;
97
107
  }
98
108
  };
109
+ const findNestedRouteForJob = (runId: string) => {
110
+ try {
111
+ return findNestedRouteForRootId(runId);
112
+ } catch (error) {
113
+ console.error(`Failed to resolve nested event route for '${runId}':`, error);
114
+ return undefined;
115
+ }
116
+ };
99
117
  const summaryToJob = (run: AsyncRunSummary): AsyncJobState => {
100
118
  const groups = normalizeParallelGroups(run.parallelGroups, run.steps.length, run.chainStepCount ?? run.steps.length);
101
119
  const activeGroup = run.currentStep !== undefined
@@ -144,6 +162,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
144
162
  totalTokens: run.totalTokens,
145
163
  sessionFile: run.sessionFile,
146
164
  controlEventCursor: restoredControlEventCursor(run.asyncDir),
165
+ nestedRoute: findNestedRouteForJob(run.id),
147
166
  nestedChildren: run.nestedChildren,
148
167
  parentWorkflowRunId: run.parentWorkflowRunId,
149
168
  workflowKey: run.workflowKey,
@@ -160,6 +179,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
160
179
  cancelCleanup(asyncId);
161
180
  const timer = setTimeout(() => {
162
181
  state.cleanupTimers.delete(asyncId);
182
+ closeJobWatcher(asyncId);
163
183
  state.asyncJobs.delete(asyncId);
164
184
  rerenderLastWidget();
165
185
  }, completionRetentionMs);
@@ -282,140 +302,245 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
282
302
  }
283
303
  };
284
304
 
305
+ const closeJobWatcher = (asyncId: string) => {
306
+ const watched = jobWatchers.get(asyncId);
307
+ if (watched?.retryTimer) clearTimeout(watched.retryTimer);
308
+ for (const watcher of watched?.watchers.values() ?? []) watcher.close();
309
+ jobWatchers.delete(asyncId);
310
+ const timer = refreshTimers.get(asyncId);
311
+ if (timer) clearTimeout(timer);
312
+ refreshTimers.delete(asyncId);
313
+ runningJobIds.delete(asyncId);
314
+ };
315
+
316
+ const refreshJob = (job: AsyncJobState): boolean => {
317
+ const widgetStateBefore = widgetRenderKey(job);
318
+ let nestedRefreshFailed = false;
319
+ const refreshNestedProjection = () => {
320
+ try {
321
+ updateAsyncJobNestedProjection(job);
322
+ } catch (error) {
323
+ nestedRefreshFailed = true;
324
+ console.error(`Failed to refresh nested async descendants for '${job.asyncDir}':`, error);
325
+ }
326
+ };
327
+ try {
328
+ emitNewControlEvents(job);
329
+ try {
330
+ if (job.nestedRoute) reconcileNestedAsyncDescendants(job.nestedRoute, { resultsDir, kill: options.kill, now: options.now });
331
+ } catch (error) {
332
+ nestedRefreshFailed = true;
333
+ console.error(`Failed to refresh nested async descendants for '${job.asyncDir}':`, error);
334
+ }
335
+ refreshNestedProjection();
336
+ const reconciliation = reconcileAsyncRun(job.asyncDir, {
337
+ resultsDir,
338
+ kill: options.kill,
339
+ now: options.now,
340
+ startedRun: {
341
+ runId: job.asyncId,
342
+ pid: job.pid,
343
+ sessionId: job.sessionId,
344
+ mode: job.mode,
345
+ agents: job.agents,
346
+ chainStepCount: job.chainStepCount,
347
+ parallelGroups: job.parallelGroups,
348
+ startedAt: job.startedAt,
349
+ sessionFile: job.sessionFile,
350
+ },
351
+ });
352
+ const status = reconciliation.status ?? readStatus(job.asyncDir);
353
+ if (status) {
354
+ const previousStatus = job.status;
355
+ job.status = status.state;
356
+ if (job.status === "running") runningJobIds.add(job.asyncId);
357
+ else runningJobIds.delete(job.asyncId);
358
+ if (job.status !== "complete" && job.status !== "failed" && job.status !== "paused" && job.status !== "stopped") cancelCleanup(job.asyncId);
359
+ job.sessionId = status.sessionId ?? job.sessionId;
360
+ job.activityState = status.activityState;
361
+ job.lastActivityAt = status.lastActivityAt ?? job.lastActivityAt;
362
+ job.currentTool = status.currentTool;
363
+ job.currentToolStartedAt = status.currentToolStartedAt;
364
+ job.currentPath = status.currentPath;
365
+ job.turnCount = status.turnCount ?? job.turnCount;
366
+ job.toolCount = status.toolCount ?? job.toolCount;
367
+ job.steering = status.steering ?? job.steering;
368
+ job.mode = status.mode;
369
+ job.parentWorkflowRunId = status.parentWorkflowRunId ?? job.parentWorkflowRunId;
370
+ job.workflowKey = status.workflowKey ?? job.workflowKey;
371
+ job.workflow = status.workflow ?? job.workflow;
372
+ job.currentStep = status.currentStep ?? job.currentStep;
373
+ job.chainStepCount = status.chainStepCount ?? job.chainStepCount;
374
+ job.startedAt = status.startedAt ?? job.startedAt;
375
+ if (status.lastUpdate !== undefined) job.updatedAt = status.lastUpdate;
376
+ if (status.steps?.length) {
377
+ const groups = normalizeParallelGroups(status.parallelGroups, status.steps.length, status.chainStepCount ?? status.steps.length);
378
+ job.parallelGroups = groups.length ? groups : job.parallelGroups;
379
+ job.hasParallelGroups = groups.length > 0 || job.hasParallelGroups;
380
+ const activeGroup = status.currentStep !== undefined
381
+ ? groups.find((group) => status.currentStep! >= group.start && status.currentStep! < group.start + group.count)
382
+ : undefined;
383
+ const visibleSteps = activeGroup
384
+ ? status.steps.slice(activeGroup.start, activeGroup.start + activeGroup.count).map((step, index) => ({ ...step, index: activeGroup.start + index }))
385
+ : status.steps.map((step, index) => ({ ...step, index }));
386
+ job.activeParallelGroup = Boolean(activeGroup);
387
+ job.agents = visibleSteps.map((step) => step.agent);
388
+ job.steps = visibleSteps;
389
+ refreshNestedProjection();
390
+ job.stepsTotal = visibleSteps.length;
391
+ job.runningSteps = visibleSteps.filter((step) => step.status === "running").length;
392
+ job.completedSteps = visibleSteps.filter((step) => step.status === "complete" || step.status === "completed").length;
393
+ if (status.state === "complete") job.completedSteps = visibleSteps.length;
394
+ }
395
+ job.sessionDir = status.sessionDir ?? job.sessionDir;
396
+ job.outputFile = status.outputFile ?? job.outputFile;
397
+ job.totalTokens = status.totalTokens ?? job.totalTokens;
398
+ job.timeoutMs = status.timeoutMs ?? job.timeoutMs;
399
+ job.deadlineAt = status.deadlineAt ?? job.deadlineAt;
400
+ job.timedOut = status.timedOut ?? job.timedOut;
401
+ job.stopped = status.stopped ?? job.stopped;
402
+ job.turnBudget = status.turnBudget ?? job.turnBudget;
403
+ job.turnBudgetExceeded = status.turnBudgetExceeded ?? job.turnBudgetExceeded;
404
+ job.wrapUpRequested = status.wrapUpRequested ?? job.wrapUpRequested;
405
+ job.sessionFile = status.sessionFile ?? job.sessionFile;
406
+ if (job.status === "complete" || job.status === "failed" || job.status === "paused" || job.status === "stopped") {
407
+ rememberFleetJob(state, job);
408
+ if (!nestedRefreshFailed && !hasLiveNestedDescendants(job.nestedChildren) && (previousStatus !== job.status || !state.cleanupTimers.has(job.asyncId))) {
409
+ scheduleCleanup(job.asyncId);
410
+ }
411
+ }
412
+ return widgetRenderKey(job) !== widgetStateBefore;
413
+ }
414
+ if (job.status === "queued") {
415
+ job.status = "running";
416
+ job.updatedAt = Date.now();
417
+ runningJobIds.add(job.asyncId);
418
+ }
419
+ } catch (error) {
420
+ if (job.status !== "failed") {
421
+ console.error(`Failed to read async status for '${job.asyncDir}':`, error);
422
+ job.status = "failed";
423
+ job.updatedAt = Date.now();
424
+ }
425
+ runningJobIds.delete(job.asyncId);
426
+ rememberFleetJob(state, job);
427
+ if (!hasLiveNestedDescendants(job.nestedChildren) && !state.cleanupTimers.has(job.asyncId)) scheduleCleanup(job.asyncId);
428
+ }
429
+ return widgetRenderKey(job) !== widgetStateBefore;
430
+ };
431
+
432
+ const scheduleJobRefresh = (asyncId: string, delayMs = EVENT_REFRESH_DEBOUNCE_MS) => {
433
+ if (refreshTimers.has(asyncId)) return;
434
+ const timer = setTimeout(() => {
435
+ refreshTimers.delete(asyncId);
436
+ const job = state.asyncJobs.get(asyncId);
437
+ if (job && refreshJob(job)) rerenderLastWidget();
438
+ }, delayMs);
439
+ timer.unref?.();
440
+ refreshTimers.set(asyncId, timer);
441
+ };
442
+
443
+ const watchJob = (job: AsyncJobState) => {
444
+ const watched = jobWatchers.get(job.asyncId) ?? { watchers: new Map<string, fs.FSWatcher>() };
445
+ const active = job.status === "queued" || job.status === "running";
446
+ const watchPath = (watchPath: string): boolean => {
447
+ if (watched.watchers.has(watchPath)) return true;
448
+ const nestedEventSink = job.nestedRoute?.eventSink === watchPath;
449
+ let watcher: fs.FSWatcher;
450
+ try {
451
+ watcher = fs.watch(resolveWatchPath(watchPath), (_event, file) => {
452
+ const rawFileName = file?.toString();
453
+ const fileName = rawFileName ?? path.basename(watchPath);
454
+ const nestedEventFile = nestedEventSink && (!rawFileName || rawFileName.endsWith(".json") || rawFileName.endsWith(".jsonl"));
455
+ if (fileName === "status.json" || fileName === "events.jsonl" || nestedEventFile) {
456
+ scheduleJobRefresh(job.asyncId);
457
+ }
458
+ if (watchPath !== job.asyncDir && !nestedEventSink) {
459
+ watcher.close();
460
+ watched.watchers.delete(watchPath);
461
+ watchJob(job);
462
+ }
463
+ });
464
+ } catch {
465
+ return false;
466
+ }
467
+ watcher.on("error", () => {
468
+ watcher.close();
469
+ watched.watchers.delete(watchPath);
470
+ if (watchPath.endsWith("status.json") || nestedEventSink) watchJob(job);
471
+ });
472
+ watcher.unref?.();
473
+ watched.watchers.set(watchPath, watcher);
474
+ return true;
475
+ };
476
+ watchPath(job.asyncDir);
477
+ const statusPath = path.join(job.asyncDir, "status.json");
478
+ const hadStatusWatcher = watched.watchers.has(statusPath);
479
+ const statusWatched = watchPath(statusPath);
480
+ if (statusWatched && !hadStatusWatcher) scheduleJobRefresh(job.asyncId);
481
+ watchPath(path.join(job.asyncDir, "events.jsonl"));
482
+ if (job.nestedRoute) watchPath(job.nestedRoute.eventSink);
483
+ if (!statusWatched && active && !watched.retryTimer) {
484
+ watched.retryTimer = setTimeout(() => {
485
+ watched.retryTimer = undefined;
486
+ const current = state.asyncJobs.get(job.asyncId);
487
+ if (current) watchJob(current);
488
+ }, WATCH_ATTACHMENT_RETRY_MS);
489
+ watched.retryTimer.unref?.();
490
+ } else if (statusWatched && watched.retryTimer) {
491
+ clearTimeout(watched.retryTimer);
492
+ watched.retryTimer = undefined;
493
+ }
494
+ if (watched.watchers.size > 0 || watched.retryTimer) jobWatchers.set(job.asyncId, watched);
495
+ else jobWatchers.delete(job.asyncId);
496
+ };
497
+
498
+ const watchAsyncRoot = () => {
499
+ if (rootWatcher) return;
500
+ try {
501
+ rootWatcher = fs.watch(resolveWatchPath(asyncDirRoot), (_event, file) => {
502
+ const runDirName = file?.toString();
503
+ for (const job of state.asyncJobs.values()) {
504
+ if (jobWatchers.has(job.asyncId) || (runDirName && path.basename(job.asyncDir) !== runDirName)) continue;
505
+ watchJob(job);
506
+ if (jobWatchers.has(job.asyncId)) scheduleJobRefresh(job.asyncId);
507
+ }
508
+ });
509
+ rootWatcher.on("error", () => {
510
+ rootWatcher?.close();
511
+ rootWatcher = undefined;
512
+ });
513
+ rootWatcher.unref?.();
514
+ } catch {
515
+ // The liveness sweep retries if the async root is not available yet.
516
+ }
517
+ };
518
+
285
519
  const ensurePoller = () => {
520
+ watchAsyncRoot();
286
521
  if (state.poller) return;
522
+ nextLivenessAt = Date.now() + livenessIntervalMs;
287
523
  state.poller = setInterval(() => {
288
524
  if (state.asyncJobs.size === 0) {
289
525
  rerenderLastWidget([]);
290
- if (state.poller) {
291
- clearInterval(state.poller);
292
- state.poller = null;
293
- }
526
+ if (state.poller) clearInterval(state.poller);
527
+ state.poller = null;
528
+ rootWatcher?.close();
529
+ rootWatcher = undefined;
294
530
  return;
295
531
  }
296
-
297
- let widgetChanged = false;
298
- for (const job of state.asyncJobs.values()) {
299
- const widgetStateBefore = widgetRenderKey(job);
300
- let nestedRefreshFailed = false;
301
- const refreshNestedProjection = () => {
302
- try {
303
- updateAsyncJobNestedProjection(job);
304
- } catch (error) {
305
- nestedRefreshFailed = true;
306
- console.error(`Failed to refresh nested async descendants for '${job.asyncDir}':`, error);
307
- }
308
- };
309
- const reconcileNestedDescendants = () => {
310
- try {
311
- if (job.nestedRoute) reconcileNestedAsyncDescendants(job.nestedRoute, { resultsDir, kill: options.kill, now: options.now });
312
- } catch (error) {
313
- nestedRefreshFailed = true;
314
- console.error(`Failed to refresh nested async descendants for '${job.asyncDir}':`, error);
315
- }
316
- refreshNestedProjection();
317
- };
318
- try {
319
- emitNewControlEvents(job);
320
- reconcileNestedDescendants();
321
- const reconciliation = reconcileAsyncRun(job.asyncDir, {
322
- resultsDir,
323
- kill: options.kill,
324
- now: options.now,
325
- startedRun: {
326
- runId: job.asyncId,
327
- pid: job.pid,
328
- sessionId: job.sessionId,
329
- mode: job.mode,
330
- agents: job.agents,
331
- chainStepCount: job.chainStepCount,
332
- parallelGroups: job.parallelGroups,
333
- startedAt: job.startedAt,
334
- sessionFile: job.sessionFile,
335
- },
336
- });
337
- const status = reconciliation.status ?? readStatus(job.asyncDir);
338
- if (status) {
339
- const previousStatus = job.status;
340
- job.status = status.state;
341
- if (job.status !== "complete" && job.status !== "failed" && job.status !== "paused" && job.status !== "stopped") cancelCleanup(job.asyncId);
342
- job.sessionId = status.sessionId ?? job.sessionId;
343
- job.activityState = status.activityState;
344
- job.lastActivityAt = status.lastActivityAt ?? job.lastActivityAt;
345
- job.currentTool = status.currentTool;
346
- job.currentToolStartedAt = status.currentToolStartedAt;
347
- job.currentPath = status.currentPath;
348
- job.turnCount = status.turnCount ?? job.turnCount;
349
- job.toolCount = status.toolCount ?? job.toolCount;
350
- job.steering = status.steering ?? job.steering;
351
- job.mode = status.mode;
352
- job.parentWorkflowRunId = status.parentWorkflowRunId ?? job.parentWorkflowRunId;
353
- job.workflowKey = status.workflowKey ?? job.workflowKey;
354
- job.workflow = status.workflow ?? job.workflow;
355
- job.currentStep = status.currentStep ?? job.currentStep;
356
- job.chainStepCount = status.chainStepCount ?? job.chainStepCount;
357
- job.startedAt = status.startedAt ?? job.startedAt;
358
- if (status.lastUpdate !== undefined) job.updatedAt = status.lastUpdate;
359
- if (status.steps?.length) {
360
- const groups = normalizeParallelGroups(status.parallelGroups, status.steps.length, status.chainStepCount ?? status.steps.length);
361
- job.parallelGroups = groups.length ? groups : job.parallelGroups;
362
- job.hasParallelGroups = groups.length > 0 || job.hasParallelGroups;
363
- const activeGroup = status.currentStep !== undefined
364
- ? groups.find((group) => status.currentStep! >= group.start && status.currentStep! < group.start + group.count)
365
- : undefined;
366
- const visibleSteps = activeGroup
367
- ? status.steps.slice(activeGroup.start, activeGroup.start + activeGroup.count).map((step, index) => ({ ...step, index: activeGroup.start + index }))
368
- : status.steps.map((step, index) => ({ ...step, index }));
369
- job.activeParallelGroup = Boolean(activeGroup);
370
- job.agents = visibleSteps.map((step) => step.agent);
371
- job.steps = visibleSteps;
372
- refreshNestedProjection();
373
- job.stepsTotal = visibleSteps.length;
374
- job.runningSteps = visibleSteps.filter((step) => step.status === "running").length;
375
- job.completedSteps = visibleSteps.filter((step) => step.status === "complete" || step.status === "completed").length;
376
- if (status.state === "complete") job.completedSteps = visibleSteps.length;
377
- }
378
- job.sessionDir = status.sessionDir ?? job.sessionDir;
379
- job.outputFile = status.outputFile ?? job.outputFile;
380
- job.totalTokens = status.totalTokens ?? job.totalTokens;
381
- job.timeoutMs = status.timeoutMs ?? job.timeoutMs;
382
- job.deadlineAt = status.deadlineAt ?? job.deadlineAt;
383
- job.timedOut = status.timedOut ?? job.timedOut;
384
- job.stopped = status.stopped ?? job.stopped;
385
- job.turnBudget = status.turnBudget ?? job.turnBudget;
386
- job.turnBudgetExceeded = status.turnBudgetExceeded ?? job.turnBudgetExceeded;
387
- job.wrapUpRequested = status.wrapUpRequested ?? job.wrapUpRequested;
388
- job.sessionFile = status.sessionFile ?? job.sessionFile;
389
- if (job.status === "complete" || job.status === "failed" || job.status === "paused" || job.status === "stopped") {
390
- rememberFleetJob(state, job);
391
- if (!nestedRefreshFailed && !hasLiveNestedDescendants(job.nestedChildren) && (previousStatus !== job.status || !state.cleanupTimers.has(job.asyncId))) {
392
- scheduleCleanup(job.asyncId);
393
- }
394
- }
395
- if (widgetRenderKey(job) !== widgetStateBefore) widgetChanged = true;
396
- continue;
397
- }
398
- if (job.status === "queued") {
399
- job.status = "running";
400
- job.updatedAt = Date.now();
401
- }
402
- } catch (error) {
403
- if (job.status !== "failed") {
404
- console.error(`Failed to read async status for '${job.asyncDir}':`, error);
405
- job.status = "failed";
406
- job.updatedAt = Date.now();
407
- }
408
- rememberFleetJob(state, job);
409
- if (!hasLiveNestedDescendants(job.nestedChildren) && !state.cleanupTimers.has(job.asyncId)) {
410
- scheduleCleanup(job.asyncId);
411
- }
532
+ const now = Date.now();
533
+ if (now >= nextLivenessAt) {
534
+ nextLivenessAt = now + livenessIntervalMs;
535
+ let widgetChanged = false;
536
+ for (const job of state.asyncJobs.values()) {
537
+ watchJob(job);
538
+ if (refreshJob(job)) widgetChanged = true;
412
539
  }
413
- if (widgetRenderKey(job) !== widgetStateBefore) widgetChanged = true;
540
+ if (widgetChanged) rerenderLastWidget();
414
541
  }
415
-
416
- if (widgetChanged) rerenderLastWidget();
417
- else if (Array.from(state.asyncJobs.values()).some((job) => job.status === "running")) requestLastWidgetRender();
418
- }, pollIntervalMs);
542
+ if (runningJobIds.size > 0) requestLastWidgetRender();
543
+ }, Math.min(POLL_INTERVAL_MS, livenessIntervalMs));
419
544
  state.poller.unref?.();
420
545
  };
421
546
 
@@ -457,7 +582,10 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
457
582
  workflowKey: info.workflowKey,
458
583
  controlEventCursor: 0,
459
584
  });
460
- rememberFleetJob(state, state.asyncJobs.get(info.id)!);
585
+ const job = state.asyncJobs.get(info.id)!;
586
+ rememberFleetJob(state, job);
587
+ watchJob(job);
588
+ scheduleJobRefresh(info.id, 0);
461
589
  ensurePoller();
462
590
  rerenderLastWidget();
463
591
  };
@@ -471,9 +599,14 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
471
599
  let nestedRefreshFailed = false;
472
600
  if (job) {
473
601
  job.status = result.state ?? (result.success ? "complete" : "failed");
602
+ runningJobIds.delete(asyncId);
474
603
  job.stopped = result.stopped ?? job.stopped;
475
604
  job.updatedAt = Date.now();
476
- if (result.asyncDir) job.asyncDir = result.asyncDir;
605
+ if (result.asyncDir && result.asyncDir !== job.asyncDir) {
606
+ closeJobWatcher(asyncId);
607
+ job.asyncDir = result.asyncDir;
608
+ watchJob(job);
609
+ }
477
610
  try {
478
611
  updateAsyncJobNestedProjection(job);
479
612
  } catch (error) {
@@ -486,10 +619,20 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
486
619
  if (!nestedRefreshFailed && !hasLiveNestedDescendants(job?.nestedChildren)) scheduleCleanup(asyncId);
487
620
  };
488
621
 
622
+ const dispose = () => {
623
+ if (state.poller) clearInterval(state.poller);
624
+ state.poller = null;
625
+ rootWatcher?.close();
626
+ rootWatcher = undefined;
627
+ for (const asyncId of jobWatchers.keys()) closeJobWatcher(asyncId);
628
+ for (const timer of refreshTimers.values()) clearTimeout(timer);
629
+ refreshTimers.clear();
630
+ runningJobIds.clear();
631
+ };
632
+
489
633
  const resetJobs = (ctx?: ExtensionContext) => {
490
- for (const timer of state.cleanupTimers.values()) {
491
- clearTimeout(timer);
492
- }
634
+ dispose();
635
+ for (const timer of state.cleanupTimers.values()) clearTimeout(timer);
493
636
  state.cleanupTimers.clear();
494
637
  state.asyncJobs.clear();
495
638
  state.fleetJobs?.clear();
@@ -515,12 +658,14 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
515
658
  for (const run of runs) {
516
659
  const job = summaryToJob(run);
517
660
  state.asyncJobs.set(run.id, job);
661
+ if (job.status === "running") runningJobIds.add(job.asyncId);
518
662
  rememberFleetJob(state, job);
663
+ watchJob(job);
519
664
  }
520
665
  if (runs.length === 0) return;
521
666
  ensurePoller();
522
667
  rerenderLastWidget();
523
668
  };
524
669
 
525
- return { ensurePoller, refreshWidget, handleStarted, handleComplete, resetJobs, restoreActiveJobs };
670
+ return { ensurePoller, refreshWidget, handleStarted, handleComplete, resetJobs, restoreActiveJobs, dispose };
526
671
  }
@@ -5,6 +5,7 @@ import type { AgentConfig } from "../../agents/agents.ts";
5
5
  import { validateAcceptanceInput } from "../shared/acceptance.ts";
6
6
  import { validateToolBudgetConfig } from "../shared/tool-budget.ts";
7
7
  import { intersectSubagentCapabilityCeilings, parseSubagentCapabilityCeiling, type ResolvedSubagentCapabilityCeiling } from "../shared/capability-ceiling.ts";
8
+ import { validateRunFanoutBudgetDescriptor } from "../shared/run-fanout-budget.ts";
8
9
  import { resolveTurnBudgetConfig } from "../shared/turn-budget.ts";
9
10
  import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
10
11
 
@@ -307,7 +308,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
307
308
  "subagentOnlyExtensions", "mcpDirectTools", "systemPrompt", "systemPromptMode", "inheritProjectContext", "inheritSkills", "skills",
308
309
  "skillPath", "agentFilePath", "completionGuard", "memory", "outputPath", "outputMode", "structuredOutputSchema", "acceptance", "sessionDir", "artifactConfig",
309
310
  "artifactsDir", "maxOutput", "controlConfig", "absoluteDeadlineAt", "initialTurnBudget", "initialToolBudget", "maxSubagentDepth", "share", "capabilityCeiling",
310
- "launchResolvedExtensions",
311
+ "launchResolvedExtensions", "runFanoutBudget",
311
312
  ]);
312
313
  for (const field of Object.keys(parsed)) {
313
314
  if (!allowedFields.has(field)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': unknown field '${field}'.`);
@@ -317,6 +318,11 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
317
318
  if (typeof parsed[field] !== "string" || !(parsed[field] as string).trim()) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${field} must be a non-empty string.`);
318
319
  }
319
320
  if (parsed.version !== 1) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': version must be 1.`);
321
+ try {
322
+ parsed.runFanoutBudget = validateRunFanoutBudgetDescriptor(parsed.runFanoutBudget);
323
+ } catch (error) {
324
+ throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${error instanceof Error ? error.message : String(error)}`);
325
+ }
320
326
  if (parsed.capabilityCeiling !== undefined) parsed.capabilityCeiling = parseSubagentCapabilityCeiling(parsed.capabilityCeiling, `async recovery descriptor '${descriptorPath}' capabilityCeiling`);
321
327
  if (parsed.agentContract !== undefined) {
322
328
  if (!parsed.agentContract || typeof parsed.agentContract !== "object" || Array.isArray(parsed.agentContract)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': agentContract must be an object.`);
@@ -5,12 +5,14 @@ import { formatActivityLabel, formatParallelOutcome } from "../../shared/status-
5
5
  import { type ActivityState, type AsyncJobStep, type AsyncParallelGroupStatus, type AsyncStatus, type CostSummary, type Details, type LaunchResolvedChildExtensionsV1, type RuntimeAcknowledgedChildExtensionsV1, type NestedRunSummary, type SteeringStatus, type SubagentRunMode, type TokenUsage, type TurnBudgetState, type UsageBudgetState, type ChainCheckpointState } from "../../shared/types.ts";
6
6
  import type { ResolvedSubagentCapabilityCeiling, SubagentCapabilityAudit } from "../shared/capability-ceiling.ts";
7
7
  import { pruneStatusCacheForAsyncRoot, readStatus } from "../../shared/utils.ts";
8
- import { attachRootChildrenToSteps, buildNestedRouteIndex, type NestedRoute, projectNestedEvents } from "../shared/nested-events.ts";
8
+ import { attachRootChildrenToSteps, buildNestedRouteIndex, findNestedRouteForRootId, type NestedRoute, projectNestedEvents } from "../shared/nested-events.ts";
9
9
  import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
10
+ import { formatRunFanoutBudget, getRunFanoutBudgetSnapshot, readRunFanoutBudgetDescriptor } from "../shared/run-fanout-budget.ts";
10
11
  import { flatToLogicalStepIndex, normalizeParallelGroups } from "./parallel-groups.ts";
11
12
  import { contextModeLabel, summarizeContextModes, type ContextMode, type ContextSummary } from "../shared/context-mode.ts";
12
13
  import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
13
14
  import { readProcessTerminal, sanitizeProcessTerminal } from "./process-terminal.ts";
15
+ import { ACTIVE_RUN_INDEX_DIR, isActiveAsyncState, readActiveRunIndex, releaseActiveRunIndex, updateActiveRunIndex } from "./active-run-index.ts";
14
16
 
15
17
  interface AsyncRunStepSummary {
16
18
  index: number;
@@ -105,6 +107,7 @@ export interface AsyncRunSummary {
105
107
  nestedChildren?: NestedRunSummary[];
106
108
  nestedWarnings?: string[];
107
109
  processTerminal?: AsyncStatus["processTerminal"];
110
+ runFanoutBudget?: AsyncStatus["runFanoutBudget"];
108
111
  launchResolvedExtensions?: LaunchResolvedChildExtensionsV1;
109
112
  runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensionsV1;
110
113
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
@@ -160,7 +163,7 @@ type TargetedAsyncRunResolution =
160
163
  * accepting a path whose canonical location escaped the async root.
161
164
  */
162
165
  export function resolveTargetedAsyncRun(asyncDirRoot: string, id: string, sessionId?: string): TargetedAsyncRunResolution {
163
- if (!id || id === "." || id === ".." || path.basename(id) !== id) return { kind: "reject" };
166
+ if (!id || id === "." || id === ".." || id === ACTIVE_RUN_INDEX_DIR || path.basename(id) !== id) return { kind: "reject" };
164
167
  const asyncDir = path.join(asyncDirRoot, id);
165
168
  let entryStat: fs.Stats;
166
169
  try {
@@ -221,6 +224,8 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
221
224
  const { activityState, lastActivityAt } = deriveAsyncActivityState(asyncDir, status);
222
225
  const processTerminal = readProcessTerminal(asyncDir, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId })
223
226
  ?? sanitizeProcessTerminal(status.processTerminal, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId }, path.join(asyncDir, "status.json"));
227
+ const runFanoutBudgetDescriptor = readRunFanoutBudgetDescriptor(asyncDir);
228
+ const runFanoutBudget = runFanoutBudgetDescriptor ? getRunFanoutBudgetSnapshot(runFanoutBudgetDescriptor) : status.runFanoutBudget;
224
229
  const steps = status.steps ?? [];
225
230
  const chainStepCount = status.chainStepCount ?? steps.length;
226
231
  const parallelGroups = normalizeParallelGroups(status.parallelGroups, steps.length, chainStepCount);
@@ -326,6 +331,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
326
331
  ...(nestedChildren.length ? { nestedChildren } : {}),
327
332
  ...(nestedWarnings.length ? { nestedWarnings } : {}),
328
333
  ...(processTerminal ? { processTerminal } : {}),
334
+ ...(runFanoutBudget ? { runFanoutBudget } : {}),
329
335
  ...(status.launchContractDigest ? { launchContractDigest: status.launchContractDigest } : {}),
330
336
  ...(status.launchResolvedExtensions ? { launchResolvedExtensions: status.launchResolvedExtensions } : {}),
331
337
  ...(status.runtimeAcknowledgedExtensions ? { runtimeAcknowledgedExtensions: status.runtimeAcknowledgedExtensions } : {}),
@@ -371,6 +377,12 @@ function sortRuns(runs: AsyncRunSummary[]): AsyncRunSummary[] {
371
377
  export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions = {}): AsyncRunSummary[] {
372
378
  let entries: string[];
373
379
  let scannedCompleteRoot = false;
380
+ let usedActiveIndex = false;
381
+ const activeOnly = options.runId === undefined
382
+ && options.entryLimit === undefined
383
+ && options.states !== undefined
384
+ && options.states.length > 0
385
+ && options.states.every(isActiveAsyncState);
374
386
  try {
375
387
  if (options.runId !== undefined) {
376
388
  const resolution = resolveTargetedAsyncRun(asyncDirRoot, options.runId, options.sessionId);
@@ -382,8 +394,15 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
382
394
  && resolveTargetedAsyncRun(asyncDirRoot, entry, options.sessionId).kind === "exact"
383
395
  )
384
396
  : [];
397
+ } else if (activeOnly) {
398
+ entries = (readActiveRunIndex(asyncDirRoot) ?? []).filter((entry) => {
399
+ if (resolveTargetedAsyncRun(asyncDirRoot, entry).kind === "exact") return true;
400
+ updateActiveRunIndex(path.join(asyncDirRoot, entry), "failed");
401
+ return false;
402
+ });
403
+ usedActiveIndex = true;
385
404
  } else {
386
- entries = fs.readdirSync(asyncDirRoot).filter((entry) => isAsyncRunDir(asyncDirRoot, entry));
405
+ entries = fs.readdirSync(asyncDirRoot).filter((entry) => entry !== ACTIVE_RUN_INDEX_DIR && isAsyncRunDir(asyncDirRoot, entry));
387
406
  scannedCompleteRoot = true;
388
407
  }
389
408
  } catch (error) {
@@ -424,6 +443,7 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
424
443
  // entirely when no active runs match.
425
444
  let nestedRouteIndex: Map<string, NestedRoute> | undefined;
426
445
  const resolveNestedRoute = (rootRunId: string): NestedRoute | undefined => {
446
+ if (usedActiveIndex) return findNestedRouteForRootId(rootRunId);
427
447
  if (!nestedRouteIndex) nestedRouteIndex = buildNestedRouteIndex();
428
448
  return nestedRouteIndex.get(rootRunId);
429
449
  };
@@ -433,7 +453,15 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
433
453
  ? undefined
434
454
  : reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
435
455
  const status = (reconciliation?.status ?? readStatus(asyncDir)) as (AsyncStatus & { cwd?: string }) | null;
436
- if (!status) continue;
456
+ if (!status) {
457
+ if (usedActiveIndex) updateActiveRunIndex(asyncDir, "failed");
458
+ continue;
459
+ }
460
+ if (usedActiveIndex && !isActiveAsyncState(status.state)) {
461
+ const processTerminal = readProcessTerminal(asyncDir, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId });
462
+ if (processTerminal?.state === "observed") releaseActiveRunIndex(asyncDir);
463
+ }
464
+ if (status.displayDismissedAt !== undefined) continue;
437
465
  // Filter before the nested-route lookup: the lookup builds an index over
438
466
  // the nested-events directory, so deferring it for filtered-out runs keeps
439
467
  // restoration at load from scanning that directory when no active runs
@@ -536,6 +564,7 @@ export function formatAsyncRunList(runs: AsyncRunSummary[], heading = "Active as
536
564
  const attached = new Set(run.steps.flatMap((step) => step.children?.map((child) => child.id) ?? []));
537
565
  const unattached = run.nestedChildren?.filter((child) => !attached.has(child.id)) ?? [];
538
566
  lines.push(...formatNestedRunStatusLines(unattached, { indent: " ", maxLines: 12 }));
567
+ if (run.runFanoutBudget) lines.push(` ${formatRunFanoutBudget(run.runFanoutBudget)}`);
539
568
  if (run.error) lines.push(` Error: ${run.error}`);
540
569
  for (const warning of run.nestedWarnings ?? []) lines.push(` Warning: ${warning}`);
541
570
  const outputPath = formatAsyncRunOutputPath(run);