pi-subagents 0.46.0 → 0.47.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/agents/reviewer.md +3 -4
  3. package/docs/agents.md +1 -1
  4. package/docs/configuration.md +14 -6
  5. package/docs/extension-api.md +1 -1
  6. package/docs/missions.md +5 -3
  7. package/docs/models.md +3 -1
  8. package/docs/observability.md +3 -3
  9. package/docs/tool-reference.md +2 -2
  10. package/docs/watchdog.md +1 -1
  11. package/package.json +1 -1
  12. package/skills/pi-subagents/references/execution-controls.md +4 -4
  13. package/skills/pi-subagents/references/management-authoring-rpc.md +1 -1
  14. package/src/extension/config.ts +13 -0
  15. package/src/extension/fanout-child.ts +5 -4
  16. package/src/extension/index.ts +39 -14
  17. package/src/extension/rpc.ts +7 -6
  18. package/src/extension/schemas.ts +27 -7
  19. package/src/extension/tool-description.ts +26 -8
  20. package/src/inspectors/herdr/project-panes.ts +2 -1
  21. package/src/intercom/native-supervisor-channel.ts +102 -4
  22. package/src/missions/store.ts +2 -1
  23. package/src/missions/workflow-state.ts +19 -13
  24. package/src/runs/background/active-run-index.ts +42 -0
  25. package/src/runs/background/async-execution.ts +10 -5
  26. package/src/runs/background/async-job-tracker.ts +293 -133
  27. package/src/runs/background/async-resume.ts +19 -3
  28. package/src/runs/background/async-status.ts +33 -5
  29. package/src/runs/background/control-channel.ts +91 -17
  30. package/src/runs/background/result-watcher.ts +102 -7
  31. package/src/runs/background/run-status.ts +18 -0
  32. package/src/runs/background/scheduled-runs.ts +25 -1
  33. package/src/runs/background/stale-run-reconciler.ts +12 -24
  34. package/src/runs/background/subagent-runner.ts +68 -10
  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 +3 -0
  38. package/src/runs/foreground/execution.ts +3 -0
  39. package/src/runs/foreground/subagent-executor.ts +129 -20
  40. package/src/runs/foreground/workflow-foreground-steering.ts +187 -0
  41. package/src/runs/shared/dynamic-fanout.ts +1 -1
  42. package/src/runs/shared/model-fallback.ts +8 -4
  43. package/src/runs/shared/model-scope.ts +12 -2
  44. package/src/runs/shared/parallel-utils.ts +1 -0
  45. package/src/runs/shared/subagent-prompt-runtime.ts +24 -5
  46. package/src/runs/shared/worktree.ts +3 -2
  47. package/src/shared/artifacts.ts +15 -15
  48. package/src/shared/display-text.ts +100 -0
  49. package/src/shared/file-coalescer.ts +9 -0
  50. package/src/shared/formatters.ts +4 -6
  51. package/src/shared/settings.ts +15 -2
  52. package/src/shared/types.ts +16 -2
  53. package/src/shared/utils.ts +43 -33
  54. package/src/slash/slash-commands.ts +3 -1
  55. package/src/tui/fleet-status.ts +14 -10
  56. package/src/tui/render.ts +30 -26
  57. package/src/watchdog/change-signature.ts +4 -3
  58. package/src/workflows/scripted-workflow.ts +4 -1
@@ -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,12 +55,19 @@ 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())) => {
70
+ if (state.widgetsSuspended) return;
60
71
  renderWidget(ctx, options.widgetEnabled === false ? [] : jobs);
61
72
  (ctx.ui as { requestRender?: () => void }).requestRender?.();
62
73
  };
@@ -73,6 +84,19 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
73
84
  throw error;
74
85
  }
75
86
  };
87
+ const requestLastWidgetRender = () => {
88
+ const ctx = state.lastUiContext;
89
+ if (!ctx || state.widgetsSuspended || options.widgetEnabled === false) return;
90
+ try {
91
+ if (ctx.hasUI) (ctx.ui as { requestRender?: () => void }).requestRender?.();
92
+ } catch (error) {
93
+ if (error instanceof Error && error.message.includes("extension ctx is stale")) {
94
+ state.lastUiContext = null;
95
+ return;
96
+ }
97
+ throw error;
98
+ }
99
+ };
76
100
  const refreshWidget = (ctx: ExtensionContext) => rerenderWidget(ctx);
