pi-subagents 0.49.0 → 0.50.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 (75) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/agents/oracle.md +7 -5
  3. package/agents/researcher.md +1 -1
  4. package/agents/reviewer.md +2 -2
  5. package/agents/scout.md +1 -1
  6. package/agents/worker.md +1 -1
  7. package/docs/agents.md +2 -0
  8. package/docs/configuration.md +47 -3
  9. package/docs/extension-api.md +36 -0
  10. package/docs/missions.md +1 -1
  11. package/docs/observability.md +16 -0
  12. package/docs/tool-reference.md +19 -2
  13. package/docs/workflows.md +2 -2
  14. package/package.json +1 -1
  15. package/skills/pi-subagents/references/constraints-and-recipes.md +3 -2
  16. package/skills/pi-subagents/references/execution-controls.md +5 -4
  17. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  18. package/skills/pi-subagents/references/prompting-and-roles.md +31 -15
  19. package/src/agents/agent-serializer.ts +2 -0
  20. package/src/agents/agents.ts +10 -0
  21. package/src/api/external-runs.ts +174 -84
  22. package/src/api/preflight.ts +12 -6
  23. package/src/extension/config.ts +33 -0
  24. package/src/extension/index.ts +48 -5
  25. package/src/extension/public-execution.ts +3 -1
  26. package/src/extension/rpc.ts +5 -1
  27. package/src/extension/schemas.ts +2 -1
  28. package/src/extension/tool-description.ts +4 -2
  29. package/src/inspectors/herdr/actions.ts +3 -7
  30. package/src/inspectors/herdr/project-panes.ts +2 -6
  31. package/src/inspectors/herdr/shell-command.ts +16 -0
  32. package/src/intercom/intercom-bridge.ts +2 -3
  33. package/src/intercom/native-supervisor-channel.ts +19 -42
  34. package/src/missions/goal-driver.ts +3 -1
  35. package/src/runs/background/active-run-index.ts +71 -1
  36. package/src/runs/background/async-execution.ts +49 -7
  37. package/src/runs/background/async-job-tracker.ts +1 -0
  38. package/src/runs/background/async-resume.ts +3 -5
  39. package/src/runs/background/async-status-snapshot.ts +277 -0
  40. package/src/runs/background/async-status.ts +8 -3
  41. package/src/runs/background/chain-root-attachment.ts +2 -2
  42. package/src/runs/background/completion-replay.ts +11 -1
  43. package/src/runs/background/fleet-view.ts +3 -1
  44. package/src/runs/background/result-files.ts +437 -0
  45. package/src/runs/background/result-watcher.ts +188 -41
  46. package/src/runs/background/retained-children.ts +69 -20
  47. package/src/runs/background/run-id-resolver.ts +30 -24
  48. package/src/runs/background/run-status.ts +7 -2
  49. package/src/runs/background/scheduled-runs.ts +54 -28
  50. package/src/runs/background/stale-run-reconciler.ts +27 -13
  51. package/src/runs/background/subagent-runner.ts +294 -32
  52. package/src/runs/background/subagent-wait.ts +2 -0
  53. package/src/runs/background/wait-completions.ts +5 -2
  54. package/src/runs/foreground/async-dismiss-action.ts +2 -1
  55. package/src/runs/foreground/chain-execution.ts +16 -0
  56. package/src/runs/foreground/execution.ts +219 -15
  57. package/src/runs/foreground/subagent-executor.ts +114 -28
  58. package/src/runs/shared/completion-guard.ts +96 -6
  59. package/src/runs/shared/external-cli-runner.ts +4 -0
  60. package/src/runs/shared/model-fallback.ts +16 -2
  61. package/src/runs/shared/nested-events.ts +66 -62
  62. package/src/runs/shared/orca-progress-tabs.ts +375 -0
  63. package/src/runs/shared/parallel-utils.ts +2 -0
  64. package/src/runs/shared/subagent-control.ts +15 -0
  65. package/src/runs/shared/subagent-prompt-runtime.ts +1 -9
  66. package/src/runs/shared/subagent-startup-retry.ts +12 -0
  67. package/src/runs/shared/tool-timeout.ts +93 -0
  68. package/src/shared/types.ts +29 -2
  69. package/src/slash/slash-commands.ts +34 -25
  70. package/src/slash/slash-live-state.ts +3 -0
  71. package/src/tui/fleet-status.ts +160 -45
  72. package/src/tui/fleet.ts +125 -15
  73. package/src/tui/render.ts +41 -10
  74. package/src/workflows/chat-progress.ts +6 -4
  75. package/src/workflows/scripted-workflow.ts +227 -70
