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