omnirush 0.9.1 → 0.10.1

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.
@@ -49,13 +49,35 @@
49
49
  // Ids are stable across processes (derived from the pi entry ids), so a
50
50
  // resumed session or a repeated flush merges instead of duplicating.
51
51
  //
52
+ // The 2.x engine's own fields ride along, the way the desktop's 2.x engine
53
+ // adds them to the same 1.x records (omnirush-gui engine2/native.ts, whose
54
+ // merge rules `mergeNative` repeats): a 2.x field the 1.x record does not
55
+ // have is added under its 2.x name (info.type, info.text, info.model,
56
+ // info.time.streamed, info.rawFinish, info.providerState, info.snapshot,
57
+ // text/reasoning `state`, reasoning `time`, tool `name` / `executed` /
58
+ // `providerState` / `time` {created, ran, completed}, tool state `content`,
59
+ // the shell and compaction records' fields); objects both have (time,
60
+ // tokens, error, tool state) are merged key by key; a 2.x value under a name
61
+ // the 1.x record already uses with another value goes under `v2.<name>` on
62
+ // the same record (tokens.v2.output: 2.x counts visible output without the
63
+ // reasoning tokens pi's output includes; a tool state's v2.error); and 2.x
64
+ // records 1.x never listed become parts of the nearest message, typed by
65
+ // their 2.x type: "model-switched" (pi's model and thinking-level changes,
66
+ // and a prompt sent to another model than the last one recorded) and "idle"
67
+ // (the run's outcome). What pi does not record is left out: the
68
+ // step snapshots' hashes, provider-executed tools and their result state,
69
+ // the service tier, encrypted reasoning and signatures (kept out of the
70
+ // trace, as before), retries, agent and location switches. Stream timing
71
+ // (block and tool-call starts and ends, a tool's run start, the run's idle
72
+ // outcome) comes from stream-timing.ts's `omnirush-timing` entries.
73
+ //
52
74
  // Also here, pure and shared with the tests: the helpers the desktop's turn
53
75
  // observer (apps/server/src/collector-observer.ts) applies to the engine's
54
76
  // messages when a turn settles: the turn's model, the message budget of a
55
77
  // flush and its split into `turn.messages` parts.
56
78
 
57
79
  import { createHash } from "node:crypto";
58
- import { isAbsolute, resolve } from "node:path";
80
+ import { isAbsolute, relative, resolve } from "node:path";
59
81
 
60
82
  /** customType of bgshell.ts's finished-background-command message. */
61
83
  export const BACKGROUND_BASH_TYPE = "omnirush-bash-result";
@@ -67,6 +89,8 @@ export const BACKGROUND_BASH_TYPE = "omnirush-bash-result";
67
89
  export const SUBAGENT_ENTRY_TYPE = "omnirush-subagent";
68
90
  /** A sub-agent's prompt: the task the parent agent wrote, after this prefix (agents-lib.ts buildChildArgs). */
69
91
  export const SUBAGENT_TASK_PREFIX = "Task: ";
92
+ /** customType of stream-timing.ts's entries (a model step's stream timing, a run's idle outcome). */
93
+ export const TIMING_ENTRY_TYPE = "omnirush-timing";
70
94
 
71
95
  export type EngineMessage = { info: Record<string, unknown>; parts: Array<Record<string, unknown>> };
72
96
 