77
101
  const restoredControlEventCursor = (asyncDir: string) => {
78
102
  try {
@@ -82,6 +106,14 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
82
106
  throw error;
83
107
  }
84
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
+ };
85
117
  const summaryToJob = (run: AsyncRunSummary): AsyncJobState => {
86
118
  const groups = normalizeParallelGroups(run.parallelGroups, run.steps.length, run.chainStepCount ?? run.steps.length);
87
119
  const activeGroup = run.currentStep !== undefined
@@ -130,6 +162,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
130
162
  totalTokens: run.totalTokens,
131
163
  sessionFile: run.sessionFile,
132
164
  controlEventCursor: restoredControlEventCursor(run.asyncDir),
165
+ nestedRoute: findNestedRouteForJob(run.id),
133
166
  nestedChildren: run.nestedChildren,
134
167
  parentWorkflowRunId: run.parentWorkflowRunId,
135
168
  workflowKey: run.workflowKey,
@@ -146,6 +179,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
146
179
  cancelCleanup(asyncId);
147
180
  const timer = setTimeout(() => {
148
181
  state.cleanupTimers.delete(asyncId);
182
+ closeJobWatcher(asyncId);
149
183
  state.asyncJobs.delete(asyncId);
150
184
  rerenderLastWidget();
151
185
  }, completionRetentionMs);
@@ -268,139 +302,245 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
268
302
  }
269
303
  };
