pi-background-tasks 2.5.0 → 2.6.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 (203) hide show
  1. package/BACKGROUND-TASKS-INSTRUCTIONS.md +1 -1
  2. package/README.md +70 -25
  3. package/TESTING.md +42 -2
  4. package/TEST_PLAN.md +7 -4
  5. package/dist/extensions/anthropic-attribution-child.js +4 -0
  6. package/dist/extensions/anthropic-attribution-child.js.map +1 -0
  7. package/dist/extensions/anthropic-attribution.js +107 -0
  8. package/dist/extensions/anthropic-attribution.js.map +1 -0
  9. package/dist/extensions/background-tasks.js +2 -0
  10. package/dist/extensions/background-tasks.js.map +1 -0
  11. package/dist/extensions/delegate-child.js +2 -0
  12. package/dist/extensions/delegate-child.js.map +1 -0
  13. package/dist/extensions/fusion-child.js +2 -0
  14. package/dist/extensions/fusion-child.js.map +1 -0
  15. package/dist/package.json +5 -0
  16. package/dist/src/core/anthropic-attribution-path.js +24 -0
  17. package/dist/src/core/anthropic-attribution-path.js.map +1 -0
  18. package/dist/src/core/anthropic-attribution.js +2247 -0
  19. package/dist/src/core/anthropic-attribution.js.map +1 -0
  20. package/dist/src/core/attested-pi-contract.js +5 -0
  21. package/dist/src/core/attested-pi-contract.js.map +1 -0
  22. package/dist/src/core/attested-pi-run.js +749 -0
  23. package/dist/src/core/attested-pi-run.js.map +1 -0
  24. package/dist/src/core/canonical-json.js +19 -0
  25. package/dist/src/core/canonical-json.js.map +1 -0
  26. package/dist/src/core/common.js +787 -0
  27. package/dist/src/core/common.js.map +1 -0
  28. package/dist/src/core/config.js +78 -0
  29. package/dist/src/core/config.js.map +1 -0
  30. package/dist/src/core/context/parent-snapshot.js +75 -0
  31. package/dist/src/core/context/parent-snapshot.js.map +1 -0
  32. package/dist/src/core/context/token-budget.js +631 -0
  33. package/dist/src/core/context/token-budget.js.map +1 -0
  34. package/dist/src/core/context/visible-conversation-v2.js +390 -0
  35. package/dist/src/core/context/visible-conversation-v2.js.map +1 -0
  36. package/dist/src/core/delegate/artifacts.js +357 -0
  37. package/dist/src/core/delegate/artifacts.js.map +1 -0
  38. package/dist/src/core/delegate/budget.js +266 -0
  39. package/dist/src/core/delegate/budget.js.map +1 -0
  40. package/dist/src/core/delegate/facade-contract.js +7 -0
  41. package/dist/src/core/delegate/facade-contract.js.map +1 -0
  42. package/dist/src/core/delegate/hook-contract-evidence.json +18 -0
  43. package/dist/src/core/delegate/hook-contract.js +124 -0
  44. package/dist/src/core/delegate/hook-contract.js.map +1 -0
  45. package/dist/src/core/delegate/launch.js +366 -0
  46. package/dist/src/core/delegate/launch.js.map +1 -0
  47. package/dist/src/core/delegate/result-package.js +293 -0
  48. package/dist/src/core/delegate/result-package.js.map +1 -0
  49. package/dist/src/core/delegate/runner.js +398 -0
  50. package/dist/src/core/delegate/runner.js.map +1 -0
  51. package/dist/src/core/delegate/seed.js +334 -0
  52. package/dist/src/core/delegate/seed.js.map +1 -0
  53. package/dist/src/core/delegate/types.js +103 -0
  54. package/dist/src/core/delegate/types.js.map +1 -0
  55. package/dist/src/core/durable-fs.js +372 -0
  56. package/dist/src/core/durable-fs.js.map +1 -0
  57. package/dist/src/core/extension-api.js +382 -0
  58. package/dist/src/core/extension-api.js.map +1 -0
  59. package/dist/src/core/fusion/artifacts.js +654 -0
  60. package/dist/src/core/fusion/artifacts.js.map +1 -0
  61. package/dist/src/core/fusion/budget.js +949 -0
  62. package/dist/src/core/fusion/budget.js.map +1 -0
  63. package/dist/src/core/fusion/child-protocol.js +176 -0
  64. package/dist/src/core/fusion/child-protocol.js.map +1 -0
  65. package/dist/src/core/fusion/claude-cache.js +156 -0
  66. package/dist/src/core/fusion/claude-cache.js.map +1 -0
  67. package/dist/src/core/fusion/clean-context.js +64 -0
  68. package/dist/src/core/fusion/clean-context.js.map +1 -0
  69. package/dist/src/core/fusion/config.js +349 -0
  70. package/dist/src/core/fusion/config.js.map +1 -0
  71. package/dist/src/core/fusion/context.js +184 -0
  72. package/dist/src/core/fusion/context.js.map +1 -0
  73. package/dist/src/core/fusion/evaluation.js +696 -0
  74. package/dist/src/core/fusion/evaluation.js.map +1 -0
  75. package/dist/src/core/fusion/facade-contract.js +3 -0
  76. package/dist/src/core/fusion/facade-contract.js.map +1 -0
  77. package/dist/src/core/fusion/orchestrator.js +867 -0
  78. package/dist/src/core/fusion/orchestrator.js.map +1 -0
  79. package/dist/src/core/fusion/output-contract.js +27 -0
  80. package/dist/src/core/fusion/output-contract.js.map +1 -0
  81. package/dist/src/core/fusion/pi-child.js +1741 -0
  82. package/dist/src/core/fusion/pi-child.js.map +1 -0
  83. package/dist/src/core/fusion/prompts.js +260 -0
  84. package/dist/src/core/fusion/prompts.js.map +1 -0
  85. package/dist/src/core/fusion/result-package.js +857 -0
  86. package/dist/src/core/fusion/result-package.js.map +1 -0
  87. package/dist/src/core/fusion/source-policy.js +259 -0
  88. package/dist/src/core/fusion/source-policy.js.map +1 -0
  89. package/dist/src/core/fusion/types.js +207 -0
  90. package/dist/src/core/fusion/types.js.map +1 -0
  91. package/dist/src/core/fusion/web-fetch.js +777 -0
  92. package/dist/src/core/fusion/web-fetch.js.map +1 -0
  93. package/dist/src/core/fusion/workflows.js +126 -0
  94. package/dist/src/core/fusion/workflows.js.map +1 -0
  95. package/dist/src/core/lazy-module.js +181 -0
  96. package/dist/src/core/lazy-module.js.map +1 -0
  97. package/dist/src/core/pi-launch.js +431 -0
  98. package/dist/src/core/pi-launch.js.map +1 -0
  99. package/dist/src/core/registry.js +3331 -0
  100. package/dist/src/core/registry.js.map +1 -0
  101. package/dist/src/core/reload-shell-owner.js +1396 -0
  102. package/dist/src/core/reload-shell-owner.js.map +1 -0
  103. package/dist/src/core/shell-policy.js +80 -0
  104. package/dist/src/core/shell-policy.js.map +1 -0
  105. package/dist/src/core/task-durable.js +51 -0
  106. package/dist/src/core/task-durable.js.map +1 -0
  107. package/dist/src/core/update-check.js +92 -0
  108. package/dist/src/core/update-check.js.map +1 -0
  109. package/dist/src/core/windows-taskkill.js +185 -0
  110. package/dist/src/core/windows-taskkill.js.map +1 -0
  111. package/dist/src/delegate-child-extension.js +776 -0
  112. package/dist/src/delegate-child-extension.js.map +1 -0
  113. package/dist/src/delegate-extension.js +746 -0
  114. package/dist/src/delegate-extension.js.map +1 -0
  115. package/dist/src/extension.js +953 -0
  116. package/dist/src/extension.js.map +1 -0
  117. package/dist/src/fusion-child-extension.js +760 -0
  118. package/dist/src/fusion-child-extension.js.map +1 -0
  119. package/dist/src/fusion-extension.js +1030 -0
  120. package/dist/src/fusion-extension.js.map +1 -0
  121. package/dist/src/ui/background-tasks-manager.js +689 -0
  122. package/dist/src/ui/background-tasks-manager.js.map +1 -0
  123. package/dist/src/ui/fusion-model-selector.js +277 -0
  124. package/dist/src/ui/fusion-model-selector.js.map +1 -0
  125. package/docs/INDEX.md +35 -33
  126. package/docs/api/eventbus-v1.md +21 -5
  127. package/docs/choose-a-workflow.md +2 -0
  128. package/docs/commands/bg-clear.md +3 -3
  129. package/docs/commands/bg-update.md +3 -3
  130. package/docs/commands/bg.md +18 -6
  131. package/docs/commands/claude-cache.md +4 -4
  132. package/docs/commands/fusion-models.md +49 -4
  133. package/docs/commands/fusion.md +3 -3
  134. package/docs/commands/jobs.md +3 -3
  135. package/docs/commands/kill.md +3 -3
  136. package/docs/commands/logs.md +3 -3
  137. package/docs/commands/task-manager.md +5 -5
  138. package/docs/concepts/completion-delivery.md +6 -2
  139. package/docs/getting-started.md +7 -1
  140. package/docs/manifest.json +228 -42
  141. package/docs/operations/configuration.md +58 -5
  142. package/docs/operations/releasing.md +4 -2
  143. package/docs/operations/testing.md +22 -0
  144. package/docs/operations/troubleshooting.md +6 -2
  145. package/docs/read-before-edit.md +44 -31
  146. package/docs/reference/runtime-contracts.md +87 -51
  147. package/docs/reference/shortcuts-and-dock.md +28 -14
  148. package/docs/subsystems/anthropic-attribution.md +23 -11
  149. package/docs/subsystems/attested-pi-runs.md +12 -7
  150. package/docs/subsystems/background-task-runtime.md +53 -11
  151. package/docs/subsystems/child-launch-durability-and-safety.md +28 -9
  152. package/docs/subsystems/delegation.md +16 -3
  153. package/docs/subsystems/docs-freshness-gate.md +14 -7
  154. package/docs/subsystems/fusion.md +36 -11
  155. package/docs/subsystems/host-ui-and-telemetry.md +22 -9
  156. package/docs/tools/bg_delegate.md +3 -1
  157. package/docs/tools/bg_kill.md +3 -1
  158. package/docs/tools/bg_logs.md +3 -1
  159. package/docs/tools/bg_result.md +5 -3
  160. package/docs/tools/bg_run.md +31 -5
  161. package/docs/tools/bg_run_pi_attested.md +3 -1
  162. package/docs/tools/bg_status.md +3 -1
  163. package/docs/tools/fusion_investigate.md +3 -1
  164. package/docs/tools/fusion_reason.md +3 -1
  165. package/docs/tools/fusion_research.md +3 -1
  166. package/docs/tools/fusion_validate.md +3 -1
  167. package/extensions/anthropic-attribution-child.ts +3 -0
  168. package/extensions/anthropic-attribution.ts +166 -1
  169. package/package.json +12 -10
  170. package/src/core/anthropic-attribution-path.ts +5 -4
  171. package/src/core/anthropic-attribution.ts +293 -113
  172. package/src/core/attested-pi-contract.ts +4 -0
  173. package/src/core/attested-pi-run.ts +468 -81
  174. package/src/core/canonical-json.ts +21 -0
  175. package/src/core/common.ts +450 -40
  176. package/src/core/config.ts +121 -0
  177. package/src/core/context/visible-conversation-v2.ts +3 -6
  178. package/src/core/delegate/artifacts.ts +13 -9
  179. package/src/core/delegate/budget.ts +13 -6
  180. package/src/core/delegate/facade-contract.ts +6 -0
  181. package/src/core/delegate/result-package.ts +32 -14
  182. package/src/core/delegate/runner.ts +129 -58
  183. package/src/core/delegate/seed.ts +5 -7
  184. package/src/core/durable-fs.ts +178 -30
  185. package/src/core/extension-api.ts +36 -8
  186. package/src/core/fusion/artifacts.ts +17 -16
  187. package/src/core/fusion/clean-context.ts +8 -5
  188. package/src/core/fusion/config.ts +2 -1
  189. package/src/core/fusion/context.ts +2 -6
  190. package/src/core/fusion/facade-contract.ts +2 -0
  191. package/src/core/fusion/orchestrator.ts +3 -3
  192. package/src/core/fusion/prompts.ts +1 -1
  193. package/src/core/fusion/result-package.ts +259 -72
  194. package/src/core/fusion/source-policy.ts +43 -17
  195. package/src/core/lazy-module.ts +215 -0
  196. package/src/core/pi-launch.ts +390 -65
  197. package/src/core/registry.ts +2010 -381
  198. package/src/core/reload-shell-owner.ts +1662 -0
  199. package/src/core/shell-policy.ts +134 -0
  200. package/src/core/task-durable.ts +67 -0
  201. package/src/delegate-extension.ts +404 -144
  202. package/src/extension.ts +387 -114
  203. package/src/fusion-extension.ts +202 -96
