pi-subagents 0.45.2 → 0.47.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 (70) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +2 -0
  3. package/docs/agents.md +342 -0
  4. package/docs/configuration.md +328 -0
  5. package/docs/extension-api.md +308 -0
  6. package/docs/missions.md +119 -0
  7. package/docs/models.md +192 -0
  8. package/docs/observability.md +174 -0
  9. package/docs/tool-reference.md +343 -0
  10. package/docs/watchdog.md +176 -0
  11. package/docs/workflows.md +163 -0
  12. package/package.json +4 -2
  13. package/skills/pi-subagents/references/execution-controls.md +6 -6
  14. package/skills/pi-subagents/references/management-authoring-rpc.md +1 -1
  15. package/src/agents/agents.ts +17 -8
  16. package/src/agents/frontmatter.ts +7 -3
  17. package/src/agents/skills.ts +2 -9
  18. package/src/api/project-panes.ts +30 -0
  19. package/src/extension/config.ts +18 -1
  20. package/src/extension/fanout-child.ts +5 -4
  21. package/src/extension/index.ts +66 -19
  22. package/src/extension/rpc.ts +3 -6
  23. package/src/extension/schemas.ts +28 -7
  24. package/src/extension/subagent-guide.ts +39 -0
  25. package/src/extension/tool-description.ts +30 -12
  26. package/src/inspectors/herdr/project-panes.ts +459 -63
  27. package/src/missions/actions.ts +25 -2
  28. package/src/missions/lifecycle.ts +21 -2
  29. package/src/missions/store.ts +79 -2
  30. package/src/missions/types.ts +33 -0
  31. package/src/missions/workflow-state.ts +19 -13
  32. package/src/runs/background/async-execution.ts +17 -6
  33. package/src/runs/background/async-job-tracker.ts +15 -0
  34. package/src/runs/background/async-resume.ts +19 -3
  35. package/src/runs/background/async-status.ts +6 -1
  36. package/src/runs/background/completion-replay.ts +267 -0
  37. package/src/runs/background/control-channel.ts +36 -0
  38. package/src/runs/background/result-watcher.ts +28 -6
  39. package/src/runs/background/scheduled-runs.ts +2 -1
  40. package/src/runs/background/stale-run-reconciler.ts +2 -21
  41. package/src/runs/background/subagent-runner.ts +47 -6
  42. package/src/runs/background/wait-completions.ts +39 -5
  43. package/src/runs/background/wait-subscriptions.ts +18 -3
  44. package/src/runs/foreground/async-steering-action.ts +1 -1
  45. package/src/runs/foreground/chain-execution.ts +3 -0
  46. package/src/runs/foreground/execution.ts +7 -0
  47. package/src/runs/foreground/foreground-history.ts +137 -0
  48. package/src/runs/foreground/subagent-executor.ts +403 -54
  49. package/src/runs/foreground/workflow-foreground-steering.ts +187 -0
  50. package/src/runs/shared/dynamic-fanout.ts +1 -1
  51. package/src/runs/shared/model-fallback.ts +8 -4
  52. package/src/runs/shared/model-scope.ts +12 -2
  53. package/src/runs/shared/parallel-utils.ts +1 -0
  54. package/src/runs/shared/worktree.ts +3 -2
  55. package/src/shared/artifacts.ts +14 -14
  56. package/src/shared/display-text.ts +100 -0
  57. package/src/shared/fork-context.ts +13 -0
  58. package/src/shared/formatters.ts +4 -6
  59. package/src/shared/prompt-resources.ts +51 -0
  60. package/src/shared/settings.ts +15 -2
  61. package/src/shared/types.ts +41 -2
  62. package/src/shared/utf8.ts +11 -0
  63. package/src/shared/utils.ts +43 -33
  64. package/src/slash/prompt-workflows.ts +2 -15
  65. package/src/slash/slash-commands.ts +22 -2
  66. package/src/tui/fleet-status.ts +22 -12
  67. package/src/tui/fleet.ts +135 -25
  68. package/src/tui/render.ts +150 -33
  69. package/src/watchdog/change-signature.ts +4 -3
  70. package/src/workflows/scripted-workflow.ts +167 -10
@@ -160,7 +160,7 @@ function persistedBinding(binding: MissionLaunchBinding): PersistedMissionBindin
160
160
  };
