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
@@ -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,16 @@ import { POLL_INTERVAL_MS } from "../../shared/types.ts";
21
21
  import { resolveWatchPath } from "../../shared/utils.ts";
22
22
 
23
23
  export type ControlChannelFs = Pick<typeof fs, "mkdirSync" | "existsSync" | "rmSync" | "watch" | "readdirSync" | "readFileSync" | "realpathSync">;
24
+
25
+ function writeJsonToExistingDir(filePath: string, payload: object): void {
26
+ const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`);
27
+ try {
28
+ fs.writeFileSync(tempPath, JSON.stringify(payload, null, 2), { encoding: "utf-8", flag: "wx" });
29
+ fs.renameSync(tempPath, filePath);
30
+ } finally {
31
+ fs.rmSync(tempPath, { force: true });
32
+ }
33
+ }
24
34
  export type ControlChannelTimers = { setInterval: typeof setInterval; clearInterval: typeof clearInterval };
25
35
  type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => unknown;
26
36
 
@@ -205,6 +215,13 @@ export function writeSteerRequestToDir(dir: string, request: SteerRequest): stri
205
215
  return requestPath;
206
216
  }
207
217
 
218
+ export function writeSteerRequestToExistingDir(dir: string, request: SteerRequest): string {
219
+ if (!validSteerRequest(request)) throw new Error("steer request is malformed or exceeds transport limits.");
220
+ const requestPath = path.join(dir, steerRequestFileName(request));
221
+ writeJsonToExistingDir(requestPath, request);
222
+ return requestPath;
223
+ }
224
+
208
225
  export function writeSteerCapabilityAt(filePath: string, capability: Omit<SteerCapability, "type" | "protocolVersion">): string {
209
226
  assertChildIndex(capability.index);
210
227
  if (!Number.isInteger(capability.pid) || capability.pid <= 0) throw new Error("steer capability pid must be a positive integer.");
@@ -385,6 +402,25 @@ export function consumeSteerCapabilities(asyncDir: string, fsImpl: Pick<typeof f
385
402
  return capabilities;
386
403
  }
387
404
 
405
+ export function consumeSteerAckFromDir(
406
+ dir: string,
407
+ requestId: string,
408
+ fsImpl: Pick<typeof fs, "existsSync" | "readdirSync" | "readFileSync" | "rmSync"> = fs,
409
+ ): SteerAck | undefined {
410
+ if (!fsImpl.existsSync(dir)) return undefined;
411
+ let entries: string[];
412
+ try { entries = fsImpl.readdirSync(dir).filter((name) => name.endsWith(".json")).sort(); } catch { return undefined; }
413
+ for (const entry of entries) {
414
+ const target = path.join(dir, entry);
415
+ let ack: SteerAck | undefined;
416
+ try { ack = parseSteerAck(JSON.parse(fsImpl.readFileSync(target, "utf-8"))); } catch { ack = undefined; }
417
+ if (ack?.requestId !== requestId) continue;
418
+ try { fsImpl.rmSync(target, { force: true }); } catch { return undefined; }
419
+ return ack;
420
+ }
421
+ return undefined;
422
+ }
423
+
388
424
  export function consumeSteerAcks(asyncDir: string, fsImpl: Pick<typeof fs, "existsSync" | "readdirSync" | "readFileSync" | "rmSync"> = fs): SteerAck[] {
389
425
  const root = path.join(controlInboxDir(asyncDir), STEER_ACKS_DIR);
390
426
  if (!fsImpl.existsSync(root)) return [];
@@ -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;
@@ -125,6 +126,7 @@ export function createResultWatcher(
125
126
  const processing = new Set<string>();
126
127
  let deliveryActive = true;
127
128
  let deliveryEpoch = 0;
129
+ let resultScanTimer: ReturnType<typeof setInterval> | null = null;
128
130
  // The sole in-memory ownership lease. It is acquired for one active session
129
131
  // and revoked before the watcher, queues, or callbacks are torn down.
130
132
  let activeSessionId: string | null = null;
@@ -149,6 +151,11 @@ export function createResultWatcher(
149
151
  const data = JSON.parse(fsApi.readFileSync(resultPath, "utf-8")) as ResultFileData;
150
152
  if (typeof data.sessionId !== "string" || !data.sessionId) return;
151
153
  const runId = data.runId ?? data.id ?? file.replace(/\.json$/i, "");
154
+ try {
155
+ syncMissionFromAsyncCompletion({ ...data, runId });
156
+ } catch (error) {
157
+ console.error(`Mission completion sync failed for '${resultPath}':`, error);
158
+ }
152
159
  try {
153
160
  deps.observeCompletion?.({ ...data, runId });
154
161
  } catch (error) {
@@ -156,10 +163,12 @@ export function createResultWatcher(
156
163
  }
157
164
  const epoch = deliveryEpoch;
158
165
  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);
166
+ // Recorded before dedupe and before the unlink below so subagent_wait can
167
+ // use the in-memory record or its bounded durable replay after cleanup.
168
+ recordWaitCompletion(state, runId, data, Date.now(), completionTtlMs, {
169
+ resultsDir,
170
+ sessionId: data.sessionId,
171
+ });
163
172
  const hasExplicitNestedChildren = data.nestedChildren !== undefined;
164
173
  let nestedChildren = compactNestedResultChildren(sanitizeNestedResultChildren(data.nestedChildren, resultPath, "nestedChildren"));
165
174
  if (!nestedChildren?.length && !hasExplicitNestedChildren) {
@@ -335,9 +344,15 @@ export function createResultWatcher(
335
344
  }
336
345
  };
337
346
 
347
+ const clearResultScan = () => {
348
+ if (resultScanTimer) timers.clearInterval(resultScanTimer);
349
+ resultScanTimer = null;
350
+ };
351
+
338
352
  const startPolling = (reason: unknown) => {
339
353
  state.watcher?.close();
340
354
  state.watcher = null;
355
+ clearResultScan();
341
356
  if (state.watcherRestartTimer) return;
342
357
  console.error(`Subagent result watcher for '${resultsDir}' fell back to polling because native fs.watch is unavailable (${errorCode(reason) ?? "unknown error"}).`);
343
358
  primeExistingResults();
@@ -346,6 +361,7 @@ export function createResultWatcher(
346
361
  };
347
362
 
348
363
  const scheduleRestart = () => {
364
+ clearResultScan();
349
365
  if (state.watcherRestartTimer) return;
350
366
  state.watcherRestartTimer = timers.setTimeout(() => {
351
367
  state.watcherRestartTimer = null;
@@ -373,8 +389,11 @@ export function createResultWatcher(
373
389
  }
374
390
  try {
375
391
  const watchDir = resolveWatchPath(resultsDir, fsApi.realpathSync.native);
376
- state.watcher = fsApi.watch(watchDir, (event, file) => {
377
- if (event !== "rename" || !file) return;
392
+ state.watcher = fsApi.watch(watchDir, (_event, file) => {
393
+ if (!file) {
394
+ primeExistingResults();
395
+ return;
396
+ }
378
397
  const fileName = file.toString();
379
398
  if (fileName.endsWith(".json")) scheduleResult(fileName, true);
380
399
  });
@@ -386,6 +405,8 @@ export function createResultWatcher(
386
405
  scheduleRestart();
387
406
  });
388
407
  state.watcher.unref?.();
408
+ resultScanTimer = timers.setInterval(primeExistingResults, POLL_INTERVAL_MS);
409
+ resultScanTimer.unref?.();
389
410
  } catch (error) {
390
411
  if (shouldPoll(error)) return startPolling(error);
391
412
  console.error(`Failed to start subagent result watcher for '${resultsDir}':`, error);
@@ -405,6 +426,7 @@ export function createResultWatcher(
405
426
  timers.clearInterval(state.watcherRestartTimer);
406
427
  }
407
428
  state.watcherRestartTimer = null;
429
+ clearResultScan();
408
430
  state.resultFileCoalescer.clear();
409
431
  pendingTriggerTurn.clear();
410
432
  processing.clear();
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
5
5
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { getProjectSubagentsDir } from "../../shared/artifacts.ts";
6
7
  import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
7
8
  import { shortenPath } from "../../shared/formatters.ts";
8
9
  import type { AsyncStatus, Details, ExtensionConfig } from "../../shared/types.ts";
@@ -87,7 +88,7 @@ export function scheduledRunsEnabled(config: ExtensionConfig): boolean {
87
88
  }
88
89
 
89
90
  export function scheduledRunStorePath(cwd: string, _sessionId?: string, root?: string): string {
90
- if (!root) return path.join(path.resolve(cwd), ".pi-subagents", "schedules");
91
+ if (!root) return path.join(getProjectSubagentsDir(path.resolve(cwd)), "schedules");
91
92
  const projectKey = createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 20);
92
93
  return path.join(root, projectKey);
93
94
  }
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
4
+ import { readStatus } from "../../shared/utils.ts";
4
5
  import { DIRS, type AsyncParallelGroupStatus, type AsyncStatus, type NestedRunSummary, type SubagentRunMode } from "../../shared/types.ts";
5
6
  import { resolveEffectiveThinking } from "../../shared/model-info.ts";
6
7
  import { normalizeParallelGroups } from "./parallel-groups.ts";
@@ -84,26 +85,6 @@ function appendJsonlBestEffort(filePath: string, payload: object): void {
84
85
  }
85
86
  }
86
87
 
87
- function readStatusFile(asyncDir: string): AsyncStatus | null {
88
- const statusPath = path.join(asyncDir, "status.json");
89
- let content: string;
90
- try {
91
- content = fs.readFileSync(statusPath, "utf-8");
92
- } catch (error) {
93
- if (isNotFoundError(error)) return null;
94
- throw new Error(`Failed to read async status file '${statusPath}': ${getErrorMessage(error)}`, {
95
- cause: error instanceof Error ? error : undefined,
96
- });
97
- }
98
- try {
99
- return JSON.parse(content) as AsyncStatus;
100
- } catch (error) {
101
- throw new Error(`Failed to parse async status file '${statusPath}': ${getErrorMessage(error)}`, {
102
- cause: error instanceof Error ? error : undefined,
103
- });
104
- }
105
- }
106
-
107
88
  interface ResultChildOutcome {
108
89
  agent?: string;
109
90
  success?: boolean;
@@ -348,7 +329,7 @@ export function checkPidLiveness(pid: number, kill: KillFn = process.kill): PidL
348
329
 
349
330
  export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOptions = {}): ReconcileAsyncRunResult {
350
331
  const now = options.now?.() ?? Date.now();
351
- const status = readStatusFile(asyncDir);
332
+ const status = readStatus(asyncDir);
352
333
  const startedStatus = !status && options.startedRun ? buildStartedStatus(asyncDir, options.startedRun, now) : undefined;
353
334
  const effectiveStatus = status ?? startedStatus;
354
335
  if (!effectiveStatus) return { status: null, repaired: false };
@@ -1955,6 +1955,47 @@ function combinedAbortSignal(signals: Array<AbortSignal | undefined>): AbortSign
1955
1955
  return controller.signal;
1956
1956
  }
1957
1957
 
1958
+ async function runSingleStepWithTimeout(
1959
+ step: SubagentStep,
1960
+ ctx: SingleStepContext,
1961
+ parentDeadlineAt?: number,
1962
+ ): Promise<SingleStepResult> {
1963
+ if (step.timeoutMs === undefined) return runSingleStep(step, ctx);
1964
+
1965
+ const parentRemainingMs = parentDeadlineAt === undefined ? undefined : Math.max(0, parentDeadlineAt - Date.now());
1966
+ const timeoutMs = parentRemainingMs === undefined ? step.timeoutMs : Math.min(step.timeoutMs, parentRemainingMs);
1967
+ const timeoutMessage = parentRemainingMs !== undefined && parentRemainingMs <= step.timeoutMs
1968
+ ? ctx.timeoutMessage
1969
+ : `Subagent timed out after ${step.timeoutMs}ms.`;
1970
+ const timeoutController = new AbortController();
1971
+ let timeoutAction: (() => void) | undefined;
1972
+ let timeoutTriggered = false;
1973
+ const triggerTimeout = (): void => {
1974
+ if (timeoutTriggered) return;
1975
+ timeoutTriggered = true;
1976
+ timeoutController.abort();
1977
+ timeoutAction?.();
1978
+ };
1979
+ const registerTimeout = (action: (() => void) | undefined): void => {
1980
+ timeoutAction = action;
1981
+ ctx.registerTimeout?.(action ? triggerTimeout : undefined);
1982
+ if (action && timeoutTriggered) action();
1983
+ };
1984
+ const timer = setTimeout(triggerTimeout, timeoutMs);
1985
+ timer.unref?.();
1986
+ try {
1987
+ return await runSingleStep(step, {
1988
+ ...ctx,
1989
+ registerTimeout,
1990
+ timeoutSignal: combinedAbortSignal([ctx.timeoutSignal, timeoutController.signal]),
1991
+ timeoutMessage,
1992
+ });
1993
+ } finally {
1994
+ clearTimeout(timer);
1995
+ ctx.registerTimeout?.(undefined);
1996
+ }
1997
+ }
1998
+
1958
1999
  async function runSubagent(
1959
2000
  config: SubagentRunConfig,
1960
2001
  onWriterProcess?: (writer: { state: "none" | "spawning" } | { state: "running"; pid: number }) => void,
@@ -3449,7 +3490,7 @@ async function runSubagent(
3449
3490
  writeStatusPayload();
3450
3491
  appendJsonl(eventsPath, JSON.stringify({ type: "subagent.step.started", ts: taskStartTime, runId: id, stepIndex: fi, agent: task.agent }));
3451
3492
  flushPendingStepSteers(fi);
3452
- const singleResult = await runSingleStep(task, compactOptional<SingleStepContext>({
3493
+ const singleResult = await runSingleStepWithTimeout(task, compactOptional<SingleStepContext>({
3453
3494
  previousOutput, placeholder, cwd, sessionEnabled,
3454
3495
  outputs,
3455
3496
  sessionDir: config.sessionDir ? path.join(config.sessionDir, `dynamic-${stepIndex}-${taskIdx}`) : undefined,
@@ -3479,7 +3520,7 @@ async function runSubagent(
3479
3520
  onWriterProcess,
3480
3521
  onExternalProcess: (process) => updateExternalProcess(fi, process),
3481
3522
  skipAcceptance: () => timedOut || stopped,
3482
- }));
3523
+ }), config.deadlineAt);
3483
3524
  const taskEndTime = Date.now();
3484
3525
  const childInterrupted = singleResult.interrupted === true;
3485
3526
  const childStopped = singleResult.stopped === true;
@@ -3831,7 +3872,7 @@ async function runSubagent(
3831
3872
  const { taskForRun, taskCwd } = prepareParallelTaskRun(task, cwd, worktreeSetup, taskIdx);
3832
3873
  flushPendingStepSteers(fi);
3833
3874
 
3834
- const singleResult = await runSingleStep(taskForRun, compactOptional<SingleStepContext>({
3875
+ const singleResult = await runSingleStepWithTimeout(taskForRun, compactOptional<SingleStepContext>({
3835
3876
  previousOutput, placeholder, cwd: taskCwd, sessionEnabled,
3836
3877
  outputs,
3837
3878
  sessionDir: taskSessionDir,
@@ -3861,7 +3902,7 @@ async function runSubagent(
3861
3902
  onWriterProcess,
3862
3903
  onExternalProcess: (process) => updateExternalProcess(fi, process),
3863
3904
  skipAcceptance: () => timedOut || stopped,
3864
- }));
3905
+ }), config.deadlineAt);
3865
3906
  if (task.sessionFile) {
3866
3907
  latestSessionFile = task.sessionFile;
3867
3908
  }
@@ -4120,7 +4161,7 @@ async function runSubagent(
4120
4161
  }));
4121
4162
 
4122
4163
  flushPendingStepSteers(flatIndex);
4123
- const singleResult = await runSingleStep(seqStep, compactOptional<SingleStepContext>({
4164
+ const singleResult = await runSingleStepWithTimeout(seqStep, compactOptional<SingleStepContext>({
4124
4165
  previousOutput, placeholder, cwd, sessionEnabled,
4125
4166
  outputs: statusPayload.mode === "single" ? undefined : outputs,
4126
4167
  sessionDir: config.sessionDir,
@@ -4150,7 +4191,7 @@ async function runSubagent(
4150
4191
  onWriterProcess,
4151
4192
  onExternalProcess: (process) => updateExternalProcess(flatIndex, process),
4152
4193
  skipAcceptance: () => timedOut || stopped,
4153
- }));
4194
+ }), config.deadlineAt);
4154
4195
  if (seqStep.sessionFile) {
4155
4196
  latestSessionFile = seqStep.sessionFile;
4156
4197
  }
@@ -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;