pi-subagents 0.45.2 → 0.46.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 (45) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +2 -0
  3. package/docs/agents.md +342 -0
  4. package/docs/configuration.md +320 -0
  5. package/docs/extension-api.md +308 -0
  6. package/docs/missions.md +117 -0
  7. package/docs/models.md +190 -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 +2 -2
  14. package/src/agents/agents.ts +17 -8
  15. package/src/agents/frontmatter.ts +7 -3
  16. package/src/agents/skills.ts +2 -9
  17. package/src/api/project-panes.ts +30 -0
  18. package/src/extension/config.ts +15 -1
  19. package/src/extension/index.ts +36 -16
  20. package/src/extension/schemas.ts +3 -2
  21. package/src/extension/subagent-guide.ts +39 -0
  22. package/src/extension/tool-description.ts +4 -4
  23. package/src/inspectors/herdr/project-panes.ts +457 -62
  24. package/src/missions/actions.ts +25 -2
  25. package/src/missions/lifecycle.ts +21 -2
  26. package/src/missions/store.ts +77 -1
  27. package/src/missions/types.ts +33 -0
  28. package/src/runs/background/async-execution.ts +7 -1
  29. package/src/runs/background/completion-replay.ts +267 -0
  30. package/src/runs/background/result-watcher.ts +12 -4
  31. package/src/runs/background/wait-completions.ts +39 -5
  32. package/src/runs/background/wait-subscriptions.ts +18 -3
  33. package/src/runs/foreground/execution.ts +4 -0
  34. package/src/runs/foreground/foreground-history.ts +137 -0
  35. package/src/runs/foreground/subagent-executor.ts +310 -44
  36. package/src/shared/fork-context.ts +13 -0
  37. package/src/shared/prompt-resources.ts +51 -0
  38. package/src/shared/types.ts +30 -1
  39. package/src/shared/utf8.ts +11 -0
  40. package/src/slash/prompt-workflows.ts +2 -15
  41. package/src/slash/slash-commands.ts +19 -1
  42. package/src/tui/fleet-status.ts +8 -2
  43. package/src/tui/fleet.ts +135 -25
  44. package/src/tui/render.ts +120 -7
  45. 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
  }
@@ -27,6 +27,7 @@ import {
27
27
  type MissionTokenBudget,
28
28
  type MissionTokenUsage,
29
29
  type MissionUpdateInput,
30
+ type MissionWorkflowChild,
30
31
  } from "./types.ts";
31
32
 
32
33
  const MISSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
@@ -149,6 +150,33 @@ function parseDecision(value: unknown, label: string): MissionDecision {
149
150
  };
150
151
  }
151
152
 
