pi-background-tasks 0.9.0 → 1.0.4

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 (72) hide show
  1. package/BACKGROUND-TASKS-INSTRUCTIONS.md +63 -0
  2. package/PUBLISHING.md +43 -29
  3. package/README.md +233 -441
  4. package/TESTING.md +16 -10
  5. package/TEST_PLAN.md +43 -17
  6. package/docs/INDEX.md +157 -0
  7. package/docs/api/eventbus-v1.md +166 -0
  8. package/docs/assets/architecture.svg +78 -0
  9. package/docs/assets/footer-dock.svg +47 -0
  10. package/docs/assets/logo.svg +49 -0
  11. package/docs/attestations.json +189 -0
  12. package/docs/choose-a-workflow.md +98 -0
  13. package/docs/commands/bg-clear.md +70 -0
  14. package/docs/commands/bg-update.md +82 -0
  15. package/docs/commands/bg.md +90 -0
  16. package/docs/commands/fusion-models.md +70 -0
  17. package/docs/commands/fusion.md +69 -0
  18. package/docs/commands/jobs.md +74 -0
  19. package/docs/commands/kill.md +82 -0
  20. package/docs/commands/logs.md +90 -0
  21. package/docs/commands/task-manager.md +109 -0
  22. package/docs/concepts/completion-delivery.md +66 -0
  23. package/docs/concepts/context-projection-and-budgeting.md +79 -0
  24. package/docs/getting-started.md +122 -0
  25. package/docs/manifest.json +1825 -0
  26. package/docs/operations/configuration.md +110 -0
  27. package/docs/operations/releasing.md +67 -0
  28. package/docs/operations/testing.md +101 -0
  29. package/docs/operations/troubleshooting.md +38 -0
  30. package/docs/read-before-edit.md +94 -0
  31. package/docs/reference/runtime-contracts.md +213 -0
  32. package/docs/reference/shortcuts-and-dock.md +70 -0
  33. package/docs/subsystems/attested-pi-runs.md +141 -0
  34. package/docs/subsystems/background-task-runtime.md +85 -0
  35. package/docs/subsystems/child-launch-durability-and-safety.md +57 -0
  36. package/docs/subsystems/delegation.md +190 -0
  37. package/docs/subsystems/docs-freshness-gate.md +26 -0
  38. package/docs/subsystems/fusion.md +123 -0
  39. package/docs/subsystems/host-ui-and-telemetry.md +83 -0
  40. package/docs/tools/bg_delegate.md +193 -0
  41. package/docs/tools/bg_kill.md +114 -0
  42. package/docs/tools/bg_logs.md +133 -0
  43. package/docs/tools/bg_result.md +120 -0
  44. package/docs/tools/bg_run.md +168 -0
  45. package/docs/tools/bg_run_pi_attested.md +170 -0
  46. package/docs/tools/bg_status.md +111 -0
  47. package/docs/tools/fusion_investigate.md +116 -0
  48. package/docs/tools/fusion_reason.md +75 -0
  49. package/docs/tools/fusion_research.md +162 -0
  50. package/docs/tools/fusion_validate.md +206 -0
  51. package/logo.png +0 -0
  52. package/package.json +27 -9
  53. package/src/core/delegate/budget.ts +1 -1
  54. package/src/core/delegate/launch.ts +5 -0
  55. package/src/core/fusion/artifacts.ts +34 -4
  56. package/src/core/fusion/budget.ts +112 -20
  57. package/src/core/fusion/child-protocol.ts +82 -0
  58. package/src/core/fusion/clean-context.ts +91 -0
  59. package/src/core/fusion/config.ts +124 -35
  60. package/src/core/fusion/context.ts +29 -7
  61. package/src/core/fusion/evaluation.ts +392 -15
  62. package/src/core/fusion/orchestrator.ts +217 -23
  63. package/src/core/fusion/pi-child.ts +227 -24
  64. package/src/core/fusion/prompts.ts +39 -26
  65. package/src/core/fusion/source-policy.ts +257 -0
  66. package/src/core/fusion/types.ts +156 -11
  67. package/src/core/fusion/web-fetch.ts +104 -15
  68. package/src/core/fusion/workflows.ts +119 -65
  69. package/src/extension.ts +3 -3
  70. package/src/fusion-child-extension.ts +375 -141
  71. package/src/fusion-extension.ts +585 -240
  72. package/src/testing/normalize.ts +0 -22