270
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
+
271
519
  const ensurePoller = () => {
520
+ watchAsyncRoot();
272
521
  if (state.poller) return;
522
+ nextLivenessAt = Date.now() + livenessIntervalMs;
273
523
  state.poller = setInterval(() => {
274
524
  if (state.asyncJobs.size === 0) {
275
525
  rerenderLastWidget([]);
276
- if (state.poller) {
277
- clearInterval(state.poller);
278
- state.poller = null;
279
- }
526
+ if (state.poller) clearInterval(state.poller);
527
+ state.poller = null;
528
+ rootWatcher?.close();
529
+ rootWatcher = undefined;
280
530
  return;
281
531
  }
282
-
283
- let widgetChanged = false;
284
- for (const job of state.asyncJobs.values()) {
285
- const widgetStateBefore = widgetRenderKey(job);
286
- let nestedRefreshFailed = false;
287
- const refreshNestedProjection = () => {
288
- try {
289
- updateAsyncJobNestedProjection(job);
290
- } catch (error) {
291
- nestedRefreshFailed = true;
292
- console.error(`Failed to refresh nested async descendants for '${job.asyncDir}':`, error);
293
- }
294
- };
295
- const reconcileNestedDescendants = () => {
296
- try {
297
- if (job.nestedRoute) reconcileNestedAsyncDescendants(job.nestedRoute, { resultsDir, kill: options.kill, now: options.now });
298
- } catch (error) {
299
- nestedRefreshFailed = true;
300
- console.error(`Failed to refresh nested async descendants for '${job.asyncDir}':`, error);
301
- }
302
- refreshNestedProjection();
303
- };
304
- try {
305
- emitNewControlEvents(job);
306
- reconcileNestedDescendants();
307
- const reconciliation = reconcileAsyncRun(job.asyncDir, {
308
- resultsDir,
309
- kill: options.kill,
310
- now: options.now,
311
- startedRun: {
312
- runId: job.asyncId,
313
- pid: job.pid,
314
- sessionId: job.sessionId,
315
- mode: job.mode,
316
- agents: job.agents,
317
- chainStepCount: job.chainStepCount,
318
- parallelGroups: job.parallelGroups,
319
- startedAt: job.startedAt,
320
- sessionFile: job.sessionFile,
321
- },
322
- });
323
- const status = reconciliation.status ?? readStatus(job.asyncDir);
324
- if (status) {
325
- const previousStatus = job.status;
326
- job.status = status.state;
327
- if (job.status !== "complete" && job.status !== "failed" && job.status !== "paused" && job.status !== "stopped") cancelCleanup(job.asyncId);
328
- job.sessionId = status.sessionId ?? job.sessionId;
329
- job.activityState = status.activityState;
330
- job.lastActivityAt = status.lastActivityAt ?? job.lastActivityAt;
331
- job.currentTool = status.currentTool;
332
- job.currentToolStartedAt = status.currentToolStartedAt;
333
- job.currentPath = status.currentPath;
334
- job.turnCount = status.turnCount ?? job.turnCount;
335
- job.toolCount = status.toolCount ?? job.toolCount;
336
- job.steering = status.steering ?? job.steering;
337
- job.mode = status.mode;
338
- job.parentWorkflowRunId = status.parentWorkflowRunId ?? job.parentWorkflowRunId;
339
- job.workflowKey = status.workflowKey ?? job.workflowKey;
340
- job.workflow = status.workflow ?? job.workflow;
341
- job.currentStep = status.currentStep ?? job.currentStep;
342
- job.chainStepCount = status.chainStepCount ?? job.chainStepCount;
343
- job.startedAt = status.startedAt ?? job.startedAt;
344
- if (status.lastUpdate !== undefined) job.updatedAt = status.lastUpdate;
345
- if (status.steps?.length) {
346
- const groups = normalizeParallelGroups(status.parallelGroups, status.steps.length, status.chainStepCount ?? status.steps.length);
347
- job.parallelGroups = groups.length ? groups : job.parallelGroups;
348
- job.hasParallelGroups = groups.length > 0 || job.hasParallelGroups;
349
- const activeGroup = status.currentStep !== undefined
350
- ? groups.find((group) => status.currentStep! >= group.start && status.currentStep! < group.start + group.count)
351
- : undefined;
352
- const visibleSteps = activeGroup
353
- ? status.steps.slice(activeGroup.start, activeGroup.start + activeGroup.count).map((step, index) => ({ ...step, index: activeGroup.start + index }))
354
- : status.steps.map((step, index) => ({ ...step, index }));
355
- job.activeParallelGroup = Boolean(activeGroup);
356
- job.agents = visibleSteps.map((step) => step.agent);
357
- job.steps = visibleSteps;
358
- refreshNestedProjection();
359
- job.stepsTotal = visibleSteps.length;
360
- job.runningSteps = visibleSteps.filter((step) => step.status === "running").length;
361
- job.completedSteps = visibleSteps.filter((step) => step.status === "complete" || step.status === "completed").length;
362
- if (status.state === "complete") job.completedSteps = visibleSteps.length;
363
- }
364
- job.sessionDir = status.sessionDir ?? job.sessionDir;
365
- job.outputFile = status.outputFile ?? job.outputFile;
366
- job.totalTokens = status.totalTokens ?? job.totalTokens;
367
- job.timeoutMs = status.timeoutMs ?? job.timeoutMs;
368
- job.deadlineAt = status.deadlineAt ?? job.deadlineAt;
369
- job.timedOut = status.timedOut ?? job.timedOut;
370
- job.stopped = status.stopped ?? job.stopped;
371
- job.turnBudget = status.turnBudget ?? job.turnBudget;
372
- job.turnBudgetExceeded = status.turnBudgetExceeded ?? job.turnBudgetExceeded;
373
- job.wrapUpRequested = status.wrapUpRequested ?? job.wrapUpRequested;
374
- job.sessionFile = status.sessionFile ?? job.sessionFile;
375
- if (job.status === "complete" || job.status === "failed" || job.status === "paused" || job.status === "stopped") {
376
- rememberFleetJob(state, job);
377
- if (!nestedRefreshFailed && !hasLiveNestedDescendants(job.nestedChildren) && (previousStatus !== job.status || !state.cleanupTimers.has(job.asyncId))) {
378
- scheduleCleanup(job.asyncId);
379
- }
380
- }
381
- if (widgetRenderKey(job) !== widgetStateBefore) widgetChanged = true;
382
- continue;
383
- }
384
- if (job.status === "queued") {
385
- job.status = "running";
386
- job.updatedAt = Date.now();
387
- }
388
- } catch (error) {
389
- if (job.status !== "failed") {
390
- console.error(`Failed to read async status for '${job.asyncDir}':`, error);
391
- job.status = "failed";
392
- job.updatedAt = Date.now();
393
- }
394
- rememberFleetJob(state, job);
395
- if (!hasLiveNestedDescendants(job.nestedChildren) && !state.cleanupTimers.has(job.asyncId)) {
396
- scheduleCleanup(job.asyncId);
397
- }
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;
398
539
  }
399
- if (widgetRenderKey(job) !== widgetStateBefore) widgetChanged = true;
540
+ if (widgetChanged) rerenderLastWidget();
400
541
  }
401
-
402
- if (widgetChanged) rerenderLastWidget();
403
- }, pollIntervalMs);
542
+ if (runningJobIds.size > 0) requestLastWidgetRender();
543
+ }, Math.min(POLL_INTERVAL_MS, livenessIntervalMs));
404
544
  state.poller.unref?.();