@@ -262,6 +286,172 @@ export function spawnedSessionIds(details: unknown): string[] {
262
286
 
263
287
  type ToolResult = { message: Record<string, any>; at: number | null };
264
288
 
289
+ // --- the 2.x engine's own fields (omnirush-gui apps/server/src/engine2/native.ts) ---
290
+
291
+ /** Where a 2.x value goes when its name is taken on the 1.x record by another value. */
292
+ export const NATIVE_COLLISIONS = "v2";
293
+ const INFO_NESTED = { time: {}, tokens: { cache: {} }, error: { data: {} } };
294
+ const STATE_NESTED = { metadata: {}, time: {} };
295
+
296
+ function deepEqual(a: unknown, b: unknown): boolean {
297
+ if (a === b) return true;
298
+ if (typeof a !== typeof b || a === null || b === null || typeof a !== "object") return false;
299
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
300
+ if (Array.isArray(a)) {
301
+ const other = b as unknown[];
302
+ return a.length === other.length && a.every((item, index) => deepEqual(item, other[index]));
303
+ }
304
+ const ka = Object.keys(a as object);
305
+ const kb = Object.keys(b as object);
306
+ return ka.length === kb.length && ka.every((key) => deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]));
307
+ }
308
+
309
+ /**
310
+ * Adds 2.x fields (`native`) to a 1.x record, as the desktop's 2.x engine
311
+ * does: a new name is added as-is, an equal value is not repeated, the
312
+ * objects `nested` names are merged key by key (its value: the keys merged
313
+ * one level further down), and any other value under a taken name goes under
314
+ * `v2.<name>`. Objects of `target` are copied before they change.
315
+ */
316
+ export function mergeNative(
317
+ target: Record<string, unknown>,
318
+ native: Record<string, unknown>,
319
+ nested: Record<string, Record<string, unknown>> = {},
320
+ ): Record<string, unknown> {
321
+ for (const [key, value] of Object.entries(native)) {
322
+ if (value === undefined || key === NATIVE_COLLISIONS) continue;
323
+ if (!(key in target)) {
324
+ target[key] = value;
325
+ continue;
326
+ }
327
+ const current = target[key];
328
+ if (deepEqual(current, value)) continue;
329
+ if (key in nested && isRecord(current) && isRecord(value)) {
330
+ target[key] = mergeNative({ ...current }, value, nested[key] as Record<string, Record<string, unknown>>);
331
+ continue;
332
+ }
333
+ const bucket: Record<string, unknown> = isRecord(target[NATIVE_COLLISIONS]) ? { ...(target[NATIVE_COLLISIONS] as Record<string, unknown>) } : {};
334
+ bucket[key] = value;
335
+ target[NATIVE_COLLISIONS] = bucket;
336
+ }
337
+ return target;
338
+ }
339
+
340
+ /** `record` without its undefined values. */
341
+ function defined<T extends Record<string, unknown>>(record: T): T {
342
+ for (const key of Object.keys(record)) if (record[key] === undefined) delete record[key];
343
+ return record;
344
+ }
345
+
346
+ function parseJson(text: string): any {
347
+ try {
348
+ return JSON.parse(text);
349
+ } catch {
350
+ return undefined;
351
+ }
352
+ }
353
+
354
+ /** OpenAI Responses APIs: pi keeps their item and response ids, the 2.x engine's provider state. */
355
+ function responsesApi(api: unknown): boolean {
356
+ return typeof api === "string" && api.includes("responses");
357
+ }
358
+
359
+ /** A text block's 2.x provider state: its Responses message item id and phase (pi's textSignature). */
360
+ function textState(block: Record<string, any>, api: unknown): Record<string, unknown> | undefined {
361
+ const signature = typeof block.textSignature === "string" ? block.textSignature : "";
362
+ if (!responsesApi(api) || !signature) return undefined;
363
+ if (!signature.startsWith("{")) return { itemId: signature };
364
+ const parsed = parseJson(signature);
365
+ if (!isRecord(parsed) || typeof parsed.id !== "string") return undefined;
366
+ return { itemId: parsed.id, ...(typeof parsed.phase === "string" ? { phase: parsed.phase } : {}) };
367
+ }
368
+
369
+ /**
370
+ * A thinking block's 2.x provider state: its Responses reasoning item id.
371
+ * The encrypted reasoning the item also holds stays out of the trace.
372
+ */
373
+ function reasoningState(block: Record<string, any>, api: unknown): Record<string, unknown> | undefined {
374
+ if (!responsesApi(api) || typeof block.thinkingSignature !== "string") return undefined;
375
+ const item = parseJson(block.thinkingSignature);
376
+ return isRecord(item) && typeof item.id === "string" ? { itemId: item.id } : undefined;
377
+ }
378
+
379
+ /** A pi tool call id as the 2.x call id and item id (Responses calls are `<call_id>|<item id>`). */
380
+ function nativeCallId(id: string, api: unknown): { id: string; itemId?: string } {
381
+ if (!responsesApi(api)) return { id };
382
+ const [callId, itemId] = id.split("|");
383
+ return callId && itemId ? { id: callId, itemId } : { id };
384
+ }
385
+
386
+ /** The provider's own finish reason, as the 2.x engine keeps it (Responses: the incomplete reason only). */
387
+ function nativeRawFinish(message: Record<string, any>): string | undefined {
388
+ const raw = typeof message.rawStopReason === "string" ? message.rawStopReason : "";
389
+ if (!raw) return undefined;
390
+ if (!responsesApi(message.api)) return raw;
391
+ const incomplete = /^incomplete\.(.+)$/.exec(raw);
392
+ return incomplete ? incomplete[1] : undefined;
393
+ }
394
+
395
+ /** A failed or aborted step's 2.x error ({type, message}). */
396
+ function nativeError(message: Record<string, any>): Record<string, unknown> | undefined {
397
+ const text = typeof message.errorMessage === "string" && message.errorMessage ? message.errorMessage : undefined;
398
+ if (message.stopReason === "aborted") return { type: "aborted", message: text ?? "aborted" };
399
+ if (message.stopReason === "error") return { type: "provider.unknown", message: text ?? "error" };
400
+ return undefined;
401
+ }
402
+
403
+ /** pi usage as the 2.x step tokens: output without the reasoning tokens pi counts in it. */
404
+ function nativeTokens(usage: unknown): Record<string, unknown> | undefined {
405
+ if (!isRecord(usage)) return undefined;
406
+ const tokens = stepTokens(usage) as { input: number; output: number; reasoning: number; cache: { read: number; write: number } };
407
+ return { input: tokens.input, output: Math.max(0, tokens.output - tokens.reasoning), reasoning: tokens.reasoning, cache: tokens.cache };
408
+ }
409
+
410
+ /** A tool result's content as the 2.x tool content (images: the 1.x attachment holds their description). */
411
+ function nativeToolContent(content: unknown): Array<Record<string, unknown>> {
412
+ const out: Array<Record<string, unknown>> = [];
413
+ let images = 0;
414
+ for (const block of Array.isArray(content) ? content : typeof content === "string" ? [content] : []) {
415
+ if (typeof block === "string") {
416
+ if (block) out.push({ type: "text", text: block });
417
+ } else if (isRecord(block) && block.type === "text" && typeof block.text === "string") {
418
+ out.push({ type: "text", text: block.text });
419
+ } else if (isRecord(block) && block.type === "image") {
420
+ const file = imagePart(block, images);
421
+ out.push({ type: "file", uri: `sameAs:state.attachments[${images}].url`, mime: file.mime, name: file.filename });
422
+ images += 1;
423
+ }
424
+ }
425
+ return out;
426
+ }
427
+
428
+ /** Stream timing a step's `omnirush-timing` entry recorded (stream-timing.ts). */
429
+ type StepTiming = { streamed?: number; blocks: Map<number, { created?: number; completed?: number }>; tools: Record<string, number> };
430
+
431
+ function stepTimings(entries: readonly PiEntry[]): Map<number, StepTiming> {
432
+ const timings = new Map<number, StepTiming>();
433
+ for (const entry of entries) {
434
+ if (entry.type !== "custom" || entry.customType !== TIMING_ENTRY_TYPE || !isRecord(entry.data)) continue;
435
+ const data = entry.data;
436
+ if (typeof data.step !== "number") continue;
437
+ const blocks = new Map<number, { created?: number; completed?: number }>();
438
+ for (const block of Array.isArray(data.blocks) ? data.blocks : []) {
439
+ if (!isRecord(block) || typeof block.index !== "number") continue;
440
+ blocks.set(block.index, defined({ created: epochMs(block.created) ?? undefined, completed: epochMs(block.completed) ?? undefined }));
441
+ }
442
+ const tools: Record<string, number> = {};
443
+ if (isRecord(data.tools)) for (const [id, at] of Object.entries(data.tools)) if (epochMs(at) !== null) tools[id] = epochMs(at)!;
444
+ timings.set(data.step, { ...(epochMs(data.streamed) !== null ? { streamed: epochMs(data.streamed)! } : {}), blocks, tools });
445
+ }
446
+ return timings;
447
+ }
448
+
449
+ /** A user message the viewer never takes for a prompt (only synthetic text or a compaction): context records do not join it. */
450
+ function engineWritten(message: EngineMessage): boolean {
451
+ return message.info.role === "user"
452
+ && message.parts.every((part) => (part.type === "text" && part.synthetic === true) || part.type === "compaction");
453
+ }
454
+
265
455
  /**
266
456
  * The engine messages of a pi session branch (entries root to leaf, as
267
457
  * SessionManager.getBranch() returns them or a session file lists them).
@@ -291,15 +481,69 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
291
481
  if (message?.role === "user") upcoming = null;
292
482
  }
293
483
 
484
+ const timings = stepTimings(entries);
485
+
294
486
  const out: EngineMessage[] = [];
295
487
  let model: { providerID: string; modelID: string } | null = options.initialModel ?? null;
296
488
  let thinkingLevel: string | null = options.initialThinkingLevel ?? null;
297
489
  let lastUserId: string | null = null;
298
490
  const variant = () => (thinkingLevel && thinkingLevel !== "off" ? thinkingLevel : null);
299
491
 
492
+ // 2.x context records (model switches) wait for the next message that takes them.
493
+ const pendingRecords: Array<Record<string, unknown>> = [];
494
+ const contextPart = (record: Record<string, unknown>, host: EngineMessage) => ({ ...record, sessionID: sessionId, messageID: host.info.id });
495
+ /** Every message goes out through here: waiting context records join the front of the first one the viewer does not take for engine-written. */
496
+ const emit = (message: EngineMessage): void => {
497
+ if (pendingRecords.length > 0 && !engineWritten(message)) {
498
+ message.parts = [...pendingRecords.splice(0).map((record) => contextPart(record, message)), ...message.parts];
499
+ }
500
+ out.push(message);
501
+ };
502
+ /** A 2.x record at the end of the latest message that takes one (the run's idle marker). */
503
+ const appendRecord = (record: Record<string, unknown>): void => {
504
+ for (let index = out.length - 1; index >= 0; index -= 1) {
505
+ const host = out[index]!;
506
+ if (engineWritten(host)) continue;
507
+ host.parts = [...host.parts, contextPart(record, host)];
508
+ return;
509
+ }
510
+ };
511
+ // 2.x model-switched records: a model or thinking-level change pi recorded
512
+ // (the latest of a run of changes, once a message follows), or a prompt
513
+ // sent to another model than the last one recorded (pi records no change
514
+ // for `--model` on a resumed session). Tracked apart from the 1.x state.
515
+ type ModelState = { model: { providerID: string; modelID: string } | null; thinkingLevel: string | null };
516
+ let recorded: ModelState = { model, thinkingLevel };
517
+ const modelRef = (state: ModelState) => {
518
+ if (!state.model) return undefined;
519
+ const effort = state.thinkingLevel && state.thinkingLevel !== "off" ? state.thinkingLevel : undefined;
520
+ return { id: state.model.modelID, providerID: state.model.providerID, ...(effort ? { variant: effort } : {}) };
521
+ };
522
+ const recordSwitch = (id: string, at: number | null, next: ModelState) => {
523
+ const now = modelRef(next);
524
+ const previous = modelRef(recorded);
525
+ recorded = next;
526
+ if (!now || deepEqual(now, previous)) return;
527
+ pendingRecords.push(defined({ id, time: { created: at ?? 0 }, type: "model-switched", model: now, previous }));
528
+ };
529
+ let pendingSwitch: { entry: PiEntry; at: number | null } | null = null;
530
+ const settleSwitch = () => {
531
+ if (!pendingSwitch) return;
532
+ const { entry, at } = pendingSwitch;
533
+ pendingSwitch = null;
534
+ recordSwitch(engineMessageId(String(entry.id)), at, { model, thinkingLevel });
535
+ };
536
+
300
537
  // The effort of the prompt the current turn answers (a live setting when known).