153
+ function parseWorkflowChild(value: unknown, label: string): MissionWorkflowChild {
154
+ const input = asObject(value, label);
155
+ const artifactPaths = input.artifactPaths === undefined ? [] : stringArray(input.artifactPaths, `${label}.artifactPaths`);
156
+ const heartbeat = input.heartbeat === undefined ? undefined : asObject(input.heartbeat, `${label}.heartbeat`);
157
+ return {
158
+ workflowRunId: requiredString(input.workflowRunId, `${label}.workflowRunId`),
159
+ key: validateMissionId(input.key, `${label}.key`),
160
+ status: requiredString(input.status, `${label}.status`),
161
+ startedAt: timestamp(input.startedAt, `${label}.startedAt`),
162
+ updatedAt: timestamp(input.updatedAt, `${label}.updatedAt`),
163
+ artifactPaths,
164
+ ...(optionalString(input.runId, `${label}.runId`) ? { runId: input.runId as string } : {}),
165
+ ...(optionalString(input.agent, `${label}.agent`) ? { agent: input.agent as string } : {}),
166
+ ...(optionalString(input.task, `${label}.task`) ? { task: input.task as string } : {}),
167
+ ...(optionalString(input.label, `${label}.label`) ? { label: input.label as string } : {}),
168
+ ...(optionalString(input.phase, `${label}.phase`) ? { phase: input.phase as string } : {}),
169
+ ...(input.completedAt !== undefined ? { completedAt: timestamp(input.completedAt, `${label}.completedAt`) } : {}),
170
+ ...(optionalString(input.sessionPath, `${label}.sessionPath`) ? { sessionPath: input.sessionPath as string } : {}),
171
+ ...(heartbeat ? { heartbeat: {
172
+ updatedAt: timestamp(heartbeat.updatedAt, `${label}.heartbeat.updatedAt`),
173
+ ...(optionalString(heartbeat.status, `${label}.heartbeat.status`) ? { status: heartbeat.status as string } : {}),
174
+ ...(optionalString(heartbeat.phase, `${label}.heartbeat.phase`) ? { phase: heartbeat.phase as string } : {}),
175
+ ...(optionalString(heartbeat.message, `${label}.heartbeat.message`) ? { message: heartbeat.message as string } : {}),
176
+ } } : {}),
177
+ };
178
+ }
179
+
152
180
  function parseArtifact(value: unknown, label: string): MissionArtifact {
153
181
  const input = asObject(value, label);
154
182
  const kind = requiredString(input.kind, `${label}.kind`) as MissionArtifactKind;
@@ -186,10 +214,12 @@ export function parseMissionRecord(value: unknown, source = "mission record"): M
186
214
  const input = asObject(value, source);
187
215
  if (input.schemaVersion !== 1) throw new Error(`${source}.schemaVersion must be 1`);
188
216
  if (!Array.isArray(input.runs)) throw new Error(`${source}.runs must be an array`);
217
+ if (input.workflowChildren !== undefined && !Array.isArray(input.workflowChildren)) throw new Error(`${source}.workflowChildren must be an array`);
189
218
  if (!Array.isArray(input.decisions)) throw new Error(`${source}.decisions must be an array`);
190
219
  if (!Array.isArray(input.artifacts)) throw new Error(`${source}.artifacts must be an array`);
191
220
  if (input.receipts !== undefined && !Array.isArray(input.receipts)) throw new Error(`${source}.receipts must be an array`);
192
221
  const runs = input.runs as unknown[];
222
+ const workflowChildren = (input.workflowChildren ?? []) as unknown[];
193
223
  const decisions = input.decisions as unknown[];
194
224
  const artifacts = input.artifacts as unknown[];
195
225
  const receipts = (input.receipts ?? []) as unknown[];
@@ -211,6 +241,7 @@ export function parseMissionRecord(value: unknown, source = "mission record"): M
211
241
  createdAt: timestamp(input.createdAt, `${source}.createdAt`),
212
242
  updatedAt: timestamp(input.updatedAt, `${source}.updatedAt`),
213
243
  runs: runs.map((item, index) => parseRunLink(item, `${source}.runs[${index}]`)),
244
+ workflowChildren: workflowChildren.map((item, index) => parseWorkflowChild(item, `${source}.workflowChildren[${index}]`)),
214
245
  decisions: decisions.map((item, index) => parseDecision(item, `${source}.decisions[${index}]`)),
215
246
  artifacts: artifacts.map((item, index) => parseArtifact(item, `${source}.artifacts[${index}]`)),
216
247
  receipts: receipts.map((item, index) => parseReceipt(item, `${source}.receipts[${index}]`)),
@@ -346,6 +377,7 @@ export function createMission(location: MissionStoreLocation, input: MissionCrea
346
377
  updatedAt: createdAt,
347
378
  cwd: location.projectRoot,
348
379
  runs: [],
380
+ workflowChildren: [],
349
381
  decisions: [],
350
382
  artifacts: [],
351
383
  receipts: [],
@@ -412,6 +444,28 @@ export function updateMission(location: MissionStoreLocation, missionId: string,
412
444
  if (existingIndex === -1) runs.push(run);
413
445
  else runs[existingIndex] = { ...runs[existingIndex]!, ...run };
414
446
  }
447
+ const workflowChildren = [...current.workflowChildren];
448
+ for (const candidate of update.upsertWorkflowChildren ?? []) {
449
+ const nowIso = now.toISOString();
450
+ const parsed = parseWorkflowChild({
451
+ ...candidate,
452
+ startedAt: candidate.startedAt ?? nowIso,
453
+ updatedAt: nowIso,
454
+ artifactPaths: candidate.artifactPaths ?? [],
455
+ ...(candidate.heartbeat ? { heartbeat: { ...candidate.heartbeat, updatedAt: nowIso } } : {}),
456
+ }, "mission.update.upsertWorkflowChildren[]");
457
+ const existingIndex = workflowChildren.findIndex((child) => child.workflowRunId === parsed.workflowRunId && child.key === parsed.key);
458
+ if (existingIndex === -1) workflowChildren.push(parsed);
459
+ else {
460
+ const existing = workflowChildren[existingIndex]!;
461
+ workflowChildren[existingIndex] = parseWorkflowChild({
462
+ ...existing,
463
+ ...parsed,
464
+ startedAt: existing.startedAt,
465
+ artifactPaths: [...new Set([...existing.artifactPaths, ...parsed.artifactPaths])],
466
+ }, "mission.update.upsertWorkflowChildren[]");
467
+ }
468
+ }
415
469
  const artifacts = [...current.artifacts];
416
470
  for (const candidate of update.addArtifacts ?? []) {
417
471
  const artifact = parseArtifact(candidate, "mission.update.addArtifacts[]");
@@ -439,6 +493,18 @@ export function updateMission(location: MissionStoreLocation, missionId: string,
439
493
  ...(decision.recommendation ? { recommendation: requiredString(decision.recommendation, "mission.update.addDecisions[].recommendation") } : {}),
440
494
  })),
441
495
  ];