405
545
  };
406
546
 
@@ -442,7 +582,10 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
442
582
  workflowKey: info.workflowKey,
443
583
  controlEventCursor: 0,
444
584
  });
445
- 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);
446
589
  ensurePoller();
447
590
  rerenderLastWidget();
448
591
  };
@@ -456,9 +599,14 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
456
599
  let nestedRefreshFailed = false;
457
600
  if (job) {
458
601
  job.status = result.state ?? (result.success ? "complete" : "failed");
602
+ runningJobIds.delete(asyncId);
459
603
  job.stopped = result.stopped ?? job.stopped;
460
604
  job.updatedAt = Date.now();
461
- 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
+ }
462
610
  try {
463
611
  updateAsyncJobNestedProjection(job);
464
612
  } catch (error) {
@@ -471,10 +619,20 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
471
619
  if (!nestedRefreshFailed && !hasLiveNestedDescendants(job?.nestedChildren)) scheduleCleanup(asyncId);
472
620
  };
473
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
+
474
633
  const resetJobs = (ctx?: ExtensionContext) => {
475
- for (const timer of state.cleanupTimers.values()) {
476
- clearTimeout(timer);
477
- }
634
+ dispose();
635
+ for (const timer of state.cleanupTimers.values()) clearTimeout(timer);
478
636
  state.cleanupTimers.clear();
479
637
  state.asyncJobs.clear();
480
638
  state.fleetJobs?.clear();
@@ -500,12 +658,14 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
500
658
  for (const run of runs) {
501
659
  const job = summaryToJob(run);
502
660
  state.asyncJobs.set(run.id, job);
661
+ if (job.status === "running") runningJobIds.add(job.asyncId);
503
662
  rememberFleetJob(state, job);
663
+ watchJob(job);
504
664
  }
505
665
  if (runs.length === 0) return;
506
666
  ensurePoller();
507
667
  rerenderLastWidget();
508
668
  };
509
669
 
510
- return { ensurePoller, refreshWidget, handleStarted, handleComplete, resetJobs, restoreActiveJobs };
670
+ return { ensurePoller, refreshWidget, handleStarted, handleComplete, resetJobs, restoreActiveJobs, dispose };
511
671
  }
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { DIRS, type AcceptanceInput, type AsyncStatus, type SteeringRecoveryDescriptor } from "../../shared/types.ts";
3
+ import { DIRS, type AcceptanceInput, type AsyncStatus, type ResolvedTurnBudget, type SteeringRecoveryDescriptor } from "../../shared/types.ts";
4
4
  import type { AgentConfig } from "../../agents/agents.ts";
5
5
  import { validateAcceptanceInput } from "../shared/acceptance.ts";
6
6
  import { validateToolBudgetConfig } from "../shared/tool-budget.ts";
@@ -273,6 +273,23 @@ function normalizeRecoveryAcceptance(value: unknown, descriptorPath: string): Ac
273
273
  return value as AcceptanceInput;
274
274
  }
275
275
 