301
538
  let turnVariant: string | null = null;
302
- const userMessage = (id: string, created: number | null, parts: Array<Record<string, unknown>>, index: number, extraInfo: Record<string, unknown> = {}): string | null => {
539
+ const userMessage = (
540
+ id: string,
541
+ created: number | null,
542
+ parts: Array<Record<string, unknown>>,
543
+ index: number,
544
+ extraInfo: Record<string, unknown> = {},
545
+ native?: Record<string, unknown>,
546
+ ): string | null => {
303
547
  const live = typeof entries[index]?.id === "string" ? options.promptSettings?.get(entries[index]!.id as string) : undefined;
304
548
  const messageModel = nextAssistantModel.get(index) ?? live?.model ?? model ?? null;
305
549
  const messageVariant = live && live.variant !== undefined ? live.variant : variant();
@@ -313,10 +557,23 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
313
557
  ...(messageVariant ? { variant: messageVariant } : {}),
314
558
  ...extraInfo,
315
559
  };
316
- out.push({
560
+ // A prompt sent to another model than the last one recorded: that switch first.
561
+ if (native?.type === "user") recordSwitch(engineMessageId(String(entries[index]?.id ?? id), "_model"), created, { model: messageModel, thinkingLevel: messageVariant ?? null });
562
+ const message: EngineMessage = {
317
563
  info,
318
564
  parts: parts.map((part, partIndex) => ({ id: partId(id, partIndex), sessionID: sessionId, messageID: id, ...part })),
319
- });
565
+ };
566
+ if (native) {
567
+ // 2.x prompt fields (engine2/native.ts enrichPrompt): an attached image's
568
+ // entry points at its 1.x file part (the bytes are not embedded), and its
569
+ // other fields join that part.
570
+ const files = message.parts.flatMap((part, partIndex) => part.type === "file"
571
+ ? [defined({ uri: `sameAs:parts[${partIndex}].url`, mime: part.mime as string | undefined, name: part.filename as string | undefined })]
572
+ : []);
573
+ message.info = mergeNative({ ...message.info }, { ...native, ...(files.length > 0 ? { files } : {}) }, INFO_NESTED);
574
+ for (const part of message.parts) if (part.type === "file") mergeNative(part, defined({ mime: part.mime, name: part.filename }));
575
+ }
576
+ emit(message);
320
577
  lastUserId = id;
321
578
  return messageVariant ?? null;
322
579
  };