161
161
  }
162
162
 
163
- function writeAsyncBinding(asyncDir: string, binding: MissionLaunchBinding): void {
163
+ export function writeMissionAsyncBinding(asyncDir: string, binding: MissionLaunchBinding): void {
164
164
  writePrivateAtomicJson(path.join(asyncDir, MISSION_BINDING_FILE), persistedBinding(binding));
165
165
  }
166
166
 
@@ -210,7 +210,7 @@ export function attachMissionToLaunchResult(input: {
210
210
  ...(input.result.details.results.length === 1 && input.result.details.results[0]?.acceptance ? { acceptance: input.result.details.results[0].acceptance } : {}),
211
211
  });
212
212
  if (input.result.details.asyncDir) {
213
- writeAsyncBinding(input.result.details.asyncDir, input.binding);
213
+ writeMissionAsyncBinding(input.result.details.asyncDir, input.binding);
214
214
  const statusPath = path.join(input.result.details.asyncDir, "status.json");
215
215
  if (fs.existsSync(statusPath)) {
216
216
  try {
@@ -337,10 +337,29 @@ export function syncMissionFromAsyncCompletion(value: unknown): MissionRecord |
337
337
  return total + (usageFromUnknown((result as { tokens?: unknown }).tokens)?.tokens ?? 0);
338
338
  }, 0) }
339
339
  : undefined);
340
+ const workflowRunId = typeof event.parentWorkflowRunId === "string" && event.parentWorkflowRunId.trim() ? event.parentWorkflowRunId.trim() : undefined;
341
+ const workflowKey = typeof event.workflowKey === "string" && event.workflowKey.trim() ? event.workflowKey.trim() : undefined;
342
+ const workflowChildStatus = runStatus === "complete" || runStatus === "completed" || event.success === true
343
+ ? "completed"
344
+ : runStatus === "paused"
345
+ ? "paused"
346
+ : runStatus === "stopped"
347
+ ? "stopped"
348
+ : "failed";
349
+ const workflowChildTerminal = !["running", "queued", "active", "paused"].includes(workflowChildStatus);
340
350
  return updateMission(binding.location, binding.missionId, {
341
351
  status: missionStatusForRun(current, runId, runStatus),
342
352
  addRuns: [{ runId, mode: typeof event.mode === "string" && ["single", "parallel", "chain", "workflow"].includes(event.mode) ? event.mode as SubagentRunMode : "external", asyncDir: event.asyncDir, status: runStatus, completedAt, ...(usage && usage.tokens > 0 ? { usage } : {}) }],
343
353
  addArtifacts: artifacts,
354
+ ...(workflowRunId && workflowKey ? { upsertWorkflowChildren: [{
355
+ workflowRunId,
356
+ key: workflowKey,
357
+ runId,
358
+ status: workflowChildStatus,
359
+ artifactPaths: artifacts.map((artifact) => artifact.path),
360
+ ...(workflowChildTerminal ? { completedAt } : {}),
361
+ heartbeat: { status: workflowChildStatus, ...(summary ? { message: summary } : {}) },
362
+ }] } : {}),
344
363
  ...(summary ? { summary } : {}),
345
364
  });
346
365
  }
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
+ import { getProjectSubagentsDir } from "../shared/artifacts.ts";
5
6
  import { writePrivateAtomicJson } from "../shared/atomic-json.ts";
6
7
  import { getAgentDir } from "../shared/utils.ts";
7
8
  import {
@@ -27,6 +28,7 @@ import {
27
28
  type MissionTokenBudget,
28
29
  type MissionTokenUsage,
29
30
  type MissionUpdateInput,
31
+ type MissionWorkflowChild,
30
32
  } from "./types.ts";
31
33
 
32
34
  const MISSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
@@ -149,6 +151,33 @@ function parseDecision(value: unknown, label: string): MissionDecision {
149
151
  };
150
152
  }
151
153
 