276
+ function normalizeRecoveryTurnBudget(value: unknown, descriptorPath: string): ResolvedTurnBudget {
277
+ if (value && typeof value === "object" && !Array.isArray(value)) {
278
+ const {
279
+ outcome: _outcome,
280
+ turnCount: _turnCount,
281
+ wrapUpRequestedAtTurn: _wrapUpRequestedAtTurn,
282
+ terminationDeferredAtTurn: _terminationDeferredAtTurn,
283
+ exceededAtTurn: _exceededAtTurn,
284
+ ...publicTurnBudget
285
+ } = value as Record<string, unknown>;
286
+ value = publicTurnBudget;
287
+ }
288
+ const result = resolveTurnBudgetConfig(value, "recoveryDescriptor.initialTurnBudget");
289
+ if (result.error || !result.turnBudget) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${result.error ?? "recoveryDescriptor.initialTurnBudget is invalid."}`);
290
+ return result.turnBudget;
291
+ }
292
+
276
293
  export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): SteeringRecoveryDescriptor | undefined {
277
294
  if (!asyncDir) return undefined;
278
295
  const descriptorPath = path.join(asyncDir, "recovery-descriptor.json");
@@ -329,8 +346,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
329
346
  }
330
347
  if (parsed.absoluteDeadlineAt !== undefined && (!Number.isFinite(parsed.absoluteDeadlineAt) || (parsed.absoluteDeadlineAt as number) <= 0)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': absoluteDeadlineAt must be a positive timestamp.`);