@@ -45,9 +45,13 @@ export interface DurableFileOperations {
45
45
  temporaryPath(target: string): string;
46
46
  }
47
47
 
48
+ export interface DurableWriteOptions {
49
+ readonly signal?: AbortSignal | undefined;
50
+ }
51
+
48
52
  export interface DurableFileWriter {
49
- write(path: string, data: DurableData): Promise<void>;
50
- replace(path: string, data: DurableData): Promise<void>;
53
+ write(path: string, data: DurableData, options?: DurableWriteOptions): Promise<void>;
54
+ replace(path: string, data: DurableData, options?: DurableWriteOptions): Promise<void>;
51
55
  }
52
56
 
53
57
  interface DurableErrorInput {
@@ -63,6 +67,48 @@ interface DurableErrorInput {
63
67
  interface WritableSequenceResult {
64
68
  primaryFailure: DurableFailure | undefined;
65
69
  cleanupFailures: DurableFailure[];
70
+ cancelled: boolean;
71
+ }
72
+
73
+ interface RenameSequenceResult {
74
+ error: unknown | undefined;
75
+ cancelled: boolean;
76
+ }
77
+
78
+ export class DurableFileCancellationError extends Error {
79
+ readonly code = 'durable_file_cancelled';
80
+ readonly path: string;
81
+ readonly reason: unknown;
82
+ readonly cleanupFailures: readonly DurableFailure[];
83
+ readonly targetPath: string | undefined;
84
+ readonly temporaryPath: string | undefined;
85
+ readonly renameCompleted: boolean;
86
+
87
+ constructor(input: {
88
+ path: string;
89
+ reason: unknown;
90
+ cleanupFailures: readonly DurableFailure[];
91
+ targetPath: string | undefined;
92
+ temporaryPath: string | undefined;
93
+ renameCompleted: boolean;
94
+ }) {
95
+ const reasonText = describeCause(input.reason);
96
+ const cleanupText =
97
+ input.cleanupFailures.length === 0
98
+ ? ''
99
+ : ` Cleanup failures: ${input.cleanupFailures.map(formatFailureForMessage).join('; ')}`;
100
+ const commitText = input.renameCompleted
101
+ ? ` Replacement may already be visible at ${input.targetPath ?? input.path}.`
102
+ : '';
103
+ super(`Durable file operation cancelled for ${input.path}: ${reasonText}.${commitText}${cleanupText}`);
104
+ this.name = 'DurableFileCancellationError';
105
+ this.path = input.path;
106
+ this.reason = input.reason;
107
+ this.cleanupFailures = [...input.cleanupFailures];
108
+ this.targetPath = input.targetPath;
109
+ this.temporaryPath = input.temporaryPath;
110
+ this.renameCompleted = input.renameCompleted;
111
+ }
66
112
  }
67
113
 
68
114
  export class DurableFileError extends Error {
@@ -149,20 +195,26 @@ async function writeSyncClose(
149
195
  handle: DurableWritableHandle,
150
196
  path: string,
151
197
  data: DurableData,
198
+ signal?: AbortSignal,
152
199
  ): Promise<WritableSequenceResult> {
153
200
  const cleanupFailures: DurableFailure[] = [];
154
201
  let primaryFailure: DurableFailure | undefined;
155
- try {
156
- await handle.writeFile(data);
157
- } catch (error) {
158
- primaryFailure = failure('write_file', path, error);
202
+ let cancelled = signal?.aborted === true;
203
+ if (!cancelled) {
204
+ try {
205
+ await handle.writeFile(data);
206
+ } catch (error) {
207
+ primaryFailure = failure('write_file', path, error);
208
+ }
209
+ cancelled = signal?.aborted === true;
159
210
  }
160
- if (primaryFailure === undefined) {
211
+ if (primaryFailure === undefined && !cancelled) {
161
212
  try {
162
213
  await handle.sync();
163
214
  } catch (error) {
164
215
  primaryFailure = failure('sync_file', path, error);
165
216
  }
217
+ cancelled = signal?.aborted === true;
166
218
  }
167
219
  try {
168
220
  await handle.close();
@@ -171,7 +223,8 @@ async function writeSyncClose(
171
223
  if (primaryFailure === undefined) primaryFailure = closeFailure;
172
224
  else cleanupFailures.push(closeFailure);
173
225
  }
174
- return { primaryFailure, cleanupFailures };
226
+ cancelled ||= signal?.aborted === true;
227
+ return { primaryFailure, cleanupFailures, cancelled };
175
228
  }
176
229
 
177
230
  async function syncCloseDirectory(
@@ -192,7 +245,31 @@ async function syncCloseDirectory(
192
245
  if (primaryFailure === undefined) primaryFailure = closeFailure;
193
246
  else cleanupFailures.push(closeFailure);
194
247
  }
195
- return { primaryFailure, cleanupFailures };
248
+ return { primaryFailure, cleanupFailures, cancelled: false };
249
+ }
250
+
251
+ function cancellationReason(signal: AbortSignal): unknown {
252
+ return signal.reason ?? new Error('operation aborted');
253
+ }
254
+
255
+ function throwCancellation(
256
+ signal: AbortSignal,
257
+ path: string,
258
+ cleanupFailures: readonly DurableFailure[],
259
+ targetPath: string | undefined,
260
+ temporaryPath: string | undefined,
261
+ renameCompleted: boolean,
262
+ ): never {
263
+ const reason = cancellationReason(signal);
264
+ if (cleanupFailures.length === 0 && !renameCompleted && reason instanceof Error) throw reason;
265
+ throw new DurableFileCancellationError({
266
+ path,
267
+ reason,
268
+ cleanupFailures,
269
+ targetPath,
270
+ temporaryPath,
271
+ renameCompleted,
272
+ });
196
273
  }
197
274
 
198
275
  function throwDurable(
@@ -225,21 +302,34 @@ async function removeTemporary(
225
302
  }
226
303
  }
227
304
 
305
+ function isAborted(signal: AbortSignal | undefined): boolean {
306
+ return signal?.aborted === true;
307
+ }
308
+
228
309
  async function writeWithOperations(
229
310
  operations: DurableFileOperations,
230
311
  path: string,
231
312
  data: DurableData,
313
+ options: DurableWriteOptions,
232
314
  ): Promise<void> {
315
+ const signal = options.signal;
316
+ if (signal !== undefined && isAborted(signal))
317
+ throwCancellation(signal, path, [], path, undefined, false);
233
318
  let handle: DurableWritableHandle;
234
319
  try {
235
320
  handle = await operations.openWritable(path, 'w');
236
321
  } catch (error) {
322
+ if (signal !== undefined && isAborted(signal))
323
+ throwCancellation(signal, path, [], path, undefined, false);
237
324
  throwDurable(failure('open_file', path, error), [], path, undefined, false);
238
325
  }
239
- const result = await writeSyncClose(handle, path, data);
326
+ const result = await writeSyncClose(handle, path, data, signal);
240
327
  if (result.primaryFailure !== undefined) {
241
328
  throwDurable(result.primaryFailure, result.cleanupFailures, path, undefined, false);
242
329
  }
330
+ if (result.cancelled && signal !== undefined) {
331
+ throwCancellation(signal, path, result.cleanupFailures, path, undefined, false);
332
+ }
243
333
  }
244
334
 
245
335
  async function syncDirectoryAfterRename(
@@ -265,25 +355,52 @@ async function replaceWithOperations(
265
355
  operations: DurableFileOperations,
266
356
  path: string,
267
357
  data: DurableData,
358
+ options: DurableWriteOptions,
268
359
  ): Promise<void> {
360
+ const signal = options.signal;
361
+ if (signal !== undefined && isAborted(signal))
362
+ throwCancellation(signal, path, [], path, undefined, false);
269
363
  const temporaryPath = operations.temporaryPath(path);
270
364
  let handle: DurableWritableHandle;
271
365
  try {
272
366
  handle = await operations.openWritable(temporaryPath, 'wx', 0o600);
273
367
  } catch (error) {
368
+ if (signal !== undefined && isAborted(signal))
369
+ throwCancellation(signal, temporaryPath, [], path, temporaryPath, false);
274
370
  throwDurable(failure('open_file', temporaryPath, error), [], path, temporaryPath, false);
275
371
  }
276
- const writeResult = await writeSyncClose(handle, temporaryPath, data);
372
+ const writeResult = await writeSyncClose(handle, temporaryPath, data, signal);
277
373
  if (writeResult.primaryFailure !== undefined) {
278
374
  await removeTemporary(operations, temporaryPath, writeResult.cleanupFailures);
279
375
  throwDurable(writeResult.primaryFailure, writeResult.cleanupFailures, path, temporaryPath, false);
280
376
  }
281
- const renameError = await renameWithWindowsContention(operations, temporaryPath, path);
282
- if (renameError !== undefined) {
377
+ if (writeResult.cancelled && signal !== undefined) {
378
+ await removeTemporary(operations, temporaryPath, writeResult.cleanupFailures);
379
+ throwCancellation(
380
+ signal,
381
+ temporaryPath,
382
+ writeResult.cleanupFailures,
383
+ path,
384
+ temporaryPath,
385
+ false,
386
+ );
387
+ }
388
+ const renameResult = await renameWithWindowsContention(
389
+ operations,
390
+ temporaryPath,
391
+ path,
392
+ signal,
393
+ );
394
+ if (renameResult.cancelled && signal !== undefined) {
395
+ const cleanupFailures: DurableFailure[] = [];
396
+ await removeTemporary(operations, temporaryPath, cleanupFailures);
397
+ throwCancellation(signal, temporaryPath, cleanupFailures, path, temporaryPath, false);
398
+ }
399
+ if (renameResult.error !== undefined) {
283
400
  const cleanupFailures: DurableFailure[] = [];
284
401
  await removeTemporary(operations, temporaryPath, cleanupFailures);
285
402
  throwDurable(
286
- failure('rename_file', path, renameError),
403
+ failure('rename_file', path, renameResult.error),
287
404
  cleanupFailures,
288
405
  path,
289
406
  temporaryPath,
@@ -291,6 +408,9 @@ async function replaceWithOperations(
291
408
  );
292
409
  }
293
410
  await syncDirectoryAfterRename(operations, path, temporaryPath);
411
+ if (signal !== undefined && isAborted(signal)) {
412
+ throwCancellation(signal, path, [], path, temporaryPath, true);
413
+ }
294
414
  }
295
415
 
296
416
  /**
@@ -310,9 +430,18 @@ const WINDOWS_RENAME_CONTENTION_CODES: ReadonlySet<string> = new Set([
310
430
  const WINDOWS_RENAME_ATTEMPTS = 10;
311
431
  const WINDOWS_RENAME_RETRY_DELAY_MS = 20;
312
432
 
313
- function delay(ms: number): Promise<void> {
433
+ function delay(ms: number, signal?: AbortSignal): Promise<void> {
434
+ if (isAborted(signal)) return Promise.resolve();
314
435
  return new Promise((resolve) => {
315
- setTimeout(resolve, ms);
436
+ const timer = setTimeout(() => {
437
+ signal?.removeEventListener('abort', aborted);
438
+ resolve();
439
+ }, ms);
440
+ const aborted = () => {
441
+ clearTimeout(timer);
442
+ resolve();
443
+ };
444
+ signal?.addEventListener('abort', aborted, { once: true });
316
445
  });
317
446
  }
318
447
 
@@ -324,31 +453,34 @@ function delay(ms: number): Promise<void> {
324
453
  * final failure is still returned and raised loudly, no alternative write path
325
454
  * is taken, and no other platform or error code is retried.
326
455
  *
327
- * Returns the failure cause, or undefined on success.
456
+ * Returns an explicit success/error/cancellation sequence result.
328
457
  */
329
458
  async function renameWithWindowsContention(
330
459
  operations: DurableFileOperations,
331
460
  temporaryPath: string,
332
461
  targetPath: string,
333
- ): Promise<unknown> {
462
+ signal?: AbortSignal,
463
+ ): Promise<RenameSequenceResult> {
334
464
  const attempts = operations.platform === 'win32' ? WINDOWS_RENAME_ATTEMPTS : 1;
335
465
  let lastError: unknown;
336
466
  for (let attempt = 1; attempt <= attempts; attempt++) {
467
+ if (isAborted(signal)) return { error: undefined, cancelled: true };
337
468
  try {
338
469
  await operations.rename(temporaryPath, targetPath);
339
- return undefined;
470
+ return { error: undefined, cancelled: false };
340
471
  } catch (error) {
341
472
  lastError = error;
473
+ if (isAborted(signal)) return { error: undefined, cancelled: true };
342
474
  const code = nativeCodeForCause(error);
343
475
  const retryable =
344
476
  operations.platform === 'win32' &&
345
477
  code !== undefined &&
346
478
  WINDOWS_RENAME_CONTENTION_CODES.has(code);
347
- if (!retryable || attempt === attempts) return error;
348
- await delay(WINDOWS_RENAME_RETRY_DELAY_MS * attempt);
479
+ if (!retryable || attempt === attempts) return { error, cancelled: false };
480
+ await delay(WINDOWS_RENAME_RETRY_DELAY_MS * attempt, signal);
349
481
  }
350
482
  }
351
- return lastError;
483
+ return { error: lastError, cancelled: false };
352
484
  }
353
485
 
354
486
  function temporaryPathForTarget(target: string): string {
@@ -382,19 +514,35 @@ const defaultWriter = createDurableFileWriter(nodeOperations);
382
514
 
383
515
  export function createDurableFileWriter(operations: DurableFileOperations): DurableFileWriter {
384
516
  return {
385
- async write(path: string, data: DurableData): Promise<void> {
386
- await writeWithOperations(operations, path, data);
517
+ async write(
518
+ path: string,
519
+ data: DurableData,
520
+ options: DurableWriteOptions = {},
521
+ ): Promise<void> {
522
+ await writeWithOperations(operations, path, data, options);
387
523
  },
388
- async replace(path: string, data: DurableData): Promise<void> {
389
- await replaceWithOperations(operations, path, data);
524
+ async replace(
525
+ path: string,
526
+ data: DurableData,
527
+ options: DurableWriteOptions = {},
528
+ ): Promise<void> {
529
+ await replaceWithOperations(operations, path, data, options);
390
530
  },
391
531
  };
392
532
  }
393
533
 
394
- export async function writeFileDurable(path: string, data: DurableData): Promise<void> {
395
- await defaultWriter.write(path, data);
534
+ export async function writeFileDurable(
535
+ path: string,
536
+ data: DurableData,
537
+ options: DurableWriteOptions = {},
538
+ ): Promise<void> {
539
+ await defaultWriter.write(path, data, options);
396
540
  }
397
541
 
398
- export async function replaceFileDurable(path: string, data: DurableData): Promise<void> {
399
- await defaultWriter.replace(path, data);
542
+ export async function replaceFileDurable(
543
+ path: string,
544
+ data: DurableData,
545
+ options: DurableWriteOptions = {},
546
+ ): Promise<void> {
547
+ await defaultWriter.replace(path, data, options);
400
548
  }
@@ -15,6 +15,18 @@ export const BG_TERMINAL_CHANNEL = 'pi-background-tasks:terminal:v1';
15
15
  export const BG_REQUEST_SCHEMA = 'pi-background-tasks.extension-request.v1';
16
16
  export const BG_RESPONSE_SCHEMA = 'pi-background-tasks.extension-response.v1';
17
17
  export const BG_TERMINAL_SCHEMA = 'pi-background-tasks.extension-terminal.v1';
18
+ export const BG_EXTENSION_SERVICE_CLOSED_CODE = 'pi_background_tasks_eventbus_closed';
19
+
20
+ export type BackgroundTaskExtensionServiceState = 'open' | 'closed';
21
+
22
+ export class BackgroundTaskExtensionServiceClosedError extends Error {
23
+ readonly code = BG_EXTENSION_SERVICE_CLOSED_CODE;
24
+
25
+ constructor() {
26
+ super('pi-background-tasks EventBus service is closed');
27
+ this.name = 'BackgroundTaskExtensionServiceClosedError';
28
+ }
29
+ }
18
30
 
19
31
  const MAX_ERROR_CHARS = 240;
20
32
  const MAX_REQUEST_ID_CHARS = 200;
@@ -109,6 +121,7 @@ export interface BackgroundTaskExtensionTerminal {
109
121
  }
110
122
 
111
123
  export interface BackgroundTaskExtensionService {
124
+ readonly state: BackgroundTaskExtensionServiceState;
112
125
  publishTerminal(task: BgTaskSnapshot): void;
113
126
  close(): void;
114
127
  }
@@ -406,7 +419,7 @@ class InstalledBackgroundTaskExtensionService implements BackgroundTaskExtension
406
419
  private readonly logger: Pick<Console, 'error'>;
407
420
  private readonly seenRequestIds = new Set<string>();
408
421
  private readonly unsubscribe: () => void;
409
- private closed = false;
422
+ private serviceState: BackgroundTaskExtensionServiceState = 'open';
410
423
 
411
424
  constructor(options: BackgroundTaskExtensionServiceOptions) {
412
425
  this.events = options.events;
@@ -419,8 +432,16 @@ class InstalledBackgroundTaskExtensionService implements BackgroundTaskExtension
419
432
  });
420
433
  }
421
434
 
435
+ get state(): BackgroundTaskExtensionServiceState {
436
+ return this.serviceState;
437
+ }
438
+
439
+ private isClosed(): boolean {
440
+ return this.serviceState === 'closed';
441
+ }
442
+
422
443
  publishTerminal(task: BgTaskSnapshot): void {
423
- if (this.closed) throw new Error('pi-background-tasks EventBus service is closed');
444
+ if (this.serviceState === 'closed') throw new BackgroundTaskExtensionServiceClosedError();
424
445
  const terminal: BackgroundTaskExtensionTerminal = {
425
446
  schema_version: BG_TERMINAL_SCHEMA,
426
447
  task,
@@ -429,8 +450,9 @@ class InstalledBackgroundTaskExtensionService implements BackgroundTaskExtension
429
450
  }
430
451
 
431
452
  close(): void {
432
- if (this.closed) return;
433
- this.closed = true;
453
+ if (this.serviceState === 'closed') return;
454
+ this.serviceState = 'closed';
455
+ this.registry.closeTerminalPublication('publisher_closed');
434
456
  this.unsubscribe();
435
457
  }
436
458
 
@@ -459,7 +481,7 @@ class InstalledBackgroundTaskExtensionService implements BackgroundTaskExtension
459
481
  ? createTerminalPublicationGate()
460
482
  : undefined;
461
483
  try {
462
- if (this.closed) throw new Error('pi-background-tasks EventBus service is closed');
484
+ if (this.serviceState === 'closed') throw new BackgroundTaskExtensionServiceClosedError();
463
485
  if (this.isShuttingDown() || this.registry.isShuttingDown()) {
464
486
  throw new Error('pi-background-tasks EventBus service is shutting down');
465
487
  }
@@ -467,10 +489,16 @@ class InstalledBackgroundTaskExtensionService implements BackgroundTaskExtension
467
489
  if (ctx === undefined) {
468
490
  throw new Error('pi-background-tasks EventBus service is unavailable before session_start');
469
491
  }
470
- this.emitResponse(
471
- successResponse(request, await this.execute(ctx, request, terminalGate?.promise)),
472
- );
492
+ const result = await this.execute(ctx, request, terminalGate?.promise);
493
+ if (this.isClosed()) return;
494
+ if (this.isShuttingDown() || this.registry.isShuttingDown()) {
495
+ throw new Error('pi-background-tasks EventBus service is shutting down');
496
+ }
497
+ this.emitResponse(successResponse(request, result));
473
498
  } catch (error) {
499
+ // A request accepted before close may report failure, but it must never
500
+ // report post-close success. Requests first emitted after close remain
501
+ // unhandled because the listener has already been removed.
474
502
  this.emitResponse(errorResponse(request.request_id, request.operation, error));
475
503
  } finally {
476
504
  await terminalGate?.releaseAfterResponse();
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import { chmod, mkdir } from 'node:fs/promises';
3
3
  import { basename, isAbsolute, join, relative, sep } from 'node:path';
4
- import { canonicalJson, sha256Buffer } from '../attested-pi-run.js';
4
+ import { canonicalJson, sha256Buffer } from '../canonical-json.js';
5
5
  import { sanitizePathSegment } from '../common.js';
6
6
  import { replaceFileDurable } from '../durable-fs.js';
7
7
  import {
@@ -443,7 +443,9 @@ function failureArtifactClassification(
443
443
  return 'evidence_only';
444
444
  }
445
445
 
446
- function failureAttemptMetadata(manifest: FusionArtifactManifest): readonly FusionFailureAttemptMetadata[] {
446
+ function failureAttemptMetadata(
447
+ manifest: FusionArtifactManifest,
448
+ ): readonly FusionFailureAttemptMetadata[] {
447
449
  return manifest.attempts
448
450
  .map((attempt) => ({
449
451
  stage: attempt.stage,
@@ -452,10 +454,11 @@ function failureAttemptMetadata(manifest: FusionArtifactManifest): readonly Fusi
452
454
  status: attempt.status,
453
455
  child_created: attempt.child_created,
454
456
  }))
455
- .sort((left, right) =>
456
- compareArtifactText(left.stage, right.stage) ||
457
- (left.slot ?? 0) - (right.slot ?? 0) ||
458
- left.attempt - right.attempt,
457
+ .sort(
458
+ (left, right) =>
459
+ compareArtifactText(left.stage, right.stage) ||
460
+ (left.slot ?? 0) - (right.slot ?? 0) ||
461
+ left.attempt - right.attempt,
459
462
  );
460
463
  }
461
464
 
@@ -470,7 +473,9 @@ export function buildFusionFailureSummary(input: {
470
473
  (input.terminalState !== 'failed' && input.terminalState !== 'cancelled') ||
471
474
  input.manifest.state !== input.terminalState
472
475
  ) {
473
- throw errorForArtifact('failure summary requires a matching failed/cancelled terminal manifest');
476
+ throw errorForArtifact(
477
+ 'failure summary requires a matching failed/cancelled terminal manifest',
478
+ );
474
479
  }
475
480
  if (input.manifest.error !== input.terminalError.message) {
476
481
  throw errorForArtifact('failure summary terminal error does not match the durable manifest');
@@ -750,10 +755,7 @@ export class FusionArtifactStore {
750
755
  * stage-output bodies.
751
756
  */
752
757
  async writeFailureSummary(summary: FusionFailureSummaryV1): Promise<FusionArtifactRef> {
753
- if (
754
- this.manifest.state !== 'failed' &&
755
- this.manifest.state !== 'cancelled'
756
- ) {
758
+ if (this.manifest.state !== 'failed' && this.manifest.state !== 'cancelled') {
757
759
  throw errorForArtifact('failure summary requires a failed/cancelled terminal manifest');
758
760
  }
759
761
  if (summary.terminal_state !== this.manifest.state) {
@@ -766,10 +768,7 @@ export class FusionArtifactStore {
766
768
  ) {
767
769
  throw errorForArtifact('failure summary identity does not match the terminal manifest');
768
770
  }
769
- if (
770
- summary.answer?.present !== false ||
771
- summary.answer.reason !== 'run_did_not_commit'
772
- ) {
771
+ if (summary.answer?.present !== false || summary.answer.reason !== 'run_did_not_commit') {
773
772
  throw errorForArtifact('failure summary must assert that no answer was committed');
774
773
  }
775
774
  if (this.manifest.error === undefined || this.manifest.artifacts['error.json'] === undefined) {
@@ -781,7 +780,9 @@ export class FusionArtifactStore {
781
780
  ) {
782
781
  throw errorForArtifact('failure summary terminal error metadata does not match the manifest');
783
782
  }
784
- if (canonicalJson(summary.progress) !== canonicalJson(buildFusionRunProgress(this.snapshot()))) {
783
+ if (
784
+ canonicalJson(summary.progress) !== canonicalJson(buildFusionRunProgress(this.snapshot()))
785
+ ) {
785
786
  throw errorForArtifact('failure summary progress does not match the terminal manifest');
786
787
  }
787
788
  if (canonicalJson(summary.usage_so_far) !== canonicalJson(this.manifest.usage)) {
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { canonicalJson } from '../attested-pi-run.js';
2
+ import { canonicalJson } from '../canonical-json.js';
3
3
  import { normalizeFusionDeclaredSources, type DeclaredFusionSourceInput } from './source-policy.js';
4
4
  import {
5
5
  FUSION_INPUT_SCHEMA_VERSION,
@@ -47,10 +47,13 @@ export function buildFusionCleanTaskCanonicalInput(
47
47
  });
48
48
  }
49
49
  if (!['investigate', 'research', 'validate'].includes(options.workflow)) {
50
- throw new FusionError('clean-task fusion input is available only to investigate, research, and validate workflows', {
51
- code: 'context_capture_failed',
52
- childCreated: false,
53
- });
50
+ throw new FusionError(
51
+ 'clean-task fusion input is available only to investigate, research, and validate workflows',
52
+ {
53
+ code: 'context_capture_failed',
54
+ childCreated: false,
55
+ },
56
+ );
54
57
  }
55
58
  const declaredSources = normalizeFusionDeclaredSources(options.declaredSources ?? []);
56
59
  if (options.workflow === 'research' && declaredSources.length === 0) {
@@ -6,6 +6,7 @@ import type { Api, Model } from '@earendil-works/pi-ai';
6
6
  import { isJsonObject, parseJsonText, type JsonObject } from '../common.js';
7
7
  import { replaceFileDurable } from '../durable-fs.js';
8
8
  import { CLAUDE_CODE_200K_SUBSCRIPTION_CONTEXT_WINDOW } from '../anthropic-attribution.js';
9
+ import { CURRENT_MODEL_SELECTION } from './facade-contract.js';
9
10
  import {
10
11
  FUSION_MODEL_CONFIG_SCHEMA_VERSION,
11
12
  FusionError,
@@ -19,7 +20,7 @@ import {
19
20
  } from './types.js';
20
21
 
21
22
  export const FUSION_MODEL_CONFIG_FILE = 'fusion-models.json';
22
- export const CURRENT_MODEL_SELECTION = '$current';
23
+ export { CURRENT_MODEL_SELECTION } from './facade-contract.js';
23
24
 
24
25
  export interface FusionModelRegistry {
25
26
  getAll(): Model<Api>[];
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { canonicalJson } from '../attested-pi-run.js';
2
+ import { canonicalJson } from '../canonical-json.js';
3
3
  import {
4
4
  projectVisibleConversationV2,
5
5
  type OmittedRunCounts,
@@ -100,11 +100,7 @@ function policyDescriptor(source: FusionSource): FusionContextPolicyDescriptor {
100
100
  }
101
101
 
102
102
  function compactOmissionCounts(counts: OmittedRunCounts): FusionProjectionOmissionCounts {
103
- return [
104
- counts.assistant_thinking ?? 0,
105
- counts.tool_calls ?? 0,
106
- counts.tool_result_texts ?? 0,
107
- ];
103
+ return [counts.assistant_thinking ?? 0, counts.tool_calls ?? 0, counts.tool_result_texts ?? 0];
108
104
  }
109
105
 
110
106
  function expandOmissionCounts(counts: FusionProjectionOmissionCounts): OmittedRunCounts {
@@ -0,0 +1,2 @@
1
+ /** Light registration/config sentinel that must not pull the config engine into the facade. */
2
+ export const CURRENT_MODEL_SELECTION = '$current' as const;
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomBytes as nodeRandomBytes } from 'node:crypto';
2
- import { canonicalJson } from '../attested-pi-run.js';
2
+ import { canonicalJson } from '../canonical-json.js';
3
3
  import { parseJsonText } from '../common.js';
4
4
  import { FUSION_BUDGET_POLICY, FusionBudget } from './budget.js';
5
5
  import { assertChildOutputWithinContract } from './output-contract.js';
@@ -841,6 +841,7 @@ export class FusionOrchestrator {
841
841
  const mergePrompt = buildMergePrompt(mergeInput);
842
842
  budget.assertStagePrompt('merge', profile.mergerSystemPrompt, mergePrompt);
843
843
  input.onProgress?.({ type: 'merge_started' });
844
+ // Stage policy, not caller input: evaluator and merger are always reasoning-only.
844
845
  const merged = await this.runChildWithRetry(
845
846
  input,
846
847
  store,
@@ -850,7 +851,6 @@ export class FusionOrchestrator {
850
851
  profile.mergerSystemPrompt,
851
852
  mergePrompt,
852
853
  input.signal ?? new AbortController().signal,
853
- // Stage policy, not caller input: evaluator and merger are always reasoning-only.
854
854
  FUSION_NO_TOOLS_CAPABILITY,
855
855
  undefined,
856
856
  'md',
@@ -1144,6 +1144,7 @@ export class FusionOrchestrator {
1144
1144
  const systemPrompt = repair
1145
1145
  ? profile.evaluationRepairSystemPrompt
1146
1146
  : profile.evaluatorSystemPrompt;
1147
+ // Stage policy, not caller input: evaluator and merger are always reasoning-only.
1147
1148
  const result = await this.runChildWithRetry(
1148
1149
  input,
1149
1150
  store,
@@ -1153,7 +1154,6 @@ export class FusionOrchestrator {
1153
1154
  systemPrompt,
1154
1155
  prompt,
1155
1156
  input.signal ?? new AbortController().signal,
1156
- // Stage policy, not caller input: evaluator and merger are always reasoning-only.
1157
1157
  FUSION_NO_TOOLS_CAPABILITY,
1158
1158
  undefined,
1159
1159
  'txt',
@@ -1,4 +1,4 @@
1
- import { canonicalJson } from '../attested-pi-run.js';
1
+ import { canonicalJson } from '../canonical-json.js';
2
2
  import { FUSION_CANDIDATE_OUTPUT_CONTRACT_INSTRUCTION } from './output-contract.js';
3
3
  import {
4
4
  FUSION_EVALUATION_SCHEMA_VERSION,