154
+ function parseWorkflowChild(value: unknown, label: string): MissionWorkflowChild {
155
+ const input = asObject(value, label);
156
+ const artifactPaths = input.artifactPaths === undefined ? [] : stringArray(input.artifactPaths, `${label}.artifactPaths`);
157
+ const heartbeat = input.heartbeat === undefined ? undefined : asObject(input.heartbeat, `${label}.heartbeat`);
158
+ return {
159
+ workflowRunId: requiredString(input.workflowRunId, `${label}.workflowRunId`),
160
+ key: validateMissionId(input.key, `${label}.key`),
161
+ status: requiredString(input.status, `${label}.status`),
162
+ startedAt: timestamp(input.startedAt, `${label}.startedAt`),
163
+ updatedAt: timestamp(input.updatedAt, `${label}.updatedAt`),
164
+ artifactPaths,
165
+ ...(optionalString(input.runId, `${label}.runId`) ? { runId: input.runId as string } : {}),
166
+ ...(optionalString(input.agent, `${label}.agent`) ? { agent: input.agent as string } : {}),
167
+ ...(optionalString(input.task, `${label}.task`) ? { task: input.task as string } : {}),
168
+ ...(optionalString(input.label, `${label}.label`) ? { label: input.label as string } : {}),
169
+ ...(optionalString(input.phase, `${label}.phase`) ? { phase: input.phase as string } : {}),
170
+ ...(input.completedAt !== undefined ? { completedAt: timestamp(input.completedAt, `${label}.completedAt`) } : {}),
171
+ ...(optionalString(input.sessionPath, `${label}.sessionPath`) ? { sessionPath: input.sessionPath as string } : {}),
172
+ ...(heartbeat ? { heartbeat: {
173
+ updatedAt: timestamp(heartbeat.updatedAt, `${label}.heartbeat.updatedAt`),
174
+ ...(optionalString(heartbeat.status, `${label}.heartbeat.status`) ? { status: heartbeat.status as string } : {}),
175
+ ...(optionalString(heartbeat.phase, `${label}.heartbeat.phase`) ? { phase: heartbeat.phase as string } : {}),
176
+ ...(optionalString(heartbeat.message, `${label}.heartbeat.message`) ? { message: heartbeat.message as string } : {}),
177
+ } } : {}),
178
+ };
179
+ }
180
+
152
181
  function parseArtifact(value: unknown, label: string): MissionArtifact {
153
182
  const input = asObject(value, label);
154
183
  const kind = requiredString(input.kind, `${label}.kind`) as MissionArtifactKind;
@@ -186,10 +215,12 @@ export function parseMissionRecord(value: unknown, source = "mission record"): M
186
215
  const input = asObject(value, source);
187
216
  if (input.schemaVersion !== 1) throw new Error(`${source}.schemaVersion must be 1`);
188
217
  if (!Array.isArray(input.runs)) throw new Error(`${source}.runs must be an array`);
218
+ if (input.workflowChildren !== undefined && !Array.isArray(input.workflowChildren)) throw new Error(`${source}.workflowChildren must be an array`);
189
219
  if (!Array.isArray(input.decisions)) throw new Error(`${source}.decisions must be an array`);
190
220
  if (!Array.isArray(input.artifacts)) throw new Error(`${source}.artifacts must be an array`);
191
221
  if (input.receipts !== undefined && !Array.isArray(input.receipts)) throw new Error(`${source}.receipts must be an array`);
192
222
  const runs = input.runs as unknown[];
223
+ const workflowChildren = (input.workflowChildren ?? []) as unknown[];
193
224
  const decisions = input.decisions as unknown[];
194
225
  const artifacts = input.artifacts as unknown[];
195
226
  const receipts = (input.receipts ?? []) as unknown[];
@@ -211,6 +242,7 @@ export function parseMissionRecord(value: unknown, source = "mission record"): M
211
242
  createdAt: timestamp(input.createdAt, `${source}.createdAt`),
212
243
  updatedAt: timestamp(input.updatedAt, `${source}.updatedAt`),
213
244
  runs: runs.map((item, index) => parseRunLink(item, `${source}.runs[${index}]`)),
245
+ workflowChildren: workflowChildren.map((item, index) => parseWorkflowChild(item, `${source}.workflowChildren[${index}]`)),
214
246
  decisions: decisions.map((item, index) => parseDecision(item, `${source}.decisions[${index}]`)),
215
247
  artifacts: artifacts.map((item, index) => parseArtifact(item, `${source}.artifacts[${index}]`)),
216
248
  receipts: receipts.map((item, index) => parseReceipt(item, `${source}.receipts[${index}]`)),
@@ -259,7 +291,7 @@ export function resolveMissionStoreLocation(input: {
259
291
  const projectRoot = path.resolve(input.projectRoot);
260
292
  const missionDir = input.config?.directory
261
293
  ? expandConfiguredPath(input.config.directory, projectRoot)
262
- : path.join(projectRoot, ".pi-subagents", "missions");
294
+ : path.join(getProjectSubagentsDir(projectRoot), "missions");
263
295
  const globalIndexDir = input.config?.globalIndexDir
264
296
  ? expandConfiguredPath(input.config.globalIndexDir, projectRoot)
265
297
  : path.join(input.agentDir ?? getAgentDir(), "missions", "index");
@@ -346,6 +378,7 @@ export function createMission(location: MissionStoreLocation, input: MissionCrea
346
378
  updatedAt: createdAt,
347
379
  cwd: location.projectRoot,
348
380
  runs: [],
381
+ workflowChildren: [],
349
382
  decisions: [],
350
383
  artifacts: [],
351
384
  receipts: [],
@@ -412,6 +445,28 @@ export function updateMission(location: MissionStoreLocation, missionId: string,
412
445
  if (existingIndex === -1) runs.push(run);
413
446
  else runs[existingIndex] = { ...runs[existingIndex]!, ...run };
414
447
  }
448
+ const workflowChildren = [...current.workflowChildren];
449
+ for (const candidate of update.upsertWorkflowChildren ?? []) {
450
+ const nowIso = now.toISOString();
451
+ const parsed = parseWorkflowChild({
452
+ ...candidate,
453
+ startedAt: candidate.startedAt ?? nowIso,
454
+ updatedAt: nowIso,
455
+ artifactPaths: candidate.artifactPaths ?? [],
456
+ ...(candidate.heartbeat ? { heartbeat: { ...candidate.heartbeat, updatedAt: nowIso } } : {}),
457
+ }, "mission.update.upsertWorkflowChildren[]");
458
+ const existingIndex = workflowChildren.findIndex((child) => child.workflowRunId === parsed.workflowRunId && child.key === parsed.key);
459
+ if (existingIndex === -1) workflowChildren.push(parsed);
460
+ else {
461
+ const existing = workflowChildren[existingIndex]!;
462
+ workflowChildren[existingIndex] = parseWorkflowChild({
463
+ ...existing,
464
+ ...parsed,
465
+ startedAt: existing.startedAt,
466
+ artifactPaths: [...new Set([...existing.artifactPaths, ...parsed.artifactPaths])],
467
+ }, "mission.update.upsertWorkflowChildren[]");
468
+ }
469
+ }
415
470
  const artifacts = [...current.artifacts];
416
471
  for (const candidate of update.addArtifacts ?? []) {
417
472
  const artifact = parseArtifact(candidate, "mission.update.addArtifacts[]");
@@ -439,6 +494,18 @@ export function updateMission(location: MissionStoreLocation, missionId: string,
439
494
  ...(decision.recommendation ? { recommendation: requiredString(decision.recommendation, "mission.update.addDecisions[].recommendation") } : {}),
440
495
  })),
441
496
  ];
497
+ if (update.resolveDecision) {
498
+ const decisionId = validateMissionId(update.resolveDecision.id, "mission.update.resolveDecision.id");
499
+ const decisionIndex = decisions.findIndex((decision) => decision.id === decisionId);
500
+ if (decisionIndex === -1) throw new Error(`Decision '${decisionId}' was not found in mission '${missionId}'`);
501
+ if (decisions[decisionIndex]!.status === "resolved") throw new Error(`Decision '${decisionId}' is already resolved`);
502
+ decisions[decisionIndex] = {
503
+ ...decisions[decisionIndex]!,
504
+ status: "resolved",
505
+ resolvedAt: createdAt,
506
+ resolution: requiredString(update.resolveDecision.resolution, "mission.update.resolveDecision.resolution").trim(),
507
+ };
508
+ }
442
509
  const budget = update.budget !== undefined ? parseBudget(update.budget, "mission.update.budget") : current.budget;
443
510
  const usage = update.usage !== undefined
444
511
  ? parseUsage(update.usage, "mission.update.usage")
@@ -452,10 +519,20 @@ export function updateMission(location: MissionStoreLocation, missionId: string,
452
519
  ? { status: "active" }
453
520
  : goal;
454
521
  }
522
+ const hasOpenDecisions = decisions.some((decision) => decision.status === "open");
523
+ const requestedStatus = update.status !== undefined ? missionStatus(update.status, "mission.update.status") : undefined;
524
+ const candidateStatus = requestedStatus
525
+ ?? (update.addDecisions?.length && current.status === "active"
526
+ ? "needs_decision"
527
+ : update.resolveDecision && current.status === "needs_decision" && !hasOpenDecisions
528
+ ? "active"
529
+ : current.status);
530
+ const decisionStatus = hasOpenDecisions && (candidateStatus === "active" || candidateStatus === "completed") ? "needs_decision" : candidateStatus;
455
531
  const next: MissionRecord = {
456
532
  ...current,
457
533
  updatedAt: createdAt,
458
534
  runs,
535
+ workflowChildren,
459
536
  artifacts,
460
537
  receipts,
461
538
  decisions,
@@ -463,7 +540,7 @@ export function updateMission(location: MissionStoreLocation, missionId: string,
463
540
  ...(update.objective !== undefined ? { objective: requiredString(update.objective, "mission.update.objective").trim() } : {}),
464
541
  ...(budget ? { budget } : {}),
465
542
  ...(goal ? { goal, usage } : {}),
466
- ...(update.status !== undefined ? { status: missionStatus(update.status, "mission.update.status") } : {}),
543
+ status: decisionStatus,
467
544
  ...(update.summary !== undefined ? { summary: requiredString(update.summary, "mission.update.summary") } : {}),
468
545
  ...(update.labels !== undefined ? { labels: stringArray(update.labels, "mission.update.labels") } : {}),
469
546
  ...(update.acceptance !== undefined ? { acceptance: update.acceptance } : {}),
@@ -51,6 +51,36 @@ export interface MissionDecision {
51
51
  resolution?: string;
52
52
  }
53
53
 
54
+ export interface MissionChildHeartbeat {
55
+ updatedAt: string;
56
+ status?: string;
57
+ phase?: string;
58
+ message?: string;
59
+ }
60
+
61
+ export interface MissionWorkflowChild {
62
+ workflowRunId: string;
63
+ key: string;
64
+ status: string;
65
+ startedAt: string;
66
+ updatedAt: string;
67
+ runId?: string;
68
+ agent?: string;
69
+ task?: string;
70
+ label?: string;
71
+ phase?: string;
72
+ completedAt?: string;
73
+ sessionPath?: string;
74
+ artifactPaths: string[];
75
+ heartbeat?: MissionChildHeartbeat;
76
+ }
77
+
78
+ export type MissionWorkflowChildUpdate = Pick<MissionWorkflowChild, "workflowRunId" | "key" | "status"> & Partial<Omit<MissionWorkflowChild, "workflowRunId" | "key" | "status" | "startedAt" | "updatedAt" | "artifactPaths" | "heartbeat">> & {
79
+ startedAt?: string;
80
+ artifactPaths?: string[];
81
+ heartbeat?: Omit<MissionChildHeartbeat, "updatedAt"> & { updatedAt?: string };
82
+ };
83
+
54
84
  export interface MissionArtifact {
55
85
  kind: MissionArtifactKind;
56
86
  path: string;
@@ -80,6 +110,7 @@ export interface MissionRecord {
80
110
  cwd?: string;
81
111
  ownerSessionId?: string;
82
112
  runs: MissionRunLink[];
113
+ workflowChildren: MissionWorkflowChild[];
83
114
  decisions: MissionDecision[];
84
115
  artifacts: MissionArtifact[];
85
116
  receipts: MissionReceipt[];
@@ -151,7 +182,9 @@ export interface MissionUpdateInput {
151
182
  labels?: string[];
152
183
  acceptance?: unknown;
153
184
  addRuns?: MissionRunLink[];
185
+ upsertWorkflowChildren?: MissionWorkflowChildUpdate[];
154
186
  addArtifacts?: MissionArtifact[];
155
187
  addDecisions?: Array<Omit<MissionDecision, "id" | "status" | "createdAt">>;
188
+ resolveDecision?: { id: string; resolution: string };
156
189
  addReceipts?: Array<Omit<MissionReceipt, "createdAt">>;
157
190
  }
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { writePrivateAtomicJson } from "../shared/atomic-json.ts";
6
- import { DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS, waitForFileSystemRetry } from "../shared/file-system-retry.ts";
6
+ import { DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS, isRetryableFileSystemError, waitForFileSystemRetry } from "../shared/file-system-retry.ts";
7
7
  import { assertWorkflowJsonValue } from "../workflows/scripted-workflow.ts";
8
8
  import type { MissionStoreLocation } from "./types.ts";
9
9
  import { validateMissionId } from "./store.ts";
@@ -162,24 +162,30 @@ function withStateFileLock<T>(filePath: string, operation: () => T): T {
162
162
  waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
163
163
  continue;
164
164
  }
165
+ let acquired = false;
165
166
  try {
166
- fs.mkdirSync(lockPath, { mode: 0o700 });
167
- owner = { pid: process.pid, token: randomUUID(), createdAt: Date.now(), ...(CURRENT_PROCESS_KEY ? { processKey: CURRENT_PROCESS_KEY } : {}) };
168
- try {
169
- fs.writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify(owner), { encoding: "utf-8", mode: 0o600 });
170
- } catch (error) {
171
- removeOwnedStateLock(lockPath, owner);
172
- owner = undefined;
173
- throw error;
174
- }
175
- break;
167
+ acquired = tryMakeDirectory(lockPath, 0o700);
176
168
  } catch (error) {
177
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
178
- throw new Error(`Failed to acquire mission state lock '${lockPath}': ${error instanceof Error ? error.message : String(error)}`);
169
+ if (isRetryableFileSystemError(error)) {
170
+ waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
171
+ continue;
179
172
  }
173
+ throw new Error(`Failed to acquire mission state lock '${lockPath}': ${error instanceof Error ? error.message : String(error)}`);
174
+ }
175
+ if (!acquired) {
180
176
  if (reclaimStaleStateLock(lockPath, reclaimPath)) continue;
181
177
  waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
178
+ continue;
179
+ }
180
+ owner = { pid: process.pid, token: randomUUID(), createdAt: Date.now(), ...(CURRENT_PROCESS_KEY ? { processKey: CURRENT_PROCESS_KEY } : {}) };
181
+ try {
182
+ fs.writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify(owner), { encoding: "utf-8", mode: 0o600 });
183
+ } catch (error) {
184
+ fs.rmSync(lockPath, { recursive: true, force: true });
185
+ owner = undefined;
186
+ throw error;
182
187
  }
188
+ break;
183
189
  }
184
190
  try {
185
191
  return operation();
@@ -15,7 +15,7 @@ import { appendAgentRefinementOverlay } from "../../agents/agent-refinements.ts"
15
15
  import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
16
16
  import { applyThinkingSuffix, projectLaunchResolvedChildExtensions, resolvePiLaunchToolPlan } from "../shared/pi-args.ts";
17
17
  import { injectOutputPathSystemPrompt, injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
18
- import { buildChainInstructions, isCheckpointStep, isDynamicParallelStep, isParallelStep, resolveChainPath, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
18
+ import { buildChainInstructions, isCheckpointStep, isDynamicParallelStep, isParallelStep, resolveChainPath, resolveExistingReadPaths, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
19
19
  import type { RunnerStep } from "../shared/parallel-utils.ts";
20
20
  import type { ContextMode } from "../shared/context-mode.ts";
21
21
  import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
@@ -128,6 +128,8 @@ interface AsyncExecutionContext {
128
128
  interactive?: boolean;
129
129
  }
130
130
 
131
+ export const DEFAULT_ASYNC_TIMEOUT_MS = 30 * 60 * 1000;
132
+
131
133
  interface AsyncChainParams {
132
134
  chain: ChainStep[];
133
135
  task?: string;
@@ -668,6 +670,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
668
670
  if (resolvedToolBudget.error) throw new AsyncStartValidationError(resolvedToolBudget.error);
669
671
  const stepCwd = resolveChildCwd(runnerCwd, s.cwd);
670
672
  const instructionCwd = behaviorCwd ?? stepCwd;
673
+ const readExistenceCwd = behaviorCwd ? stepCwd : instructionCwd;
671
674
  let behavior = suppressProgressForReadOnlyTask(resolvedBehavior ?? resolveStepBehavior(a, buildStepOverrides(s), chainSkills), s.task, originalTask);
672
675
  const inheritedRelativeParallelOutput = parallelOutputNamespace && s.output === undefined && typeof behavior.output === "string" && !path.isAbsolute(behavior.output);
673
676
  if (inheritedRelativeParallelOutput && parallelOutputNamespace.taskIndex !== undefined) {
@@ -698,7 +701,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
698
701
  }
699
702
  systemPrompt = appendAgentRefinementOverlay(systemPrompt, { cwd: stepCwd, agentName: a.name });
700
703
 
701
- const readInstructions = buildChainInstructions({ ...behavior, output: false, progress: false }, instructionCwd, false);
704
+ const readInstructions = buildChainInstructions({ ...behavior, output: false, progress: false }, instructionCwd, false, undefined, readExistenceCwd);
702
705
  const isFirstProgressAgent = behavior.progress && !progressPrecreated && !progressInstructionCreated;
703
706
  if (behavior.progress) progressInstructionCreated = true;
704
707
  const progressInstructions = buildChainInstructions({ ...behavior, output: false, reads: false }, progressDir, isFirstProgressAgent);
@@ -776,6 +779,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
776
779
  outputMode: behavior.outputMode,
777
780
  sessionFile,
778
781
  maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, a.maxSubagentDepth),
782
+ timeoutMs: a.defaultTimeoutMs ?? DEFAULT_ASYNC_TIMEOUT_MS,
779
783
  waitToolEnabled: params.waitToolEnabled,
780
784
  effectiveAcceptance: resolveEffectiveAcceptance({
781
785
  explicit: s.acceptance,
@@ -1197,6 +1201,10 @@ export function executeAsyncChain(
1197
1201
  /**
1198
1202
  * Execute a single agent asynchronously
1199
1203
  */
1204
+ export function workflowAwaitedAsyncResultPath(asyncDir: string): string {
1205
+ return path.join(asyncDir, "workflow-result.json");
1206
+ }
1207
+
1200
1208
  export function executeAsyncSingle(
1201
1209
  id: string,
1202
1210
  params: AsyncSingleParams,
@@ -1292,8 +1300,9 @@ export function executeAsyncSingle(
1292
1300
  // Reads: caller override > agent defaultReads > none. `~`/`~/` expand to home;
1293
1301
  // absolute paths pass through; relative paths resolve against the child cwd.
1294
1302
  const reads = params.reads !== undefined ? params.reads : agentConfig.defaultReads ?? false;
1295
- const readsInstruction = Array.isArray(reads) && reads.length > 0
1296
- ? `[Read from: ${reads.map((f) => resolveChainPath(f, runnerCwd)).join(", ")}]\n\n`
1303
+ const readPaths = Array.isArray(reads) ? resolveExistingReadPaths(reads, runnerCwd) : [];
1304
+ const readsInstruction = readPaths.length > 0
1305
+ ? `[Read from: ${readPaths.join(", ")}]\n\n`
1297
1306
  : "";
1298
1307
  const taskText = readsInstruction + taskWithOutputInstruction;
1299
1308
  const primaryModel = externalRunner ? undefined : resolveSubagentModelOverride(
@@ -1395,7 +1404,7 @@ export function executeAsyncSingle(
1395
1404
  ...(params.acceptance !== undefined ? { acceptance: params.acceptance } : {}),
1396
1405
  ...(controlConfig ? { controlConfig } : {}),
1397
1406
  ...(deadlineAt !== undefined ? { absoluteDeadlineAt: deadlineAt } : {}),
1398
- ...(initialTurnBudget ? { initialTurnBudget } : {}),
1407
+ ...(initialTurnBudget ? { initialTurnBudget: { maxTurns: initialTurnBudget.maxTurns, graceTurns: initialTurnBudget.graceTurns } } : {}),
1399
1408
  ...(resolvedToolBudget.budget ? { initialToolBudget: resolvedToolBudget.budget } : {}),
1400
1409
  maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, agentConfig.maxSubagentDepth),
1401
1410
  ...(maxOutput ? { maxOutput } : {}),
@@ -1456,7 +1465,9 @@ export function executeAsyncSingle(
1456
1465
  ...(resolvedToolBudget.budget ? { toolBudget: resolvedToolBudget.budget } : {}),
1457
1466
  },
1458
1467
  ],
1459
- resultPath: inheritedNestedRoute ? nestedResultsPath(inheritedNestedRoute.rootRunId, id) : path.join(DIRS.results, `${id}.json`),
1468
+ resultPath: params.parentWorkflowRunId !== undefined && params.revivalLease !== undefined
1469
+ ? workflowAwaitedAsyncResultPath(asyncDir)
1470
+ : inheritedNestedRoute ? nestedResultsPath(inheritedNestedRoute.rootRunId, id) : path.join(DIRS.results, `${id}.json`),
1460
1471
  cwd: runnerCwd,
1461
1472
  placeholder: "{previous}",
1462
1473
  maxOutput,
@@ -57,6 +57,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
57
57
  const resultsDir = options.resultsDir ?? DIRS.results;
58
58
  const steeringNoticeSeen = new Map<string, number>();
59
59
  const rerenderWidget = (ctx: ExtensionContext, jobs = Array.from(state.asyncJobs.values())) => {
60
+ if (state.widgetsSuspended) return;
60
61
  renderWidget(ctx, options.widgetEnabled === false ? [] : jobs);
61
62
  (ctx.ui as { requestRender?: () => void }).requestRender?.();
62
63
  };
@@ -73,6 +74,19 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
73
74
  throw error;
74
75
  }
75
76
  };
77
+ const requestLastWidgetRender = () => {
78
+ const ctx = state.lastUiContext;
79
+ if (!ctx || state.widgetsSuspended || options.widgetEnabled === false) return;
80
+ try {
81
+ if (ctx.hasUI) (ctx.ui as { requestRender?: () => void }).requestRender?.();
82
+ } catch (error) {
83
+ if (error instanceof Error && error.message.includes("extension ctx is stale")) {
84
+ state.lastUiContext = null;
85
+ return;
86
+ }
87
+ throw error;
88
+ }
89
+ };
76
90
  const refreshWidget = (ctx: ExtensionContext) => rerenderWidget(ctx);
77
91
  const restoredControlEventCursor = (asyncDir: string) => {
78
92
  try {
@@ -400,6 +414,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
400
414
  }
401
415
 
402
416
  if (widgetChanged) rerenderLastWidget();
417
+ else if (Array.from(state.asyncJobs.values()).some((job) => job.status === "running")) requestLastWidgetRender();
403
418
  }, pollIntervalMs);
404
419
  state.poller.unref?.();
405
420
  };
@@ -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,7 +4,7 @@ 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";
7
+ import { pruneStatusCacheForAsyncRoot, readStatus } from "../../shared/utils.ts";
8
8
  import { attachRootChildrenToSteps, buildNestedRouteIndex, 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";
@@ -370,6 +370,7 @@ function sortRuns(runs: AsyncRunSummary[]): AsyncRunSummary[] {
370
370
 
371
371
  export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions = {}): AsyncRunSummary[] {
372
372
  let entries: string[];
373
+ let scannedCompleteRoot = false;
373
374
  try {
374
375
  if (options.runId !== undefined) {
375
376
  const resolution = resolveTargetedAsyncRun(asyncDirRoot, options.runId, options.sessionId);
@@ -383,6 +384,7 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
383
384
  : [];
384
385
  } else {
385
386
  entries = fs.readdirSync(asyncDirRoot).filter((entry) => isAsyncRunDir(asyncDirRoot, entry));
387
+ scannedCompleteRoot = true;
386
388
  }
387
389
  } catch (error) {
388
390
  if (isNotFoundError(error)) return [];
@@ -392,6 +394,7 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
392
394
  }
393
395
 
394
396
  if (options.entryLimit !== undefined) {
397
+ scannedCompleteRoot = false;
395
398
  const limit = Math.max(0, Math.floor(options.entryLimit));
396
399
  entries = entries
397
400
  .map((entry) => {
@@ -410,6 +413,8 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
410
413
  .map((candidate) => candidate.entry);
411
414
  }
412
415
 
416
+ if (scannedCompleteRoot) pruneStatusCacheForAsyncRoot(asyncDirRoot, entries);
417
+
413
418
  const allowedStates = options.states ? new Set(options.states) : undefined;
414
419
  const runs: AsyncRunSummary[] = [];
415
420
  // Route resolution for every run shares a single index built from the