331
348
  if (parsed.initialTurnBudget !== undefined) {
332
- const result = resolveTurnBudgetConfig(parsed.initialTurnBudget, "recoveryDescriptor.initialTurnBudget");
333
- if (result.error) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${result.error}`);
349
+ parsed.initialTurnBudget = normalizeRecoveryTurnBudget(parsed.initialTurnBudget, descriptorPath);
334
350
  }
335
351
  if (parsed.initialToolBudget !== undefined) {
336
352
  const result = validateToolBudgetConfig(parsed.initialToolBudget, "recoveryDescriptor.initialToolBudget");
@@ -4,13 +4,14 @@ import { formatDuration, formatModelThinking, formatTokens, shortenPath } from "
4
4
  import { formatActivityLabel, formatParallelOutcome } from "../../shared/status-format.ts";
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
- import { readStatus } from "../../shared/utils.ts";
8
- import { attachRootChildrenToSteps, buildNestedRouteIndex, type NestedRoute, projectNestedEvents } from "../shared/nested-events.ts";
7
+ import { pruneStatusCacheForAsyncRoot, readStatus } from "../../shared/utils.ts";
8
+ import { attachRootChildrenToSteps, buildNestedRouteIndex, findNestedRouteForRootId, type NestedRoute, projectNestedEvents } from "../shared/nested-events.ts";
9
9
  import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
10
10
  import { flatToLogicalStepIndex, normalizeParallelGroups } from "./parallel-groups.ts";
11
11
  import { contextModeLabel, summarizeContextModes, type ContextMode, type ContextSummary } from "../shared/context-mode.ts";
12
12
  import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
13
13
  import { readProcessTerminal, sanitizeProcessTerminal } from "./process-terminal.ts";
14
+ import { ACTIVE_RUN_INDEX_DIR, isActiveAsyncState, readActiveRunIndex, updateActiveRunIndex } from "./active-run-index.ts";
14
15
 
15
16
  interface AsyncRunStepSummary {
16
17
  index: number;
@@ -160,7 +161,7 @@ type TargetedAsyncRunResolution =
160
161
  * accepting a path whose canonical location escaped the async root.
161
162
  */
162
163
  export function resolveTargetedAsyncRun(asyncDirRoot: string, id: string, sessionId?: string): TargetedAsyncRunResolution {
163
- if (!id || id === "." || id === ".." || path.basename(id) !== id) return { kind: "reject" };
164
+ if (!id || id === "." || id === ".." || id === ACTIVE_RUN_INDEX_DIR || path.basename(id) !== id) return { kind: "reject" };
164
165
  const asyncDir = path.join(asyncDirRoot, id);
165
166
  let entryStat: fs.Stats;
166
167
  try {
@@ -370,6 +371,13 @@ function sortRuns(runs: AsyncRunSummary[]): AsyncRunSummary[] {
370
371
 
371
372
  export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions = {}): AsyncRunSummary[] {
372
373
  let entries: string[];
374
+ let scannedCompleteRoot = false;
375
+ let usedActiveIndex = false;
376
+ const activeOnly = options.runId === undefined
377
+ && options.entryLimit === undefined
378
+ && options.states !== undefined
379
+ && options.states.length > 0
380
+ && options.states.every(isActiveAsyncState);
373
381
  try {
374
382
  if (options.runId !== undefined) {
375
383
  const resolution = resolveTargetedAsyncRun(asyncDirRoot, options.runId, options.sessionId);
@@ -381,8 +389,16 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
381
389
  && resolveTargetedAsyncRun(asyncDirRoot, entry, options.sessionId).kind === "exact"
382
390
  )
383
391
  : [];
392
+ } else if (activeOnly) {
393
+ entries = (readActiveRunIndex(asyncDirRoot) ?? []).filter((entry) => {
394
+ if (resolveTargetedAsyncRun(asyncDirRoot, entry).kind === "exact") return true;
395
+ updateActiveRunIndex(path.join(asyncDirRoot, entry), "failed");
396
+ return false;
397
+ });
398
+ usedActiveIndex = true;
384
399
  } else {
385
- entries = fs.readdirSync(asyncDirRoot).filter((entry) => isAsyncRunDir(asyncDirRoot, entry));
400
+ entries = fs.readdirSync(asyncDirRoot).filter((entry) => entry !== ACTIVE_RUN_INDEX_DIR && isAsyncRunDir(asyncDirRoot, entry));
401
+ scannedCompleteRoot = true;
386
402
  }
387
403
  } catch (error) {
388
404
  if (isNotFoundError(error)) return [];
@@ -392,6 +408,7 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
392
408
  }
393
409
 
394
410
  if (options.entryLimit !== undefined) {
411
+ scannedCompleteRoot = false;
395
412
  const limit = Math.max(0, Math.floor(options.entryLimit));
396
413
  entries = entries
397
414
  .map((entry) => {
@@ -410,6 +427,8 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
410
427
  .map((candidate) => candidate.entry);
411
428
  }
412
429
 
430
+ if (scannedCompleteRoot) pruneStatusCacheForAsyncRoot(asyncDirRoot, entries);
431
+
413
432
  const allowedStates = options.states ? new Set(options.states) : undefined;
414
433
  const runs: AsyncRunSummary[] = [];
415
434
  // Route resolution for every run shares a single index built from the
@@ -419,6 +438,7 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
419
438
  // entirely when no active runs match.
420
439
  let nestedRouteIndex: Map<string, NestedRoute> | undefined;
421
440
  const resolveNestedRoute = (rootRunId: string): NestedRoute | undefined => {
441
+ if (usedActiveIndex) return findNestedRouteForRootId(rootRunId);
422
442
  if (!nestedRouteIndex) nestedRouteIndex = buildNestedRouteIndex();
423
443
  return nestedRouteIndex.get(rootRunId);
424
444
  };
@@ -428,7 +448,15 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
428
448
  ? undefined
429
449
  : reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
430
450
  const status = (reconciliation?.status ?? readStatus(asyncDir)) as (AsyncStatus & { cwd?: string }) | null;
431
- if (!status) continue;
451
+ if (!status) {
452
+ if (usedActiveIndex) updateActiveRunIndex(asyncDir, "failed");
453
+ continue;
454
+ }
455
+ if (status.displayDismissedAt !== undefined) {
456
+ if (usedActiveIndex) updateActiveRunIndex(asyncDir, "complete");
457
+ continue;
458
+ }
459
+ if (usedActiveIndex && !isActiveAsyncState(status.state)) updateActiveRunIndex(asyncDir, status.state);
432
460
  // Filter before the nested-route lookup: the lookup builds an index over
433
461
  // the nested-events directory, so deferring it for filtered-out runs keeps
434
462
  // restoration at load from scanning that directory when no active runs