496
+ if (update.resolveDecision) {
497
+ const decisionId = validateMissionId(update.resolveDecision.id, "mission.update.resolveDecision.id");
498
+ const decisionIndex = decisions.findIndex((decision) => decision.id === decisionId);
499
+ if (decisionIndex === -1) throw new Error(`Decision '${decisionId}' was not found in mission '${missionId}'`);
500
+ if (decisions[decisionIndex]!.status === "resolved") throw new Error(`Decision '${decisionId}' is already resolved`);
501
+ decisions[decisionIndex] = {
502
+ ...decisions[decisionIndex]!,
503
+ status: "resolved",
504
+ resolvedAt: createdAt,
505
+ resolution: requiredString(update.resolveDecision.resolution, "mission.update.resolveDecision.resolution").trim(),
506
+ };
507
+ }
442
508
  const budget = update.budget !== undefined ? parseBudget(update.budget, "mission.update.budget") : current.budget;
443
509
  const usage = update.usage !== undefined
444
510
  ? parseUsage(update.usage, "mission.update.usage")
@@ -452,10 +518,20 @@ export function updateMission(location: MissionStoreLocation, missionId: string,
452
518
  ? { status: "active" }
453
519
  : goal;
454
520
  }
521
+ const hasOpenDecisions = decisions.some((decision) => decision.status === "open");
522
+ const requestedStatus = update.status !== undefined ? missionStatus(update.status, "mission.update.status") : undefined;
523
+ const candidateStatus = requestedStatus
524
+ ?? (update.addDecisions?.length && current.status === "active"
525
+ ? "needs_decision"
526
+ : update.resolveDecision && current.status === "needs_decision" && !hasOpenDecisions
527
+ ? "active"
528
+ : current.status);
529
+ const decisionStatus = hasOpenDecisions && (candidateStatus === "active" || candidateStatus === "completed") ? "needs_decision" : candidateStatus;
455
530
  const next: MissionRecord = {
456
531
  ...current,
457
532
  updatedAt: createdAt,
458
533
  runs,
534
+ workflowChildren,
459
535
  artifacts,
460
536
  receipts,
461
537
  decisions,
@@ -463,7 +539,7 @@ export function updateMission(location: MissionStoreLocation, missionId: string,
463
539
  ...(update.objective !== undefined ? { objective: requiredString(update.objective, "mission.update.objective").trim() } : {}),
464
540
  ...(budget ? { budget } : {}),
465
541
  ...(goal ? { goal, usage } : {}),
466
- ...(update.status !== undefined ? { status: missionStatus(update.status, "mission.update.status") } : {}),
542
+ status: decisionStatus,
467
543
  ...(update.summary !== undefined ? { summary: requiredString(update.summary, "mission.update.summary") } : {}),
468
544
  ...(update.labels !== undefined ? { labels: stringArray(update.labels, "mission.update.labels") } : {}),
469
545
  ...(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
  }
@@ -1197,6 +1197,10 @@ export function executeAsyncChain(
1197
1197
  /**
1198
1198
  * Execute a single agent asynchronously
1199
1199
  */
1200
+ export function workflowAwaitedAsyncResultPath(asyncDir: string): string {
1201
+ return path.join(asyncDir, "workflow-result.json");
1202
+ }
1203
+
1200
1204
  export function executeAsyncSingle(
1201
1205
  id: string,
1202
1206
  params: AsyncSingleParams,
@@ -1456,7 +1460,9 @@ export function executeAsyncSingle(
1456
1460
  ...(resolvedToolBudget.budget ? { toolBudget: resolvedToolBudget.budget } : {}),
1457
1461
  },
1458
1462
  ],
1459
- resultPath: inheritedNestedRoute ? nestedResultsPath(inheritedNestedRoute.rootRunId, id) : path.join(DIRS.results, `${id}.json`),
1463
+ resultPath: params.parentWorkflowRunId !== undefined && params.revivalLease !== undefined
1464
+ ? workflowAwaitedAsyncResultPath(asyncDir)
1465
+ : inheritedNestedRoute ? nestedResultsPath(inheritedNestedRoute.rootRunId, id) : path.join(DIRS.results, `${id}.json`),
1460
1466
  cwd: runnerCwd,
1461
1467
  placeholder: "{previous}",
1462
1468
  maxOutput,
@@ -0,0 +1,267 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
4
+ import type { WaitCompletion } from "../../shared/types.ts";
5
+ import { utf8Tail } from "../../shared/utf8.ts";
6
+
7
+ const REPLAY_VERSION = 1;
8
+ const ARCHIVE_VERSION = 1;
9
+ const ARCHIVE_TEXT_LIMIT_BYTES = 64 * 1024;
10
+ const REPLAY_DIR_NAME = "completion-replay";
11
+ const ARCHIVE_DIR_NAME = "output-archives";
12
+
13
+ export interface CompletionArchiveEntry {
14
+ agent?: string;
15
+ source: "output-artifact" | "session" | "result-tail";
16
+ path?: string;
17
+ text?: string;
18
+ truncated?: boolean;
19
+ }
20
+
21
+ export interface CompletionArchive {
22
+ version: 1;
23
+ runId: string;
24
+ createdAt: number;
25
+ entries: CompletionArchiveEntry[];
26
+ }
27
+
28
+ export interface CompletionReplayRecord {
29
+ version: 1;
30
+ runId: string;
31
+ sessionId: string;
32
+ completedAt: number;
33
+ expiresAt: number;
34
+ completion: WaitCompletion;
35
+ archivePath: string;
36
+ }
37
+
38
+ function safeRunFile(runId: string): string {
39
+ return `${encodeURIComponent(runId)}.json`;
40
+ }
41
+
42
+ export function completionReplayPath(resultsDir: string, runId: string): string {
43
+ return path.join(resultsDir, REPLAY_DIR_NAME, safeRunFile(runId));
44
+ }
45
+
46
+ export function completionArchivePath(resultsDir: string, runId: string): string {
47
+ return path.join(resultsDir, ARCHIVE_DIR_NAME, safeRunFile(runId));
48
+ }
49
+
50
+ function nonEmptyString(value: unknown): string | undefined {
51
+ return typeof value === "string" && value.length > 0 ? value : undefined;
52
+ }
53
+
54
+ function existingFile(value: unknown): string | undefined {
55
+ const filePath = nonEmptyString(value);
56
+ if (!filePath) return undefined;
57
+ try {
58
+ return fs.statSync(filePath).isFile() ? filePath : undefined;
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
63
+
64
+ function outputArtifactPath(child: Record<string, unknown>): string | undefined {
65
+ if (!child.artifactPaths || typeof child.artifactPaths !== "object" || Array.isArray(child.artifactPaths)) return undefined;
66
+ return existingFile((child.artifactPaths as Record<string, unknown>).outputPath);
67
+ }
68
+
69
+ /** Create a small archive that references saved child artifacts and retains only bounded fallback output text. */
70
+ export function writeCompletionArchive(resultsDir: string, runId: string, data: Record<string, unknown>, createdAt: number): string {
71
+ const entries: CompletionArchiveEntry[] = [];
72
+ const fallback: string[] = [];
73
+ const results = Array.isArray(data.results) ? data.results : [];
74
+ for (const value of results) {
75
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
76
+ const child = value as Record<string, unknown>;
77
+ const agent = nonEmptyString(child.agent);
78
+ const artifactPath = outputArtifactPath(child);
79
+ if (artifactPath) {
80
+ entries.push({ ...(agent ? { agent } : {}), source: "output-artifact", path: artifactPath });
81
+ continue;
82
+ }
83
+ const sessionPath = existingFile(child.sessionFile);
84
+ if (sessionPath) {
85
+ entries.push({ ...(agent ? { agent } : {}), source: "session", path: sessionPath });
86
+ continue;
87
+ }
88
+ const output = nonEmptyString(child.output);
89
+ const error = nonEmptyString(child.error);
90
+ if (output || error) {
91
+ fallback.push([agent ? `[${agent}]` : undefined, error ? `Error: ${error}` : undefined, output].filter(Boolean).join("\n"));
92
+ }
93
+ }
94
+ if (results.length === 0) {
95
+ const sessionPath = existingFile(data.sessionFile);
96
+ if (sessionPath) entries.push({ source: "session", path: sessionPath });
97
+ }
98
+ if (entries.length === 0 && fallback.length === 0) {
99
+ const summary = nonEmptyString(data.summary);
100
+ if (summary) fallback.push(summary);
101
+ }
102
+ if (fallback.length > 0) {
103
+ const bounded = utf8Tail(fallback.join("\n\n"), ARCHIVE_TEXT_LIMIT_BYTES);
104
+ entries.push({ source: "result-tail", text: bounded.text, ...(bounded.truncated ? { truncated: true } : {}) });
105
+ }
106
+ const archive: CompletionArchive = { version: ARCHIVE_VERSION, runId, createdAt, entries };
107
+ const archivePath = completionArchivePath(resultsDir, runId);
108
+ writePrivateAtomicJson(archivePath, archive);
109
+ return archivePath;
110
+ }
111
+
112
+ function parseCompletion(value: unknown, runId: string): WaitCompletion | undefined {
113
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
114
+ const completion = value as Partial<WaitCompletion>;
115
+ if (completion.runId !== runId) return undefined;
116
+ return completion as WaitCompletion;
117
+ }
118
+
119
+ function parseReplay(value: unknown): CompletionReplayRecord | undefined {
120
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
121
+ const record = value as Partial<CompletionReplayRecord>;
122
+ if (record.version !== REPLAY_VERSION
123
+ || typeof record.runId !== "string"
124
+ || typeof record.sessionId !== "string"
125
+ || typeof record.completedAt !== "number"
126
+ || typeof record.expiresAt !== "number"
127
+ || typeof record.archivePath !== "string") return undefined;
128
+ const completion = parseCompletion(record.completion, record.runId);
129
+ return completion ? { ...record, completion } as CompletionReplayRecord : undefined;
130
+ }
131
+
132
+ function validateReplayRecord(resultsDir: string, runId: string, record: CompletionReplayRecord): CompletionReplayRecord | undefined {
133
+ if (record.runId !== runId) return undefined;
134
+ const archivePath = completionArchivePath(resultsDir, runId);
135
+ return path.resolve(record.archivePath) === path.resolve(archivePath)
136
+ ? { ...record, archivePath, completion: { ...record.completion, archivePath } }
137
+ : undefined;
138
+ }
139
+
140
+ function runIdFromReplayFile(file: string): string | undefined {
141
+ if (!file.endsWith(".json")) return undefined;
142
+ try {
143
+ const runId = decodeURIComponent(file.slice(0, -".json".length));
144
+ return safeRunFile(runId) === file ? runId : undefined;
145
+ } catch {
146
+ return undefined;
147
+ }
148
+ }
149
+
150
+ function removeBestEffort(filePath: string): void {
151
+ try {
152
+ fs.rmSync(filePath, { force: true });
153
+ } catch { /* cleanup only */ }
154
+ }
155
+
156
+ function parseArchive(value: unknown): CompletionArchive | undefined {
157
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
158
+ const archive = value as Partial<CompletionArchive>;
159
+ if (archive.version !== ARCHIVE_VERSION || typeof archive.runId !== "string" || typeof archive.createdAt !== "number" || !Array.isArray(archive.entries)) return undefined;
160
+ const entries = archive.entries.flatMap((value): CompletionArchiveEntry[] => {
161
+ if (!value || typeof value !== "object" || Array.isArray(value)) return [];
162
+ const entry = value as Partial<CompletionArchiveEntry>;
163
+ if (entry.source !== "output-artifact" && entry.source !== "session" && entry.source !== "result-tail") return [];
164
+ return [{
165
+ ...(typeof entry.agent === "string" ? { agent: entry.agent } : {}),
166
+ source: entry.source,
167
+ ...(typeof entry.path === "string" ? { path: entry.path } : {}),
168
+ ...(typeof entry.text === "string" ? { text: entry.text } : {}),
169
+ ...(entry.truncated === true ? { truncated: true } : {}),
170
+ }];
171
+ });
172
+ return { version: ARCHIVE_VERSION, runId: archive.runId, createdAt: archive.createdAt, entries };
173
+ }
174
+
175
+ /** Persist a terminal completion before its one-shot result file is removed. */
176
+ export function writeCompletionReplay(input: {
177
+ resultsDir: string;
178
+ runId: string;
179
+ sessionId: string;
180
+ completion: WaitCompletion;
181
+ data: Record<string, unknown>;
182
+ now: number;
183
+ ttlMs: number;
184
+ }): CompletionReplayRecord {
185
+ const archivePath = writeCompletionArchive(input.resultsDir, input.runId, input.data, input.now);
186
+ const completion = { ...input.completion, archivePath };
187
+ const record: CompletionReplayRecord = {
188
+ version: REPLAY_VERSION,
189
+ runId: input.runId,
190
+ sessionId: input.sessionId,
191
+ completedAt: input.now,
192
+ expiresAt: input.now + input.ttlMs,
193
+ completion,
194
+ archivePath,
195
+ };
196
+ writePrivateAtomicJson(completionReplayPath(input.resultsDir, input.runId), record);
197
+ cleanupCompletionReplay(input.resultsDir, input.now, input.ttlMs);
198
+ return record;
199
+ }
200
+
201
+ /** Read a current replay record. Unknown fields are ignored and unknown versions are skipped. */
202
+ export function readCompletionReplay(resultsDir: string, runId: string, options: { sessionId?: string; now?: number } = {}): CompletionReplayRecord | undefined {
203
+ const replayPath = completionReplayPath(resultsDir, runId);
204
+ let parsed: CompletionReplayRecord | undefined;
205
+ try {
206
+ parsed = parseReplay(JSON.parse(fs.readFileSync(replayPath, "utf-8")));
207
+ } catch (error) {
208
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
209
+ throw error;
210
+ }
211
+ if (!parsed) return undefined;
212
+ const safeRecord = validateReplayRecord(resultsDir, runId, parsed);
213
+ if (!safeRecord) {
214
+ removeBestEffort(replayPath);
215
+ return undefined;
216
+ }
217
+ parsed = safeRecord;
218
+ if (options.sessionId !== undefined && parsed.sessionId !== options.sessionId) return undefined;
219
+ if (parsed.expiresAt <= (options.now ?? Date.now())) {
220
+ removeBestEffort(replayPath);
221
+ removeBestEffort(parsed.archivePath);
222
+ return undefined;
223
+ }
224
+ return parsed;
225
+ }
226
+
227
+ export function readCompletionArchive(archivePath: string): CompletionArchive | undefined {
228
+ try {
229
+ return parseArchive(JSON.parse(fs.readFileSync(archivePath, "utf-8")));
230
+ } catch (error) {
231
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
232
+ throw error;
233
+ }
234
+ }
235
+
236
+ /** Opportunistically remove expired replay and orphan archive files without affecting delivery. */
237
+ export function cleanupCompletionReplay(resultsDir: string, now: number, maxAgeMs: number): void {
238
+ const replayDir = path.join(resultsDir, REPLAY_DIR_NAME);
239
+ try {
240
+ for (const file of fs.readdirSync(replayDir)) {
241
+ const runId = runIdFromReplayFile(file);
242
+ if (!runId) continue;
243
+ const filePath = path.join(replayDir, file);
244
+ try {
245
+ const record = parseReplay(JSON.parse(fs.readFileSync(filePath, "utf-8")));
246
+ const safeRecord = record ? validateReplayRecord(resultsDir, runId, record) : undefined;
247
+ if (record && !safeRecord) {
248
+ fs.rmSync(filePath, { force: true });
249
+ } else if (safeRecord && safeRecord.expiresAt <= now) {
250
+ fs.rmSync(filePath, { force: true });
251
+ fs.rmSync(safeRecord.archivePath, { force: true });
252
+ } else if (!record && now - fs.statSync(filePath).mtimeMs > maxAgeMs) {
253
+ fs.rmSync(filePath, { force: true });
254
+ }
255
+ } catch { /* one bad entry must not block cleanup */ }
256
+ }
257
+ } catch { /* replay directory may not exist yet */ }
258
+ const archiveDir = path.join(resultsDir, ARCHIVE_DIR_NAME);
259
+ try {
260
+ for (const file of fs.readdirSync(archiveDir)) {
261
+ const filePath = path.join(archiveDir, file);
262
+ try {
263
+ if (now - fs.statSync(filePath).mtimeMs > maxAgeMs) fs.rmSync(filePath, { force: true });
264
+ } catch { /* one bad entry must not block cleanup */ }
265
+ }
266
+ } catch { /* archive directory may not exist yet */ }
267
+ }
@@ -21,6 +21,7 @@ import {
21
21
  import { projectNestedRegistryForRoot, sanitizeSummary } from "../shared/nested-events.ts";
22
22
  import { resolveWatchPath } from "../../shared/utils.ts";
23
23
  import { recordWaitCompletion } from "./wait-completions.ts";
24
+ import { syncMissionFromAsyncCompletion } from "../../missions/lifecycle.ts";
24
25
  import type { CompletionNotifier, CompletionNotification } from "./notify.ts";
25
26
 
26
27
  const WATCHER_RESTART_DELAY_MS = 3000;
@@ -149,6 +150,11 @@ export function createResultWatcher(
149
150
  const data = JSON.parse(fsApi.readFileSync(resultPath, "utf-8")) as ResultFileData;
150
151
  if (typeof data.sessionId !== "string" || !data.sessionId) return;
151
152
  const runId = data.runId ?? data.id ?? file.replace(/\.json$/i, "");
153
+ try {
154
+ syncMissionFromAsyncCompletion({ ...data, runId });
155
+ } catch (error) {
156
+ console.error(`Mission completion sync failed for '${resultPath}':`, error);
157
+ }
152
158
  try {
153
159
  deps.observeCompletion?.({ ...data, runId });
154
160
  } catch (error) {
@@ -156,10 +162,12 @@ export function createResultWatcher(
156
162
  }
157
163
  const epoch = deliveryEpoch;
158
164
  if (!ownsSession(data.sessionId, epoch)) return;
159
- // Recorded before dedupe and before the unlink below: the result file is
160
- // the only durable carrier of the per-run payload, and subagent_wait
161
- // surfaces this record in details once the file is gone.
162
- recordWaitCompletion(state, runId, data, Date.now(), completionTtlMs);
165
+ // Recorded before dedupe and before the unlink below so subagent_wait can
166
+ // use the in-memory record or its bounded durable replay after cleanup.
167
+ recordWaitCompletion(state, runId, data, Date.now(), completionTtlMs, {
168
+ resultsDir,
169
+ sessionId: data.sessionId,
170
+ });
163
171
  const hasExplicitNestedChildren = data.nestedChildren !== undefined;
164
172
  let nestedChildren = compactNestedResultChildren(sanitizeNestedResultChildren(data.nestedChildren, resultPath, "nestedChildren"));
165
173
  if (!nestedChildren?.length && !hasExplicitNestedChildren) {
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { ArtifactPaths, SubagentState, WaitCompletion, WaitCompletionChild } from "../../shared/types.ts";
4
4
  import type { AsyncRunSummary } from "./async-status.ts";
5
+ import { readCompletionReplay, writeCompletionReplay } from "./completion-replay.ts";
5
6
 
6
7
  function asNonEmptyString(value: unknown): string | undefined {
7
8
  return typeof value === "string" && value ? value : undefined;
@@ -68,12 +69,34 @@ export function toWaitCompletion(data: Record<string, unknown>, runId: string):
68
69
  * file is deleted after delivery, so this record is the only in-process source once
69
70
  * the watcher has consumed it.
70
71
  */
71
- export function recordWaitCompletion(state: SubagentState, runId: string, data: Record<string, unknown>, now: number, ttlMs: number): void {
72
+ export function recordWaitCompletion(
73
+ state: SubagentState,
74
+ runId: string,
75
+ data: Record<string, unknown>,
76
+ now: number,
77
+ ttlMs: number,
78
+ persistence?: { resultsDir: string; sessionId: string },
79
+ ): void {
72
80
  const store = state.completedResults ??= new Map();
73
81
  for (const [key, entry] of store) {
74
82
  if (now - entry.seenAt > ttlMs) store.delete(key);
75
83
  }
76
- store.set(runId, { seenAt: now, completion: toWaitCompletion(data, runId) });
84
+ let completion = toWaitCompletion(data, runId);
85
+ if (persistence) {
86
+ try {
87
+ completion = writeCompletionReplay({
88
+ ...persistence,
89
+ runId,
90
+ completion,
91
+ data,
92
+ now,
93
+ ttlMs,
94
+ }).completion;
95
+ } catch (error) {
96
+ console.error(`Failed to persist completion replay for '${runId}':`, error);
97
+ }
98
+ }
99
+ store.set(runId, { seenAt: now, completion });
77
100
  }
78
101
 
79
102
  /**
@@ -102,10 +125,21 @@ export function collectWaitCompletions(terminal: AsyncRunSummary[], state: Subag
102
125
  });
103
126
  }
104
127
  // The watcher may have consumed the file between the store check and the
105
- // read; its record is authoritative when present, otherwise the payload
106
- // is gone and the text summary remains the only surface for this run.
128
+ // read. Prefer its in-memory record, then the durable replay written before
129
+ // result cleanup so watcher reloads do not lose completion details.
107
130
  const late = state.completedResults?.get(run.id);
108
- if (late) completions.push(late.completion);
131
+ if (late) {
132
+ completions.push(late.completion);
133
+ continue;
134
+ }
135
+ try {
136
+ const replay = readCompletionReplay(resultsDir, run.id, { sessionId: run.sessionId });
137
+ if (replay) completions.push(replay.completion);
138
+ } catch (replayError) {
139
+ throw new Error(`Failed to read completion replay for '${run.id}': ${errorMessage(replayError)}`, {
140
+ cause: replayError instanceof Error ? replayError : undefined,
141
+ });
142
+ }
109
143
  }
110
144
  }
111
145
  return completions.length > 0 ? completions : undefined;