pi-background-tasks 0.7.0 → 0.7.2

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,5 +1,13 @@
1
1
  import { spawn as nodeSpawn, type SpawnOptions } from 'node:child_process';
2
- import { StringDecoder } from 'node:string_decoder';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync } from 'node:fs';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import {
7
+ FUSION_CHILD_RESULT_PREFIX,
8
+ FUSION_CHILD_RESULT_SCHEMA_VERSION,
9
+ type FusionChildResultMetadata,
10
+ } from '../../fusion-child-extension.js';
3
11
  import type { ResolvedFusionModel } from './types.js';
4
12
  import {
5
13
  FusionError,
@@ -10,6 +18,7 @@ import {
10
18
  } from './types.js';
11
19
  import { isJsonObject, parseJsonText } from '../common.js';
12
20
 
21
+ // The response cap now applies to one final full answer, not cumulative Pi JSON events.
13
22
  export const FUSION_CHILD_STDOUT_LIMIT_BYTES = 32 * 1024 * 1024;
14
23
  export const FUSION_CHILD_STDERR_LIMIT_BYTES = 4 * 1024 * 1024;
15
24
  export const FUSION_CHILD_TIMEOUT_MS = 30 * 60 * 1000;
@@ -76,6 +85,7 @@ export interface RunPiChildOptions {
76
85
  platform?: NodeJS.Platform | undefined;
77
86
  env?: NodeJS.ProcessEnv | undefined;
78
87
  stdoutLimitBytes?: number | undefined;
88
+ childExtensionPath?: string | undefined;
79
89
  stderrLimitBytes?: number | undefined;
80
90
  timeoutMs?: number | undefined;
81
91
  killGraceMs?: number | undefined;
@@ -105,7 +115,8 @@ interface ObservedChildSnapshot {
105
115
  }
106
116
 
107
117
  export class FusionChildRunError extends FusionError {
108
- readonly stdout: Buffer;
118
+ readonly events: Buffer;
119
+ readonly response: Buffer;
109
120
  readonly stderr: Buffer;
110
121
  readonly exitCode: number | null;
111
122
  readonly signalName: NodeJS.Signals | null;
@@ -116,7 +127,8 @@ export class FusionChildRunError extends FusionError {
116
127
 
117
128
  constructor(
118
129
  error: FusionError,
119
- stdout: Buffer,
130
+ events: Buffer,
131
+ response: Buffer,
120
132
  stderr: Buffer,
121
133
  close: CloseRecord,
122
134
  observed: ObservedChildSnapshot,
@@ -132,7 +144,8 @@ export class FusionChildRunError extends FusionError {
132
144
  if (error.artifactDir !== undefined) details.artifactDir = error.artifactDir;
133
145
  super(error.message, details);
134
146
  this.name = 'FusionChildRunError';
135
- this.stdout = stdout;
147
+ this.events = events;
148
+ this.response = response;
136
149
  this.stderr = stderr;
137
150
  this.exitCode = close.code;
138
151
  this.signalName = close.signal;
@@ -150,10 +163,27 @@ export function fusionPiChildEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.P
150
163
  return out;
151
164
  }
152
165
 
153
- export function buildFusionPiChildArgv(model: ResolvedFusionModel, systemPrompt: string): string[] {
166
+ export function resolveFusionChildExtensionPath(
167
+ moduleUrl = import.meta.url,
168
+ pathExists: (path: string) => boolean = existsSync,
169
+ ): string {
170
+ const modulePath = fileURLToPath(moduleUrl);
171
+ const extension = modulePath.endsWith('.ts') ? 'fusion-child.ts' : 'fusion-child.js';
172
+ const candidate = resolve(dirname(modulePath), '../../../extensions', extension);
173
+ if (!pathExists(candidate)) {
174
+ throw new Error(`Fusion child metadata extension is missing: ${candidate}`);
175
+ }
176
+ return candidate;
177
+ }
178
+
179
+ export function buildFusionPiChildArgv(
180
+ model: ResolvedFusionModel,
181
+ systemPrompt: string,
182
+ childExtensionPath = resolveFusionChildExtensionPath(),
183
+ ): string[] {
154
184
  return [
155
185
  '--mode',
156
- 'json',
186
+ 'text',
157
187
  '--no-session',
158
188
  '--no-tools',
159
189
  '--no-extensions',
@@ -161,6 +191,8 @@ export function buildFusionPiChildArgv(model: ResolvedFusionModel, systemPrompt:
161
191
  '--no-prompt-templates',
162
192
  '--no-themes',
163
193
  '--no-context-files',
194
+ '--extension',
195
+ childExtensionPath,
164
196
  '--provider',
165
197
  model.provider,
166
198
  '--model',
@@ -172,207 +204,262 @@ export function buildFusionPiChildArgv(model: ResolvedFusionModel, systemPrompt:
172
204
  ];
173
205
  }
174
206
 
175
- function nonNegativeInteger(value: unknown): number {
176
- return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
207
+ function addUsage(target: FusionUsage, delta: FusionUsage): void {
208
+ target.input += delta.input;
209
+ target.output += delta.output;
210
+ target.cacheRead += delta.cacheRead;
211
+ target.cacheWrite += delta.cacheWrite;
212
+ target.totalTokens += delta.totalTokens;
213
+ if (delta.costTotal !== undefined) target.costTotal = (target.costTotal ?? 0) + delta.costTotal;
214
+ }
215
+
216
+ const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
217
+ const FUSION_CHILD_RESULT_PREFIX_BYTES = Buffer.from(FUSION_CHILD_RESULT_PREFIX, 'utf8');
218
+
219
+ interface ParsedFusionChildStderr {
220
+ records: FusionChildResultMetadata[];
221
+ events: Buffer;
222
+ diagnostics: Buffer;
223
+ }
224
+
225
+ function assertClosedRecord(
226
+ value: unknown,
227
+ keys: readonly string[],
228
+ label: string,
229
+ ): Record<PropertyKey, unknown> {
230
+ if (!isJsonObject(value) || Array.isArray(value)) throw new Error(`${label} must be an object`);
231
+ const actual = Object.keys(value).sort();
232
+ const expected = [...keys].sort();
233
+ if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
234
+ throw new Error(`${label} keys mismatch: expected ${expected.join(', ')}`);
235
+ }
236
+ return value;
237
+ }
238
+
239
+ function requireNonBlankString(
240
+ record: Record<PropertyKey, unknown>,
241
+ key: string,
242
+ label: string,
243
+ ): string {
244
+ const value = record[key];
245
+ if (typeof value !== 'string' || value.trim().length === 0)
246
+ throw new Error(`${label}.${key} must be a non-blank string`);
247
+ return value;
177
248
  }
178
249
 
179
- function readString(record: Record<PropertyKey, unknown>, key: string): string | undefined {
250
+ function requireSha256(record: Record<PropertyKey, unknown>, key: string, label: string): string {
180
251
  const value = record[key];
181
- return typeof value === 'string' ? value : undefined;
252
+ if (typeof value !== 'string' || !SHA256_HEX_PATTERN.test(value))
253
+ throw new Error(`${label}.${key} must be a lowercase SHA-256 hex digest`);
254
+ return value;
182
255
  }
183
256
 
184
- function readRecord(
257
+ function requireUsageInteger(
185
258
  record: Record<PropertyKey, unknown>,
186
259
  key: string,
187
- ): Record<PropertyKey, unknown> | undefined {
260
+ label: string,
261
+ ): number {
188
262
  const value = record[key];
189
- return isJsonObject(value) && !Array.isArray(value) ? value : undefined;
190
- }
191
-
192
- function normalizeUsage(value: unknown): FusionUsage {
193
- const usage: FusionUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 };
194
- if (!isJsonObject(value) || Array.isArray(value)) return usage;
195
- usage.input = nonNegativeInteger(value['input']);
196
- usage.output = nonNegativeInteger(value['output']);
197
- usage.cacheRead = nonNegativeInteger(value['cacheRead']);
198
- usage.cacheWrite = nonNegativeInteger(value['cacheWrite']);
199
- usage.totalTokens = nonNegativeInteger(value['totalTokens']);
200
- if (usage.totalTokens <= 0) {
201
- usage.totalTokens = usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
263
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
264
+ throw new Error(`${label}.${key} must be a non-negative safe integer`);
265
+ return value;
266
+ }
267
+
268
+ function parseCompactUsage(value: unknown): FusionUsage {
269
+ if (!isJsonObject(value) || Array.isArray(value))
270
+ throw new Error('fusion child usage must be an object');
271
+ const allowed = new Set(['input', 'output', 'cacheRead', 'cacheWrite', 'totalTokens', 'costTotal']);
272
+ for (const key of Object.keys(value)) {
273
+ if (!allowed.has(key)) throw new Error(`fusion child usage contains unknown key ${key}`);
274
+ }
275
+ for (const key of ['input', 'output', 'cacheRead', 'cacheWrite', 'totalTokens']) {
276
+ if (!Object.prototype.hasOwnProperty.call(value, key))
277
+ throw new Error(`fusion child usage is missing key ${key}`);
278
+ }
279
+ const record = value;
280
+ const usage: FusionUsage = {
281
+ input: requireUsageInteger(record, 'input', 'fusion child usage'),
282
+ output: requireUsageInteger(record, 'output', 'fusion child usage'),
283
+ cacheRead: requireUsageInteger(record, 'cacheRead', 'fusion child usage'),
284
+ cacheWrite: requireUsageInteger(record, 'cacheWrite', 'fusion child usage'),
285
+ totalTokens: requireUsageInteger(record, 'totalTokens', 'fusion child usage'),
286
+ };
287
+ const costTotal = record['costTotal'];
288
+ if (costTotal !== undefined) {
289
+ if (typeof costTotal !== 'number' || !Number.isFinite(costTotal) || costTotal < 0)
290
+ throw new Error('fusion child usage.costTotal must be a non-negative finite number');
291
+ usage.costTotal = costTotal;
202
292
  }
203
- const cost = readRecord(value, 'cost');
204
- const total = cost === undefined ? undefined : cost['total'];
205
- if (typeof total === 'number' && Number.isFinite(total) && total >= 0) usage.costTotal = total;
206
293
  return usage;
207
294
  }
208
295
 
209
- function addUsage(target: FusionUsage, delta: FusionUsage): void {
210
- target.input += delta.input;
211
- target.output += delta.output;
212
- target.cacheRead += delta.cacheRead;
213
- target.cacheWrite += delta.cacheWrite;
214
- target.totalTokens += delta.totalTokens;
215
- if (delta.costTotal !== undefined) target.costTotal = (target.costTotal ?? 0) + delta.costTotal;
296
+ function parseChildResultMetadata(value: unknown): FusionChildResultMetadata {
297
+ const record = assertClosedRecord(
298
+ value,
299
+ ['schema_version', 'provider', 'model', 'stop_reason', 'text_blocks', 'text_sha256', 'usage'],
300
+ 'fusion child result',
301
+ );
302
+ if (record['schema_version'] !== FUSION_CHILD_RESULT_SCHEMA_VERSION)
303
+ throw new Error('fusion child result schema_version mismatch');
304
+ const textBlocksValue = record['text_blocks'];
305
+ if (!Array.isArray(textBlocksValue))
306
+ throw new Error('fusion child result.text_blocks must be an array');
307
+ const textBlocks = textBlocksValue.map((value, index) => {
308
+ const label = `fusion child result.text_blocks[${String(index)}]`;
309
+ const block = assertClosedRecord(value, ['utf8_bytes', 'sha256'], label);
310
+ return {
311
+ utf8_bytes: requireUsageInteger(block, 'utf8_bytes', label),
312
+ sha256: requireSha256(block, 'sha256', label),
313
+ };
314
+ });
315
+ const usage = parseCompactUsage(record['usage']);
316
+ return {
317
+ schema_version: FUSION_CHILD_RESULT_SCHEMA_VERSION,
318
+ provider: requireNonBlankString(record, 'provider', 'fusion child result'),
319
+ model: requireNonBlankString(record, 'model', 'fusion child result'),
320
+ stop_reason: requireNonBlankString(record, 'stop_reason', 'fusion child result'),
321
+ text_blocks: textBlocks,
322
+ text_sha256: requireSha256(record, 'text_sha256', 'fusion child result'),
323
+ usage,
324
+ };
216
325
  }
217
326
 
218
- function textBlocks(message: Record<PropertyKey, unknown>): string[] {
219
- const content = message['content'];
220
- if (!Array.isArray(content)) return [];
221
- const out: string[] = [];
222
- for (const part of content) {
223
- if (!isJsonObject(part) || Array.isArray(part)) continue;
224
- if (part['type'] === 'text' && typeof part['text'] === 'string') out.push(part['text']);
327
+ export function parseFusionChildStderr(stderr: Buffer): ParsedFusionChildStderr {
328
+ const records: FusionChildResultMetadata[] = [];
329
+ const diagnostics: Buffer[] = [];
330
+ let cursor = 0;
331
+ for (;;) {
332
+ const frameStart = stderr.indexOf(FUSION_CHILD_RESULT_PREFIX_BYTES, cursor);
333
+ if (frameStart < 0) {
334
+ if (cursor < stderr.length) diagnostics.push(stderr.subarray(cursor));
335
+ break;
336
+ }
337
+ if (frameStart > cursor) diagnostics.push(stderr.subarray(cursor, frameStart));
338
+ const payloadStart = frameStart + FUSION_CHILD_RESULT_PREFIX_BYTES.length;
339
+ const newline = stderr.indexOf(10, payloadStart);
340
+ if (newline < 0) throw new Error('fusion child metadata frame is not newline-terminated');
341
+ const payloadBytes = stderr.subarray(payloadStart, newline);
342
+ const payloadText = payloadBytes.toString('utf8');
343
+ if (!Buffer.from(payloadText, 'utf8').equals(payloadBytes))
344
+ throw new Error('fusion child metadata frame is not valid UTF-8');
345
+ let parsed: unknown;
346
+ try {
347
+ parsed = parseJsonText(payloadText);
348
+ } catch (error) {
349
+ throw new Error(
350
+ `fusion child metadata frame is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
351
+ );
352
+ }
353
+ records.push(parseChildResultMetadata(parsed));
354
+ cursor = newline + 1;
225
355
  }
226
- return out;
356
+ const events = Buffer.from(
357
+ records.length === 0 ? '' : `${records.map((record) => JSON.stringify(record)).join('\n')}\n`,
358
+ 'utf8',
359
+ );
360
+ return { records, events, diagnostics: Buffer.concat(diagnostics) };
361
+ }
362
+
363
+ function sha256Buffer(bytes: Buffer): string {
364
+ return createHash('sha256').update(bytes).digest('hex');
227
365
  }
228
366
 
229
- export class FusionPiJsonEventParser {
230
- private readonly decoder = new StringDecoder('utf8');
367
+ function reconstructFinalText(response: Buffer, record: FusionChildResultMetadata): string {
368
+ const blocks: Buffer[] = [];
369
+ let cursor = 0;
370
+ for (const [index, block] of record.text_blocks.entries()) {
371
+ const end = cursor + block.utf8_bytes;
372
+ if (end > response.length)
373
+ throw new Error(`Pi final text block ${String(index)} is shorter than its metadata length`);
374
+ const bytes = response.subarray(cursor, end);
375
+ if (sha256Buffer(bytes) !== block.sha256)
376
+ throw new Error(`Pi final text block ${String(index)} hash mismatch`);
377
+ blocks.push(bytes);
378
+ if (response.at(end) !== 10)
379
+ throw new Error(`Pi final text block ${String(index)} lacks its print-mode newline`);
380
+ cursor = end + 1;
381
+ }
382
+ if (cursor !== response.length)
383
+ throw new Error('Pi final text stdout contains bytes outside declared text blocks');
384
+ const joined = Buffer.concat(blocks);
385
+ if (sha256Buffer(joined) !== record.text_sha256)
386
+ throw new Error('Pi final text aggregate hash mismatch');
387
+ const text = joined.toString('utf8');
388
+ if (!Buffer.from(text, 'utf8').equals(joined)) throw new Error('Pi final text is not valid UTF-8');
389
+ if (text.trim().length === 0) throw new Error('Pi assistant response is empty');
390
+ return text;
391
+ }
392
+
393
+ export class FusionPiCompactResultParser {
231
394
  private readonly expectedProvider: string;
232
395
  private readonly expectedModel: string;
233
- private lineBuffer = '';
234
- private bytesSeen = 0;
235
- private lastByteWasLf = false;
236
- private sessionCount = 0;
237
- private sessionId: string | undefined;
238
- private sessionCwd: string | undefined;
239
- private assistantCount = 0;
240
- private finalProvider: string | undefined;
241
- private finalModel: string | undefined;
242
- private finalStopReason: string | undefined;
243
- private finalText = '';
244
- private readonly usage: FusionUsage = {
245
- input: 0,
246
- output: 0,
247
- cacheRead: 0,
248
- cacheWrite: 0,
249
- totalTokens: 0,
250
- };
251
396
 
252
397
  constructor(expectedProvider: string, expectedModel: string) {
253
398
  this.expectedProvider = expectedProvider;
254
399
  this.expectedModel = expectedModel;
255
400
  }
256
401
 
257
- push(chunk: Buffer): void {
258
- if (chunk.length === 0) return;
259
- this.bytesSeen += chunk.length;
260
- this.lastByteWasLf = chunk.at(-1) === 10;
261
- this.lineBuffer += this.decoder.write(chunk);
262
- this.consumeLines();
263
- }
264
-
265
- snapshot(): ObservedChildSnapshot {
266
- const observed: ObservedChildSnapshot = { usage: { ...this.usage } };
267
- if (this.finalProvider !== undefined) observed.provider = this.finalProvider;
268
- if (this.finalModel !== undefined) observed.model = this.finalModel;
269
- if (this.finalProvider !== undefined && this.finalModel !== undefined) {
270
- observed.qualifiedId = `${this.finalProvider}/${this.finalModel}`;
402
+ snapshot(stderr: Buffer): ObservedChildSnapshot {
403
+ try {
404
+ const parsed = parseFusionChildStderr(stderr);
405
+ return this.observedFromRecords(parsed.records);
406
+ } catch {
407
+ return { usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 } };
271
408
  }
272
- return observed;
273
409
  }
274
410
 
275
- finish(): {
411
+ finish(response: Buffer, stderr: Buffer): {
276
412
  text: string;
277
413
  usage: FusionUsage;
278
414
  provider: string;
279
415
  model: string;
280
416
  qualifiedId: string;
417
+ events: Buffer;
418
+ diagnostics: Buffer;
281
419
  } {
282
- const rest = this.decoder.end();
283
- if (rest.length > 0) this.lineBuffer += rest;
284
- if (this.bytesSeen > 0 && !this.lastByteWasLf)
285
- throw new Error('Pi JSON event stream is not newline-terminated');
286
- if (this.lineBuffer.length > 0)
287
- throw new Error('Pi JSON event stream has an unterminated line');
288
- if (this.sessionCount !== 1 || this.sessionId === undefined || this.sessionCwd === undefined) {
289
- throw new Error('Pi JSON events must contain exactly one session header');
290
- }
291
- if (this.assistantCount < 1) throw new Error('Pi JSON events contain no assistant message');
292
- if (this.finalProvider !== this.expectedProvider || this.finalModel !== this.expectedModel) {
293
- throw new Error(
294
- `Pi final model mismatch: expected ${this.expectedProvider}/${this.expectedModel}, observed ${this.finalProvider ?? 'missing'}/${this.finalModel ?? 'missing'}`,
295
- );
296
- }
297
- if (this.finalStopReason !== 'stop') {
298
- throw new Error(`Pi final stop reason is not stop: ${this.finalStopReason ?? 'missing'}`);
299
- }
300
- if (this.finalText.trim().length === 0) throw new Error('Pi assistant response is empty');
420
+ const parsed = parseFusionChildStderr(stderr);
421
+ const final = parsed.records.at(-1);
422
+ if (final === undefined) throw new Error('Pi child emitted no compact result metadata');
423
+ for (const record of parsed.records) this.assertModel(record);
424
+ if (final.stop_reason !== 'stop')
425
+ throw new Error(`Pi final stop reason is not stop: ${final.stop_reason}`);
426
+ const observed = this.observedFromRecords(parsed.records);
301
427
  return {
302
- text: this.finalText,
303
- usage: { ...this.usage },
304
- provider: this.finalProvider,
305
- model: this.finalModel,
306
- qualifiedId: `${this.finalProvider}/${this.finalModel}`,
428
+ text: reconstructFinalText(response, final),
429
+ usage: observed.usage,
430
+ provider: final.provider,
431
+ model: final.model,
432
+ qualifiedId: `${final.provider}/${final.model}`,
433
+ events: parsed.events,
434
+ diagnostics: parsed.diagnostics,
307
435
  };
308
436
  }
309
437
 
310
- private consumeLines(): void {
311
- let newlineIndex = this.lineBuffer.indexOf('\n');
312
- while (newlineIndex >= 0) {
313
- const raw = this.lineBuffer.slice(0, newlineIndex);
314
- this.lineBuffer = this.lineBuffer.slice(newlineIndex + 1);
315
- this.consumeLine(raw.endsWith('\r') ? raw.slice(0, -1) : raw);
316
- newlineIndex = this.lineBuffer.indexOf('\n');
317
- }
318
- }
319
-
320
- private consumeLine(line: string): void {
321
- if (line.length === 0) throw new Error('Pi JSON event line is blank');
322
- let parsed: unknown;
323
- try {
324
- parsed = parseJsonText(line);
325
- } catch (error) {
438
+ private assertModel(record: FusionChildResultMetadata): void {
439
+ if (record.provider !== this.expectedProvider || record.model !== this.expectedModel) {
326
440
  throw new Error(
327
- `Pi JSON event line is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
441
+ `Pi assistant model mismatch: expected ${this.expectedProvider}/${this.expectedModel}, observed ${record.provider}/${record.model}`,
328
442
  );
329
443
  }
330
- if (!isJsonObject(parsed) || Array.isArray(parsed))
331
- throw new Error('Pi JSON event line is not an object');
332
- const eventType = parsed['type'];
333
- if (eventType === 'session') {
334
- this.consumeSession(parsed);
335
- return;
336
- }
337
- if (eventType === 'message_end') this.consumeMessageEnd(parsed);
338
444
  }
339
445
 
340
- private consumeSession(event: Record<PropertyKey, unknown>): void {
341
- this.sessionCount += 1;
342
- const id = readString(event, 'id');
343
- const cwd = readString(event, 'cwd');
344
- if (id === undefined || id.trim().length === 0) throw new Error('Pi session event lacks id');
345
- if (cwd === undefined || cwd.trim().length === 0) throw new Error('Pi session event lacks cwd');
346
- this.sessionId = id;
347
- this.sessionCwd = cwd;
348
- }
349
-
350
- private consumeMessageEnd(event: Record<PropertyKey, unknown>): void {
351
- const message = readRecord(event, 'message');
352
- if (message === undefined) throw new Error('Pi message_end event lacks message object');
353
- if (message['role'] !== 'assistant') return;
354
- const provider = readString(message, 'provider');
355
- const model = readString(message, 'model');
356
- if (
357
- provider === undefined ||
358
- provider.trim().length === 0 ||
359
- model === undefined ||
360
- model.trim().length === 0
361
- ) {
362
- throw new Error('Pi assistant message lacks provider/model');
363
- }
364
- if (provider !== this.expectedProvider || model !== this.expectedModel) {
365
- throw new Error(
366
- `Pi assistant model mismatch: expected ${this.expectedProvider}/${this.expectedModel}, observed ${provider}/${model}`,
367
- );
368
- }
369
- this.assistantCount += 1;
370
- this.finalProvider = provider;
371
- this.finalModel = model;
372
- const stopReason = readString(message, 'stopReason');
373
- this.finalStopReason = stopReason;
374
- this.finalText = textBlocks(message).join('');
375
- addUsage(this.usage, normalizeUsage(message['usage']));
446
+ private observedFromRecords(records: readonly FusionChildResultMetadata[]): ObservedChildSnapshot {
447
+ const usage: FusionUsage = {
448
+ input: 0,
449
+ output: 0,
450
+ cacheRead: 0,
451
+ cacheWrite: 0,
452
+ totalTokens: 0,
453
+ };
454
+ for (const record of records) addUsage(usage, record.usage);
455
+ const final = records.at(-1);
456
+ if (final === undefined) return { usage };
457
+ return {
458
+ usage,
459
+ provider: final.provider,
460
+ model: final.model,
461
+ qualifiedId: `${final.provider}/${final.model}`,
462
+ };
376
463
  }
377
464
  }
378
465
 
@@ -591,8 +678,12 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
591
678
  const timeoutMs = options.timeoutMs ?? FUSION_CHILD_TIMEOUT_MS;
592
679
  const killGraceMs = options.killGraceMs ?? FUSION_CHILD_KILL_GRACE_MS;
593
680
  const sigkillWaitMs = options.sigkillWaitMs ?? FUSION_CHILD_SIGKILL_WAIT_MS;
594
- const argv = buildFusionPiChildArgv(options.model, options.systemPrompt);
595
- const parser = new FusionPiJsonEventParser(options.model.provider, options.model.model);
681
+ const argv = buildFusionPiChildArgv(
682
+ options.model,
683
+ options.systemPrompt,
684
+ options.childExtensionPath ?? resolveFusionChildExtensionPath(),
685
+ );
686
+ const parser = new FusionPiCompactResultParser(options.model.provider, options.model.model);
596
687
  const stdoutChunks: Buffer[] = [];
597
688
  const stderrChunks: Buffer[] = [];
598
689
  let stdoutBytes = 0;
@@ -648,29 +739,9 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
648
739
  const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
649
740
  const appended = appendCapped(stdoutChunks, stdoutBytes, chunk, stdoutLimit);
650
741
  stdoutBytes = appended.bytes;
651
- if (state.primaryError === undefined && appended.accepted.length > 0) {
652
- try {
653
- parser.push(appended.accepted);
654
- } catch (error) {
655
- state.primaryError = childError(
656
- `Pi child JSON event stream invalid: ${error instanceof Error ? error.message : String(error)}`,
657
- 'child_event_invalid',
658
- options,
659
- );
660
- terminateChild(
661
- child,
662
- state,
663
- platform,
664
- killProcess,
665
- killGraceMs,
666
- sigkillWaitMs,
667
- settleClose,
668
- );
669
- }
670
- }
671
742
  if (appended.exceeded && state.primaryError === undefined) {
672
743
  state.primaryError = childError(
673
- `Pi child stdout exceeded ${String(stdoutLimit)} bytes`,
744
+ `Pi child final response exceeded ${String(stdoutLimit)} bytes`,
674
745
  'child_output_cap',
675
746
  options,
676
747
  );
@@ -743,15 +814,26 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
743
814
  }
744
815
 
745
816
  const close = await closePromise;
746
- const stdout = Buffer.concat(stdoutChunks);
747
- const stderr = Buffer.concat(stderrChunks);
748
- const observed = parser.snapshot();
817
+ const response = Buffer.concat(stdoutChunks);
818
+ const rawStderr = Buffer.concat(stderrChunks);
819
+ const observed = parser.snapshot(rawStderr);
820
+ let compactEvents: Buffer = Buffer.alloc(0);
821
+ let diagnostics: Buffer = rawStderr;
822
+ try {
823
+ const decoded = parseFusionChildStderr(rawStderr);
824
+ compactEvents = decoded.events;
825
+ diagnostics = decoded.diagnostics;
826
+ } catch {
827
+ // A primary process/cap error remains authoritative; malformed metadata is
828
+ // surfaced below when the child otherwise exits successfully.
829
+ }
749
830
  const primary = state.primaryError;
750
831
  if (primary !== undefined)
751
832
  throw new FusionChildRunError(
752
833
  withCleanupErrors(primary, state.cleanupErrors),
753
- stdout,
754
- stderr,
834
+ compactEvents,
835
+ response,
836
+ diagnostics,
755
837
  close,
756
838
  observed,
757
839
  );
@@ -765,27 +847,29 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
765
847
  ),
766
848
  state.cleanupErrors,
767
849
  ),
768
- stdout,
769
- stderr,
850
+ compactEvents,
851
+ response,
852
+ diagnostics,
770
853
  close,
771
854
  observed,
772
855
  );
773
856
  }
774
- let parsed: ReturnType<FusionPiJsonEventParser['finish']>;
857
+ let parsed: ReturnType<FusionPiCompactResultParser['finish']>;
775
858
  try {
776
- parsed = parser.finish();
859
+ parsed = parser.finish(response, rawStderr);
777
860
  } catch (error) {
778
861
  throw new FusionChildRunError(
779
862
  withCleanupErrors(
780
863
  childError(
781
- `Pi child JSON event stream invalid: ${error instanceof Error ? error.message : String(error)}`,
864
+ `Pi child compact result invalid: ${error instanceof Error ? error.message : String(error)}`,
782
865
  'child_event_invalid',
783
866
  options,
784
867
  ),
785
868
  state.cleanupErrors,
786
869
  ),
787
- stdout,
788
- stderr,
870
+ compactEvents,
871
+ response,
872
+ diagnostics,
789
873
  close,
790
874
  observed,
791
875
  );
@@ -798,8 +882,8 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
798
882
  qualifiedId: parsed.qualifiedId,
799
883
  text: parsed.text,
800
884
  usage: parsed.usage,
801
- stdout,
802
- stderr,
885
+ events: parsed.events,
886
+ stderr: parsed.diagnostics,
803
887
  exitCode: close.code,
804
888
  signal: close.signal,
805
889
  };
@@ -232,7 +232,7 @@ export interface FusionChildRunResult {
232
232
  qualifiedId: string;
233
233
  text: string;
234
234
  usage: FusionUsage;
235
- stdout: Buffer;
235
+ events: Buffer;
236
236
  stderr: Buffer;
237
237
  exitCode: number;
238
238
  signal: NodeJS.Signals | null;
@@ -247,6 +247,7 @@ export interface FusionAttemptArtifactRecord {
247
247
  events_path?: string;
248
248
  stderr_path?: string;
249
249
  response_path?: string;
250
+ partial_response_path?: string;
250
251
  provider?: string;
251
252
  model?: string;
252
253
  qualifiedId?: string;
@@ -1538,6 +1538,7 @@ export class BackgroundTaskRegistry {
1538
1538
  error,
1539
1539
  ` <output-file>${escapeXml(task.outputPath)}</output-file>`,
1540
1540
  ` <summary>${escapeXml(`Background task ${JSON.stringify(taskName)} ${task.status}`)}</summary>`,
1541
+ ' <guidance>Terminal state and output metadata are durable. Do not call bg_status to reconfirm; use bg_logs only if output is needed.</guidance>',
1541
1542
  '</background-task-notification>',
1542
1543
  ]
1543
1544
  .filter(Boolean)