@@ -1,6 +1,14 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { closeSync, fsyncSync, openSync, writeSync } from 'node:fs';
3
- import type { Usage } from '@earendil-works/pi-ai';
2
+ import {
3
+ closeSync,
4
+ constants,
5
+ fstatSync,
6
+ fsyncSync,
7
+ openSync,
8
+ readFileSync,
9
+ writeSync,
10
+ } from 'node:fs';
11
+ import { dirname } from 'node:path';
4
12
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
5
13
  import { Type, type Static } from 'typebox';
6
14
  import {
@@ -13,22 +21,40 @@ import {
13
21
  FusionWebFetchError,
14
22
  FUSION_WEB_FETCH_TIMEOUT_MS,
15
23
  } from './core/fusion/web-fetch.js';
24
+ import {
25
+ canonicalizeFusionPublicUrl,
26
+ parseFusionSourcePolicy,
27
+ } from './core/fusion/source-policy.js';
28
+ import {
29
+ FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES,
30
+ FUSION_CHILD_RESULT_PREFIX,
31
+ FUSION_RESEARCH_ENABLED_ENV,
32
+ FUSION_SOURCE_POLICY_PATH_ENV,
33
+ FUSION_SOURCE_POLICY_SHA256_ENV,
34
+ FUSION_TOOL_CALL_LOG_PATH_ENV,
35
+ FUSION_TOOL_CALL_SEAL_SCHEMA_VERSION,
36
+ FUSION_TOOL_CALL_SEAL_SUFFIX,
37
+ buildFusionChildResultMetadata,
38
+ type FusionChildResultMetadata,
39
+ } from './core/fusion/child-protocol.js';
16
40
 
17
- export const FUSION_CHILD_RESULT_SCHEMA_VERSION =
18
- 'pi-background-tasks.fusion-child-result.v2' as const;
19
- export const FUSION_CHILD_RESULT_PREFIX = '\u001ePI_FUSION_CHILD_RESULT ';
20
- export const FUSION_TOOL_CALL_LOG_PATH_ENV = 'PI_FUSION_TOOL_CALL_LOG_PATH';
21
- export const FUSION_RESEARCH_ENABLED_ENV = 'PI_FUSION_RESEARCH_ENABLED';
41
+ export {
42
+ FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES,
43
+ FUSION_CHILD_RESULT_PREFIX,
44
+ FUSION_CHILD_RESULT_SCHEMA_VERSION,
45
+ FUSION_RESEARCH_ENABLED_ENV,
46
+ FUSION_SOURCE_POLICY_PATH_ENV,
47
+ FUSION_SOURCE_POLICY_SHA256_ENV,
48
+ FUSION_TOOL_CALL_LOG_PATH_ENV,
49
+ FUSION_TOOL_CALL_SEAL_SCHEMA_VERSION,
50
+ FUSION_TOOL_CALL_SEAL_SUFFIX,
51
+ buildFusionChildResultMetadata,
52
+ type FusionChildResultMetadata,
53
+ type FusionChildResultUsageMetadata,
54
+ type FusionChildTextBlockMetadata,
55
+ } from './core/fusion/child-protocol.js';
22
56
 
23
- /**
24
- * Aggregate ceiling on tool-result bytes a single candidate child may accumulate.
25
- *
26
- * v1 deliberately has no tool-CALL cap, so this byte budget is the only bound on how much
27
- * a read-only candidate can pull into its context. 8 MiB is generous for targeted
28
- * grep/read investigation while still preventing an unbounded read loop from degrading
29
- * into an opaque provider-side context failure.
30
- */
31
- export const FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES = 8 * 1024 * 1024;
57
+ const FUSION_CHILD_O_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
32
58
 
33
59
  const FusionWebFetchParams = Type.Object(
34
60
  {
@@ -58,30 +84,14 @@ interface FusionWebFetchDetails {
58
84
  }
59
85
 
60
86
  interface FusionWebFetchAuditMetadata {
61
- url: string;
87
+ url?: string | undefined;
88
+ rejected_url_sha256?: string | undefined;
62
89
  final_url?: string | undefined;
63
90
  http_status?: number | undefined;
64
91
  response_bytes?: number | undefined;
65
92
  content_sha256?: string | undefined;
66
93
  }
67
94
 
68
- export interface FusionChildTextBlockMetadata {
69
- utf8_bytes: number;
70
- sha256: string;
71
- }
72
-
73
- export type FusionChildResultUsageMetadata = Usage;
74
-
75
- export interface FusionChildResultMetadata {
76
- schema_version: typeof FUSION_CHILD_RESULT_SCHEMA_VERSION;
77
- provider: string;
78
- model: string;
79
- stop_reason: string;
80
- text_blocks: FusionChildTextBlockMetadata[];
81
- text_sha256: string;
82
- usage: FusionChildResultUsageMetadata;
83
- }
84
-
85
95
  function sha256(value: string | Buffer): string {
86
96
  return createHash('sha256').update(value).digest('hex');
87
97
  }
@@ -99,62 +109,148 @@ function utf8JsonBytes(value: unknown, label: string): Buffer {
99
109
  return Buffer.from(text, 'utf8');
100
110
  }
101
111
 
102
- function appendToolCallLogLine(path: string, record: FusionToolCallLogRecord): void {
103
- // The log is an audit trail, not a payload copy: raw tool arguments/results may
104
- // contain secrets, so only byte counts and SHA-256 digests are persisted.
105
- const line = `${JSON.stringify(record)}\n`;
106
- const expectedBytes = Buffer.byteLength(line, 'utf8');
112
+ function throwableError(value: unknown): Error {
113
+ return value instanceof Error ? value : new Error(String(value));
114
+ }
115
+
116
+ function writeAllSync(fd: number, bytes: Buffer, label: string): void {
117
+ let offset = 0;
118
+ while (offset < bytes.length) {
119
+ const written = writeSync(fd, bytes, offset, bytes.length - offset, null);
120
+ if (written <= 0) {
121
+ throw new Error(`${label} made no write progress at byte ${String(offset)}`);
122
+ }
123
+ offset += written;
124
+ }
125
+ }
126
+
127
+ function withRegularFileDescriptorSync(
128
+ path: string,
129
+ flags: number,
130
+ mode: number | undefined,
131
+ label: string,
132
+ operation: (fd: number) => void,
133
+ ): void {
107
134
  let fd: number | undefined;
135
+ let primaryFailure: unknown;
136
+ let closeFailure: unknown;
108
137
  try {
109
- fd = openSync(path, 'a', 0o600);
110
- const written = writeSync(fd, line, undefined, 'utf8');
111
- if (written !== expectedBytes) {
112
- throw new Error(
113
- `short write: wrote ${String(written)} of ${String(expectedBytes)} bytes`,
114
- );
138
+ fd = mode === undefined ? openSync(path, flags) : openSync(path, flags, mode);
139
+ const stats = fstatSync(fd);
140
+ if (!stats.isFile()) throw new Error(`${label} at ${path} is not a regular file`);
141
+ operation(fd);
142
+ } catch (error) {
143
+ primaryFailure = error;
144
+ }
145
+ if (fd !== undefined) {
146
+ try {
147
+ closeSync(fd);
148
+ } catch (error) {
149
+ closeFailure = error;
115
150
  }
151
+ }
152
+ if (primaryFailure !== undefined && closeFailure !== undefined) {
153
+ throw new AggregateError(
154
+ [primaryFailure, closeFailure],
155
+ `${label} operation and descriptor close both failed`,
156
+ );
157
+ }
158
+ if (primaryFailure !== undefined) throw throwableError(primaryFailure);
159
+ if (closeFailure !== undefined) throw throwableError(closeFailure);
160
+ }
161
+
162
+ function fsyncParentDirectorySync(path: string): void {
163
+ if (process.platform === 'win32') return;
164
+ const parent = dirname(path);
165
+ let fd: number | undefined;
166
+ let primaryFailure: unknown;
167
+ let closeFailure: unknown;
168
+ try {
169
+ fd = openSync(parent, constants.O_RDONLY | FUSION_CHILD_O_NOFOLLOW);
170
+ const stats = fstatSync(fd);
171
+ if (!stats.isDirectory())
172
+ throw new Error(`fusion audit parent at ${parent} is not a directory`);
116
173
  fsyncSync(fd);
117
- } finally {
118
- if (fd !== undefined) closeSync(fd);
174
+ } catch (error) {
175
+ primaryFailure = error;
176
+ }
177
+ if (fd !== undefined) {
178
+ try {
179
+ closeSync(fd);
180
+ } catch (error) {
181
+ closeFailure = error;
182
+ }
183
+ }
184
+ if (primaryFailure !== undefined && closeFailure !== undefined) {
185
+ throw new AggregateError(
186
+ [primaryFailure, closeFailure],
187
+ 'fusion audit directory sync and descriptor close both failed',
188
+ );
119
189
  }
190
+ if (primaryFailure !== undefined) throw throwableError(primaryFailure);
191
+ if (closeFailure !== undefined) throw throwableError(closeFailure);
120
192
  }
121
193
 
122
- export function buildFusionChildResultMetadata(message: {
123
- provider: string;
124
- model: string;
125
- stopReason: string;
126
- content: ReadonlyArray<{ type: string; text?: string }>;
127
- usage: Usage;
128
- }): FusionChildResultMetadata {
129
- const textBlocks = message.content.flatMap((part) =>
130
- part.type === 'text' && typeof part.text === 'string' ? [part.text] : [],
194
+ function createToolCallLog(path: string): void {
195
+ withRegularFileDescriptorSync(
196
+ path,
197
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | FUSION_CHILD_O_NOFOLLOW,
198
+ 0o600,
199
+ 'fusion tool-call log',
200
+ (fd) => {
201
+ fsyncSync(fd);
202
+ },
131
203
  );
132
- const usage: FusionChildResultUsageMetadata = {
133
- input: message.usage.input,
134
- output: message.usage.output,
135
- cacheRead: message.usage.cacheRead,
136
- cacheWrite: message.usage.cacheWrite,
137
- totalTokens: message.usage.totalTokens,
138
- cost: {
139
- input: message.usage.cost.input,
140
- output: message.usage.cost.output,
141
- cacheRead: message.usage.cost.cacheRead,
142
- cacheWrite: message.usage.cost.cacheWrite,
143
- total: message.usage.cost.total,
204
+ fsyncParentDirectorySync(path);
205
+ }
206
+
207
+ function appendToolCallLogLine(path: string, record: FusionToolCallLogRecord): void {
208
+ // The log is an audit trail, not a payload copy: raw tool arguments/results may
209
+ // contain secrets, so only byte counts and SHA-256 digests are persisted.
210
+ const bytes = Buffer.from(`${JSON.stringify(record)}\n`, 'utf8');
211
+ withRegularFileDescriptorSync(
212
+ path,
213
+ constants.O_WRONLY | constants.O_APPEND | FUSION_CHILD_O_NOFOLLOW,
214
+ undefined,
215
+ 'fusion tool-call log',
216
+ (fd) => {
217
+ writeAllSync(fd, bytes, 'fusion tool-call log append');
218
+ fsyncSync(fd);
144
219
  },
145
- };
146
- return {
147
- schema_version: FUSION_CHILD_RESULT_SCHEMA_VERSION,
148
- provider: message.provider,
149
- model: message.model,
150
- stop_reason: message.stopReason,
151
- text_blocks: textBlocks.map((text) => ({
152
- utf8_bytes: Buffer.byteLength(text, 'utf8'),
153
- sha256: sha256(text),
154
- })),
155
- text_sha256: sha256(textBlocks.join('')),
156
- usage,
157
- };
220
+ );
221
+ }
222
+
223
+ function writeToolCallLogSeal(
224
+ path: string,
225
+ recordCount: number,
226
+ totalResultBytes: number,
227
+ complete: boolean,
228
+ ): void {
229
+ const logBytes = readRegularFileNoSymlinkSync(path, 'fusion tool-call log');
230
+ const seal = {
231
+ schema_version: FUSION_TOOL_CALL_SEAL_SCHEMA_VERSION,
232
+ status: complete ? 'complete' : 'failed',
233
+ record_count: recordCount,
234
+ total_result_bytes: totalResultBytes,
235
+ log_sha256: sha256(logBytes),
236
+ } as const;
237
+ const bytes = Buffer.from(`${JSON.stringify(seal)}\n`, 'utf8');
238
+ const sealPath = `${path}${FUSION_TOOL_CALL_SEAL_SUFFIX}`;
239
+ withRegularFileDescriptorSync(
240
+ sealPath,
241
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | FUSION_CHILD_O_NOFOLLOW,
242
+ 0o600,
243
+ 'fusion tool-call audit completion seal',
244
+ (fd) => {
245
+ writeAllSync(fd, bytes, 'fusion tool-call audit completion seal');
246
+ fsyncSync(fd);
247
+ },
248
+ );
249
+ fsyncParentDirectorySync(sealPath);
250
+ }
251
+
252
+ function latchAuditProcessFailure(): void {
253
+ if (process.exitCode === undefined || process.exitCode === 0) process.exitCode = 1;
158
254
  }
159
255
 
160
256
  async function writeMetadata(record: FusionChildResultMetadata): Promise<void> {
@@ -174,7 +270,9 @@ function strictFusionWebFetchArgs(args: unknown): FusionWebFetchParamsValue {
174
270
  const keys = Object.keys(args);
175
271
  const unknownKeys = keys.filter((key) => key !== 'url' && key !== 'extract');
176
272
  if (unknownKeys.length > 0 || !keys.includes('url')) {
177
- throw new Error(`${FUSION_WEB_FETCH_TOOL_NAME} arguments must contain url and optional extract only`);
273
+ throw new Error(
274
+ `${FUSION_WEB_FETCH_TOOL_NAME} arguments must contain url and optional extract only`,
275
+ );
178
276
  }
179
277
  const url = Reflect.get(args, 'url');
180
278
  if (typeof url !== 'string' || url.trim().length === 0) {
@@ -198,8 +296,13 @@ function stringField(value: object, key: string): string | undefined {
198
296
  return typeof field === 'string' && field.length > 0 ? field : undefined;
199
297
  }
200
298
 
201
- function fetchAuditMetadataFromObject(value: object, fallbackUrl: string): FusionWebFetchAuditMetadata {
202
- const metadata: FusionWebFetchAuditMetadata = { url: stringField(value, 'url') ?? fallbackUrl };
299
+ function fetchAuditMetadataFromObject(
300
+ value: object,
301
+ fallbackUrl: string,
302
+ ): FusionWebFetchAuditMetadata {
303
+ const metadata: FusionWebFetchAuditMetadata = {
304
+ url: stringField(value, 'url') ?? canonicalizeFusionPublicUrl(fallbackUrl),
305
+ };
203
306
  const finalUrl = stringField(value, 'final_url');
204
307
  if (finalUrl !== undefined) metadata.final_url = finalUrl;
205
308
  const status = numberField(value, 'status');
@@ -211,15 +314,54 @@ function fetchAuditMetadataFromObject(value: object, fallbackUrl: string): Fusio
211
314
  return metadata;
212
315
  }
213
316
 
214
- function fetchAuditMetadataFromError(error: unknown, fallbackUrl: string): FusionWebFetchAuditMetadata {
215
- if (!(error instanceof FusionWebFetchError) || typeof error !== 'object' || error === null) {
216
- return { url: fallbackUrl };
317
+ function fetchAuditMetadataFromError(
318
+ error: unknown,
319
+ attemptedUrl: string,
320
+ ): FusionWebFetchAuditMetadata {
321
+ const metadata: FusionWebFetchAuditMetadata = { rejected_url_sha256: sha256(attemptedUrl) };
322
+ if (error instanceof FusionWebFetchError && typeof error === 'object' && error !== null) {
323
+ const status = numberField(error, 'status');
324
+ if (status !== undefined) metadata.http_status = status;
325
+ }
326
+ return metadata;
327
+ }
328
+
329
+ function readRegularFileNoSymlinkSync(path: string, label: string): Buffer {
330
+ let fd: number | undefined;
331
+ try {
332
+ fd = openSync(path, constants.O_RDONLY | FUSION_CHILD_O_NOFOLLOW);
333
+ } catch (error) {
334
+ if (typeof error === 'object' && error !== null && Reflect.get(error, 'code') === 'ELOOP') {
335
+ throw new Error(`${label} at ${path} is a symlink; refusing to follow it`);
336
+ }
337
+ throw error;
338
+ }
339
+ try {
340
+ const stats = fstatSync(fd);
341
+ if (!stats.isFile()) throw new Error(`${label} at ${path} is not a regular file`);
342
+ return readFileSync(fd);
343
+ } finally {
344
+ if (fd !== undefined) closeSync(fd);
217
345
  }
218
- const result = Reflect.get(error, 'result');
219
- if (typeof result === 'object' && result !== null) {
220
- return fetchAuditMetadataFromObject(result, fallbackUrl);
346
+ }
347
+
348
+ function loadDeclaredResearchUrls(): ReadonlySet<string> {
349
+ const policyPath = process.env[FUSION_SOURCE_POLICY_PATH_ENV];
350
+ const expectedHash = process.env[FUSION_SOURCE_POLICY_SHA256_ENV];
351
+ if (policyPath === undefined || expectedHash === undefined) {
352
+ throw new Error(
353
+ `${FUSION_WEB_FETCH_TOOL_NAME} research mode requires source policy path and sha256`,
354
+ );
221
355
  }
222
- return fetchAuditMetadataFromObject(error, fallbackUrl);
356
+ if (!/^[0-9a-f]{64}$/.test(expectedHash))
357
+ throw new Error('fusion source policy hash is malformed');
358
+ const bytes = readRegularFileNoSymlinkSync(policyPath, 'fusion source policy');
359
+ if (sha256(bytes) !== expectedHash) throw new Error('fusion source policy hash mismatch');
360
+ const text = bytes.toString('utf8');
361
+ if (!Buffer.from(text, 'utf8').equals(bytes))
362
+ throw new Error('fusion source policy is not UTF-8');
363
+ const parsed = parseFusionSourcePolicy(JSON.parse(text));
364
+ return new Set(parsed.sources.map((source) => source.canonical_url));
223
365
  }
224
366
 
225
367
  function fusionWebFetchResultText(result: Awaited<ReturnType<typeof fusionWebFetch>>): string {
@@ -253,65 +395,146 @@ export default function fusionChildExtension(pi: ExtensionAPI): void {
253
395
  throw new Error(`${FUSION_RESEARCH_ENABLED_ENV} must be unset or exactly 1`);
254
396
  }
255
397
  if (researchEnabled === '1' && toolCallLogPath === undefined) {
256
- throw new Error(`${FUSION_WEB_FETCH_TOOL_NAME} research mode requires ${FUSION_TOOL_CALL_LOG_PATH_ENV}`);
398
+ throw new Error(
399
+ `${FUSION_WEB_FETCH_TOOL_NAME} research mode requires ${FUSION_TOOL_CALL_LOG_PATH_ENV}`,
400
+ );
257
401
  }
402
+ const declaredResearchUrls = researchEnabled === '1' ? loadDeclaredResearchUrls() : undefined;
258
403
  const fetchAuditMetadata = new Map<string, FusionWebFetchAuditMetadata>();
259
404
  if (toolCallLogPath !== undefined) {
260
- // Create the log immediately, before tools can run. Without this, an absent file
261
- // is ambiguous: it could mean "this child made zero tool calls" or "the audit trail
262
- // was never written". The parent must be able to tell those apart, so existence is
263
- // established up front and a missing file is a hard failure rather than an empty trace.
264
- closeSync(openSync(toolCallLogPath, 'a', 0o600));
405
+ // Establish the audit file before tools can run. Exclusive creation makes a reused
406
+ // attempt path or redirected file loud instead of appending to untrusted history.
407
+ try {
408
+ createToolCallLog(toolCallLogPath);
409
+ } catch (error) {
410
+ latchAuditProcessFailure();
411
+ throw error;
412
+ }
413
+
414
+ type AuditPhase = 'open' | 'finalizing' | 'sealed-complete' | 'sealed-failed';
415
+ interface ToolStart {
416
+ startedAt: number;
417
+ toolName: string;
418
+ }
419
+
420
+ let phase: AuditPhase = 'open';
265
421
  let ordinal = 0;
266
422
  let totalToolResultBytes = 0;
267
- const starts = new Map<string, number>();
423
+ let auditFailed = false;
424
+ const starts = new Map<string, ToolStart>();
425
+
426
+ const failAudit = (error: unknown): never => {
427
+ auditFailed = true;
428
+ latchAuditProcessFailure();
429
+ throw error instanceof Error ? error : new Error(String(error));
430
+ };
431
+ const requireOpen = (eventName: string): void => {
432
+ if (phase !== 'open') {
433
+ failAudit(`fusion tool-call audit received ${eventName} while ${phase}`);
434
+ }
435
+ };
436
+ const finalizeAudit = (normalSettlement: boolean, trigger: string): void => {
437
+ if (phase !== 'open') {
438
+ failAudit(
439
+ `fusion tool-call audit received duplicate finalization from ${trigger} while ${phase}`,
440
+ );
441
+ }
442
+ phase = 'finalizing';
443
+ const unmatchedStarts = starts.size;
444
+ const complete = normalSettlement && !auditFailed && unmatchedStarts === 0;
445
+ if (!complete) {
446
+ auditFailed = true;
447
+ latchAuditProcessFailure();
448
+ }
449
+ try {
450
+ writeToolCallLogSeal(toolCallLogPath, ordinal, totalToolResultBytes, complete);
451
+ } catch (error) {
452
+ phase = 'sealed-failed';
453
+ failAudit(error);
454
+ }
455
+ phase = complete ? 'sealed-complete' : 'sealed-failed';
456
+ if (!complete) {
457
+ failAudit(
458
+ `fusion tool-call audit finalized as failed from ${trigger}: ${String(unmatchedStarts)} unmatched tool start(s)`,
459
+ );
460
+ }
461
+ };
462
+
268
463
  pi.on('tool_call', (event) => {
269
- starts.set(event.toolCallId, Date.now());
464
+ try {
465
+ requireOpen('tool_call');
466
+ if (starts.has(event.toolCallId)) {
467
+ throw new Error(`fusion tool-call log duplicate start for ${event.toolCallId}`);
468
+ }
469
+ starts.set(event.toolCallId, {
470
+ startedAt: Date.now(),
471
+ toolName: event.toolName,
472
+ });
473
+ } catch (error) {
474
+ failAudit(error);
475
+ }
270
476
  });
271
477
  pi.on('tool_result', (event) => {
272
- const start = starts.get(event.toolCallId);
273
- if (start === undefined) {
274
- throw new Error(`fusion tool-call log missing start for ${event.toolCallId}`);
275
- }
276
- starts.delete(event.toolCallId);
277
- const argumentsBytes = utf8JsonBytes(event.input, 'arguments');
278
- const resultBytes = utf8JsonBytes(
279
- {
280
- content: event.content,
281
- details: event.details,
282
- isError: event.isError,
283
- usage: event.usage,
284
- },
285
- 'result',
286
- );
287
- const fetchMetadata = fetchAuditMetadata.get(event.toolCallId);
288
- fetchAuditMetadata.delete(event.toolCallId);
289
- const record: FusionToolCallLogRecord = {
290
- schema_version: FUSION_TOOL_CALL_LOG_SCHEMA_VERSION,
291
- ordinal,
292
- tool_name: event.toolName,
293
- arguments_sha256: sha256(argumentsBytes),
294
- arguments_bytes: argumentsBytes.length,
295
- result_bytes: resultBytes.length,
296
- result_sha256: sha256(resultBytes),
297
- status: event.isError === true ? 'error' : 'ok',
298
- duration_ms: Math.max(0, Date.now() - start),
299
- ...(fetchMetadata === undefined ? {} : fetchMetadata),
300
- };
301
- ordinal += 1;
302
- appendToolCallLogLine(toolCallLogPath, record);
303
- // Aggregate output ceiling. There is no tool-CALL cap in v1 by design, so bytes are
304
- // the only bound on how much a read-only candidate can pull into its context. The
305
- // record is durable before this check, so the offending call stays auditable; the
306
- // failure is loud rather than a truncation, because a silently shortened tool result
307
- // would corrupt the candidate's reasoning with no signal at all.
308
- totalToolResultBytes += resultBytes.length;
309
- if (totalToolResultBytes > FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES) {
310
- throw new Error(
311
- `fusion candidate exceeded the aggregate tool-output budget: ${String(totalToolResultBytes)} bytes across ${String(ordinal)} calls exceeds ${String(FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES)}`,
478
+ try {
479
+ requireOpen('tool_result');
480
+ const start = starts.get(event.toolCallId);
481
+ if (start === undefined) {
482
+ throw new Error(`fusion tool-call log missing start for ${event.toolCallId}`);
483
+ }
484
+ if (start.toolName !== event.toolName) {
485
+ throw new Error(
486
+ `fusion tool-call log tool mismatch for ${event.toolCallId}: started ${start.toolName}, completed ${event.toolName}`,
487
+ );
488
+ }
489
+ const argumentsBytes = utf8JsonBytes(event.input, 'arguments');
490
+ const resultBytes = utf8JsonBytes(
491
+ {
492
+ content: event.content,
493
+ details: event.details,
494
+ isError: event.isError,
495
+ usage: event.usage,
496
+ },
497
+ 'result',
312
498
  );
499
+ const fetchMetadata = fetchAuditMetadata.get(event.toolCallId);
500
+ const nextTotalToolResultBytes = totalToolResultBytes + resultBytes.length;
501
+ const record: FusionToolCallLogRecord = {
502
+ schema_version: FUSION_TOOL_CALL_LOG_SCHEMA_VERSION,
503
+ ordinal,
504
+ tool_name: event.toolName,
505
+ arguments_sha256: sha256(argumentsBytes),
506
+ arguments_bytes: argumentsBytes.length,
507
+ result_bytes: resultBytes.length,
508
+ result_sha256: sha256(resultBytes),
509
+ status: event.isError === true ? 'error' : 'ok',
510
+ duration_ms: Math.max(0, Date.now() - start.startedAt),
511
+ ...(fetchMetadata === undefined ? {} : fetchMetadata),
512
+ };
513
+ appendToolCallLogLine(toolCallLogPath, record);
514
+ starts.delete(event.toolCallId);
515
+ fetchAuditMetadata.delete(event.toolCallId);
516
+ ordinal += 1;
517
+ totalToolResultBytes = nextTotalToolResultBytes;
518
+ if (totalToolResultBytes > FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES) {
519
+ throw new Error(
520
+ `fusion candidate exceeded the aggregate tool-output budget: ${String(totalToolResultBytes)} bytes across ${String(ordinal)} calls exceeds ${String(FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES)}`,
521
+ );
522
+ }
523
+ } catch (error) {
524
+ failAudit(error);
313
525
  }
314
526
  });
527
+ // agent_end is only the end of one low-level run. Pi may still retry, compact and
528
+ // retry, or consume queued continuations. Sealing there created stale prefix seals.
529
+ pi.on('agent_settled', (_event, ctx) => {
530
+ if (!ctx.isIdle()) {
531
+ failAudit('fusion child emitted agent_settled while the agent was not idle');
532
+ }
533
+ finalizeAudit(true, 'agent_settled');
534
+ });
535
+ pi.on('session_shutdown', () => {
536
+ if (phase === 'open') finalizeAudit(false, 'session_shutdown before agent_settled');
537
+ });
315
538
  }
316
539
 
317
540
  if (researchEnabled === '1') {
@@ -332,10 +555,21 @@ export default function fusionChildExtension(pi: ExtensionAPI): void {
332
555
  },
333
556
  async execute(toolCallId, params) {
334
557
  try {
558
+ const canonicalUrl = canonicalizeFusionPublicUrl(params.url);
559
+ if (params.url !== canonicalUrl) {
560
+ throw new Error(
561
+ `${FUSION_WEB_FETCH_TOOL_NAME} URL must exactly match its declared canonical URL`,
562
+ );
563
+ }
564
+ if (declaredResearchUrls === undefined || !declaredResearchUrls.has(canonicalUrl)) {
565
+ throw new Error(
566
+ `${FUSION_WEB_FETCH_TOOL_NAME} URL was not declared in the research source policy`,
567
+ );
568
+ }
335
569
  const result = await fusionWebFetch(
336
570
  params.extract === undefined
337
- ? { url: params.url }
338
- : { url: params.url, extract: params.extract },
571
+ ? { url: canonicalUrl }
572
+ : { url: canonicalUrl, extract: params.extract },
339
573
  );
340
574
  fetchAuditMetadata.set(toolCallId, fetchAuditMetadataFromObject(result, params.url));
341
575
  return {