@llblab/pi-actors 0.41.0 → 0.42.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 (150) hide show
  1. package/AGENTS.md +13 -8
  2. package/BACKLOG.md +2 -81
  3. package/CHANGELOG.md +22 -0
  4. package/README.md +22 -4
  5. package/dist/index.js +29 -82
  6. package/dist/lib/async-runs.d.ts +25 -5
  7. package/dist/lib/async-runs.js +136 -47
  8. package/dist/lib/automatic-review-runtime.d.ts +18 -0
  9. package/dist/lib/automatic-review-runtime.js +96 -0
  10. package/dist/lib/draft-consolidation-transaction.d.ts +65 -0
  11. package/dist/lib/draft-consolidation-transaction.js +610 -0
  12. package/dist/lib/draft-consolidation.d.ts +35 -0
  13. package/dist/lib/draft-consolidation.js +126 -0
  14. package/dist/lib/draft-review.d.ts +56 -0
  15. package/dist/lib/draft-review.js +254 -0
  16. package/dist/lib/draft-sleep.d.ts +65 -0
  17. package/dist/lib/draft-sleep.js +468 -0
  18. package/dist/lib/file-state.d.ts +8 -1
  19. package/dist/lib/file-state.js +115 -18
  20. package/dist/lib/inspector-actions.d.ts +16 -0
  21. package/dist/lib/inspector-actions.js +57 -0
  22. package/dist/lib/inspector-command.d.ts +7 -0
  23. package/dist/lib/inspector-command.js +37 -0
  24. package/dist/lib/inspector-overlay.d.ts +29 -1
  25. package/dist/lib/inspector-overlay.js +537 -90
  26. package/dist/lib/inspector.d.ts +9 -0
  27. package/dist/lib/inspector.js +52 -1
  28. package/dist/lib/observability.d.ts +39 -3
  29. package/dist/lib/observability.js +162 -34
  30. package/dist/lib/paths.d.ts +7 -0
  31. package/dist/lib/paths.js +28 -0
  32. package/dist/lib/recipes-discovery.js +32 -24
  33. package/dist/lib/recipes-usage.d.ts +18 -6
  34. package/dist/lib/recipes-usage.js +445 -34
  35. package/dist/lib/review-control.d.ts +14 -0
  36. package/dist/lib/review-control.js +111 -0
  37. package/dist/lib/review-diagnostics.d.ts +11 -0
  38. package/dist/lib/review-diagnostics.js +148 -0
  39. package/dist/lib/review-projection.d.ts +14 -0
  40. package/dist/lib/review-projection.js +170 -0
  41. package/dist/lib/run-ui-runtime.d.ts +18 -0
  42. package/dist/lib/run-ui-runtime.js +123 -0
  43. package/dist/lib/runs-artifacts.d.ts +1 -1
  44. package/dist/lib/runs-artifacts.js +1 -1
  45. package/dist/lib/runs-control.d.ts +8 -2
  46. package/dist/lib/runs-control.js +23 -6
  47. package/dist/lib/runs-identity.d.ts +1 -1
  48. package/dist/lib/runs-identity.js +1 -1
  49. package/dist/lib/runs-index.d.ts +11 -2
  50. package/dist/lib/runs-index.js +46 -23
  51. package/dist/lib/runs-mailbox.d.ts +1 -1
  52. package/dist/lib/runs-mailbox.js +1 -1
  53. package/dist/lib/runs-messages.d.ts +1 -1
  54. package/dist/lib/runs-messages.js +1 -1
  55. package/dist/lib/runs-outbox.d.ts +1 -1
  56. package/dist/lib/runs-outbox.js +1 -1
  57. package/dist/lib/runs-ownership.d.ts +1 -1
  58. package/dist/lib/runs-ownership.js +1 -1
  59. package/dist/lib/runs-parent-teardown.d.ts +51 -0
  60. package/dist/lib/runs-parent-teardown.js +172 -0
  61. package/dist/lib/runs-process.d.ts +1 -1
  62. package/dist/lib/runs-process.js +1 -1
  63. package/dist/lib/runs-retention.d.ts +1 -1
  64. package/dist/lib/runs-retention.js +1 -1
  65. package/dist/lib/runs-start.d.ts +5 -3
  66. package/dist/lib/runs-start.js +6 -48
  67. package/dist/lib/runs-status.d.ts +5 -3
  68. package/dist/lib/runs-status.js +4 -6
  69. package/dist/lib/runtime.d.ts +7 -1
  70. package/dist/lib/runtime.js +32 -18
  71. package/dist/lib/tool-review-lineage-transaction.d.ts +27 -0
  72. package/dist/lib/tool-review-lineage-transaction.js +597 -0
  73. package/dist/lib/tool-review-lineage.d.ts +24 -0
  74. package/dist/lib/tool-review-lineage.js +98 -0
  75. package/dist/lib/tool-review-scheduler.d.ts +80 -0
  76. package/dist/lib/tool-review-scheduler.js +494 -0
  77. package/dist/lib/tool-review-transaction.d.ts +50 -0
  78. package/dist/lib/tool-review-transaction.js +362 -0
  79. package/dist/lib/tool-review.d.ts +56 -0
  80. package/dist/lib/tool-review.js +197 -0
  81. package/dist/lib/tools-inspect.js +26 -3
  82. package/dist/lib/tools-local.js +4 -2
  83. package/dist/lib/tools-message.d.ts +1 -0
  84. package/dist/lib/tools-message.js +29 -17
  85. package/dist/lib/tools-response.d.ts +1 -1
  86. package/dist/lib/tools-response.js +5 -7
  87. package/dist/lib/tools-spawn.js +4 -1
  88. package/dist/lib/tools.d.ts +1 -0
  89. package/dist/lib/tools.js +1 -0
  90. package/dist/recipes/draft-review.json +24 -0
  91. package/dist/recipes/tool-review.json +24 -0
  92. package/dist/scripts/release-gates.mjs +165 -0
  93. package/dist/skills/actors/SKILL.md +11 -14
  94. package/dist/skills/swarm/SKILL.md +1 -1
  95. package/docs/actor-inspector.md +32 -18
  96. package/docs/async-runs.md +10 -2
  97. package/docs/recipe-library.md +7 -3
  98. package/docs/template-recipes.md +4 -11
  99. package/docs/tool-registry.md +11 -4
  100. package/index.ts +29 -103
  101. package/lib/async-runs.ts +218 -62
  102. package/lib/automatic-review-runtime.ts +135 -0
  103. package/lib/draft-consolidation-transaction.ts +821 -0
  104. package/lib/draft-consolidation.ts +181 -0
  105. package/lib/draft-review.ts +325 -0
  106. package/lib/draft-sleep.ts +576 -0
  107. package/lib/file-state.ts +143 -19
  108. package/lib/inspector-actions.ts +79 -0
  109. package/lib/inspector-command.ts +54 -0
  110. package/lib/inspector-overlay.ts +675 -105
  111. package/lib/inspector.ts +78 -3
  112. package/lib/observability.ts +219 -40
  113. package/lib/paths.ts +43 -0
  114. package/lib/recipes-discovery.ts +34 -26
  115. package/lib/recipes-usage.ts +569 -40
  116. package/lib/review-control.ts +137 -0
  117. package/lib/review-diagnostics.ts +164 -0
  118. package/lib/review-projection.ts +200 -0
  119. package/lib/run-ui-runtime.ts +153 -0
  120. package/lib/runs-artifacts.ts +1 -1
  121. package/lib/runs-control.ts +49 -5
  122. package/lib/runs-identity.ts +1 -1
  123. package/lib/runs-index.ts +57 -21
  124. package/lib/runs-mailbox.ts +1 -1
  125. package/lib/runs-messages.ts +1 -1
  126. package/lib/runs-outbox.ts +1 -1
  127. package/lib/runs-ownership.ts +1 -1
  128. package/lib/runs-parent-teardown.ts +257 -0
  129. package/lib/runs-process.ts +1 -1
  130. package/lib/runs-retention.ts +1 -1
  131. package/lib/runs-start.ts +12 -68
  132. package/lib/runs-status.ts +12 -8
  133. package/lib/runtime.ts +34 -17
  134. package/lib/tool-review-lineage-transaction.ts +881 -0
  135. package/lib/tool-review-lineage.ts +145 -0
  136. package/lib/tool-review-scheduler.ts +635 -0
  137. package/lib/tool-review-transaction.ts +563 -0
  138. package/lib/tool-review.ts +270 -0
  139. package/lib/tools-inspect.ts +33 -3
  140. package/lib/tools-local.ts +8 -2
  141. package/lib/tools-message.ts +45 -30
  142. package/lib/tools-response.ts +5 -6
  143. package/lib/tools-spawn.ts +8 -1
  144. package/lib/tools.ts +5 -0
  145. package/package.json +3 -2
  146. package/recipes/draft-review.json +24 -0
  147. package/recipes/tool-review.json +24 -0
  148. package/scripts/release-gates.mjs +165 -0
  149. package/skills/actors/SKILL.md +11 -14
  150. package/skills/swarm/SKILL.md +1 -1
