pi-background-tasks 0.7.7 → 1.0.3

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 +234 -385
  4. package/TESTING.md +15 -9
  5. package/TEST_PLAN.md +46 -13
  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 +121 -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 +29 -6
  53. package/src/core/delegate/budget.ts +1 -1
  54. package/src/core/delegate/launch.ts +6 -0
  55. package/src/core/fusion/artifacts.ts +80 -5
  56. package/src/core/fusion/budget.ts +129 -28
  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 +33 -6
  61. package/src/core/fusion/evaluation.ts +392 -15
  62. package/src/core/fusion/orchestrator.ts +274 -25
  63. package/src/core/fusion/pi-child.ts +635 -10
  64. package/src/core/fusion/prompts.ts +167 -6
  65. package/src/core/fusion/source-policy.ts +257 -0
  66. package/src/core/fusion/types.ts +232 -5
  67. package/src/core/fusion/web-fetch.ts +993 -0
  68. package/src/core/fusion/workflows.ts +184 -0
  69. package/src/extension.ts +3 -3
  70. package/src/fusion-child-extension.ts +370 -54
  71. package/src/fusion-extension.ts +625 -125
  72. package/src/testing/normalize.ts +0 -22
@@ -1,25 +1,44 @@
1
1
  import { spawn as nodeSpawn, type SpawnOptions } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { existsSync } from 'node:fs';
3
+ import { constants, existsSync, readFileSync } from 'node:fs';
4
+ import { open } from 'node:fs/promises';
5
+ import { createRequire } from 'node:module';
4
6
  import { dirname, resolve } from 'node:path';
5
7
  import { fileURLToPath } from 'node:url';
6
8
  import {
7
9
  FUSION_CHILD_RESULT_PREFIX,
8
10
  FUSION_CHILD_RESULT_SCHEMA_VERSION,
11
+ FUSION_RESEARCH_ENABLED_ENV,
12
+ FUSION_SOURCE_POLICY_PATH_ENV,
13
+ FUSION_SOURCE_POLICY_SHA256_ENV,
14
+ FUSION_TOOL_CALL_LOG_PATH_ENV,
15
+ FUSION_TOOL_CALL_SEAL_SCHEMA_VERSION,
16
+ FUSION_TOOL_CALL_SEAL_SUFFIX,
17
+ FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES,
9
18
  type FusionChildResultMetadata,
10
- } from '../../fusion-child-extension.js';
19
+ } from './child-protocol.js';
11
20
  import {
21
+ FUSION_FORBIDDEN_TOOLS,
22
+ FUSION_NO_TOOLS_CAPABILITY,
23
+ FUSION_INSPECT_TOOLS,
24
+ FUSION_RESEARCH_TOOLS,
25
+ FUSION_TOOL_CALL_LOG_SCHEMA_VERSION,
26
+ FUSION_WEB_FETCH_TOOL_NAME,
12
27
  FusionError,
13
28
  addFusionUsage,
14
29
  cloneFusionUsage,
15
30
  createEmptyFusionUsage,
31
+ type FusionCapability,
16
32
  type FusionChildRunResult,
17
33
  type FusionErrorDetails,
18
34
  type FusionStage,
35
+ type FusionToolCallLogRecord,
36
+ type FusionToolCallTrace,
19
37
  type FusionUsage,
20
38
  type ResolvedFusionModel,
21
39
  } from './types.js';
22
40
  import { isJsonObject, parseJsonText } from '../common.js';