@@ -356,7 +613,7 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
356
613
  },
357
614
  };
358
615
  });
359
- out.push({
616
+ emit({
360
617
  info: {
361
618
  id: shellId,
362
619
  sessionID: sessionId,
@@ -380,13 +637,28 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
380
637
  if (!entryId) return;
381
638
  const at = epochMs(entry.timestamp);
382
639
  if (entry.type === "model_change") {
383
- if (typeof entry.provider === "string" && typeof entry.modelId === "string") model = { providerID: entry.provider, modelID: entry.modelId };
640
+ if (typeof entry.provider === "string" && typeof entry.modelId === "string") {
641
+ model = { providerID: entry.provider, modelID: entry.modelId };
642
+ pendingSwitch = { entry, at };
643
+ }
384
644
  return;
385
645
  }
386
646
  if (entry.type === "thinking_level_change") {
387
- if (typeof entry.thinkingLevel === "string") thinkingLevel = entry.thinkingLevel;
647
+ if (typeof entry.thinkingLevel === "string") {
648
+ thinkingLevel = entry.thinkingLevel;
649
+ pendingSwitch = { entry, at };
650
+ }
651
+ return;
652
+ }
653
+ if (entry.type === "custom" && entry.customType === TIMING_ENTRY_TYPE) {
654
+ // The run went idle here: its 2.x idle marker closes the run's last message.
655
+ const idle = isRecord(entry.data) && isRecord(entry.data.idle) ? entry.data.idle : null;
656
+ const outcome = idle && typeof idle.outcome === "string" ? idle.outcome : null;
657
+ if (idle && outcome) appendRecord({ id: engineMessageId(entryId), time: { created: epochMs(idle.at) ?? at ?? 0 }, type: "idle", outcome });
388
658
  return;
389
659
  }
660
+ // A message follows: the model switch before it is recorded.
661
+ settleSwitch();
390
662
  if (entry.type === "compaction") {
391
663
  const id = engineMessageId(entryId);
392
664
  userMessage(id, at, [{ type: "compaction" }], index);
@@ -394,8 +666,9 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
394
666
  const summaryParts: Array<Record<string, unknown>> = [];
395
667
  if (typeof entry.summary === "string" && entry.summary) summaryParts.push({ type: "text", text: entry.summary });
396
668
  if (isRecord(entry.usage)) summaryParts.push({ type: "step-finish", reason: "stop", tokens: stepTokens(entry.usage), cost: stepCost(entry.usage) });
397
- out.push({
398
- info: {
669
+ emit({
670
+ // The 2.x compaction record's fields on its summary reply, as the desktop keeps them.
671
+ info: mergeNative({
399
672
  id: summaryId,
400
673
  sessionID: sessionId,
401
674
  role: "assistant",
@@ -407,21 +680,26 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
407
680
  path: { cwd, root: cwd },
408
681
  ...(isRecord(entry.usage) ? { tokens: stepTokens(entry.usage), cost: stepCost(entry.usage) } : {}),
409
682
  finish: "stop",
410
- },
683
+ }, defined({
684
+ type: "compaction",
685
+ status: "completed",
686
+ summary: typeof entry.summary === "string" ? entry.summary : undefined,
687
+ tokens: nativeTokens(entry.usage),
688
+ }), INFO_NESTED),
411
689
  parts: summaryParts.map((part, partIndex) => ({ id: partId(summaryId, partIndex), sessionID: sessionId, messageID: summaryId, ...part })),
412
690
  });
413
691
  return;
414
692
  }
415
693
  if (entry.type === "branch_summary") {
416
694
  if (typeof entry.summary === "string" && entry.summary) {
417
- userMessage(engineMessageId(entryId), at, [{ type: "text", text: entry.summary, synthetic: true, metadata: { branchSummary: true } }], index);
695
+ userMessage(engineMessageId(entryId), at, [{ type: "text", text: entry.summary, synthetic: true, metadata: { branchSummary: true } }], index, {}, { type: "synthetic", text: entry.summary });
418
696
  }
419
697
  return;
420
698
  }
421
699
  if (entry.type === "custom_message") {
422
700
  const parts = contentParts(entry.content, { synthetic: true, metadata: { customType: entry.customType ?? null } });
423
701
  const id = engineMessageId(entryId);
424
- if (parts.length > 0) userMessage(id, at, parts, index);
702
+ if (parts.length > 0) userMessage(id, at, parts, index, {}, { type: "synthetic", text: contentText(entry.content) });
425
703
  if (entry.customType === BACKGROUND_BASH_TYPE) backgroundBash(id, entryId, (entry as any).details, at, index);
426
704
  return;
427
705
  }
@@ -433,19 +711,20 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
433
711
  case "user": {
434
712
  const injected = options.injectedPrompts?.get(entryId);
435
713
  const parts = contentParts(message.content, injected ? { synthetic: true, metadata: { ...injected } } : {});
436
- if (parts.length > 0) turnVariant = userMessage(id, created, parts, index);
714
+ const native = { type: injected ? "synthetic" : "user", text: contentText(message.content) };
715
+ if (parts.length > 0) turnVariant = userMessage(id, created, parts, index, {}, native);
437
716
  return;
438
717
  }
439
718
  case "custom": {
440
719
  const parts = contentParts(message.content, { synthetic: true, metadata: { customType: message.customType ?? null } });
441
- if (parts.length > 0) userMessage(id, created, parts, index);
720
+ if (parts.length > 0) userMessage(id, created, parts, index, {}, { type: "synthetic", text: contentText(message.content) });
442
721
  if (message.customType === BACKGROUND_BASH_TYPE) backgroundBash(id, entryId, message.details, created, index);
443
722
  return;
444
723
  }
445
724
  case "branchSummary":
446
725
  case "compactionSummary": {
447
726
  if (typeof message.summary === "string" && message.summary) {
448
- userMessage(id, created, [{ type: "text", text: message.summary, synthetic: true, metadata: { [message.role]: true } }], index);
727
+ userMessage(id, created, [{ type: "text", text: message.summary, synthetic: true, metadata: { [message.role]: true } }], index, {}, { type: "synthetic", text: message.summary });
449
728
  }
450
729
  return;
451
730
  }
@@ -478,8 +757,10 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
478
757
  },
479
758
  },
480
759
  ];
481
- out.push({
482
- info: {
760
+ const outputBytes = Buffer.byteLength(output);
761
+ emit({
762
+ // The 2.x shell record's fields on the reply, as the desktop keeps them (pi keeps no shell id).
763
+ info: mergeNative({
483
764
  id: shellId,
484
765
  sessionID: sessionId,
485
766
  role: "assistant",
@@ -492,32 +773,54 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
492
773
  cost: 0,
493
774
  tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
494
775
  finish: "stop",
495
- },
776
+ }, defined({
777
+ type: "shell",
778
+ command,
779
+ status: message.cancelled === true ? "killed" : "exited",
780
+ exit: typeof message.exitCode === "number" ? message.exitCode : undefined,
781
+ output: { output, cursor: outputBytes, size: outputBytes, truncated: message.truncated === true },
782
+ }), INFO_NESTED),
496
783
  parts: parts.map((part, partIndex) => ({ id: partId(shellId, partIndex), sessionID: sessionId, messageID: shellId, ...part })),
497
784
  });
498
785
  return;
499
786
  }
500
787
  case "assistant": {
501
788
  const completed = at ?? created;
789
+ const api = message.api;
790
+ const timing = typeof message.timestamp === "number" ? timings.get(message.timestamp) : undefined;
502
791
  const parts: Array<Record<string, unknown>> = [{ type: "step-start" }];
792
+ // The 2.x content entry of each part (by part index), added once the parts have their ids.
793
+ const natives = new Map<number, Record<string, unknown>>();
503
794
  const changed: string[] = [];
795
+ // When the step's tools settled (2.x time.completed): the last result, once every call has one.
796
+ let settled: number | null = completed;
797
+ let pending = false;
504
798
  const content = Array.isArray(message.content) ? message.content : [];
505
- for (const block of content) {
506
- if (!isRecord(block)) continue;
799
+ content.forEach((block: unknown, blockIndex: number) => {
800
+ if (!isRecord(block)) return;
801
+ const blockTime = timing?.blocks.get(blockIndex);
507
802
  if (block.type === "thinking") {
508
803
  if (typeof block.thinking === "string" && block.thinking.trim()) {
509
804
  parts.push({ type: "reasoning", text: block.thinking, ...(block.redacted === true ? { metadata: { redacted: true } } : {}) });
805
+ const time = blockTime && (blockTime.created !== undefined || blockTime.completed !== undefined) ? { ...blockTime } : undefined;
806
+ natives.set(parts.length - 1, defined({ type: "reasoning", text: block.thinking, state: reasoningState(block, api), time }));
510
807
  }
511
808
  } else if (block.type === "text") {
512
- if (typeof block.text === "string" && block.text) parts.push({ type: "text", text: block.text });
809
+ if (typeof block.text === "string" && block.text) {
810
+ parts.push({ type: "text", text: block.text });
811
+ natives.set(parts.length - 1, defined({ type: "text", text: block.text, state: textState(block, api) }));
812
+ }
513
813
  } else if (block.type === "toolCall") {
514
814
  const callId = typeof block.id === "string" ? block.id : "";
515
815
  const input = isRecord(block.arguments) ? block.arguments : {};
516
816
  const result = callId ? results.get(callId) : undefined;
517
817
  const start = (callId ? options.toolStarts?.get(callId) : undefined) ?? completed ?? 0;
518
818
  let state: Record<string, unknown>;
819
+ let nativeState: Record<string, unknown>;
519
820
  if (!result) {
520
821
  state = { status: "running", input, time: { start } };
822
+ nativeState = { status: "running", input };
823
+ pending = true;
521
824
  } else {
522
825
  const text = contentText(result.message.content);
523
826
  const attachments = contentParts(result.message.content).filter((part) => part.type === "file");
@@ -540,6 +843,11 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
540
843
  ...(attachments.length > 0 ? { attachments } : {}),
541
844
  time,
542
845
  };
846
+ const toolContent = nativeToolContent(result.message.content);
847
+ nativeState = result.message.isError === true
848
+ ? { status: "error", input, error: { type: "tool.execution", message: text } }
849
+ : { status: "completed", input, ...(toolContent.length > 0 ? { content: toolContent } : {}) };
850
+ if (result.at !== null && (settled === null || result.at > settled)) settled = result.at;
543
851
  const target = typeof input.path === "string" ? input.path : null;
544
852
  if (result.message.isError !== true && FILE_TOOLS.has(String(block.name)) && target) {
545
853
  const absolute = isAbsolute(target) ? target : resolve(cwd, target);
@@ -547,8 +855,21 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
547
855
  }
548
856
  }
549
857
  parts.push({ type: "tool", callID: callId, tool: typeof block.name === "string" ? block.name : "tool", state });
858
+ const call = nativeCallId(callId, api);
859
+ const ran = (callId ? timing?.tools[callId] ?? options.toolStarts?.get(callId) : undefined) ?? undefined;
860
+ const time = defined({ created: blockTime?.created, ran, completed: result?.at ?? undefined });
861
+ natives.set(parts.length - 1, defined({
862
+ type: "tool",
863
+ id: call.id,
864
+ name: typeof block.name === "string" ? block.name : "tool",
865
+ // pi runs every tool itself, none provider-side.
866
+ executed: false,
867
+ providerState: call.itemId ? { itemId: call.itemId } : undefined,
868
+ state: nativeState,
869
+ time: Object.keys(time).length > 0 ? time : undefined,
870
+ }));
550
871
  }
551
- }
872
+ });
552
873
  if (changed.length > 0) parts.push({ type: "patch", files: changed });
553
874
  const finish = finishReason(message.stopReason);
554
875
  const tokens = stepTokens(message.usage);
@@ -559,29 +880,59 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
559
880
  }
560
881
  const error = assistantError(message);
561
882
  const effort = typeof message.providerThinkingLevel === "string" && message.providerThinkingLevel ? message.providerThinkingLevel : turnVariant ?? variant();
562
- out.push({
563
- info: {
564
- id,
565
- sessionID: sessionId,
566
- role: "assistant",
567
- ...(lastUserId ? { parentID: lastUserId } : {}),
568
- time: { created: created ?? 0, completed: completed ?? 0 },
569
- ...(typeof message.model === "string" ? { modelID: message.model } : {}),
570
- ...(typeof message.provider === "string" ? { providerID: message.provider } : {}),
571
- ...(typeof message.responseModel === "string" && message.responseModel && message.responseModel !== message.model
572
- ? { responseModelID: message.responseModel }
573
- : {}),
574
- agent,
575
- mode: agent,
576
- ...(effort ? { variant: effort } : {}),
577
- path: { cwd, root: cwd },
578
- cost,
579
- tokens,
580
- ...(finish ? { finish } : {}),
581
- ...(error ? { error } : {}),
582
- },
583
- parts: parts.map((part, partIndex) => ({ id: partId(id, partIndex), sessionID: sessionId, messageID: id, ...part })),
584
- });
883
+ const stepParts = parts.map((part, partIndex) => ({ id: partId(id, partIndex), sessionID: sessionId, messageID: id, ...part }));
884
+ // The 2.x content entries' own fields on their parts (engine2/native.ts enrichPart).
885
+ for (const [partIndex, native] of natives) {
886
+ const part = stepParts[partIndex]!;
887
+ if (part.type !== "tool") {
888
+ mergeNative(part, native, { time: {} });
889
+ continue;
890
+ }
891
+ const { id: nativeId, state: nativeState, ...rest } = native;
892
+ mergeNative(part, rest);
893
+ if (nativeId !== part.callID) mergeNative(part, { id: nativeId });
894
+ if (isRecord(part.state) && isRecord(nativeState)) part.state = mergeNative({ ...part.state }, nativeState, STATE_NESTED);
895
+ }
896
+ const streamed = timing?.streamed ?? at ?? undefined;
897
+ const relativeChanged = changed
898
+ .map((file) => relative(cwd, file))
899
+ .filter((file) => file && !file.startsWith("..") && !isAbsolute(file));
900
+ const info = mergeNative({
901
+ id,
902
+ sessionID: sessionId,
903
+ role: "assistant",
904
+ ...(lastUserId ? { parentID: lastUserId } : {}),
905
+ time: { created: created ?? 0, completed: completed ?? 0 },
906
+ ...(typeof message.model === "string" ? { modelID: message.model } : {}),
907
+ ...(typeof message.provider === "string" ? { providerID: message.provider } : {}),
908
+ ...(typeof message.responseModel === "string" && message.responseModel && message.responseModel !== message.model
909
+ ? { responseModelID: message.responseModel }
910
+ : {}),
911
+ agent,
912
+ mode: agent,
913
+ ...(effort ? { variant: effort } : {}),
914
+ path: { cwd, root: cwd },
915
+ cost,
916
+ tokens,
917
+ ...(finish ? { finish } : {}),
918
+ ...(error ? { error } : {}),
919
+ }, defined({
920
+ // The 2.x assistant record's own fields (engine2/native.ts enrichAssistant).
921
+ type: "assistant",
922
+ agent,
923
+ model: typeof message.model === "string" && typeof message.provider === "string"
924
+ ? { id: message.model, providerID: message.provider, ...(effort ? { variant: effort } : {}) }
925
+ : undefined,
926
+ snapshot: relativeChanged.length > 0 ? { files: relativeChanged } : undefined,
927
+ finish,
928
+ rawFinish: nativeRawFinish(message),
929
+ providerState: responsesApi(api) && typeof message.responseId === "string" && message.responseId ? { responseId: message.responseId } : undefined,
930
+ cost,
931
+ tokens: nativeTokens(message.usage),
932
+ error: nativeError(message),
933
+ time: defined({ created: created ?? 0, streamed, completed: pending ? undefined : settled ?? undefined }),
934
+ }), INFO_NESTED);
935
+ emit({ info, parts: stepParts });
585
936
  return;
586
937
  }
587
938
  default:
@@ -590,6 +941,9 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
590
941
  return;
591
942
  }
592
943
  });
944
+ // Changes after the last message: at the end of the last message that takes them.
945
+ settleSwitch();
946
+ for (const record of pendingRecords.splice(0)) appendRecord(record);
593
947
  return out;
594
948
  }
595
949
 
@@ -0,0 +1,15 @@
1
+ /** Environment passed to user-controlled tools and MCP servers. */
2
+ const SECRET_ENV_NAMES = new Set([
3
+ "OMNIRUSH_TOKEN",
4
+ "OMNIRUSH_ACCESS_TOKEN",
5
+ "OMNIRUSH_APPROVAL_TOKEN",
6
+ ]);
7
+
8
+ /** Keep provider credentials in the agent process, never in shell/MCP children. */
9
+ export function sanitizeToolEnvironment(input: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
10
+ const output: NodeJS.ProcessEnv = {};
11
+ for (const [key, value] of Object.entries(input)) {
12
+ if (!SECRET_ENV_NAMES.has(key) && value !== undefined) output[key] = value;
13
+ }
14
+ return output;
15
+ }