package/lib/inspector.ts CHANGED
@@ -74,6 +74,7 @@ export interface ActorInspectorPreviewReadOptions {
74
74
 
75
75
  export interface ActorInspectorRunItem {
76
76
  run: string;
77
+ runInstanceId?: string;
77
78
  status: string;
78
79
  updatedAt?: string;
79
80
  }
@@ -89,6 +90,10 @@ export function readActorInspectorRuns(
89
90
  .flatMap((entry) => {
90
91
  const stateDir = path.join(stateRoot, entry.name);
91
92
  if (!matchesOwner(stateDir, ownerId)) return [];
93
+ const runMeta = readJsonFileResilient<Record<string, unknown>>(
94
+ path.join(stateDir, "run.json"),
95
+ {},
96
+ ).value;
92
97
  const progress = readJsonFileResilient<Record<string, unknown>>(
93
98
  path.join(stateDir, "progress.json"),
94
99
  {},
@@ -99,6 +104,9 @@ export function readActorInspectorRuns(
99
104
  ).value;
100
105
  return [{
101
106
  run: entry.name,
107
+ ...(typeof runMeta.run_instance_id === "string"
108
+ ? { runInstanceId: runMeta.run_instance_id }
109
+ : {}),
102
110
  status: String(progress.phase ?? (Object.keys(result).length ? "terminal" : "unknown")),
103
111
  ...(typeof progress.updatedAt === "string"
104
112
  ? { updatedAt: progress.updatedAt }
@@ -107,15 +115,82 @@ export function readActorInspectorRuns(
107
115
  : {}),
108
116
  }];
109
117
  })
110
- .sort((left, right) =>
111
- String(left.updatedAt ?? "").localeCompare(String(right.updatedAt ?? "")),
112
- );
118
+ .sort((left, right) => {
119
+ const recency = String(right.updatedAt ?? "").localeCompare(
120
+ String(left.updatedAt ?? ""),
121
+ );
122
+ return recency || right.run.localeCompare(left.run);
123
+ });
113
124
  } catch (error) {
114
125
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
115
126
  return [];
116
127
  }
117
128
  }
118
129
 
130
+ export interface ActorInspectorRecipeView {
131
+ composition: Array<Record<string, unknown>>;
132
+ definition?: Record<string, unknown>;
133
+ diagnostics: string[];
134
+ identity: Record<string, unknown>;
135
+ launch: Record<string, unknown>;
136
+ }
137
+
138
+ export function readActorInspectorRecipe(
139
+ stateDir: string,
140
+ ): ActorInspectorRecipeView {
141
+ const result = readJsonFileResilient<Record<string, unknown>>(
142
+ path.join(stateDir, "run.json"),
143
+ {},
144
+ );
145
+ const meta = result.value;
146
+ const contexts = Array.isArray(meta.recipe_context_records)
147
+ ? meta.recipe_context_records
148
+ .filter((entry): entry is Record<string, unknown> =>
149
+ Boolean(entry && typeof entry === "object" && !Array.isArray(entry)),
150
+ )
151
+ : [];
152
+ const primary = contexts.find((entry) => entry.depth === 0) ?? contexts[0];
153
+ const definition = primary?.recipe && typeof primary.recipe === "object" &&
154
+ !Array.isArray(primary.recipe)
155
+ ? primary.recipe as Record<string, unknown>
156
+ : undefined;
157
+ const redacted = (value: unknown): unknown =>
158
+ SessionEvidence.redactSessionEvidenceValue(value);
159
+ const redactedRecord = (value: Record<string, unknown>): Record<string, unknown> =>
160
+ redacted(Object.fromEntries(
161
+ Object.entries(value).filter(([, entry]) => entry !== undefined),
162
+ )) as Record<string, unknown>;
163
+ return {
164
+ composition: contexts.map((entry) => redactedRecord({
165
+ ...(typeof entry.alias === "string" ? { alias: entry.alias } : {}),
166
+ depth: entry.depth,
167
+ file: entry.file,
168
+ import_path: entry.import_path,
169
+ name: entry.name,
170
+ })),
171
+ ...(definition
172
+ ? { definition: redactedRecord(definition) }
173
+ : {}),
174
+ diagnostics: result.diagnostics.map((diagnostic) => diagnostic.message),
175
+ identity: redactedRecord({
176
+ run: meta.run,
177
+ recipe: meta.recipe,
178
+ recipe_file: meta.recipe_file,
179
+ launch_source: meta.launch_source,
180
+ }),
181
+ launch: redactedRecord({
182
+ cwd: meta.cwd,
183
+ template: meta.template,
184
+ values: meta.values,
185
+ model_policy: meta.model_policy,
186
+ mailbox: meta.mailbox,
187
+ artifacts: meta.artifacts,
188
+ notification_policy: meta.notification_policy,
189
+ retire_when: meta.retire_when,
190
+ }),
191
+ };
192
+ }
193
+
119
194
  export interface ActorInspectorTurnItem extends SessionEvidence.SessionEvidenceTurn {
120
195
  commandId: string;
121
196
  diagnostics: string[];
@@ -43,6 +43,7 @@ export interface RunObservation {
43
43
  artifacts?: Record<string, string>;
44
44
  launchSource?: AsyncRuns.AsyncRunLaunchSource;
45
45
  modelPolicy?: Record<string, unknown>;
46
+ notificationPolicy?: "normal" | "silent";
46
47
  recipeFile?: string;
47
48
  terminalHandled?: boolean;
48
49
  retireWhen?: string;
@@ -101,15 +102,19 @@ export function createRunUiObservationState(): RunUiObservationState {
101
102
  export function readRunUiSnapshot(
102
103
  state: RunUiObservationState,
103
104
  ownerId: string,
105
+ options: { includeOutbox?: boolean; stateRoot?: string } = {},
104
106
  ): RunUiSnapshot {
105
- const summary = summarizeRuns(undefined, ownerId);
107
+ const summary = summarizeRuns(options.stateRoot, ownerId);
106
108
  const status = renderRunStatus(summary, state.frame++);
107
109
  return {
108
- outboxEvents: detectRunOutboxEvents(
109
- state.eventLines,
110
- summary,
111
- state.outboxEventIds,
112
- ),
110
+ outboxEvents:
111
+ options.includeOutbox === false
112
+ ? []
113
+ : detectRunOutboxEvents(
114
+ state.eventLines,
115
+ summary,
116
+ state.outboxEventIds,
117
+ ),
113
118
  status,
114
119
  summary,
115
120
  transitions: detectRunTransitions(state.observed, summary),
@@ -134,27 +139,55 @@ export function pruneRunUiObservationState(
134
139
  export function deliverRunTransitionNotifications(
135
140
  transitions: RunTransition[],
136
141
  sink: RunUiNotificationSink,
142
+ inFlight: Set<string> = new Set(),
137
143
  ): void {
138
144
  for (const transition of transitions) {
139
145
  if (!shouldNotifyRunTransition(transition)) continue;
140
- const text = formatRunTransitionMessage(transition);
141
- sink.notify(text, getRunTransitionNotificationType(transition));
142
- if (!shouldSendRunTransitionFollowUp(transition)) continue;
143
- sink.sendFollowUp({
144
- customType: "pi-actors-run",
145
- content: text,
146
- display: true,
147
- details: transition,
148
- });
149
- if (transition.stateDir) {
150
- AsyncRuns.markRunTerminalNotificationHandled(
151
- transition.stateDir,
152
- transition.to,
153
- );
146
+ const key = transition.stateDir ?? transition.run;
147
+ if (inFlight.has(key)) continue;
148
+ inFlight.add(key);
149
+ try {
150
+ const text = formatRunTransitionMessage(transition);
151
+ sink.notify(text, getRunTransitionNotificationType(transition));
152
+ if (!shouldSendRunTransitionFollowUp(transition)) continue;
153
+ sink.sendFollowUp({
154
+ customType: "pi-actors-run",
155
+ content: text,
156
+ display: true,
157
+ details: transition,
158
+ });
159
+ if (transition.stateDir) {
160
+ AsyncRuns.markRunTerminalNotificationHandled(
161
+ transition.stateDir,
162
+ transition.to,
163
+ );
164
+ }
165
+ } finally {
166
+ inFlight.delete(key);
154
167
  }
155
168
  }
156
169
  }
157
170
 
171
+ export function reconcileRunTerminalNotifications(input: {
172
+ inFlight?: Set<string>;
173
+ ownerId: string;
174
+ sink: RunUiNotificationSink;
175
+ state: RunUiObservationState;
176
+ stateRoot?: string;
177
+ }): RunUiSnapshot {
178
+ const snapshot = readRunUiSnapshot(input.state, input.ownerId, {
179
+ includeOutbox: false,
180
+ stateRoot: input.stateRoot,
181
+ });
182
+ deliverRunTransitionNotifications(
183
+ snapshot.transitions,
184
+ input.sink,
185
+ input.inFlight,
186
+ );
187
+ pruneRunUiObservationState(input.state, snapshot);
188
+ return snapshot;
189
+ }
190
+
158
191
  export function deliverRunOutboxNotifications(
159
192
  events: RunOutboxEvent[],
160
193
  sink: RunUiNotificationSink,
@@ -189,18 +222,94 @@ export interface RunRetirementExecution {
189
222
  stateDir: string;
190
223
  }
191
224
 
225
+ export type RunStateWatcherDiagnosticCode =
226
+ | "attach_failed"
227
+ | "error"
228
+ | "removed"
229
+ | "rearmed";
230
+
231
+ export interface RunStateWatcherDiagnostic {
232
+ code: RunStateWatcherDiagnosticCode;
233
+ id: number;
234
+ message: string;
235
+ path: string;
236
+ scope: "root" | "run";
237
+ ts: string;
238
+ }
239
+
192
240
  export interface RunStateWatcher {
193
241
  close(): void;
242
+ getDiagnostics(): RunStateWatcherDiagnostic[];
194
243
  refresh(): void;
195
244
  }
196
245
 
246
+ const RUN_WATCHER_DIAGNOSTIC_LIMIT = 32;
247
+
197
248
  export function createRunStateWatcher(input: {
198
- stateRoot?: string;
249
+ exists?: (path: string) => boolean;
250
+ listDirectories?: (path: string) => string[];
199
251
  onChange: () => void;
252
+ stateRoot?: string;
253
+ watchPath?: (path: string, onChange: () => void) => FSWatcher;
200
254
  }): RunStateWatcher {
201
255
  const stateRoot = input.stateRoot ?? Paths.getRunStateRoot();
256
+ const pathExists = input.exists ?? existsSync;
257
+ const listDirectories =
258
+ input.listDirectories ??
259
+ ((path: string) =>
260
+ readdirSync(path, { withFileTypes: true })
261
+ .filter((entry) => entry.isDirectory())
262
+ .map((entry) => join(path, entry.name)));
263
+ const watchPath = input.watchPath ?? ((path, onChange) => watch(path, onChange));
264
+ let diagnosticId = 0;
265
+ let rootDegraded = false;
202
266
  let stateRootWatcher: FSWatcher | undefined;
267
+ const degradedRunDirs = new Set<string>();
268
+ const diagnostics: RunStateWatcherDiagnostic[] = [];
269
+ const lastDiagnosticSignatures = new Map<string, string>();
203
270
  const runDirWatchers = new Map<string, FSWatcher>();
271
+ const record = (
272
+ code: RunStateWatcherDiagnosticCode,
273
+ scope: "root" | "run",
274
+ path: string,
275
+ error?: unknown,
276
+ ): void => {
277
+ const detail = error instanceof Error ? `: ${error.message}` : "";
278
+ const signature = `${code}${detail}`;
279
+ const signatureKey = `${scope}:${path}`;
280
+ if (lastDiagnosticSignatures.get(signatureKey) === signature) return;
281
+ lastDiagnosticSignatures.set(signatureKey, signature);
282
+ const recovery =
283
+ code === "rearmed"
284
+ ? "; terminal watch acceleration restored"
285
+ : "; terminal reconciliation remains active and watcher rearm will retry";
286
+ diagnostics.push({
287
+ code,
288
+ id: ++diagnosticId,
289
+ message: `Run-state ${scope} watcher ${code.replace("_", " ")} for ${path}${detail}${recovery}`,
290
+ path,
291
+ scope,
292
+ ts: new Date().toISOString(),
293
+ });
294
+ if (diagnostics.length > RUN_WATCHER_DIAGNOSTIC_LIMIT) diagnostics.shift();
295
+ };
296
+ const removeRunWatcher = (
297
+ stateDir: string,
298
+ watcher: FSWatcher,
299
+ options: { degraded?: boolean; error?: unknown } = {},
300
+ ): void => {
301
+ if (runDirWatchers.get(stateDir) !== watcher) return;
302
+ watcher.close();
303
+ runDirWatchers.delete(stateDir);
304
+ if (options.degraded === false) {
305
+ degradedRunDirs.delete(stateDir);
306
+ lastDiagnosticSignatures.delete(`run:${stateDir}`);
307
+ return;
308
+ }
309
+ degradedRunDirs.add(stateDir);
310
+ if (options.error) record("error", "run", stateDir, options.error);
311
+ record("removed", "run", stateDir);
312
+ };
204
313
  const close = (): void => {
205
314
  stateRootWatcher?.close();
206
315
  stateRootWatcher = undefined;
@@ -208,37 +317,95 @@ export function createRunStateWatcher(input: {
208
317
  runDirWatchers.clear();
209
318
  };
210
319
  const watchRunDir = (stateDir: string): void => {
211
- if (runDirWatchers.has(stateDir) || !existsSync(stateDir)) return;
320
+ if (runDirWatchers.has(stateDir) || !pathExists(stateDir)) return;
212
321
  try {
213
- const watcher = watch(stateDir, input.onChange);
214
- watcher.on("error", () => {
215
- watcher.close();
216
- runDirWatchers.delete(stateDir);
217
- });
322
+ const watcher = watchPath(stateDir, input.onChange);
323
+ watcher.on("error", (error) =>
324
+ removeRunWatcher(stateDir, watcher, { error }),
325
+ );
218
326
  runDirWatchers.set(stateDir, watcher);
219
- } catch {
220
- // Watching is best-effort; explicit inspect remains available.
327
+ if (degradedRunDirs.delete(stateDir)) record("rearmed", "run", stateDir);
328
+ } catch (error) {
329
+ degradedRunDirs.add(stateDir);
330
+ record("attach_failed", "run", stateDir, error);
221
331
  }
222
332
  };
223
333
  function refresh(): void {
224
- if (!existsSync(stateRoot)) return;
334
+ if (!pathExists(stateRoot)) return;
225
335
  if (!stateRootWatcher) {
226
336
  try {
227
- stateRootWatcher = watch(stateRoot, input.onChange);
228
- stateRootWatcher.on("error", () => {
229
- stateRootWatcher?.close();
337
+ const watcher = watchPath(stateRoot, input.onChange);
338
+ stateRootWatcher = watcher;
339
+ watcher.on("error", (error) => {
340
+ if (stateRootWatcher !== watcher) return;
341
+ watcher.close();
230
342
  stateRootWatcher = undefined;
343
+ rootDegraded = true;
344
+ record("error", "root", stateRoot, error);
345
+ record("removed", "root", stateRoot);
231
346
  });
232
- } catch {
233
- // Watching is best-effort; explicit inspect remains available.
347
+ if (rootDegraded) {
348
+ rootDegraded = false;
349
+ record("rearmed", "root", stateRoot);
350
+ }
351
+ } catch (error) {
352
+ rootDegraded = true;
353
+ record("attach_failed", "root", stateRoot, error);
234
354
  }
235
355
  }
236
- for (const entry of readdirSync(stateRoot, { withFileTypes: true })) {
237
- if (!entry.isDirectory()) continue;
238
- watchRunDir(`${stateRoot}/${entry.name}`);
356
+ let stateDirs: string[];
357
+ try {
358
+ stateDirs = listDirectories(stateRoot);
359
+ } catch (error) {
360
+ record("attach_failed", "root", stateRoot, error);
361
+ return;
362
+ }
363
+ const present = new Set(stateDirs);
364
+ for (const [stateDir, watcher] of runDirWatchers) {
365
+ if (present.has(stateDir) && pathExists(stateDir)) continue;
366
+ removeRunWatcher(stateDir, watcher, { degraded: false });
239
367
  }
368
+ for (const stateDir of stateDirs) watchRunDir(stateDir);
240
369
  }
241
- return { close, refresh };
370
+ return {
371
+ close,
372
+ getDiagnostics: () => [...diagnostics],
373
+ refresh,
374
+ };
375
+ }
376
+
377
+ export interface RunTerminalReconciliationLoop {
378
+ close(): void;
379
+ reconcileNow(): void;
380
+ start(): void;
381
+ }
382
+
383
+ export function createRunTerminalReconciliationLoop(input: {
384
+ intervalMs?: number;
385
+ onError?: (error: unknown) => void;
386
+ reconcile: () => void;
387
+ refreshWatcher: () => void;
388
+ }): RunTerminalReconciliationLoop {
389
+ const intervalMs = input.intervalMs ?? 10_000;
390
+ let interval: NodeJS.Timeout | undefined;
391
+ const reconcileNow = (): void => {
392
+ try {
393
+ input.refreshWatcher();
394
+ input.reconcile();
395
+ } catch (error) {
396
+ input.onError?.(error);
397
+ }
398
+ };
399
+ const close = (): void => {
400
+ if (interval) clearInterval(interval);
401
+ interval = undefined;
402
+ };
403
+ const start = (): void => {
404
+ close();
405
+ interval = setInterval(reconcileNow, intervalMs);
406
+ interval.unref?.();
407
+ };
408
+ return { close, reconcileNow, start };
242
409
  }
243
410
 
244
411
  export interface RunRetirementExecutorOptions {
@@ -365,6 +532,9 @@ function observeRun(stateDir: string): RunObservation | undefined {
365
532
  !Array.isArray(status.model_policy)
366
533
  ? { modelPolicy: status.model_policy as Record<string, unknown> }
367
534
  : {}),
535
+ ...(status.notification_policy === "silent"
536
+ ? { notificationPolicy: "silent" as const }
537
+ : {}),
368
538
  ...(typeof status.recipe_file === "string"
369
539
  ? { recipeFile: status.recipe_file }
370
540
  : {}),
@@ -753,7 +923,11 @@ export function detectRunTransitions(
753
923
  for (const run of summary.runs) {
754
924
  const key = runObservationKey(run);
755
925
  const old = previous.get(key);
756
- if (!run.terminalHandled && TERMINAL.has(run.status)) {
926
+ if (
927
+ run.notificationPolicy !== "silent" &&
928
+ !run.terminalHandled &&
929
+ TERMINAL.has(run.status)
930
+ ) {
757
931
  transitions.push({
758
932
  from: old ?? "running",
759
933
  run: run.run,
@@ -871,6 +1045,11 @@ export function detectRunOutboxEvents(
871
1045
  const key = run.stateDir ?? run.run;
872
1046
  const records = readOutboxRecords(run);
873
1047
  const previousCount = previousLineCounts.get(key) ?? 0;
1048
+ if (run.notificationPolicy === "silent") {
1049
+ previousLineCounts.set(key, records.length);
1050
+ seenEventIds.set(key, seenEventIds.get(key) ?? new Set<string>());
1051
+ continue;
1052
+ }
874
1053
  const start = Math.min(previousCount, records.length);
875
1054
  const seen = seenEventIds.get(key) ?? new Set<string>();
876
1055
  for (let index = start; index < records.length; index += 1) {
package/lib/paths.ts CHANGED
@@ -17,6 +17,13 @@ export function getAgentDir(
17
17
  : join(homedir(), ".pi", "agent");
18
18
  }
19
19
 
20
+ export function isAutomaticRecipeReviewEnabled(
21
+ env: Record<string, string | undefined> = process.env,
22
+ ): boolean {
23
+ const value = env.PI_ACTORS_AUTOMATIC_REVIEW?.trim().toLowerCase();
24
+ return value === undefined || !["0", "false", "off"].includes(value);
25
+ }
26
+
20
27
  export interface ExtensionRuntimePaths {
21
28
  configPath: string;
22
29
  runStateRoot: string;
@@ -38,6 +45,42 @@ export function getRunStateRoot(agentDir = getAgentDir()): string {
38
45
  return join(getExtensionTmpDir(agentDir), "runs");
39
46
  }
40
47
 
48
+ export function getDraftSleepRoot(agentDir = getAgentDir()): string {
49
+ return join(getExtensionTmpDir(agentDir), "draft-sleep");
50
+ }
51
+
52
+ export function getDraftSleepStatePath(agentDir = getAgentDir()): string {
53
+ return join(getDraftSleepRoot(agentDir), "state.json");
54
+ }
55
+
56
+ export function getDraftSleepBatchDir(
57
+ batchId: string,
58
+ agentDir = getAgentDir(),
59
+ ): string {
60
+ if (!/^[a-f0-9-]{36}$/u.test(batchId)) {
61
+ throw new Error("Invalid draft sleep batch id.");
62
+ }
63
+ return join(getDraftSleepRoot(agentDir), "batches", batchId);
64
+ }
65
+
66
+ export function getToolReviewRoot(agentDir = getAgentDir()): string {
67
+ return join(getExtensionTmpDir(agentDir), "tool-review");
68
+ }
69
+
70
+ export function getToolReviewStatePath(agentDir = getAgentDir()): string {
71
+ return join(getToolReviewRoot(agentDir), "state.json");
72
+ }
73
+
74
+ export function getToolReviewBatchDir(
75
+ reviewId: string,
76
+ agentDir = getAgentDir(),
77
+ ): string {
78
+ if (!/^[a-f0-9-]{36}$/u.test(reviewId)) {
79
+ throw new Error("Invalid tool review id.");
80
+ }
81
+ return join(getToolReviewRoot(agentDir), "batches", reviewId);
82
+ }
83
+
41
84
  export function getExtensionRuntimePaths(
42
85
  agentDir = getAgentDir(),
43
86
  ): ExtensionRuntimePaths {
@@ -703,32 +703,40 @@ function templatePreview(value: unknown): string | undefined {
703
703
 
704
704
  export function listDraftRecipes(root: string): Array<Record<string, unknown>> {
705
705
  return listRecipeFiles(root).map((path) => {
706
- const id = RecipesReferences.getRecipeIdFromPath(path);
707
- const bytes = readFileSync(path);
708
- const stat = statSync(path);
709
- const config = RecipesReferences.readRawRecipeConfig(path);
710
- const resolved = RecipesReferences.readResolvedRecipeConfig(path);
711
- const diagnostics = getRecipeConfigDiagnostics(path, resolved);
712
- const sourceRun = String(config?.description ?? "").match(
713
- /spawn run ([^\s]+)/,
714
- )?.[1];
715
- const preview = templatePreview(config?.template);
716
- const riskLabels = getRecipeRiskLabels(resolved);
717
- return {
718
- id,
719
- path,
720
- sha256: createHash("sha256").update(bytes).digest("hex"),
721
- size: bytes.byteLength,
722
- created_at: stat.birthtime.toISOString(),
723
- modified_at: stat.mtime.toISOString(),
724
- valid: Boolean(resolved),
725
- diagnostics,
726
- ...(riskLabels.length ? { risk_labels: riskLabels } : {}),
727
- ...(config?.description ? { description: config.description } : {}),
728
- ...(sourceRun ? { source_run: sourceRun } : {}),
729
- ...(config?.async !== undefined ? { async: config.async } : {}),
730
- ...(preview ? { template_preview: preview } : {}),
731
- };
706
+ for (let attempt = 0; attempt < 3; attempt += 1) {
707
+ const id = RecipesReferences.getRecipeIdFromPath(path);
708
+ const bytes = readFileSync(path);
709
+ const fingerprint = createHash("sha256").update(bytes).digest("hex");
710
+ const stat = statSync(path);
711
+ const config = RecipesReferences.readRawRecipeConfig(path);
712
+ const resolved = RecipesReferences.readResolvedRecipeConfig(path);
713
+ const diagnostics = getRecipeConfigDiagnostics(path, resolved);
714
+ const after = readFileSync(path);
715
+ if (createHash("sha256").update(after).digest("hex") !== fingerprint) {
716
+ continue;
717
+ }
718
+ const sourceRun = String(config?.description ?? "").match(
719
+ /spawn run ([^\s]+)/,
720
+ )?.[1];
721
+ const preview = templatePreview(config?.template);
722
+ const riskLabels = getRecipeRiskLabels(resolved);
723
+ return {
724
+ id,
725
+ path,
726
+ sha256: fingerprint,
727
+ size: bytes.byteLength,
728
+ created_at: stat.birthtime.toISOString(),
729
+ modified_at: stat.mtime.toISOString(),
730
+ valid: Boolean(resolved),
731
+ diagnostics,
732
+ ...(riskLabels.length ? { risk_labels: riskLabels } : {}),
733
+ ...(config?.description ? { description: config.description } : {}),
734
+ ...(sourceRun ? { source_run: sourceRun } : {}),
735
+ ...(config?.async !== undefined ? { async: config.async } : {}),
736
+ ...(preview ? { template_preview: preview } : {}),
737
+ };
738
+ }
739
+ throw new Error(`Draft changed repeatedly during inventory: ${path}`);
732
740
  });
733
741
  }
734
742