41
+ import { canonicalizeFusionPublicUrl, readFusionSourcePolicyFile } from './source-policy.js';
23
42
  import {
24
43
  assertWindowsCommandLineWithinLimit,
25
44
  piLaunchArgv,
@@ -31,8 +50,21 @@ import {
31
50
  export const FUSION_CHILD_STDOUT_LIMIT_BYTES = 32 * 1024 * 1024;
32
51
  export const FUSION_CHILD_STDERR_LIMIT_BYTES = 4 * 1024 * 1024;
33
52
  export const FUSION_CHILD_TIMEOUT_MS = 30 * 60 * 1000;
53
+ /**
54
+ * Stale-action watchdog threshold.
55
+ *
56
+ * Activity is one stdout or stderr byte from the child. The child extension emits its
57
+ * compact metadata frame only at `message_end`, and Pi text mode writes stdout only for
58
+ * the final assistant message, so a single slow model turn is genuinely silent on both
59
+ * streams. The threshold must therefore exceed the longest plausible single turn, not the
60
+ * longest plausible tool call: a value tuned to tool latency would kill healthy children
61
+ * mid-reasoning. 1200s stays inside the 30-minute absolute cap while leaving a wide
62
+ * margin over observed turn latency.
63
+ */
64
+ export const FUSION_CHILD_IDLE_TIMEOUT_MS = 20 * 60 * 1000;
34
65
  export const FUSION_CHILD_KILL_GRACE_MS = 3000;
35
66
  export const FUSION_CHILD_SIGKILL_WAIT_MS = 5000;
67
+ const FUSION_PI_CHILD_O_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
36
68
 
37
69
  export const FUSION_CHILD_REMOVED_ENV_KEYS = [
38
70
  'PI_SESSION_ID',
@@ -40,6 +72,27 @@ export const FUSION_CHILD_REMOVED_ENV_KEYS = [
40
72
  'PI_PROVIDER',
41
73
  'PI_MODEL',
42
74
  'PI_REASONING_LEVEL',
75
+ 'OPENROUTER_API_KEY',
76
+ 'OPENROUTER_BASE_URL',
77
+ 'OPENAI_API_KEY',
78
+ 'OPENAI_BASE_URL',
79
+ 'ANTHROPIC_API_KEY',
80
+ 'ANTHROPIC_AUTH_TOKEN',
81
+ 'ANTHROPIC_BASE_URL',
82
+ 'AZURE_OPENAI_API_KEY',
83
+ 'AZURE_OPENAI_BASE_URL',
84
+ 'AZURE_OPENAI_ENDPOINT',
85
+ 'AZURE_OPENAI_RESOURCE_NAME',
86
+ 'AZURE_OPENAI_API_VERSION',
87
+ 'AZURE_OPENAI_DEPLOYMENT_NAME_MAP',
88
+ 'AZURE_OPENAI_AD_TOKEN',
89
+ 'PI_API_KEY',
90
+ 'PI_API_BASE_URL',
91
+ 'PI_AUTH_FILE',
92
+ FUSION_TOOL_CALL_LOG_PATH_ENV,
93
+ FUSION_RESEARCH_ENABLED_ENV,
94
+ FUSION_SOURCE_POLICY_PATH_ENV,
95
+ FUSION_SOURCE_POLICY_SHA256_ENV,
43
96
  ] as const;
44
97
 
45
98
  interface FusionReadableStream {
@@ -86,6 +139,7 @@ export interface RunPiChildOptions {
86
139
  attempt: number;
87
140
  cwd: string;
88
141
  model: ResolvedFusionModel;
142
+ capability?: FusionCapability | undefined;
89
143
  systemPrompt: string;
90
144
  userPrompt: string;
91
145
  signal?: AbortSignal | undefined;
@@ -97,9 +151,12 @@ export interface RunPiChildOptions {
97
151
  childExtensionPath?: string | undefined;
98
152
  stderrLimitBytes?: number | undefined;
99
153
  timeoutMs?: number | undefined;
154
+ idleTimeoutMs?: number | undefined;
100
155
  killGraceMs?: number | undefined;
101
156
  sigkillWaitMs?: number | undefined;
102
157
  piLaunchDependencies?: PiLaunchDependencies | undefined;
158
+ toolCallLogPath?: string | undefined;
159
+ sourcePolicy?: { path: string; sha256: string } | undefined;
103
160
  }
104
161
 
105
162
  interface CloseRecord {
@@ -114,6 +171,7 @@ interface ProcessState {
114
171
  termTimer: NodeJS.Timeout | undefined;
115
172
  waitTimer: NodeJS.Timeout | undefined;
116
173
  timeoutTimer: NodeJS.Timeout | undefined;
174
+ idleTimer: NodeJS.Timeout | undefined;
117
175
  settled: boolean;
118
176
  }
119
177
 
@@ -168,7 +226,10 @@ export class FusionChildRunError extends FusionError {
168
226
 
169
227
  export function fusionPiChildEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
170
228
  const out: NodeJS.ProcessEnv = { ...env };
171
- for (const key of FUSION_CHILD_REMOVED_ENV_KEYS) Reflect.deleteProperty(out, key);
229
+ const removed = new Set<string>(FUSION_CHILD_REMOVED_ENV_KEYS);
230
+ for (const inheritedKey of Object.keys(out)) {
231
+ if (removed.has(inheritedKey.toUpperCase())) Reflect.deleteProperty(out, inheritedKey);
232
+ }
172
233
  out['PI_SKIP_VERSION_CHECK'] = '1';
173
234
  return out;
174
235
  }
@@ -186,23 +247,198 @@ export function resolveFusionChildExtensionPath(
186
247
  return candidate;
187
248
  }
188
249
 
250
+ /**
251
+ * Provider whose children require the Anthropic system-prompt sanitizer.
252
+ *
253
+ * Pi's own system prompt contains documentation lines that Anthropic rejects, so a
254
+ * Claude child launched without the sanitizer fails at the provider rather than
255
+ * producing an answer. The parent session loads the sanitizer through ordinary
256
+ * extension discovery, but Fusion children run with `--no-extensions` for
257
+ * isolation and therefore inherit nothing; the sanitizer must be re-supplied
258
+ * explicitly per child.
259
+ */
260
+ export const FUSION_SANITIZED_PROVIDER = 'anthropic';
261
+ export const FUSION_ANTHROPIC_SANITIZER_PACKAGE = '@ravshansbox/pi-anthropic-sps';
262
+ const FUSION_ANTHROPIC_SANITIZER_MANIFEST = `${FUSION_ANTHROPIC_SANITIZER_PACKAGE}/package.json`;
263
+
264
+ export interface FusionSanitizerDependencies {
265
+ resolvePackageJson?: ((specifier: string) => string) | undefined;
266
+ readManifest?: ((path: string) => string) | undefined;
267
+ pathExists?: ((path: string) => boolean) | undefined;
268
+ }
269
+
270
+ function manifestExtensionEntry(manifestText: string, manifestPath: string): string {
271
+ let parsed: unknown;
272
+ try {
273
+ parsed = parseJsonText(manifestText);
274
+ } catch (error) {
275
+ throw new FusionError(
276
+ `Anthropic sanitizer manifest ${manifestPath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
277
+ { code: 'orchestration_failed', childCreated: false },
278
+ );
279
+ }
280
+ if (!isJsonObject(parsed)) {
281
+ throw new FusionError(`Anthropic sanitizer manifest ${manifestPath} must be an object`, {
282
+ code: 'orchestration_failed',
283
+ childCreated: false,
284
+ });
285
+ }
286
+ const pi = parsed['pi'];
287
+ if (!isJsonObject(pi)) {
288
+ throw new FusionError(
289
+ `Anthropic sanitizer manifest ${manifestPath} has no "pi" section declaring its extension`,
290
+ { code: 'orchestration_failed', childCreated: false },
291
+ );
292
+ }
293
+ const extensions = pi['extensions'];
294
+ if (!Array.isArray(extensions) || extensions.length === 0) {
295
+ throw new FusionError(
296
+ `Anthropic sanitizer manifest ${manifestPath} declares no pi.extensions entries`,
297
+ { code: 'orchestration_failed', childCreated: false },
298
+ );
299
+ }
300
+ const [entry] = extensions;
301
+ if (typeof entry !== 'string' || entry.trim().length === 0) {
302
+ throw new FusionError(
303
+ `Anthropic sanitizer manifest ${manifestPath} pi.extensions[0] must be a non-blank string`,
304
+ { code: 'orchestration_failed', childCreated: false },
305
+ );
306
+ }
307
+ return entry;
308
+ }
309
+
310
+ /**
311
+ * Resolve the sanitizer extension file shipped by the sanitizer package.
312
+ *
313
+ * The package intentionally publishes no `main`/`exports`, so the entry cannot be
314
+ * required directly; its manifest is resolved and the declared `pi.extensions[0]`
315
+ * path is joined against the package root. Every failure is loud: a Claude child
316
+ * launched without the sanitizer would fail at the provider with a far less
317
+ * actionable error, so silently omitting it is never correct.
318
+ */
319
+ export function resolveAnthropicSanitizerExtensionPath(
320
+ dependencies: FusionSanitizerDependencies = {},
321
+ ): string {
322
+ const resolvePackageJson =
323
+ dependencies.resolvePackageJson ?? createRequire(import.meta.url).resolve;
324
+ const readManifest = dependencies.readManifest ?? ((path: string) => readFileSync(path, 'utf8'));
325
+ const pathExists = dependencies.pathExists ?? existsSync;
326
+ let manifestPath: string;
327
+ try {
328
+ manifestPath = resolvePackageJson(FUSION_ANTHROPIC_SANITIZER_MANIFEST);
329
+ } catch (error) {
330
+ throw new FusionError(
331
+ `Anthropic sanitizer package ${FUSION_ANTHROPIC_SANITIZER_PACKAGE} could not be resolved: ${error instanceof Error ? error.message : String(error)}. Claude children cannot be launched without it.`,
332
+ { code: 'orchestration_failed', childCreated: false },
333
+ );
334
+ }
335
+ let manifestText: string;
336
+ try {
337
+ manifestText = readManifest(manifestPath);
338
+ } catch (error) {
339
+ throw new FusionError(
340
+ `Anthropic sanitizer manifest ${manifestPath} could not be read: ${error instanceof Error ? error.message : String(error)}`,
341
+ { code: 'orchestration_failed', childCreated: false },
342
+ );
343
+ }
344
+ const entry = manifestExtensionEntry(manifestText, manifestPath);
345
+ const extensionPath = resolve(dirname(manifestPath), entry);
346
+ if (!pathExists(extensionPath)) {
347
+ throw new FusionError(
348
+ `Anthropic sanitizer extension is missing: ${extensionPath} (declared by ${manifestPath})`,
349
+ { code: 'orchestration_failed', childCreated: false },
350
+ );
351
+ }
352
+ return extensionPath;
353
+ }
354
+
355
+ export function assertFusionToolPolicyDisjoint(
356
+ allowlist: readonly string[] = FUSION_INSPECT_TOOLS,
357
+ denylist: readonly string[] = FUSION_FORBIDDEN_TOOLS,
358
+ ): void {
359
+ for (const forbidden of denylist) {
360
+ if (allowlist.includes(forbidden)) {
361
+ throw new FusionError(
362
+ `fusion inspect capability would enable the forbidden tool ${forbidden}`,
363
+ { code: 'orchestration_failed', childCreated: false },
364
+ );
365
+ }
366
+ }
367
+ }
368
+
369
+ function researchToolAllowlist(): readonly string[] {
370
+ return FUSION_RESEARCH_TOOLS;
371
+ }
372
+
373
+ function fusionToolArgv(capability: FusionCapability): string[] {
374
+ if (capability === 'reason') return ['--no-tools'];
375
+ if (capability === 'inspect') {
376
+ assertFusionToolPolicyDisjoint(FUSION_INSPECT_TOOLS);
377
+ return [
378
+ '--no-builtin-tools',
379
+ '--tools',
380
+ FUSION_INSPECT_TOOLS.join(','),
381
+ '--exclude-tools',
382
+ FUSION_FORBIDDEN_TOOLS.join(','),
383
+ ];
384
+ }
385
+ if (capability === 'research') {
386
+ const allowlist = researchToolAllowlist();
387
+ assertFusionToolPolicyDisjoint(allowlist);
388
+ return [
389
+ '--no-builtin-tools',
390
+ '--tools',
391
+ allowlist.join(','),
392
+ '--exclude-tools',
393
+ FUSION_FORBIDDEN_TOOLS.join(','),
394
+ ];
395
+ }
396
+ throw new FusionError(`fusion capability ${String(capability)} is not supported`, {
397
+ code: 'orchestration_failed',
398
+ childCreated: false,
399
+ });
400
+ }
401
+
402
+ /**
403
+ * Extensions explicitly loaded into a child, in deterministic order.
404
+ *
405
+ * `--no-extensions` disables discovery but still honours explicit `--extension`
406
+ * paths, so this list is the complete set a child receives. The metadata
407
+ * extension is always present; the Anthropic sanitizer is appended only for
408
+ * Claude routes, keeping non-Anthropic child argv byte-identical to before.
409
+ */
410
+ export function fusionChildExtensionPaths(
411
+ model: ResolvedFusionModel,
412
+ childExtensionPath: string,
413
+ resolveSanitizer: () => string = resolveAnthropicSanitizerExtensionPath,
414
+ ): readonly string[] {
415
+ if (model.provider !== FUSION_SANITIZED_PROVIDER) return [childExtensionPath];
416
+ return [childExtensionPath, resolveSanitizer()];
417
+ }
418
+
189
419
  export function buildFusionPiChildArgv(
190
420
  model: ResolvedFusionModel,
191
421
  systemPrompt: string,
192
422
  childExtensionPath = resolveFusionChildExtensionPath(),
423
+ capability: FusionCapability = FUSION_NO_TOOLS_CAPABILITY,
424
+ resolveSanitizer: () => string = resolveAnthropicSanitizerExtensionPath,
193
425
  ): string[] {
426
+ const extensionArgs = fusionChildExtensionPaths(
427
+ model,
428
+ childExtensionPath,
429
+ resolveSanitizer,
430
+ ).flatMap((path) => ['--extension', path]);
194
431
  return [
195
432
  '--mode',
196
433
  'text',
197
434
  '--no-session',
198
- '--no-tools',
435
+ ...fusionToolArgv(capability),
199
436
  '--no-extensions',
200
437
  '--no-skills',
201
438
  '--no-prompt-templates',
202
439
  '--no-themes',
203
440
  '--no-context-files',
204
- '--extension',
205
- childExtensionPath,
441
+ ...extensionArgs,
206
442
  '--provider',
207
443
  model.provider,
208
444
  '--model',
@@ -237,6 +473,29 @@ function assertClosedRecord(
237
473
  return value;
238
474
  }
239
475
 
476
+ function assertClosedRecordWithOptional(
477
+ value: unknown,
478
+ requiredKeys: readonly string[],
479
+ optionalKeys: readonly string[],
480
+ label: string,
481
+ ): Record<PropertyKey, unknown> {
482
+ if (!isJsonObject(value) || Array.isArray(value)) throw new Error(`${label} must be an object`);
483
+ const allowed = new Set([...requiredKeys, ...optionalKeys]);
484
+ const actual = Object.keys(value).sort();
485
+ const missing = requiredKeys.filter((key) => !Object.hasOwn(value, key));
486
+ const unknownKeys = actual.filter((key) => !allowed.has(key));
487
+ if (missing.length > 0 || unknownKeys.length > 0) {
488
+ throw new Error(
489
+ `${label} keys mismatch: required ${[...requiredKeys].sort().join(', ')}; optional ${[
490
+ ...optionalKeys,
491
+ ]
492
+ .sort()
493
+ .join(', ')}`,
494
+ );
495
+ }
496
+ return value;
497
+ }
498
+
240
499
  function requireNonBlankString(
241
500
  record: Record<PropertyKey, unknown>,
242
501
  key: string,
@@ -371,6 +630,253 @@ export function parseFusionChildStderr(stderr: Buffer): ParsedFusionChildStderr
371
630
  return { records, events, diagnostics: Buffer.concat(diagnostics) };
372
631
  }
373
632
 
633
+ function parseToolCallLogRecord(value: unknown, label: string): FusionToolCallLogRecord {
634
+ const record = assertClosedRecordWithOptional(
635
+ value,
636
+ [
637
+ 'schema_version',
638
+ 'ordinal',
639
+ 'tool_name',
640
+ 'arguments_sha256',
641
+ 'arguments_bytes',
642
+ 'result_bytes',
643
+ 'result_sha256',
644
+ 'status',
645
+ 'duration_ms',
646
+ ],
647
+ ['url', 'rejected_url_sha256', 'final_url', 'http_status', 'response_bytes', 'content_sha256'],
648
+ label,
649
+ );
650
+ if (record['schema_version'] !== FUSION_TOOL_CALL_LOG_SCHEMA_VERSION) {
651
+ throw new Error(`${label}.schema_version mismatch`);
652
+ }
653
+ const status = record['status'];
654
+ if (status !== 'ok' && status !== 'error') throw new Error(`${label}.status is invalid`);
655
+ const parsedRecord: FusionToolCallLogRecord = {
656
+ schema_version: FUSION_TOOL_CALL_LOG_SCHEMA_VERSION,
657
+ ordinal: requireUsageInteger(record, 'ordinal', label),
658
+ tool_name: requireNonBlankString(record, 'tool_name', label),
659
+ arguments_sha256: requireSha256(record, 'arguments_sha256', label),
660
+ arguments_bytes: requireUsageInteger(record, 'arguments_bytes', label),
661
+ result_bytes: requireUsageInteger(record, 'result_bytes', label),
662
+ result_sha256: requireSha256(record, 'result_sha256', label),
663
+ status,
664
+ duration_ms: requireUsageInteger(record, 'duration_ms', label),
665
+ };
666
+ if (record['url'] !== undefined) parsedRecord.url = requireNonBlankString(record, 'url', label);
667
+ if (record['rejected_url_sha256'] !== undefined)
668
+ parsedRecord.rejected_url_sha256 = requireSha256(record, 'rejected_url_sha256', label);
669
+ if (record['final_url'] !== undefined)
670
+ parsedRecord.final_url = requireNonBlankString(record, 'final_url', label);
671
+ if (record['http_status'] !== undefined)
672
+ parsedRecord.http_status = requireUsageInteger(record, 'http_status', label);
673
+ if (record['response_bytes'] !== undefined)
674
+ parsedRecord.response_bytes = requireUsageInteger(record, 'response_bytes', label);
675
+ if (record['content_sha256'] !== undefined)
676
+ parsedRecord.content_sha256 = requireSha256(record, 'content_sha256', label);
677
+ return parsedRecord;
678
+ }
679
+
680
+ export function parseFusionToolCallLog(bytes: Buffer): FusionToolCallTrace {
681
+ if (bytes.length === 0) {
682
+ return {
683
+ bytes,
684
+ records: [],
685
+ summary: { count: 0, total_result_bytes: 0, trace_complete: true },
686
+ };
687
+ }
688
+ if (bytes.at(-1) !== 10) {
689
+ throw new Error('fusion tool-call log has trailing partial line');
690
+ }
691
+ const text = bytes.toString('utf8');
692
+ if (!Buffer.from(text, 'utf8').equals(bytes)) {
693
+ throw new Error('fusion tool-call log is not valid UTF-8');
694
+ }
695
+ const lines = text.split('\n');
696
+ lines.pop();
697
+ const records = lines.map((line, index) => {
698
+ let parsed: unknown;
699
+ try {
700
+ parsed = parseJsonText(line);
701
+ } catch (error) {
702
+ throw new Error(
703
+ `fusion tool-call log line ${String(index)} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
704
+ );
705
+ }
706
+ return parseToolCallLogRecord(parsed, `fusion tool-call log line ${String(index)}`);
707
+ });
708
+ const seen = new Set<number>();
709
+ for (const [index, record] of records.entries()) {
710
+ if (seen.has(record.ordinal)) {
711
+ throw new Error(`fusion tool-call log duplicate ordinal ${String(record.ordinal)}`);
712
+ }
713
+ seen.add(record.ordinal);
714
+ if (record.ordinal !== index) {
715
+ throw new Error(
716
+ `fusion tool-call log ordinal gap: expected ${String(index)}, observed ${String(record.ordinal)}`,
717
+ );
718
+ }
719
+ }
720
+ return {
721
+ bytes,
722
+ records,
723
+ summary: {
724
+ count: records.length,
725
+ total_result_bytes: records.reduce((sum, record) => sum + record.result_bytes, 0),
726
+ trace_complete: true,
727
+ },
728
+ };
729
+ }
730
+
731
+
732
+ async function assertCompletedToolPolicy(
733
+ trace: FusionToolCallTrace,
734
+ capability: FusionCapability,
735
+ sourcePolicy: { path: string; sha256: string } | undefined,
736
+ ): Promise<void> {
737
+ const allowed = capability === 'inspect' ? FUSION_INSPECT_TOOLS : capability === 'research' ? FUSION_RESEARCH_TOOLS : [];
738
+ const allowedSet = new Set<string>(allowed);
739
+ const declared =
740
+ capability === 'research' && sourcePolicy !== undefined
741
+ ? new Set((await readFusionSourcePolicyFile(sourcePolicy.path, sourcePolicy.sha256)).sources.map((source) => source.canonical_url))
742
+ : undefined;
743
+ for (const record of trace.records) {
744
+ if (!allowedSet.has(record.tool_name)) {
745
+ throw new Error(`fusion child used non-allowlisted tool ${record.tool_name}`);
746
+ }
747
+ if (capability === 'research' && record.tool_name === FUSION_WEB_FETCH_TOOL_NAME) {
748
+ if (sourcePolicy === undefined || declared === undefined) throw new Error('fusion research source policy missing during audit');
749
+ if (record.status === 'ok') {
750
+ if (record.url === undefined) throw new Error('fusion research fetch audit is missing url');
751
+ const canonicalUrl = canonicalizeFusionPublicUrl(record.url);
752
+ if (record.url !== canonicalUrl) throw new Error('fusion research fetch audit URL was not canonical');
753
+ if (!declared.has(canonicalUrl)) throw new Error('fusion research fetch audit URL was not declared');
754
+ if (record.rejected_url_sha256 !== undefined) {
755
+ throw new Error('fusion research successful fetch audit must not include rejected_url_sha256');
756
+ }
757
+ if (record.final_url === undefined) throw new Error('fusion research fetch audit is missing final_url');
758
+ if (record.http_status === undefined) throw new Error('fusion research fetch audit is missing http_status');
759
+ if (record.response_bytes === undefined) throw new Error('fusion research fetch audit is missing response_bytes');
760
+ if (record.content_sha256 === undefined) throw new Error('fusion research fetch audit is missing content_sha256');
761
+ } else {
762
+ if (record.url !== undefined || record.final_url !== undefined) {
763
+ throw new Error('fusion research rejected fetch audit must not persist raw URL');
764
+ }
765
+ if (record.rejected_url_sha256 === undefined) {
766
+ throw new Error('fusion research rejected fetch audit is missing rejected_url_sha256');
767
+ }
768
+ }
769
+ }
770
+ }
771
+ }
772
+
773
+ function isNotFound(error: unknown): boolean {
774
+ return isJsonObject(error) && error['code'] === 'ENOENT';
775
+ }
776
+
777
+ async function readFusionToolCallLog(path: string): Promise<FusionToolCallTrace> {
778
+ let handle: Awaited<ReturnType<typeof open>>;
779
+ try {
780
+ handle = await open(path, constants.O_RDONLY | FUSION_PI_CHILD_O_NOFOLLOW);
781
+ } catch (error) {
782
+ // The child extension creates this file before tools can run, so a missing file
783
+ // means the audit trail was never established - not that zero tools were used. Those
784
+ // must stay distinguishable: silently accepting absence would let a run whose activity
785
+ // was never recorded report success, defeating the purpose of the log.
786
+ if (isNotFound(error)) {
787
+ throw new Error(
788
+ `fusion tool-call log is missing at ${path}; the inspect child never initialized its audit trail`,
789
+ );
790
+ }
791
+ if (isJsonObject(error) && error['code'] === 'ELOOP') {
792
+ throw new Error(
793
+ `fusion tool-call log at ${path} is a symlink; refusing to trust a redirected audit trail`,
794
+ );
795
+ }
796
+ throw error;
797
+ }
798
+ try {
799
+ // The audit trail must be a real file inside the run directory. A symlink here would let
800
+ // anything able to pre-create the path redirect the parent's read elsewhere, so the file
801
+ // is opened with O_NOFOLLOW and then fstat-checked before its bytes are trusted.
802
+ const stats = await handle.stat();
803
+ if (!stats.isFile()) {
804
+ throw new Error(
805
+ `fusion tool-call log at ${path} is not a regular file; refusing to trust a redirected audit trail`,
806
+ );
807
+ }
808
+ return parseFusionToolCallLog(await handle.readFile());
809
+ } finally {
810
+ await handle.close();
811
+ }
812
+ }
813
+
814
+ async function assertFusionToolCallLogSeal(
815
+ path: string,
816
+ trace: FusionToolCallTrace,
817
+ ): Promise<void> {
818
+ const sealPath = `${path}${FUSION_TOOL_CALL_SEAL_SUFFIX}`;
819
+ let handle: Awaited<ReturnType<typeof open>>;
820
+ try {
821
+ handle = await open(sealPath, constants.O_RDONLY | FUSION_PI_CHILD_O_NOFOLLOW);
822
+ } catch (error) {
823
+ if (isNotFound(error)) throw new Error('fusion tool-call audit completion seal is missing');
824
+ if (isJsonObject(error) && error['code'] === 'ELOOP') {
825
+ throw new Error('fusion tool-call audit completion seal is a symlink');
826
+ }
827
+ throw error;
828
+ }
829
+ try {
830
+ const stats = await handle.stat();
831
+ if (!stats.isFile()) throw new Error('fusion tool-call audit completion seal is not a regular file');
832
+ if (stats.size > 4096) throw new Error('fusion tool-call audit completion seal is oversized');
833
+ const bytes = await handle.readFile();
834
+ if (bytes.at(-1) !== 10) throw new Error('fusion tool-call audit completion seal is partial');
835
+ const text = bytes.toString('utf8');
836
+ if (!Buffer.from(text, 'utf8').equals(bytes)) {
837
+ throw new Error('fusion tool-call audit completion seal is not UTF-8');
838
+ }
839
+ const parsed = parseJsonText(text);
840
+ if (!isJsonObject(parsed) || Array.isArray(parsed)) {
841
+ throw new Error('fusion tool-call audit completion seal must be an object');
842
+ }
843
+ const keys = Object.keys(parsed).sort();
844
+ const expected = ['log_sha256', 'record_count', 'schema_version', 'status', 'total_result_bytes'];
845
+ if (keys.join('\0') !== expected.join('\0')) {
846
+ throw new Error('fusion tool-call audit completion seal keys mismatch');
847
+ }
848
+ if (parsed['schema_version'] !== FUSION_TOOL_CALL_SEAL_SCHEMA_VERSION) {
849
+ throw new Error('fusion tool-call audit completion seal schema mismatch');
850
+ }
851
+ if (parsed['status'] !== 'complete') {
852
+ throw new Error('fusion tool-call audit completion seal reports a failed audit');
853
+ }
854
+ const recordCount = requireUsageInteger(parsed, 'record_count', 'fusion tool-call audit seal');
855
+ const totalResultBytes = requireUsageInteger(
856
+ parsed,
857
+ 'total_result_bytes',
858
+ 'fusion tool-call audit seal',
859
+ );
860
+ const logSha256 = requireSha256(parsed, 'log_sha256', 'fusion tool-call audit seal');
861
+ if (recordCount !== trace.summary.count) {
862
+ throw new Error('fusion tool-call audit completion seal record count mismatch');
863
+ }
864
+ if (totalResultBytes !== trace.summary.total_result_bytes) {
865
+ throw new Error('fusion tool-call audit completion seal result-byte total mismatch');
866
+ }
867
+ if (logSha256 !== sha256Buffer(trace.bytes)) {
868
+ throw new Error('fusion tool-call audit completion seal log hash mismatch');
869
+ }
870
+ if (totalResultBytes > FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES) {
871
+ throw new Error(
872
+ `fusion tool-call audit exceeds aggregate result-byte limit ${String(FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES)}`,
873
+ );
874
+ }
875
+ } finally {
876
+ await handle.close();
877
+ }
878
+ }
879
+
374
880
  function sha256Buffer(bytes: Buffer): string {
375
881
  return createHash('sha256').update(bytes).digest('hex');
376
882
  }
@@ -436,8 +942,7 @@ export class FusionPiCompactResultParser {
436
942
  const final = parsed.records.at(-1);
437
943
  if (final === undefined) throw new Error('Pi child emitted no compact result metadata');
438
944
  for (const record of parsed.records) this.assertModel(record);
439
- if (final.stop_reason !== 'stop')
440
- throw new Error(`Pi final stop reason is not stop: ${final.stop_reason}`);
945
+ this.assertTranscriptStopReasons(parsed.records);
441
946
  const observed = this.observedFromRecords(parsed.records);
442
947
  return {
443
948
  text: reconstructFinalText(response, final),
@@ -458,6 +963,43 @@ export class FusionPiCompactResultParser {
458
963
  }
459
964
  }
460
965
 
966
+ private assertTranscriptStopReasons(records: readonly FusionChildResultMetadata[]): void {
967
+ for (const [index, record] of records.entries()) {
968
+ const isFinal = index === records.length - 1;
969
+ if (isFinal) {
970
+ if (record.stop_reason !== 'stop') {
971
+ throw new Error(this.stopReasonError('final', 'stop', record.stop_reason, true));
972
+ }
973
+ } else if (record.stop_reason !== 'toolUse') {
974
+ throw new Error(
975
+ this.stopReasonError(`non-final record ${index}`, 'toolUse', record.stop_reason, true),
976
+ );
977
+ }
978
+ }
979
+ }
980
+
981
+ private stopReasonError(
982
+ position: string,
983
+ expected: string,
984
+ observed: string,
985
+ includeStopDetail: boolean,
986
+ ): string {
987
+ const prefix = `Pi ${position} stop reason is not ${expected}: ${observed}`;
988
+ if (!includeStopDetail) return prefix;
989
+ switch (observed) {
990
+ case 'length':
991
+ return `${prefix} (model output was truncated)`;
992
+ case 'error':
993
+ return `${prefix} (Pi reported an error stop)`;
994
+ case 'aborted':
995
+ return `${prefix} (Pi reported an aborted stop)`;
996
+ case 'pending':
997
+ return `${prefix} (Pi reported a pending stop)`;
998
+ default:
999
+ return prefix;
1000
+ }
1001
+ }
1002
+
461
1003
  private observedFromRecords(
462
1004
  records: readonly FusionChildResultMetadata[],
463
1005
  ): ObservedChildSnapshot {
@@ -542,8 +1084,8 @@ function defaultSpawn(command: string, args: string[], options: SpawnOptions): F
542
1084
  /**
543
1085
  * Termination timers must keep the event loop alive.
544
1086
  *
545
- * The SIGTERM grace, SIGKILL wait, and overall timeout timers are the only
546
- * things that settle the run promise when a child stops emitting events. An
1087
+ * The SIGTERM grace, SIGKILL wait, overall timeout, and idle timeout timers
1088
+ * are the only things that settle the run promise when a child stops emitting events. An
547
1089
  * unref'd timer lets the loop drain first, leaving the promise pending forever
548
1090
  * ("Promise resolution is still pending but the event loop has already
549
1091
  * resolved"). Every timer stored here is cleared in the `finally` of
@@ -646,9 +1188,11 @@ function cleanupTimers(state: ProcessState): void {
646
1188
  if (state.termTimer !== undefined) clearTimeout(state.termTimer);
647
1189
  if (state.waitTimer !== undefined) clearTimeout(state.waitTimer);
648
1190
  if (state.timeoutTimer !== undefined) clearTimeout(state.timeoutTimer);
1191
+ if (state.idleTimer !== undefined) clearTimeout(state.idleTimer);
649
1192
  state.termTimer = undefined;
650
1193
  state.waitTimer = undefined;
651
1194
  state.timeoutTimer = undefined;
1195
+ state.idleTimer = undefined;
652
1196
  }
653
1197
 
654
1198
  async function writePromptToStdin(child: FusionChildProcess, prompt: string): Promise<void> {
@@ -692,16 +1236,39 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
692
1236
  const spawnImpl = options.spawn ?? defaultSpawn;
693
1237
  const killProcess = options.killProcess ?? process.kill.bind(process);
694
1238
  const platform = options.platform ?? process.platform;
1239
+ const capability = options.capability ?? FUSION_NO_TOOLS_CAPABILITY;
695
1240
  const env = fusionPiChildEnv(options.env ?? process.env);
1241
+ if (capability !== 'reason') {
1242
+ if (options.toolCallLogPath === undefined) {
1243
+ throw childError(
1244
+ `fusion ${capability} child requires a tool-call log path`,
1245
+ 'orchestration_failed',
1246
+ options,
1247
+ false,
1248
+ false,
1249
+ );
1250
+ }
1251
+ env[FUSION_TOOL_CALL_LOG_PATH_ENV] = options.toolCallLogPath;
1252
+ if (capability === 'research') {
1253
+ if (options.sourcePolicy === undefined) {
1254
+ throw childError('fusion research child requires a source-policy path and hash', 'orchestration_failed', options, false, false);
1255
+ }
1256
+ env[FUSION_RESEARCH_ENABLED_ENV] = '1';
1257
+ env[FUSION_SOURCE_POLICY_PATH_ENV] = options.sourcePolicy.path;
1258
+ env[FUSION_SOURCE_POLICY_SHA256_ENV] = options.sourcePolicy.sha256;
1259
+ }
1260
+ }
696
1261
  const stdoutLimit = options.stdoutLimitBytes ?? FUSION_CHILD_STDOUT_LIMIT_BYTES;
697
1262
  const stderrLimit = options.stderrLimitBytes ?? FUSION_CHILD_STDERR_LIMIT_BYTES;
698
1263
  const timeoutMs = options.timeoutMs ?? FUSION_CHILD_TIMEOUT_MS;
1264
+ const idleTimeoutMs = options.idleTimeoutMs ?? FUSION_CHILD_IDLE_TIMEOUT_MS;
699
1265
  const killGraceMs = options.killGraceMs ?? FUSION_CHILD_KILL_GRACE_MS;
700
1266
  const sigkillWaitMs = options.sigkillWaitMs ?? FUSION_CHILD_SIGKILL_WAIT_MS;
701
1267
  const argv = buildFusionPiChildArgv(
702
1268
  options.model,
703
1269
  options.systemPrompt,
704
1270
  options.childExtensionPath ?? resolveFusionChildExtensionPath(),
1271
+ capability,
705
1272
  );
706
1273
  const parser = new FusionPiCompactResultParser(options.model.provider, options.model.model);
707
1274
  const stdoutChunks: Buffer[] = [];
@@ -715,6 +1282,7 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
715
1282
  termTimer: undefined,
716
1283
  waitTimer: undefined,
717
1284
  timeoutTimer: undefined,
1285
+ idleTimer: undefined,
718
1286
  settled: false,
719
1287
  };
720
1288
 
@@ -754,6 +1322,23 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
754
1322
  };
755
1323
  });
756
1324
 
1325
+ const resetIdleTimer = () => {
1326
+ if (state.settled) return;
1327
+ if (state.idleTimer !== undefined) clearTimeout(state.idleTimer);
1328
+ state.idleTimer = trackTimer(
1329
+ setTimeout(() => {
1330
+ if (state.settled) return;
1331
+ if (state.primaryError === undefined) {
1332
+ state.primaryError = childError(
1333
+ `Pi child produced no output for ${String(idleTimeoutMs)}ms (stalled)`,
1334
+ 'child_timeout',
1335
+ options,
1336
+ );
1337
+ }
1338
+ terminateChild(child, state, platform, killProcess, killGraceMs, sigkillWaitMs, settleClose);
1339
+ }, idleTimeoutMs),
1340
+ );
1341
+ };
757
1342
  const abortListener = () => {
758
1343
  if (state.settled) return;
759
1344
  if (state.primaryError === undefined) {
@@ -762,6 +1347,7 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
762
1347
  terminateChild(child, state, platform, killProcess, killGraceMs, sigkillWaitMs, settleClose);
763
1348
  };
764
1349
  const stdoutListener = (data: Buffer | string) => {
1350
+ resetIdleTimer();
765
1351
  const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
766
1352
  const appended = appendCapped(stdoutChunks, stdoutBytes, chunk, stdoutLimit);
767
1353
  stdoutBytes = appended.bytes;
@@ -775,6 +1361,7 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
775
1361
  }
776
1362
  };
777
1363
  const stderrListener = (data: Buffer | string) => {
1364
+ resetIdleTimer();
778
1365
  const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
779
1366
  const appended = appendCapped(stderrChunks, stderrBytes, chunk, stderrLimit);
780
1367
  stderrBytes = appended.bytes;
@@ -812,6 +1399,7 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
812
1399
  child.once('close', closeListener);
813
1400
  options.signal?.addEventListener('abort', abortListener, { once: true });
814
1401
  if (options.signal?.aborted) abortListener();
1402
+ resetIdleTimer();
815
1403
  state.timeoutTimer = trackTimer(
816
1404
  setTimeout(() => {
817
1405
  if (state.primaryError === undefined) {
@@ -900,6 +1488,42 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
900
1488
  observed,
901
1489
  );
902
1490
  }
1491
+ let toolCallTrace: FusionToolCallTrace | undefined;
1492
+ if (capability !== 'reason') {
1493
+ // The launch path above refuses to spawn a tool-enabled child without a log path, so
1494
+ // this is unreachable. Assert rather than defaulting: a `?? ''` here would silently
1495
+ // read an empty path if that guard were ever refactored away, turning a missing
1496
+ // audit trail into a successful run.
1497
+ const logPath = options.toolCallLogPath;
1498
+ if (logPath === undefined) {
1499
+ throw childError(
1500
+ `fusion ${capability} child completed without a tool-call log path`,
1501
+ 'orchestration_failed',
1502
+ options,
1503
+ );
1504
+ }
1505
+ try {
1506
+ toolCallTrace = await readFusionToolCallLog(logPath);
1507
+ await assertFusionToolCallLogSeal(logPath, toolCallTrace);
1508
+ await assertCompletedToolPolicy(toolCallTrace, capability, options.sourcePolicy);
1509
+ } catch (error) {
1510
+ throw new FusionChildRunError(
1511
+ withCleanupErrors(
1512
+ childError(
1513
+ `Pi child tool-call log invalid: ${error instanceof Error ? error.message : String(error)}`,
1514
+ 'child_event_invalid',
1515
+ options,
1516
+ ),
1517
+ state.cleanupErrors,
1518
+ ),
1519
+ compactEvents,
1520
+ response,
1521
+ diagnostics,
1522
+ close,
1523
+ observed,
1524
+ );
1525
+ }
1526
+ }
903
1527
  const result: FusionChildRunResult = {
904
1528
  stage: options.stage,
905
1529
  attempt: options.attempt,
@@ -914,6 +1538,7 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
914
1538
  signal: close.signal,
915
1539
  };
916
1540
  if (options.slot !== undefined) result.slot = options.slot;
1541
+ if (toolCallTrace !== undefined) result.toolCallTrace = toolCallTrace;
917
1542
  return result;
918
1543
  } finally {
919
1544
  cleanupTimers(state);