@@ -0,0 +1,437 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { writeAtomicJson } from "../../shared/atomic-json.ts";
4
+ import { MISSION_BINDING_FILE } from "../../missions/lifecycle.ts";
5
+
6
+ const RESULT_INDEX_VERSION = 1;
7
+ const RESULT_INDEX_DIR = "result-index";
8
+ const SESSION_INDEX_DIR = "sessions";
9
+ const OBSERVER_INDEX_DIR = "observers";
10
+ const TOOL_CALL_INDEX_DIR = "tool-calls";
11
+ const RESULT_PENDING_DIR = "result-pending";
12
+ const MISSION_OBSERVER = "mission";
13
+
14
+ export interface ResultIndexEntry {
15
+ version: 1;
16
+ runId: string;
17
+ sessionId: string;
18
+ file: string;
19
+ writtenAt: number;
20
+ asyncDir?: string;
21
+ }
22
+
23
+ function encodeSegment(value: string): string {
24
+ return encodeURIComponent(value);
25
+ }
26
+
27
+ function nonEmptyString(value: unknown): string | undefined {
28
+ return typeof value === "string" && value.length > 0 ? value : undefined;
29
+ }
30
+
31
+ export function resultFileName(runId: string): string {
32
+ return `${runId}.json`;
33
+ }
34
+
35
+ export function resultFilePath(resultsDir: string, runId: string): string {
36
+ return path.join(resultsDir, resultFileName(runId));
37
+ }
38
+
39
+ function sessionIndexDir(resultsDir: string, sessionId: string): string {
40
+ return path.join(resultsDir, RESULT_INDEX_DIR, SESSION_INDEX_DIR, encodeSegment(sessionId));
41
+ }
42
+
43
+ function resultIndexPath(resultsDir: string, sessionId: string, runId: string): string {
44
+ return path.join(sessionIndexDir(resultsDir, sessionId), `${encodeSegment(runId)}.json`);
45
+ }
46
+
47
+ function resultPendingPath(resultsDir: string, sessionId: string, runId: string): string {
48
+ return path.join(resultsDir, RESULT_PENDING_DIR, encodeSegment(sessionId), `${encodeSegment(runId)}.json`);
49
+ }
50
+
51
+ function observerIndexDir(resultsDir: string, observer: string): string {
52
+ return path.join(resultsDir, RESULT_INDEX_DIR, OBSERVER_INDEX_DIR, observer);
53
+ }
54
+
55
+ function observerIndexPath(resultsDir: string, observer: string, runId: string): string {
56
+ return path.join(observerIndexDir(resultsDir, observer), `${encodeSegment(runId)}.json`);
57
+ }
58
+
59
+ function toolCallIndexDir(resultsDir: string, toolCallId: string): string {
60
+ return path.join(resultsDir, RESULT_INDEX_DIR, TOOL_CALL_INDEX_DIR, encodeSegment(toolCallId));
61
+ }
62
+
63
+ function toolCallIndexPath(resultsDir: string, toolCallId: string, runId: string): string {
64
+ return path.join(toolCallIndexDir(resultsDir, toolCallId), `${encodeSegment(runId)}.json`);
65
+ }
66
+
67
+ function parseResultIndexEntry(value: unknown): ResultIndexEntry | undefined {
68
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
69
+ const record = value as Partial<ResultIndexEntry>;
70
+ if (record.version !== RESULT_INDEX_VERSION
71
+ || typeof record.runId !== "string"
72
+ || typeof record.sessionId !== "string"
73
+ || typeof record.file !== "string"
74
+ || typeof record.writtenAt !== "number") return undefined;
75
+ return {
76
+ version: RESULT_INDEX_VERSION,
77
+ runId: record.runId,
78
+ sessionId: record.sessionId,
79
+ file: record.file,
80
+ writtenAt: record.writtenAt,
81
+ ...(typeof record.asyncDir === "string" ? { asyncDir: record.asyncDir } : {}),
82
+ };
83
+ }
84
+
85
+ export function writeResultIndexForData(resultPath: string, data: Record<string, unknown>): void {
86
+ const runId = nonEmptyString(data.runId) ?? nonEmptyString(data.id) ?? path.basename(resultPath, ".json");
87
+ const sessionId = nonEmptyString(data.sessionId);
88
+ if (!runId || !sessionId) return;
89
+ const file = path.basename(resultPath);
90
+ const entry: ResultIndexEntry = {
91
+ version: RESULT_INDEX_VERSION,
92
+ runId,
93
+ sessionId,
94
+ file,
95
+ writtenAt: Date.now(),
96
+ ...(nonEmptyString(data.asyncDir) ? { asyncDir: nonEmptyString(data.asyncDir)! } : {}),
97
+ };
98
+ const resultsDir = path.dirname(resultPath);
99
+ writeAtomicJson(resultIndexPath(resultsDir, sessionId, runId), entry);
100
+ const toolCallId = nonEmptyString(data.toolCallId);
101
+ try {
102
+ if (toolCallId) writeAtomicJson(toolCallIndexPath(resultsDir, toolCallId, runId), entry);
103
+ } catch (error) {
104
+ console.error(`Failed to write async result tool-call index for '${resultPath}':`, error);
105
+ }
106
+ try {
107
+ if (entry.asyncDir && fs.existsSync(path.join(entry.asyncDir, MISSION_BINDING_FILE))) {
108
+ writeAtomicJson(observerIndexPath(resultsDir, MISSION_OBSERVER, runId), entry);
109
+ }
110
+ } catch (error) {
111
+ console.error(`Failed to write async result observer index for '${resultPath}':`, error);
112
+ }
113
+ }
114
+
115
+ function writeIndexedPendingResultFile(resultPath: string, data: Record<string, unknown>): { runId: string; sessionId: string; resultsDir: string } {
116
+ const runId = nonEmptyString(data.runId) ?? nonEmptyString(data.id) ?? path.basename(resultPath, ".json");
117
+ const sessionId = nonEmptyString(data.sessionId);
118
+ if (!sessionId) throw new Error(`Cannot write async result '${resultPath}' without a sessionId.`);
119
+ const resultsDir = path.dirname(resultPath);
120
+ writeAtomicJson(resultPendingPath(resultsDir, sessionId, runId), data);
121
+ writeResultIndexForData(resultPath, data);
122
+ return { runId, sessionId, resultsDir };
123
+ }
124
+
125
+ export function writePendingAsyncResultFile(resultPath: string, data: Record<string, unknown>): void {
126
+ writeIndexedPendingResultFile(resultPath, data);
127
+ }
128
+
129
+ export function writeAsyncResultFile(resultPath: string, data: Record<string, unknown>): { state: "public" | "pending" } {
130
+ const { runId, sessionId, resultsDir } = writeIndexedPendingResultFile(resultPath, data);
131
+ return promotePendingResultFile(resultsDir, sessionId, runId, path.basename(resultPath), { logFailure: false }) === "promoted"
132
+ ? { state: "public" }
133
+ : { state: "pending" };
134
+ }
135
+
136
+ export function removeResultIndex(resultsDir: string, sessionId: string | undefined, runId: string | undefined, toolCallId?: string): void {
137
+ if (!runId) return;
138
+ if (sessionId) {
139
+ try {
140
+ fs.rmSync(resultIndexPath(resultsDir, sessionId, runId), { force: true });
141
+ } catch {
142
+ // Index cleanup must not affect result delivery.
143
+ }
144
+ try {
145
+ fs.rmSync(resultPendingPath(resultsDir, sessionId, runId), { force: true });
146
+ } catch {
147
+ // Pending cleanup must not affect result delivery.
148
+ }
149
+ }
150
+ if (toolCallId) {
151
+ try {
152
+ fs.rmSync(toolCallIndexPath(resultsDir, toolCallId, runId), { force: true });
153
+ } catch {
154
+ // Index cleanup must not affect result delivery.
155
+ }
156
+ }
157
+ try {
158
+ fs.rmSync(observerIndexPath(resultsDir, MISSION_OBSERVER, runId), { force: true });
159
+ } catch {
160
+ // Index cleanup must not affect result delivery.
161
+ }
162
+ }
163
+
164
+ export function removeMissionObserverIndex(resultsDir: string, runId: string | undefined): void {
165
+ if (!runId) return;
166
+ try {
167
+ fs.rmSync(observerIndexPath(resultsDir, MISSION_OBSERVER, runId), { force: true });
168
+ } catch {
169
+ // Observer index cleanup must not affect result delivery.
170
+ }
171
+ }
172
+
173
+ function existingResultFile(resultPath: string): boolean {
174
+ try {
175
+ return fs.statSync(resultPath).isFile();
176
+ } catch (error) {
177
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Failed to inspect async result payload '${resultPath}':`, error);
178
+ return false;
179
+ }
180
+ }
181
+
182
+ function pendingResultExists(resultsDir: string, sessionId: string, runId: string): boolean {
183
+ return existingResultFile(resultPendingPath(resultsDir, sessionId, runId));
184
+ }
185
+
186
+ function pendingResultPayloadMatches(filePath: string, sessionId: string, runId: string): boolean {
187
+ try {
188
+ const data = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Record<string, unknown>;
189
+ return (nonEmptyString(data.runId) ?? nonEmptyString(data.id)) === runId && nonEmptyString(data.sessionId) === sessionId;
190
+ } catch (error) {
191
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Ignoring invalid pending async result '${filePath}':`, error);
192
+ return false;
193
+ }
194
+ }
195
+
196
+ export function promotePendingResultFile(resultsDir: string, sessionId: string, runId: string, file = resultFileName(runId), options: { logFailure?: boolean } = {}): "none" | "promoted" | "pending" {
197
+ if (file !== path.basename(file) || !file.endsWith(".json")) return "none";
198
+ const pendingPath = resultPendingPath(resultsDir, sessionId, runId);
199
+ if (!existingResultFile(pendingPath)) return "none";
200
+ const resultPath = path.join(resultsDir, file);
201
+ try {
202
+ fs.rmSync(resultPath, { force: true });
203
+ fs.renameSync(pendingPath, resultPath);
204
+ return existingResultFile(resultPath) ? "promoted" : "pending";
205
+ } catch (error) {
206
+ if (options.logFailure !== false) console.error(`Failed to promote pending async result '${pendingPath}' to '${resultPath}':`, error);
207
+ return "pending";
208
+ }
209
+ }
210
+
211
+ interface ResultPayloadLocation {
212
+ file: string;
213
+ path: string;
214
+ state: "public" | "pending";
215
+ }
216
+
217
+ function pendingResultLocationForSessionRun(resultsDir: string, sessionId: string, runId: string, file = resultFileName(runId)): ResultPayloadLocation | undefined {
218
+ if (file !== path.basename(file) || !file.endsWith(".json")) return undefined;
219
+ const pendingPath = resultPendingPath(resultsDir, sessionId, runId);
220
+ return existingResultFile(pendingPath) && pendingResultPayloadMatches(pendingPath, sessionId, runId)
221
+ ? { file, path: pendingPath, state: "pending" }
222
+ : undefined;
223
+ }
224
+
225
+ function pendingResultLocationForIndexedRun(resultsDir: string, runId: string): ResultPayloadLocation | undefined {
226
+ const root = path.join(resultsDir, RESULT_PENDING_DIR);
227
+ let sessions: fs.Dirent[];
228
+ try {
229
+ sessions = fs.readdirSync(root, { withFileTypes: true });
230
+ } catch (error) {
231
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Failed to inspect pending async result root '${root}':`, error);
232
+ return undefined;
233
+ }
234
+ for (const session of sessions) {
235
+ if (!session.isDirectory()) continue;
236
+ let sessionId: string;
237
+ try {
238
+ sessionId = decodeURIComponent(session.name);
239
+ } catch {
240
+ continue;
241
+ }
242
+ const location = pendingResultLocationForSessionRun(resultsDir, sessionId, runId);
243
+ if (location) return location;
244
+ }
245
+ return undefined;
246
+ }
247
+
248
+ function resultPayloadLocationFromIndex(resultsDir: string, entry: ResultIndexEntry): ResultPayloadLocation | undefined {
249
+ if (entry.file !== path.basename(entry.file) || !entry.file.endsWith(".json")) return undefined;
250
+ const pendingState = promotePendingResultFile(resultsDir, entry.sessionId, entry.runId, entry.file);
251
+ if (pendingState === "pending") {
252
+ return pendingResultLocationForSessionRun(resultsDir, entry.sessionId, entry.runId, entry.file);
253
+ }
254
+ const resultPath = path.join(resultsDir, entry.file);
255
+ if (pendingState === "promoted" || existingResultFile(resultPath)) return { file: entry.file, path: resultPath, state: "public" };
256
+ return undefined;
257
+ }
258
+
259
+ function readResultIndexForSessionRun(resultsDir: string, sessionId: string, runId: string): ResultIndexEntry | undefined {
260
+ try {
261
+ const entry = parseResultIndexEntry(JSON.parse(fs.readFileSync(resultIndexPath(resultsDir, sessionId, runId), "utf-8")));
262
+ if (!entry || entry.sessionId !== sessionId || entry.runId !== runId) return undefined;
263
+ return entry;
264
+ } catch (error) {
265
+ const code = (error as NodeJS.ErrnoException).code;
266
+ if (code !== "ENOENT" && code !== "ENOTDIR") console.error(`Ignoring invalid async result index for '${runId}':`, error);
267
+ return undefined;
268
+ }
269
+ }
270
+
271
+ export function resultPayloadPathForSessionRun(resultsDir: string, sessionId: string, runId: string): string | undefined {
272
+ const entry = readResultIndexForSessionRun(resultsDir, sessionId, runId);
273
+ return (entry ? resultPayloadLocationFromIndex(resultsDir, entry) : undefined)?.path
274
+ ?? pendingResultLocationForSessionRun(resultsDir, sessionId, runId)?.path;
275
+ }
276
+
277
+ export function resultPayloadPathForMissionObserverRun(resultsDir: string, runId: string): string | undefined {
278
+ try {
279
+ const entry = parseResultIndexEntry(JSON.parse(fs.readFileSync(observerIndexPath(resultsDir, MISSION_OBSERVER, runId), "utf-8")));
280
+ if (!entry || entry.runId !== runId) return undefined;
281
+ return resultPayloadLocationFromIndex(resultsDir, entry)?.path;
282
+ } catch (error) {
283
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Ignoring invalid async result observer index for '${runId}':`, error);
284
+ return undefined;
285
+ }
286
+ }
287
+
288
+ export function resultPayloadPathForIndexedRun(resultsDir: string, runId: string): string | undefined {
289
+ const root = path.join(resultsDir, RESULT_INDEX_DIR, SESSION_INDEX_DIR);
290
+ let sessions: fs.Dirent[] = [];
291
+ try {
292
+ sessions = fs.readdirSync(root, { withFileTypes: true });
293
+ } catch (error) {
294
+ const code = (error as NodeJS.ErrnoException).code;
295
+ if (code !== "ENOENT" && code !== "ENOTDIR") console.error(`Failed to inspect async result session index root '${root}':`, error);
296
+ }
297
+ for (const session of sessions) {
298
+ if (!session.isDirectory()) continue;
299
+ const entryPath = path.join(root, session.name, `${encodeSegment(runId)}.json`);
300
+ try {
301
+ const entry = parseResultIndexEntry(JSON.parse(fs.readFileSync(entryPath, "utf-8")));
302
+ if (!entry || entry.runId !== runId) continue;
303
+ const location = resultPayloadLocationFromIndex(resultsDir, entry);
304
+ if (location) return location.path;
305
+ } catch (error) {
306
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Ignoring invalid async result index '${entryPath}':`, error);
307
+ }
308
+ }
309
+ return pendingResultLocationForIndexedRun(resultsDir, runId)?.path;
310
+ }
311
+
312
+ function indexedResultFile(resultsDir: string, entry: ResultIndexEntry, includePending = false): string | undefined {
313
+ const location = resultPayloadLocationFromIndex(resultsDir, entry);
314
+ if (!location) return undefined;
315
+ return includePending || location.state === "public" ? location.file : undefined;
316
+ }
317
+
318
+ function listIndexFiles(dir: string): string[] {
319
+ let files: string[];
320
+ try {
321
+ files = fs.readdirSync(dir, { withFileTypes: true })
322
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
323
+ .map((entry) => path.join(dir, entry.name));
324
+ } catch (error) {
325
+ const code = (error as NodeJS.ErrnoException).code;
326
+ if (code === "ENOENT" || code === "ENOTDIR") return [];
327
+ throw error;
328
+ }
329
+ return files;
330
+ }
331
+
332
+ function resultFilesFromIndexDir(resultsDir: string, dir: string, includePending = false): string[] {
333
+ const candidates = new Set<string>();
334
+ for (const entryPath of listIndexFiles(dir)) {
335
+ try {
336
+ const entry = parseResultIndexEntry(JSON.parse(fs.readFileSync(entryPath, "utf-8")));
337
+ if (!entry) {
338
+ fs.rmSync(entryPath, { force: true });
339
+ continue;
340
+ }
341
+ const resultFile = indexedResultFile(resultsDir, entry, includePending);
342
+ if (resultFile) candidates.add(resultFile);
343
+ } catch (error) {
344
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Ignoring invalid async result index '${entryPath}':`, error);
345
+ }
346
+ }
347
+ return [...candidates];
348
+ }
349
+
350
+ export function resultFilesForSession(resultsDir: string, sessionId: string): string[] {
351
+ return resultFilesFromIndexDir(resultsDir, sessionIndexDir(resultsDir, sessionId));
352
+ }
353
+
354
+ function pendingResultFilesForSession(resultsDir: string, sessionId: string): string[] {
355
+ const dir = path.join(resultsDir, RESULT_PENDING_DIR, encodeSegment(sessionId));
356
+ let entries: fs.Dirent[];
357
+ try {
358
+ entries = fs.readdirSync(dir, { withFileTypes: true });
359
+ } catch (error) {
360
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
361
+ throw error;
362
+ }
363
+ const files = new Set<string>();
364
+ for (const entry of entries) {
365
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
366
+ const pendingPath = path.join(dir, entry.name);
367
+ try {
368
+ const data = JSON.parse(fs.readFileSync(pendingPath, "utf-8")) as Record<string, unknown>;
369
+ const runId = nonEmptyString(data.runId) ?? nonEmptyString(data.id);
370
+ if (runId && nonEmptyString(data.sessionId) === sessionId) files.add(resultFileName(runId));
371
+ } catch (error) {
372
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Ignoring invalid pending async result '${pendingPath}':`, error);
373
+ }
374
+ }
375
+ return [...files];
376
+ }
377
+
378
+ export function resultCandidateFilesForSession(resultsDir: string, sessionId: string): string[] {
379
+ return [...new Set([
380
+ ...resultFilesFromIndexDir(resultsDir, sessionIndexDir(resultsDir, sessionId), true),
381
+ ...pendingResultFilesForSession(resultsDir, sessionId),
382
+ ])];
383
+ }
384
+
385
+ export function resultFilesForToolCall(resultsDir: string, toolCallId: string): string[] {
386
+ return resultFilesFromIndexDir(resultsDir, toolCallIndexDir(resultsDir, toolCallId));
387
+ }
388
+
389
+ export function missionObserverResultFiles(resultsDir: string): string[] {
390
+ return resultFilesFromIndexDir(resultsDir, observerIndexDir(resultsDir, MISSION_OBSERVER));
391
+ }
392
+
393
+ export function missionObserverResultCandidateFiles(resultsDir: string): string[] {
394
+ return resultFilesFromIndexDir(resultsDir, observerIndexDir(resultsDir, MISSION_OBSERVER), true);
395
+ }
396
+
397
+ export function cleanupResultIndexes(resultsDir: string, now = Date.now(), maxAgeMs = 24 * 60 * 60 * 1000): number {
398
+ const root = path.join(resultsDir, RESULT_INDEX_DIR);
399
+ const cutoff = now - maxAgeMs;
400
+ let removed = 0;
401
+ const visit = (dir: string): void => {
402
+ let entries: fs.Dirent[];
403
+ try {
404
+ entries = fs.readdirSync(dir, { withFileTypes: true });
405
+ } catch (error) {
406
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
407
+ throw error;
408
+ }
409
+ for (const entry of entries) {
410
+ const fullPath = path.join(dir, entry.name);
411
+ if (entry.isDirectory()) {
412
+ visit(fullPath);
413
+ try { fs.rmdirSync(fullPath); } catch {}
414
+ continue;
415
+ }
416
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
417
+ try {
418
+ const stat = fs.statSync(fullPath);
419
+ const index = parseResultIndexEntry(JSON.parse(fs.readFileSync(fullPath, "utf-8")));
420
+ const resultFile = index ? indexedResultFile(resultsDir, index) : undefined;
421
+ const pendingFile = index ? pendingResultExists(resultsDir, index.sessionId, index.runId) : false;
422
+ if (!index || (!resultFile && !pendingFile && stat.mtimeMs <= cutoff)) {
423
+ fs.rmSync(fullPath, { force: true });
424
+ removed += 1;
425
+ }
426
+ } catch (error) {
427
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Ignoring invalid async result index '${fullPath}':`, error);
428
+ try {
429
+ fs.rmSync(fullPath, { force: true });
430
+ removed += 1;
431
+ } catch {}
432
+ }
433
+ }
434
+ };
435
+ visit(root);
436
+ return removed;
437
+ }