pi-background-tasks 0.7.7 → 0.9.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.
@@ -1,21 +1,33 @@
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 { existsSync, readFileSync } from 'node:fs';
4
+ import { lstat, readFile } 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_TOOL_CALL_LOG_PATH_ENV,
9
13
  type FusionChildResultMetadata,
10
14
  } from '../../fusion-child-extension.js';
11
15
  import {
16
+ FUSION_DEFAULT_CAPABILITY,
17
+ FUSION_FORBIDDEN_TOOLS,
18
+ FUSION_INSPECT_TOOLS,
19
+ FUSION_TOOL_CALL_LOG_SCHEMA_VERSION,
20
+ FUSION_WEB_FETCH_TOOL_NAME,
12
21
  FusionError,
13
22
  addFusionUsage,
14
23
  cloneFusionUsage,
15
24
  createEmptyFusionUsage,
25
+ type FusionCapability,
16
26
  type FusionChildRunResult,
17
27
  type FusionErrorDetails,
18
28
  type FusionStage,
29
+ type FusionToolCallLogRecord,
30
+ type FusionToolCallTrace,
19
31
  type FusionUsage,
20
32
  type ResolvedFusionModel,
21
33
  } from './types.js';
@@ -31,6 +43,18 @@ import {
31
43
  export const FUSION_CHILD_STDOUT_LIMIT_BYTES = 32 * 1024 * 1024;
32
44
  export const FUSION_CHILD_STDERR_LIMIT_BYTES = 4 * 1024 * 1024;
33
45
  export const FUSION_CHILD_TIMEOUT_MS = 30 * 60 * 1000;
46
+ /**
47
+ * Stale-action watchdog threshold.
48
+ *
49
+ * Activity is one stdout or stderr byte from the child. The child extension emits its
50
+ * compact metadata frame only at `message_end`, and Pi text mode writes stdout only for
51
+ * the final assistant message, so a single slow model turn is genuinely silent on both
52
+ * streams. The threshold must therefore exceed the longest plausible single turn, not the
53
+ * longest plausible tool call: a value tuned to tool latency would kill healthy children
54
+ * mid-reasoning. 900s stays well inside the 30-minute absolute cap while leaving a wide
55
+ * margin over observed turn latency.
56
+ */
57
+ export const FUSION_CHILD_IDLE_TIMEOUT_MS = 15 * 60 * 1000;
34
58
  export const FUSION_CHILD_KILL_GRACE_MS = 3000;
35
59
  export const FUSION_CHILD_SIGKILL_WAIT_MS = 5000;
36
60
 
@@ -86,6 +110,7 @@ export interface RunPiChildOptions {
86
110
  attempt: number;
87
111
  cwd: string;
88
112
  model: ResolvedFusionModel;
113
+ capability?: FusionCapability | undefined;
89
114
  systemPrompt: string;
90
115
  userPrompt: string;
91
116
  signal?: AbortSignal | undefined;
@@ -97,9 +122,11 @@ export interface RunPiChildOptions {
97
122
  childExtensionPath?: string | undefined;
98
123
  stderrLimitBytes?: number | undefined;
99
124
  timeoutMs?: number | undefined;
125
+ idleTimeoutMs?: number | undefined;
100
126
  killGraceMs?: number | undefined;
101
127
  sigkillWaitMs?: number | undefined;
102
128
  piLaunchDependencies?: PiLaunchDependencies | undefined;
129
+ toolCallLogPath?: string | undefined;
103
130
  }
104
131
 
105
132
  interface CloseRecord {
@@ -114,6 +141,7 @@ interface ProcessState {
114
141
  termTimer: NodeJS.Timeout | undefined;
115
142
  waitTimer: NodeJS.Timeout | undefined;
116
143
  timeoutTimer: NodeJS.Timeout | undefined;
144
+ idleTimer: NodeJS.Timeout | undefined;
117
145
  settled: boolean;
118
146
  }
119
147
 
@@ -186,23 +214,198 @@ export function resolveFusionChildExtensionPath(
186
214
  return candidate;
187
215
  }
188
216
 
217
+ /**
218
+ * Provider whose children require the Anthropic system-prompt sanitizer.
219
+ *
220
+ * Pi's own system prompt contains documentation lines that Anthropic rejects, so a
221
+ * Claude child launched without the sanitizer fails at the provider rather than
222
+ * producing an answer. The parent session loads the sanitizer through ordinary
223
+ * extension discovery, but Fusion children run with `--no-extensions` for
224
+ * isolation and therefore inherit nothing; the sanitizer must be re-supplied
225
+ * explicitly per child.
226
+ */
227
+ export const FUSION_SANITIZED_PROVIDER = 'anthropic';
228
+ export const FUSION_ANTHROPIC_SANITIZER_PACKAGE = '@ravshansbox/pi-anthropic-sps';
229
+ const FUSION_ANTHROPIC_SANITIZER_MANIFEST = `${FUSION_ANTHROPIC_SANITIZER_PACKAGE}/package.json`;
230
+
231
+ export interface FusionSanitizerDependencies {
232
+ resolvePackageJson?: ((specifier: string) => string) | undefined;
233
+ readManifest?: ((path: string) => string) | undefined;
234
+ pathExists?: ((path: string) => boolean) | undefined;
235
+ }
236
+
237
+ function manifestExtensionEntry(manifestText: string, manifestPath: string): string {
238
+ let parsed: unknown;
239
+ try {
240
+ parsed = parseJsonText(manifestText);
241
+ } catch (error) {
242
+ throw new FusionError(
243
+ `Anthropic sanitizer manifest ${manifestPath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
244
+ { code: 'orchestration_failed', childCreated: false },
245
+ );
246
+ }
247
+ if (!isJsonObject(parsed)) {
248
+ throw new FusionError(`Anthropic sanitizer manifest ${manifestPath} must be an object`, {
249
+ code: 'orchestration_failed',
250
+ childCreated: false,
251
+ });
252
+ }
253
+ const pi = parsed['pi'];
254
+ if (!isJsonObject(pi)) {
255
+ throw new FusionError(
256
+ `Anthropic sanitizer manifest ${manifestPath} has no "pi" section declaring its extension`,
257
+ { code: 'orchestration_failed', childCreated: false },
258
+ );
259
+ }
260
+ const extensions = pi['extensions'];
261
+ if (!Array.isArray(extensions) || extensions.length === 0) {
262
+ throw new FusionError(
263
+ `Anthropic sanitizer manifest ${manifestPath} declares no pi.extensions entries`,
264
+ { code: 'orchestration_failed', childCreated: false },
265
+ );
266
+ }
267
+ const [entry] = extensions;
268
+ if (typeof entry !== 'string' || entry.trim().length === 0) {
269
+ throw new FusionError(
270
+ `Anthropic sanitizer manifest ${manifestPath} pi.extensions[0] must be a non-blank string`,
271
+ { code: 'orchestration_failed', childCreated: false },
272
+ );
273
+ }
274
+ return entry;
275
+ }
276
+
277
+ /**
278
+ * Resolve the sanitizer extension file shipped by the sanitizer package.
279
+ *
280
+ * The package intentionally publishes no `main`/`exports`, so the entry cannot be
281
+ * required directly; its manifest is resolved and the declared `pi.extensions[0]`
282
+ * path is joined against the package root. Every failure is loud: a Claude child
283
+ * launched without the sanitizer would fail at the provider with a far less
284
+ * actionable error, so silently omitting it is never correct.
285
+ */
286
+ export function resolveAnthropicSanitizerExtensionPath(
287
+ dependencies: FusionSanitizerDependencies = {},
288
+ ): string {
289
+ const resolvePackageJson =
290
+ dependencies.resolvePackageJson ?? createRequire(import.meta.url).resolve;
291
+ const readManifest = dependencies.readManifest ?? ((path: string) => readFileSync(path, 'utf8'));
292
+ const pathExists = dependencies.pathExists ?? existsSync;
293
+ let manifestPath: string;
294
+ try {
295
+ manifestPath = resolvePackageJson(FUSION_ANTHROPIC_SANITIZER_MANIFEST);
296
+ } catch (error) {
297
+ throw new FusionError(
298
+ `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.`,
299
+ { code: 'orchestration_failed', childCreated: false },
300
+ );
301
+ }
302
+ let manifestText: string;
303
+ try {
304
+ manifestText = readManifest(manifestPath);
305
+ } catch (error) {
306
+ throw new FusionError(
307
+ `Anthropic sanitizer manifest ${manifestPath} could not be read: ${error instanceof Error ? error.message : String(error)}`,
308
+ { code: 'orchestration_failed', childCreated: false },
309
+ );
310
+ }
311
+ const entry = manifestExtensionEntry(manifestText, manifestPath);
312
+ const extensionPath = resolve(dirname(manifestPath), entry);
313
+ if (!pathExists(extensionPath)) {
314
+ throw new FusionError(
315
+ `Anthropic sanitizer extension is missing: ${extensionPath} (declared by ${manifestPath})`,
316
+ { code: 'orchestration_failed', childCreated: false },
317
+ );
318
+ }
319
+ return extensionPath;
320
+ }
321
+
322
+ export function assertFusionToolPolicyDisjoint(
323
+ allowlist: readonly string[] = FUSION_INSPECT_TOOLS,
324
+ denylist: readonly string[] = FUSION_FORBIDDEN_TOOLS,
325
+ ): void {
326
+ for (const forbidden of denylist) {
327
+ if (allowlist.includes(forbidden)) {
328
+ throw new FusionError(
329
+ `fusion inspect capability would enable the forbidden tool ${forbidden}`,
330
+ { code: 'orchestration_failed', childCreated: false },
331
+ );
332
+ }
333
+ }
334
+ }
335
+
336
+ function researchToolAllowlist(): readonly string[] {
337
+ return [...FUSION_INSPECT_TOOLS, FUSION_WEB_FETCH_TOOL_NAME];
338
+ }
339
+
340
+ function fusionToolArgv(capability: FusionCapability): string[] {
341
+ if (capability === 'reason') return ['--no-tools'];
342
+ if (capability === 'inspect') {
343
+ assertFusionToolPolicyDisjoint(FUSION_INSPECT_TOOLS);
344
+ return [
345
+ '--no-builtin-tools',
346
+ '--tools',
347
+ FUSION_INSPECT_TOOLS.join(','),
348
+ '--exclude-tools',
349
+ FUSION_FORBIDDEN_TOOLS.join(','),
350
+ ];
351
+ }
352
+ if (capability === 'research') {
353
+ const allowlist = researchToolAllowlist();
354
+ assertFusionToolPolicyDisjoint(allowlist);
355
+ return [
356
+ '--no-builtin-tools',
357
+ '--tools',
358
+ allowlist.join(','),
359
+ '--exclude-tools',
360
+ FUSION_FORBIDDEN_TOOLS.join(','),
361
+ ];
362
+ }
363
+ throw new FusionError(`fusion capability ${String(capability)} is not supported`, {
364
+ code: 'orchestration_failed',
365
+ childCreated: false,
366
+ });
367
+ }
368
+
369
+ /**
370
+ * Extensions explicitly loaded into a child, in deterministic order.
371
+ *
372
+ * `--no-extensions` disables discovery but still honours explicit `--extension`
373
+ * paths, so this list is the complete set a child receives. The metadata
374
+ * extension is always present; the Anthropic sanitizer is appended only for
375
+ * Claude routes, keeping non-Anthropic child argv byte-identical to before.
376
+ */
377
+ export function fusionChildExtensionPaths(
378
+ model: ResolvedFusionModel,
379
+ childExtensionPath: string,
380
+ resolveSanitizer: () => string = resolveAnthropicSanitizerExtensionPath,
381
+ ): readonly string[] {
382
+ if (model.provider !== FUSION_SANITIZED_PROVIDER) return [childExtensionPath];
383
+ return [childExtensionPath, resolveSanitizer()];
384
+ }
385
+
189
386
  export function buildFusionPiChildArgv(
190
387
  model: ResolvedFusionModel,
191
388
  systemPrompt: string,
192
389
  childExtensionPath = resolveFusionChildExtensionPath(),
390
+ capability: FusionCapability = FUSION_DEFAULT_CAPABILITY,
391
+ resolveSanitizer: () => string = resolveAnthropicSanitizerExtensionPath,
193
392
  ): string[] {
393
+ const extensionArgs = fusionChildExtensionPaths(
394
+ model,
395
+ childExtensionPath,
396
+ resolveSanitizer,
397
+ ).flatMap((path) => ['--extension', path]);
194
398
  return [
195
399
  '--mode',
196
400
  'text',
197
401
  '--no-session',
198
- '--no-tools',
402
+ ...fusionToolArgv(capability),
199
403
  '--no-extensions',
200
404
  '--no-skills',
201
405
  '--no-prompt-templates',
202
406
  '--no-themes',
203
407
  '--no-context-files',
204
- '--extension',
205
- childExtensionPath,
408
+ ...extensionArgs,
206
409
  '--provider',
207
410
  model.provider,
208
411
  '--model',
@@ -237,6 +440,29 @@ function assertClosedRecord(
237
440
  return value;
238
441
  }
239
442
 
443
+ function assertClosedRecordWithOptional(
444
+ value: unknown,
445
+ requiredKeys: readonly string[],
446
+ optionalKeys: readonly string[],
447
+ label: string,
448
+ ): Record<PropertyKey, unknown> {
449
+ if (!isJsonObject(value) || Array.isArray(value)) throw new Error(`${label} must be an object`);
450
+ const allowed = new Set([...requiredKeys, ...optionalKeys]);
451
+ const actual = Object.keys(value).sort();
452
+ const missing = requiredKeys.filter((key) => !Object.hasOwn(value, key));
453
+ const unknownKeys = actual.filter((key) => !allowed.has(key));
454
+ if (missing.length > 0 || unknownKeys.length > 0) {
455
+ throw new Error(
456
+ `${label} keys mismatch: required ${[...requiredKeys].sort().join(', ')}; optional ${[
457
+ ...optionalKeys,
458
+ ]
459
+ .sort()
460
+ .join(', ')}`,
461
+ );
462
+ }
463
+ return value;
464
+ }
465
+
240
466
  function requireNonBlankString(
241
467
  record: Record<PropertyKey, unknown>,
242
468
  key: string,
@@ -371,6 +597,135 @@ export function parseFusionChildStderr(stderr: Buffer): ParsedFusionChildStderr
371
597
  return { records, events, diagnostics: Buffer.concat(diagnostics) };
372
598
  }
373
599
 
600
+ function parseToolCallLogRecord(value: unknown, label: string): FusionToolCallLogRecord {
601
+ const record = assertClosedRecordWithOptional(
602
+ value,
603
+ [
604
+ 'schema_version',
605
+ 'ordinal',
606
+ 'tool_name',
607
+ 'arguments_sha256',
608
+ 'arguments_bytes',
609
+ 'result_bytes',
610
+ 'result_sha256',
611
+ 'status',
612
+ 'duration_ms',
613
+ ],
614
+ ['url', 'final_url', 'http_status', 'response_bytes', 'content_sha256'],
615
+ label,
616
+ );
617
+ if (record['schema_version'] !== FUSION_TOOL_CALL_LOG_SCHEMA_VERSION) {
618
+ throw new Error(`${label}.schema_version mismatch`);
619
+ }
620
+ const status = record['status'];
621
+ if (status !== 'ok' && status !== 'error') throw new Error(`${label}.status is invalid`);
622
+ const parsedRecord: FusionToolCallLogRecord = {
623
+ schema_version: FUSION_TOOL_CALL_LOG_SCHEMA_VERSION,
624
+ ordinal: requireUsageInteger(record, 'ordinal', label),
625
+ tool_name: requireNonBlankString(record, 'tool_name', label),
626
+ arguments_sha256: requireSha256(record, 'arguments_sha256', label),
627
+ arguments_bytes: requireUsageInteger(record, 'arguments_bytes', label),
628
+ result_bytes: requireUsageInteger(record, 'result_bytes', label),
629
+ result_sha256: requireSha256(record, 'result_sha256', label),
630
+ status,
631
+ duration_ms: requireUsageInteger(record, 'duration_ms', label),
632
+ };
633
+ if (record['url'] !== undefined) parsedRecord.url = requireNonBlankString(record, 'url', label);
634
+ if (record['final_url'] !== undefined)
635
+ parsedRecord.final_url = requireNonBlankString(record, 'final_url', label);
636
+ if (record['http_status'] !== undefined)
637
+ parsedRecord.http_status = requireUsageInteger(record, 'http_status', label);
638
+ if (record['response_bytes'] !== undefined)
639
+ parsedRecord.response_bytes = requireUsageInteger(record, 'response_bytes', label);
640
+ if (record['content_sha256'] !== undefined)
641
+ parsedRecord.content_sha256 = requireSha256(record, 'content_sha256', label);
642
+ return parsedRecord;
643
+ }
644
+
645
+ export function parseFusionToolCallLog(bytes: Buffer): FusionToolCallTrace {
646
+ if (bytes.length === 0) {
647
+ return {
648
+ bytes,
649
+ records: [],
650
+ summary: { count: 0, total_result_bytes: 0, trace_complete: true },
651
+ };
652
+ }
653
+ if (bytes.at(-1) !== 10) {
654
+ throw new Error('fusion tool-call log has trailing partial line');
655
+ }
656
+ const text = bytes.toString('utf8');
657
+ if (!Buffer.from(text, 'utf8').equals(bytes)) {
658
+ throw new Error('fusion tool-call log is not valid UTF-8');
659
+ }
660
+ const lines = text.split('\n');
661
+ lines.pop();
662
+ const records = lines.map((line, index) => {
663
+ let parsed: unknown;
664
+ try {
665
+ parsed = parseJsonText(line);
666
+ } catch (error) {
667
+ throw new Error(
668
+ `fusion tool-call log line ${String(index)} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
669
+ );
670
+ }
671
+ return parseToolCallLogRecord(parsed, `fusion tool-call log line ${String(index)}`);
672
+ });
673
+ const seen = new Set<number>();
674
+ for (const [index, record] of records.entries()) {
675
+ if (seen.has(record.ordinal)) {
676
+ throw new Error(`fusion tool-call log duplicate ordinal ${String(record.ordinal)}`);
677
+ }
678
+ seen.add(record.ordinal);
679
+ if (record.ordinal !== index) {
680
+ throw new Error(
681
+ `fusion tool-call log ordinal gap: expected ${String(index)}, observed ${String(record.ordinal)}`,
682
+ );
683
+ }
684
+ }
685
+ return {
686
+ bytes,
687
+ records,
688
+ summary: {
689
+ count: records.length,
690
+ total_result_bytes: records.reduce((sum, record) => sum + record.result_bytes, 0),
691
+ trace_complete: true,
692
+ },
693
+ };
694
+ }
695
+
696
+ function isNotFound(error: unknown): boolean {
697
+ return isJsonObject(error) && error['code'] === 'ENOENT';
698
+ }
699
+
700
+ async function readFusionToolCallLog(path: string): Promise<FusionToolCallTrace> {
701
+ let bytes: Buffer;
702
+ try {
703
+ bytes = await readFile(path);
704
+ } catch (error) {
705
+ // The child extension creates this file before tools can run, so a missing file
706
+ // means the audit trail was never established - not that zero tools were used. Those
707
+ // must stay distinguishable: silently accepting absence would let a run whose activity
708
+ // was never recorded report success, defeating the purpose of the log.
709
+ if (isNotFound(error)) {
710
+ throw new Error(
711
+ `fusion tool-call log is missing at ${path}; the inspect child never initialized its audit trail`,
712
+ );
713
+ }
714
+ throw error;
715
+ }
716
+ // The audit trail must be a real file inside the run directory. A symlink here would let
717
+ // anything able to pre-create the path redirect the parent's read elsewhere, so the type
718
+ // is checked explicitly rather than trusting the 0700 run directory alone. lstat does not
719
+ // follow the link, so a symlinked path is rejected instead of silently resolved.
720
+ const stats = await lstat(path);
721
+ if (!stats.isFile()) {
722
+ throw new Error(
723
+ `fusion tool-call log at ${path} is not a regular file; refusing to trust a redirected audit trail`,
724
+ );
725
+ }
726
+ return parseFusionToolCallLog(bytes);
727
+ }
728
+
374
729
  function sha256Buffer(bytes: Buffer): string {
375
730
  return createHash('sha256').update(bytes).digest('hex');
376
731
  }
@@ -436,8 +791,7 @@ export class FusionPiCompactResultParser {
436
791
  const final = parsed.records.at(-1);
437
792
  if (final === undefined) throw new Error('Pi child emitted no compact result metadata');
438
793
  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}`);
794
+ this.assertTranscriptStopReasons(parsed.records);
441
795
  const observed = this.observedFromRecords(parsed.records);
442
796
  return {
443
797
  text: reconstructFinalText(response, final),
@@ -458,6 +812,43 @@ export class FusionPiCompactResultParser {
458
812
  }
459
813
  }
460
814
 
815
+ private assertTranscriptStopReasons(records: readonly FusionChildResultMetadata[]): void {
816
+ for (const [index, record] of records.entries()) {
817
+ const isFinal = index === records.length - 1;
818
+ if (isFinal) {
819
+ if (record.stop_reason !== 'stop') {
820
+ throw new Error(this.stopReasonError('final', 'stop', record.stop_reason, true));
821
+ }
822
+ } else if (record.stop_reason !== 'toolUse') {
823
+ throw new Error(
824
+ this.stopReasonError(`non-final record ${index}`, 'toolUse', record.stop_reason, true),
825
+ );
826
+ }
827
+ }
828
+ }
829
+
830
+ private stopReasonError(
831
+ position: string,
832
+ expected: string,
833
+ observed: string,
834
+ includeStopDetail: boolean,
835
+ ): string {
836
+ const prefix = `Pi ${position} stop reason is not ${expected}: ${observed}`;
837
+ if (!includeStopDetail) return prefix;
838
+ switch (observed) {
839
+ case 'length':
840
+ return `${prefix} (model output was truncated)`;
841
+ case 'error':
842
+ return `${prefix} (Pi reported an error stop)`;
843
+ case 'aborted':
844
+ return `${prefix} (Pi reported an aborted stop)`;
845
+ case 'pending':
846
+ return `${prefix} (Pi reported a pending stop)`;
847
+ default:
848
+ return prefix;
849
+ }
850
+ }
851
+
461
852
  private observedFromRecords(
462
853
  records: readonly FusionChildResultMetadata[],
463
854
  ): ObservedChildSnapshot {
@@ -542,8 +933,8 @@ function defaultSpawn(command: string, args: string[], options: SpawnOptions): F
542
933
  /**
543
934
  * Termination timers must keep the event loop alive.
544
935
  *
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
936
+ * The SIGTERM grace, SIGKILL wait, overall timeout, and idle timeout timers
937
+ * are the only things that settle the run promise when a child stops emitting events. An
547
938
  * unref'd timer lets the loop drain first, leaving the promise pending forever
548
939
  * ("Promise resolution is still pending but the event loop has already
549
940
  * resolved"). Every timer stored here is cleared in the `finally` of
@@ -646,9 +1037,11 @@ function cleanupTimers(state: ProcessState): void {
646
1037
  if (state.termTimer !== undefined) clearTimeout(state.termTimer);
647
1038
  if (state.waitTimer !== undefined) clearTimeout(state.waitTimer);
648
1039
  if (state.timeoutTimer !== undefined) clearTimeout(state.timeoutTimer);
1040
+ if (state.idleTimer !== undefined) clearTimeout(state.idleTimer);
649
1041
  state.termTimer = undefined;
650
1042
  state.waitTimer = undefined;
651
1043
  state.timeoutTimer = undefined;
1044
+ state.idleTimer = undefined;
652
1045
  }
653
1046
 
654
1047
  async function writePromptToStdin(child: FusionChildProcess, prompt: string): Promise<void> {
@@ -692,16 +1085,32 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
692
1085
  const spawnImpl = options.spawn ?? defaultSpawn;
693
1086
  const killProcess = options.killProcess ?? process.kill.bind(process);
694
1087
  const platform = options.platform ?? process.platform;
1088
+ const capability = options.capability ?? FUSION_DEFAULT_CAPABILITY;
695
1089
  const env = fusionPiChildEnv(options.env ?? process.env);
1090
+ if (capability !== 'reason') {
1091
+ if (options.toolCallLogPath === undefined) {
1092
+ throw childError(
1093
+ `fusion ${capability} child requires a tool-call log path`,
1094
+ 'orchestration_failed',
1095
+ options,
1096
+ false,
1097
+ false,
1098
+ );
1099
+ }
1100
+ env[FUSION_TOOL_CALL_LOG_PATH_ENV] = options.toolCallLogPath;
1101
+ if (capability === 'research') env[FUSION_RESEARCH_ENABLED_ENV] = '1';
1102
+ }
696
1103
  const stdoutLimit = options.stdoutLimitBytes ?? FUSION_CHILD_STDOUT_LIMIT_BYTES;
697
1104
  const stderrLimit = options.stderrLimitBytes ?? FUSION_CHILD_STDERR_LIMIT_BYTES;
698
1105
  const timeoutMs = options.timeoutMs ?? FUSION_CHILD_TIMEOUT_MS;
1106
+ const idleTimeoutMs = options.idleTimeoutMs ?? FUSION_CHILD_IDLE_TIMEOUT_MS;
699
1107
  const killGraceMs = options.killGraceMs ?? FUSION_CHILD_KILL_GRACE_MS;
700
1108
  const sigkillWaitMs = options.sigkillWaitMs ?? FUSION_CHILD_SIGKILL_WAIT_MS;
701
1109
  const argv = buildFusionPiChildArgv(
702
1110
  options.model,
703
1111
  options.systemPrompt,
704
1112
  options.childExtensionPath ?? resolveFusionChildExtensionPath(),
1113
+ capability,
705
1114
  );
706
1115
  const parser = new FusionPiCompactResultParser(options.model.provider, options.model.model);
707
1116
  const stdoutChunks: Buffer[] = [];
@@ -715,6 +1124,7 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
715
1124
  termTimer: undefined,
716
1125
  waitTimer: undefined,
717
1126
  timeoutTimer: undefined,
1127
+ idleTimer: undefined,
718
1128
  settled: false,
719
1129
  };
720
1130
 
@@ -754,6 +1164,23 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
754
1164
  };
755
1165
  });
756
1166
 
1167
+ const resetIdleTimer = () => {
1168
+ if (state.settled) return;
1169
+ if (state.idleTimer !== undefined) clearTimeout(state.idleTimer);
1170
+ state.idleTimer = trackTimer(
1171
+ setTimeout(() => {
1172
+ if (state.settled) return;
1173
+ if (state.primaryError === undefined) {
1174
+ state.primaryError = childError(
1175
+ `Pi child produced no output for ${String(idleTimeoutMs)}ms (stalled)`,
1176
+ 'child_timeout',
1177
+ options,
1178
+ );
1179
+ }
1180
+ terminateChild(child, state, platform, killProcess, killGraceMs, sigkillWaitMs, settleClose);
1181
+ }, idleTimeoutMs),
1182
+ );
1183
+ };
757
1184
  const abortListener = () => {
758
1185
  if (state.settled) return;
759
1186
  if (state.primaryError === undefined) {
@@ -762,6 +1189,7 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
762
1189
  terminateChild(child, state, platform, killProcess, killGraceMs, sigkillWaitMs, settleClose);
763
1190
  };
764
1191
  const stdoutListener = (data: Buffer | string) => {
1192
+ resetIdleTimer();
765
1193
  const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
766
1194
  const appended = appendCapped(stdoutChunks, stdoutBytes, chunk, stdoutLimit);
767
1195
  stdoutBytes = appended.bytes;
@@ -775,6 +1203,7 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
775
1203
  }
776
1204
  };
777
1205
  const stderrListener = (data: Buffer | string) => {
1206
+ resetIdleTimer();
778
1207
  const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
779
1208
  const appended = appendCapped(stderrChunks, stderrBytes, chunk, stderrLimit);
780
1209
  stderrBytes = appended.bytes;
@@ -812,6 +1241,7 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
812
1241
  child.once('close', closeListener);
813
1242
  options.signal?.addEventListener('abort', abortListener, { once: true });
814
1243
  if (options.signal?.aborted) abortListener();
1244
+ resetIdleTimer();
815
1245
  state.timeoutTimer = trackTimer(
816
1246
  setTimeout(() => {
817
1247
  if (state.primaryError === undefined) {
@@ -900,6 +1330,40 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
900
1330
  observed,
901
1331
  );
902
1332
  }
1333
+ let toolCallTrace: FusionToolCallTrace | undefined;
1334
+ if (capability !== 'reason') {
1335
+ // The launch path above refuses to spawn a tool-enabled child without a log path, so
1336
+ // this is unreachable. Assert rather than defaulting: a `?? ''` here would silently
1337
+ // read an empty path if that guard were ever refactored away, turning a missing
1338
+ // audit trail into a successful run.
1339
+ const logPath = options.toolCallLogPath;
1340
+ if (logPath === undefined) {
1341
+ throw childError(
1342
+ `fusion ${capability} child completed without a tool-call log path`,
1343
+ 'orchestration_failed',
1344
+ options,
1345
+ );
1346
+ }
1347
+ try {
1348
+ toolCallTrace = await readFusionToolCallLog(logPath);
1349
+ } catch (error) {
1350
+ throw new FusionChildRunError(
1351
+ withCleanupErrors(
1352
+ childError(
1353
+ `Pi child tool-call log invalid: ${error instanceof Error ? error.message : String(error)}`,
1354
+ 'child_event_invalid',
1355
+ options,
1356
+ ),
1357
+ state.cleanupErrors,
1358
+ ),
1359
+ compactEvents,
1360
+ response,
1361
+ diagnostics,
1362
+ close,
1363
+ observed,
1364
+ );
1365
+ }
1366
+ }
903
1367
  const result: FusionChildRunResult = {
904
1368
  stage: options.stage,
905
1369
  attempt: options.attempt,
@@ -914,6 +1378,7 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
914
1378
  signal: close.signal,
915
1379
  };
916
1380
  if (options.slot !== undefined) result.slot = options.slot;
1381
+ if (toolCallTrace !== undefined) result.toolCallTrace = toolCallTrace;
917
1382
  return result;
918
1383
  } finally {
919
1384
  cleanupTimers(state);