@visual-z/xeno 0.1.4 → 0.2.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.
Files changed (146) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +143 -33
  3. package/docs/install.md +18 -6
  4. package/docs/providers.md +147 -0
  5. package/package.json +8 -2
  6. package/src/apps/cli.ts +42 -14
  7. package/src/apps/commands.ts +32 -4
  8. package/src/apps/completions.ts +9 -6
  9. package/src/apps/export-cli.ts +24 -0
  10. package/src/apps/extension-ui.ts +330 -0
  11. package/src/apps/input.ts +16 -1
  12. package/src/apps/model-catalog.ts +42 -0
  13. package/src/apps/project-trust.ts +49 -0
  14. package/src/apps/render.ts +1 -0
  15. package/src/apps/resource-browser.ts +75 -0
  16. package/src/apps/resources.ts +25 -0
  17. package/src/apps/session-browser.ts +113 -0
  18. package/src/apps/session-picker.ts +55 -0
  19. package/src/apps/shell.ts +1177 -378
  20. package/src/apps/startup.ts +400 -268
  21. package/src/core/action-defaults.ts +306 -0
  22. package/src/core/attachment-input.ts +17 -0
  23. package/src/core/catalog-merge.ts +19 -0
  24. package/src/core/event-validation.ts +19 -2
  25. package/src/core/events.ts +45 -2
  26. package/src/core/export.ts +6 -2
  27. package/src/core/extension-flags.ts +14 -0
  28. package/src/core/images.ts +46 -0
  29. package/src/core/json-event.ts +2 -0
  30. package/src/core/keymap.ts +43 -119
  31. package/src/core/model-replay.ts +22 -0
  32. package/src/core/model-scope.ts +53 -0
  33. package/src/core/packages.ts +47 -0
  34. package/src/core/project.ts +21 -3
  35. package/src/core/prompts.ts +33 -5
  36. package/src/core/queue.ts +18 -0
  37. package/src/core/resource-selection.ts +36 -0
  38. package/src/{runtime → core}/resume.ts +15 -22
  39. package/src/core/session-archive.ts +4 -0
  40. package/src/core/session-browser.ts +82 -0
  41. package/src/core/session-entry.ts +56 -0
  42. package/src/core/session-position.ts +26 -0
  43. package/src/core/settings.ts +169 -154
  44. package/src/core/theme.ts +15 -6
  45. package/src/core/thinking.ts +26 -0
  46. package/src/core/tokens.ts +2 -0
  47. package/src/core/transcript.ts +15 -5
  48. package/src/runtime/auth.ts +116 -11
  49. package/src/runtime/catalog.ts +54 -151
  50. package/src/runtime/clipboard.ts +114 -0
  51. package/src/runtime/extension-loader.ts +37 -0
  52. package/src/runtime/extension-ui.ts +55 -0
  53. package/src/runtime/extensions.ts +273 -0
  54. package/src/runtime/images.ts +76 -0
  55. package/src/runtime/loop.ts +213 -83
  56. package/src/runtime/model-config.ts +232 -0
  57. package/src/runtime/model-data.LICENSE +27 -0
  58. package/src/runtime/model-data.ts +21 -0
  59. package/src/runtime/model-replay.ts +10 -0
  60. package/src/runtime/model-scope.ts +20 -0
  61. package/src/runtime/model-sources.ts +151 -0
  62. package/src/runtime/model.ts +74 -11
  63. package/src/runtime/models-fetch.ts +6 -104
  64. package/src/runtime/oauth.ts +4 -3
  65. package/src/runtime/openai-responses.ts +12 -4
  66. package/src/runtime/openai.ts +13 -4
  67. package/src/runtime/package-manager.ts +125 -0
  68. package/src/runtime/pi-provider.ts +235 -0
  69. package/src/runtime/progress.ts +26 -0
  70. package/src/runtime/provider-access.ts +21 -0
  71. package/src/runtime/provider-protocols.ts +29 -0
  72. package/src/runtime/provider-request.ts +73 -0
  73. package/src/runtime/provider.ts +26 -6
  74. package/src/runtime/rich-text.ts +36 -0
  75. package/src/runtime/tools.ts +41 -5
  76. package/src/store/command.ts +7 -1
  77. package/src/store/context-files.ts +1 -1
  78. package/src/store/log.ts +65 -0
  79. package/src/store/models.ts +28 -9
  80. package/src/store/observations.ts +4 -2
  81. package/src/store/packages.ts +98 -0
  82. package/src/store/recovery.ts +8 -0
  83. package/src/store/resource-index.ts +170 -0
  84. package/src/store/resources.ts +75 -142
  85. package/src/store/session-archive.ts +162 -0
  86. package/src/store/session-browser.ts +68 -0
  87. package/src/store/session-position.ts +27 -0
  88. package/src/store/sessions.ts +87 -66
  89. package/src/store/trust.ts +13 -0
  90. package/src/tui/chat-viewport.ts +47 -0
  91. package/src/tui/editor.ts +62 -15
  92. package/src/tui/host.ts +50 -9
  93. package/src/tui/input.ts +8 -2
  94. package/src/tui/select.ts +24 -6
  95. package/src/tui/settings.ts +4 -0
  96. package/src/tui/system-theme.ts +700 -0
  97. package/src/tui/transcript.ts +63 -11
  98. package/src/tui/tree.ts +161 -95
  99. package/src/vendor/pi-rich/LICENSE.upstream +21 -0
  100. package/src/vendor/pi-rich/UPSTREAM.json +17 -0
  101. package/src/vendor/pi-rich/clipboard-command.ts +44 -0
  102. package/src/vendor/pi-rich/exif-orientation.ts +182 -0
  103. package/src/vendor/pi-rich/html.ts +51 -0
  104. package/src/vendor/pi-rich/image-convert.ts +49 -0
  105. package/src/vendor/pi-rich/image-resize-core.ts +164 -0
  106. package/src/vendor/pi-rich/image-resize-worker.ts +42 -0
  107. package/src/vendor/pi-rich/image-resize.ts +123 -0
  108. package/src/vendor/pi-rich/mime.ts +116 -0
  109. package/src/vendor/pi-rich/photon.ts +139 -0
  110. package/src/vendor/pi-rich/syntax-highlight.ts +214 -0
  111. package/src/vendor/pi-tui/UPSTREAM.json +50 -0
  112. package/src/vendor/pi-tui/VENDOR.md +7 -5
  113. package/src/vendor/pi-tui/autocomplete.ts +35 -10
  114. package/src/vendor/pi-tui/colors.ts +367 -0
  115. package/src/vendor/pi-tui/components/box.ts +8 -9
  116. package/src/vendor/pi-tui/components/editor.ts +4 -2
  117. package/src/vendor/pi-tui/components/markdown.ts +8 -2
  118. package/src/vendor/pi-tui/index.ts +31 -1
  119. package/src/vendor/pi-tui/native/UPSTREAM.json +23 -0
  120. package/src/vendor/pi-tui/native/clipboard.h +208 -0
  121. package/src/vendor/pi-tui/native/darwin/README.md +22 -0
  122. package/src/vendor/pi-tui/native/darwin/build.sh +69 -0
  123. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  124. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  125. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +132 -0
  126. package/src/vendor/pi-tui/native/linux/README.md +23 -0
  127. package/src/vendor/pi-tui/native/linux/build.sh +65 -0
  128. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  129. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  130. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +70 -0
  131. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +274 -0
  132. package/src/vendor/pi-tui/native/napi.h +91 -0
  133. package/src/vendor/pi-tui/native/win32/README.md +31 -0
  134. package/src/vendor/pi-tui/native/win32/build.mjs +232 -0
  135. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  136. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  137. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +241 -0
  138. package/src/vendor/pi-tui/native-platform.ts +2 -0
  139. package/src/vendor/pi-tui/oklab.ts +233 -0
  140. package/src/vendor/pi-tui/terminal-colors.ts +26 -8
  141. package/src/vendor/pi-tui/terminal-image.ts +43 -8
  142. package/src/vendor/pi-tui/terminal.ts +16 -9
  143. package/src/vendor/pi-tui/tui-alt-screen.ts +26 -18
  144. package/src/vendor/pi-tui/tui.ts +112 -75
  145. package/src/vendor/pi-tui/utils.ts +89 -49
  146. package/src/vendor/pi-tui/wheel-scroll.ts +82 -0
@@ -19,6 +19,8 @@
19
19
  */
20
20
 
21
21
  import type { LogEvent, AssistantBlock, NodeRef, CutReason } from "../core/events.ts";
22
+ import { userInputData, submissionImages, type UserInput, type ImageRef } from "../core/images.ts";
23
+ import { hydrateImages } from "./images.ts";
22
24
  import type { Handoff } from "../core/handoff.ts";
23
25
  import { boundHandoff, DEFAULT_HANDOFF_TOKENS } from "../core/history.ts";
24
26
  import { boundaryPreparation, continuationGuidance } from "../core/continuation.ts";
@@ -43,9 +45,10 @@ import { churnReview } from "../core/churn.ts";
43
45
  import { deadlineNotice } from "../core/deadline.ts";
44
46
  import { statusOf } from "./retry.ts";
45
47
  import { assertNever } from "../core/exhaustive.ts";
48
+ import { isLogEvent } from "../core/event-validation.ts";
46
49
  import {
47
50
  initNode, appendEvent, readNode, closeNode, digestNode, handoffPath, nodePath, manifestPath,
48
- listNodes, readCursor, writeCursor,
51
+ listNodes, readCursor, readCursorAt, writeCursor,
49
52
  } from "../store/log.ts";
50
53
  import { replayContext } from "../store/replay.ts";
51
54
  import { recoverCut } from "../store/recovery.ts";
@@ -66,8 +69,16 @@ import {
66
69
  type ToolContext, type Annotation, type ToolResult,
67
70
  } from "./tools.ts";
68
71
  import { diagnosePrefix, totalUsage, type UsageSample, type UsageTotals } from "./usage.ts";
69
- import { planResume } from "./resume.ts";
72
+ import { planResume } from "../core/resume.ts";
70
73
  import type { Phase } from "../core/queue.ts";
74
+ import { sameModel } from "../core/session-position.ts";
75
+ import type { Effort } from "./model.ts";
76
+ import { providerReplayKey, nativeReplay } from "./model-replay.ts";
77
+ import { needsReplayCheckpoint } from "../core/model-replay.ts";
78
+ import { resolveHistoryPosition } from "../store/session-position.ts";
79
+ import { observeCommand } from "../store/command.ts";
80
+ import { headTailView } from "../core/observation-view.ts";
81
+ import { withProgress } from "./progress.ts";
71
82
 
72
83
  /** Ids and timestamps come from here so the loop stays the only impure part. */
73
84
  export interface Clock {
@@ -90,6 +101,7 @@ export interface LoopEvent {
90
101
  | "text_delta"
91
102
  | "thinking_delta"
92
103
  | "discard"
104
+ | "progress"
93
105
  | "tool_call"
94
106
  | "tool_result"
95
107
  | "usage"
@@ -110,6 +122,7 @@ export interface LoopEvent {
110
122
  /** branch_start: wall-clock milliseconds, for asserting the stagger. */
111
123
  at?: number;
112
124
  outcome?: ExecutionOutcome;
125
+ progress?: Extract<StreamDelta, { kind: "tool" | "retry" }> | { kind: "output"; text: string; name: string };
113
126
  }
114
127
 
115
128
  /**
@@ -191,6 +204,7 @@ export interface SessionConfig {
191
204
  * usable without the store -- a read-only branch has nothing to snapshot.
192
205
  */
193
206
  blobDir?: string;
207
+ imageOptions?: { readonly autoResize?: boolean; readonly blockImages?: boolean };
194
208
  /** Directory names the scan skips, relative to the workspace root. */
195
209
  snapshotIgnore?: readonly string[];
196
210
  }
@@ -236,6 +250,8 @@ async function digestedRef(dir: string, node: number): Promise<NodeRef> {
236
250
  */
237
251
  export interface RestorePoint {
238
252
  node: number;
253
+ /** The user message is the editable navigation target; idle snapshots have none. */
254
+ userId?: string;
239
255
  /** Last event of the exchange. Inclusive: the exchange itself is kept. */
240
256
  atEvent: string;
241
257
  /** The prompt that opened the exchange, for a caller that has to show it. */
@@ -335,7 +351,7 @@ export class Session {
335
351
  const existing = await listNodes(dir);
336
352
  const recordedCursor = await readCursor(dir);
337
353
  const cursor = recordedCursor === null ? null : await recoverCut(dir, recordedCursor);
338
- const plan = planResume(cursor, model, existing.length > 0);
354
+ const plan = planResume(cursor, model, existing.length > 0, this.provider.config.spec.provider);
339
355
 
340
356
  switch (plan.kind) {
341
357
  case "refuse":
@@ -383,6 +399,7 @@ export class Session {
383
399
  else delete this.config.snapshotIgnore;
384
400
  }
385
401
  if (plan.cursor.workspace !== undefined) this.config.tools.root = plan.cursor.workspace;
402
+ if (plan.cursor.blob_dir !== undefined) this.config.blobDir = plan.cursor.blob_dir;
386
403
  for (const id of existing) {
387
404
  for (const event of (await readNode(dir, id)).events) {
388
405
  this.allocatedEventIds.add(event.id);
@@ -430,6 +447,9 @@ export class Session {
430
447
  return assertNever(plan, "Session.start");
431
448
  }
432
449
 
450
+ if (cursor?.needs_model_checkpoint || needsReplayCheckpoint(this.state.events, providerReplayKey(this.provider.config.spec), nativeReplay(this.provider.config.spec))) {
451
+ await this.performCut("model_checkpoint", model);
452
+ }
433
453
  await this.saveCursor();
434
454
  }
435
455
 
@@ -439,8 +459,76 @@ export class Session {
439
459
  * a fork that rewrote the original would leave the user with one line
440
460
  * when they thought they had two.
441
461
  */
442
- async fork(destLogDir: string, untilEvent?: string): Promise<void> {
443
- await copyAncestryTo(this.config.logDir, destLogDir, this.clock.now(), untilEvent);
462
+ async fork(destLogDir: string, untilEvent?: string, position: "at" | "before" = "at"): Promise<void> {
463
+ await copyAncestryTo(this.config.logDir, destLogDir, this.clock.now(), untilEvent, position);
464
+ }
465
+
466
+ async setThinking(effort: Effort): Promise<void> {
467
+ this.provider.config.effort = effort;
468
+ await this.saveCursor();
469
+ }
470
+
471
+ /** Pi user bash shares process ownership and archival with tools, never their authority or model dispatch. */
472
+ async runUserBash(command: string, excludeFromContext: boolean, signal?: AbortSignal,
473
+ onOutput?: (chunk: Uint8Array) => void): Promise<Extract<LogEvent, { t: "user_bash" }>> {
474
+ const owned = this.execution.begin(signal);
475
+ try {
476
+ if (!command.trim()) throw new Error("empty user command");
477
+ for await (const event of this.snapshotTurn([], false, "idle")) {
478
+ if (event.kind === "snapshot_failed") throw new Error(event.reason);
479
+ }
480
+ const captured = await observeCommand({ command, cwd: this.config.tools.root,
481
+ outputStore: join(this.config.logDir, "artifacts"), maxOutputBytes: this.config.tools.maxOutputBytes,
482
+ timeoutMs: this.config.tools.timeoutMs, signal: owned.signal, ...(onOutput ? { onOutput } : {}) });
483
+ const outcome = executionOutcome(captured.stopped === "cancelled" ? "cancelled" : captured.stopped === "timeout" ? "timeout"
484
+ : captured.exit === 0 ? "tool_completed" : "tool_failed");
485
+ const preview = headTailView(captured.head, captured.tail, captured.ref.bytes, this.config.tools.maxOutputBytes,
486
+ omitted => `\n[${omitted} bytes omitted; complete output: ${captured.ref.path}]\n`);
487
+ const content = `${preview}\nexit ${captured.exit} · ${outcome.status} · ${outcome.reason}`;
488
+ const event: Extract<LogEvent, { t: "user_bash" }> = { t: "user_bash", id: this.clock.nextId(), at: this.clock.now(),
489
+ command, content, exit: captured.exit, excludeFromContext, outcome, content_ref: captured.ref };
490
+ await this.record(event);
491
+ for await (const snapshot of this.snapshotTurn([])) {
492
+ if (snapshot.kind === "snapshot_failed") throw new Error(snapshot.reason);
493
+ }
494
+ await this.saveCursor();
495
+ return event;
496
+ } catch (error) {
497
+ await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
498
+ reason: "user command failed before completion; inspect the workspace before retrying", outcome: executionOutcome("execution_error") });
499
+ throw error;
500
+ } finally { owned.finish(); }
501
+ }
502
+
503
+ async userMessages(): Promise<{ id: string; node: number; text: string; images?: readonly ImageRef[] }[]> {
504
+ const messages: Awaited<ReturnType<Session["userMessages"]>> = [];
505
+ for (const node of await listNodes(this.config.logDir)) {
506
+ for (const event of (await readNode(this.config.logDir, node)).events) {
507
+ if (event.t === "user") messages.push({ id: event.id, node, ...userInputData(event) });
508
+ }
509
+ }
510
+ return messages;
511
+ }
512
+
513
+ /** Pi keeps the selected runtime model when navigating; N.8 checkpoints incompatible context. */
514
+ async navigateTo(eventId: string): Promise<{ editorText?: string; images?: readonly ImageRef[] }> {
515
+ const dir = this.config.logDir;
516
+ const users = await this.userMessages();
517
+ const target = await resolveHistoryPosition(dir, eventId, users.some(user => user.id === eventId) ? "before" : "at");
518
+ for await (const event of this.snapshotTurn([], false, "idle")) {
519
+ if (event.kind === "snapshot_failed") throw new Error(event.reason);
520
+ }
521
+ const node = this.state.nextNode++;
522
+ await initNode(dir, node, target.parent, this.clock.now(), target.basis);
523
+ const cursor = await readCursor(dir);
524
+ if (!cursor) throw new Error("missing current session cursor");
525
+ const next = { ...cursor, node, next_node: this.state.nextNode, updated_at: this.clock.now(),
526
+ needs_model_checkpoint: target.parent !== null && (target.selection === null || !sameModel(target.selection, cursor)) };
527
+ delete next.at_event;
528
+ await writeCursor(dir, next);
529
+ await this.start();
530
+ const images = users.find(user => user.id === eventId)?.images;
531
+ return target.editorText === undefined ? {} : { editorText: target.editorText, ...(images?.length ? { images } : {}) };
444
532
  }
445
533
 
446
534
  /**
@@ -470,18 +558,31 @@ export class Session {
470
558
  * set; switchModel is the other caller that wants it.
471
559
  */
472
560
  async switchModel(next: Provider): Promise<void> {
473
- if (this.isHalfRound()) {
561
+ if (this.running || this.isHalfRound()) {
474
562
  throw new Error(
475
563
  "cannot switch models mid-turn: a tool_use is still unpaired. " +
476
564
  "Wait for the turn to finish so every call has a result",
477
565
  );
478
566
  }
479
567
 
480
- await this.performCut("model_checkpoint", next.config.spec.id);
568
+ await this.performCut("model_checkpoint", next.config.spec.id, undefined, next.config.spec.provider, next.config.effort);
481
569
  this.provider = next;
482
570
  await this.saveCursor();
483
571
  }
484
572
 
573
+ /** D6: changing the frozen prefix is an explicit checkpoint, never in-place prompt surgery. */
574
+ async reloadResources(frozen: FrozenPrefix): Promise<boolean> {
575
+ if (JSON.stringify(frozen) === JSON.stringify(this.config.frozen)) return false;
576
+ if (this.running || this.isHalfRound()) throw new Error("cannot reload resources while a turn is active or a tool call is unpaired");
577
+ const contextRef = await this.persistContext(frozen);
578
+ await this.performCut("model_checkpoint", this.provider.config.spec.id, undefined,
579
+ this.provider.config.spec.provider, this.provider.config.effort, contextRef);
580
+ this.config.frozen = frozen;
581
+ this.config.tools.allow = frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" ? [tool.name] : []);
582
+ await this.saveCursor();
583
+ return true;
584
+ }
585
+
485
586
  /**
486
587
  * The same pairing check switchModel already used. A user message
487
588
  * between tool_use and tool_result is the same unpairable request a
@@ -503,11 +604,11 @@ export class Session {
503
604
  * offered a line — writing it would be the pairing bug this guards.
504
605
  */
505
606
  private async pullSteering(
506
- pull?: (phase: Phase) => Promise<string | null>,
507
- ): Promise<string | null> {
607
+ pull?: (phase: Phase) => Promise<UserInput | null>,
608
+ ): Promise<UserInput | null> {
508
609
  if (pull === undefined || this.isHalfRound()) return null;
509
610
  const text = await pull("after_tools");
510
- if (text === null || text.length === 0) return null;
611
+ if (text === null || typeof text === "string" && text.length === 0) return null;
511
612
  return text;
512
613
  }
513
614
 
@@ -518,11 +619,9 @@ export class Session {
518
619
  * resuming from -- a crash, a kill, a closed laptop -- do not run exit
519
620
  * handlers.
520
621
  */
521
- private async saveCursor(): Promise<void> {
522
- // Branches share storage but never own the parent's active cursor.
523
- if ((this.config.branchDepth ?? 0) > 0) return;
524
- const contextRef = await putBlob(join(this.config.logDir, "artifacts"), JSON.stringify({
525
- frozen: this.config.frozen, gates: this.config.tools.gates,
622
+ private async persistContext(frozen = this.config.frozen): Promise<string> {
623
+ return putBlob(join(this.config.logDir, "artifacts"), JSON.stringify({
624
+ frozen, gates: this.config.tools.gates,
526
625
  workNamespace: this.config.workNamespace ?? "main",
527
626
  maxParallelTools: this.config.maxParallelTools ?? 3,
528
627
  ...(this.config.boundary ? { boundary: this.config.boundary } : {}),
@@ -532,13 +631,24 @@ export class Session {
532
631
  ...(this.config.guidance === undefined ? {} : { guidance: this.config.guidance }),
533
632
  ...(this.config.snapshotIgnore ? { snapshotIgnore: this.config.snapshotIgnore } : {}),
534
633
  }));
634
+ }
635
+
636
+ private async saveCursor(model = this.provider.config.spec.id, provider = this.provider.config.spec.provider,
637
+ thinking = this.provider.config.effort, contextRef?: string): Promise<void> {
638
+ // Branches share storage but never own the parent's active cursor.
639
+ if ((this.config.branchDepth ?? 0) > 0) return;
640
+ const last = (await readNode(this.config.logDir, this.state.node)).events.at(-1);
535
641
  await writeCursor(this.config.logDir, {
536
642
  node: this.state.node,
537
643
  next_node: this.state.nextNode,
538
- model: this.provider.config.spec.id,
644
+ model,
645
+ provider,
646
+ thinking,
647
+ ...(last ? { at_event: last.id } : {}),
648
+ ...(this.config.blobDir === undefined ? {} : { blob_dir: this.config.blobDir }),
539
649
  updated_at: this.clock.now(),
540
650
  workspace: this.config.tools.root,
541
- context_ref: contextRef,
651
+ context_ref: contextRef ?? await this.persistContext(),
542
652
  });
543
653
  }
544
654
 
@@ -548,6 +658,15 @@ export class Session {
548
658
  if (this.stall !== null) this.stall = advanceStall(this.stall, [{ node: this.state.node, event }]);
549
659
  }
550
660
 
661
+ async appendExtension(entry: Omit<Extract<LogEvent, { t: "extension" }>, "t" | "id" | "at">): Promise<void> {
662
+ // Messages are only pairable between tools. Private extension state may
663
+ // be recorded during execution because it never enters model context.
664
+ if (entry.inContext && (this.running || this.isHalfRound())) throw new Error("Custom messages must be queued until the active turn finishes");
665
+ const event = { ...entry, t: "extension" as const, id: this.clock.nextId(), at: this.clock.now() };
666
+ if (!isLogEvent(event)) throw new Error("Invalid extension entry");
667
+ await this.record(event);
668
+ }
669
+
551
670
  get totals(): UsageTotals {
552
671
  return totalUsage(this.state.usage);
553
672
  }
@@ -568,9 +687,9 @@ export class Session {
568
687
  * available, and would also lie about a call that may have had effects).
569
688
  */
570
689
  async *send(
571
- userText: string,
690
+ userText: UserInput,
572
691
  signal?: AbortSignal,
573
- pull?: (phase: Phase) => Promise<string | null>,
692
+ pull?: (phase: Phase) => Promise<UserInput | null>,
574
693
  ): AsyncGenerator<LoopEvent> {
575
694
  const owned = this.execution.begin(signal);
576
695
  const events = this.run(userText, owned.signal, pull);
@@ -580,10 +699,25 @@ export class Session {
580
699
  for (;;) {
581
700
  const item = await events.next();
582
701
  if (item.done) { exhausted = true; break; }
583
- if (item.value.kind === "done") terminal = true;
702
+ if (item.value.kind === "done") {
703
+ terminal = true;
704
+ if (item.value.outcome && item.value.outcome.reason !== "model_end") await this.record({
705
+ t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
706
+ reason: item.value.reason ?? item.value.outcome.reason, outcome: item.value.outcome,
707
+ });
708
+ }
584
709
  yield item.value;
585
710
  }
586
- if (!terminal) yield { kind: "done", reason: "stopped: workspace snapshot failed", outcome: executionOutcome("snapshot_failed") };
711
+ if (!terminal) {
712
+ const outcome = executionOutcome("snapshot_failed");
713
+ await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), reason: "workspace snapshot failed", outcome });
714
+ yield { kind: "done", reason: "stopped: workspace snapshot failed", outcome };
715
+ }
716
+ } catch (error) {
717
+ // Raw provider exceptions can contain credentials; persist only the execution fact.
718
+ await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
719
+ reason: "request failed before completion; no complete response was committed", outcome: executionOutcome("provider_error") });
720
+ throw error;
587
721
  } finally {
588
722
  // Closing a consumer is not a user cancellation. Preserve the existing
589
723
  // crash/recovery points; only an explicit signal can claim cancellation.
@@ -596,12 +730,22 @@ export class Session {
596
730
  }
597
731
 
598
732
  private async *run(
599
- userText: string,
733
+ userText: UserInput,
600
734
  signal?: AbortSignal,
601
- pull?: (phase: Phase) => Promise<string | null>,
735
+ pull?: (phase: Phase) => Promise<UserInput | null>,
602
736
  ): AsyncGenerator<LoopEvent> {
737
+ if (needsReplayCheckpoint(this.state.events, providerReplayKey(this.provider.config.spec), nativeReplay(this.provider.config.spec))) {
738
+ if (this.isHalfRound()) throw new Error("Cannot change a wire profile while tool results are pending");
739
+ await this.performCut("model_checkpoint");
740
+ }
741
+ const blocked = this.config.imageOptions?.blockImages ?? false;
742
+ // A privacy change uses the existing mechanical checkpoint (D5/D8); it never edits old messages.
743
+ if (blocked && this.state.events.some(e => (e.t === "user" || e.t === "tool_result") && e.images?.some(image => !image.blocked))) {
744
+ if (this.isHalfRound()) throw new Error("Cannot change image transmission while tool results are pending");
745
+ await this.performCut("model_checkpoint");
746
+ }
603
747
  await this.record({
604
- t: "user", id: this.clock.nextId(), at: this.clock.now(), text: userText,
748
+ t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(userText, blocked),
605
749
  });
606
750
  // Idle changes belong to the new prompt. Recording them before `user`
607
751
  // would silently move the preceding exchange's restore point forward.
@@ -748,6 +892,7 @@ export class Session {
748
892
 
749
893
  await this.record({
750
894
  t: "assistant", id: this.clock.nextId(), at: this.clock.now(), blocks: result.blocks, stop_reason: result.stopReason,
895
+ replay_key: providerReplayKey(this.provider.config.spec),
751
896
  });
752
897
 
753
898
  for (const b of result.blocks) {
@@ -767,7 +912,7 @@ export class Session {
767
912
  }
768
913
  const extra = await this.pullSteering(pull);
769
914
  if (extra !== null) {
770
- await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), text: extra });
915
+ await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(extra, blocked) });
771
916
  continue;
772
917
  }
773
918
  const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
@@ -790,7 +935,7 @@ export class Session {
790
935
  const extra = await this.pullSteering(pull);
791
936
  if (extra !== null) {
792
937
  await this.record({
793
- t: "user", id: this.clock.nextId(), at: this.clock.now(), text: extra,
938
+ t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(extra, blocked),
794
939
  });
795
940
  continue;
796
941
  }
@@ -861,7 +1006,7 @@ export class Session {
861
1006
  // `branch`/`resolve` act on the tree. Each still produces an ordinary
862
1007
  // tool_result so the turn stays pairable. They go through the same
863
1008
  // gate as read/write/edit/bash, which runTool applies itself.
864
- const toolContext = { ...this.config.tools, ...(signal ? { signal } : {}) };
1009
+ const toolContext = { ...this.config.tools, imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"), imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}) };
865
1010
  if (call.name === "note" || call.name === "branch" || call.name === "resolve" || call.name === "history") {
866
1011
  const gated = await gateTool(toolContext, call.name, call.input);
867
1012
  if (gated) {
@@ -879,7 +1024,10 @@ export class Session {
879
1024
  out = await this.runResolve(call.input);
880
1025
  }
881
1026
  } else {
882
- out = await runTool(toolContext, call.name, call.input);
1027
+ const decoder = new TextDecoder();
1028
+ out = yield* withProgress<ToolResult, LoopEvent>(emit => runTool({ ...toolContext, callId: call.call_id,
1029
+ onOutput: chunk => emit({ kind: "progress", progress: { kind: "output", name: call.name, text: decoder.decode(chunk, { stream: true }) } }),
1030
+ }, call.name, call.input));
883
1031
  }
884
1032
  }
885
1033
  if (out.accept) for (const event of await out.accept()) yield event;
@@ -906,6 +1054,7 @@ export class Session {
906
1054
  ...(out.diagnostic !== undefined ? { diagnostic: out.diagnostic } : {}),
907
1055
  ...(out.content_sha256 ? { content_sha256: out.content_sha256 } : {}),
908
1056
  ...(out.content_ref ? { content_ref: out.content_ref } : {}),
1057
+ ...(out.images?.length ? { images: submissionImages(out.images, blocked) } : {}),
909
1058
  ...(out.effect ? { effect: out.effect } : {}),
910
1059
  });
911
1060
  completedThisTurn.push(call.call_id);
@@ -930,7 +1079,7 @@ export class Session {
930
1079
  const extra = await this.pullSteering(pull);
931
1080
  if (extra !== null) {
932
1081
  await this.record({
933
- t: "user", id: this.clock.nextId(), at: this.clock.now(), text: extra,
1082
+ t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(extra, blocked),
934
1083
  });
935
1084
  }
936
1085
  }
@@ -1014,62 +1163,27 @@ export class Session {
1014
1163
  messages: readonly Message[],
1015
1164
  signal?: AbortSignal,
1016
1165
  ): AsyncGenerator<LoopEvent, Turn> {
1017
- const queue: LoopEvent[] = [];
1018
- let notify: (() => void) | null = null;
1019
- const box: { settled: { ok: true; turn: Turn } | { ok: false; error: unknown } | null } = {
1020
- settled: null,
1021
- };
1022
-
1023
- const onDelta = (delta: StreamDelta): void => {
1166
+ const hydrated = await hydrateImages(messages, this.config.blobDir ?? join(this.config.logDir, "blobs"),
1167
+ this.provider.config.spec.input?.includes("image") ?? false);
1168
+ return yield* withProgress<Turn, LoopEvent>(emit => this.provider.send(
1169
+ this.config.frozen, hydrated, this.state.node, signal, (delta: StreamDelta): void => {
1024
1170
  switch (delta.kind) {
1025
1171
  case "text":
1026
- queue.push({ kind: "text_delta", text: delta.text });
1172
+ emit({ kind: "text_delta", text: delta.text });
1027
1173
  break;
1028
1174
  case "thinking":
1029
- queue.push({ kind: "thinking_delta", text: delta.text });
1175
+ emit({ kind: "thinking_delta", text: delta.text });
1030
1176
  break;
1031
1177
  case "discard":
1032
- queue.push({ kind: "discard" });
1178
+ emit({ kind: "discard" });
1179
+ break;
1180
+ case "tool": case "retry":
1181
+ emit({ kind: "progress", progress: delta });
1033
1182
  break;
1034
1183
  default:
1035
1184
  return assertNever(delta, "onDelta");
1036
1185
  }
1037
- notify?.();
1038
- };
1039
-
1040
- const pending = this.provider
1041
- .send(this.config.frozen, messages, this.state.node, signal, onDelta)
1042
- .then(
1043
- (turn) => {
1044
- box.settled = { ok: true, turn };
1045
- notify?.();
1046
- },
1047
- (error: unknown) => {
1048
- box.settled = { ok: false, error };
1049
- notify?.();
1050
- },
1051
- );
1052
-
1053
- while (box.settled === null || queue.length > 0) {
1054
- if (queue.length > 0) {
1055
- yield queue.shift()!;
1056
- continue;
1057
- }
1058
- await new Promise<void>((resolve) => {
1059
- if (box.settled !== null || queue.length > 0) {
1060
- resolve();
1061
- return;
1062
- }
1063
- notify = resolve;
1064
- });
1065
- notify = null;
1066
- }
1067
-
1068
- await pending;
1069
- const end = box.settled;
1070
- if (end === null) throw new Error("provider send settled without a result");
1071
- if (!end.ok) throw end.error;
1072
- return end.turn;
1186
+ }));
1073
1187
  }
1074
1188
 
1075
1189
  /**
@@ -1166,11 +1280,11 @@ export class Session {
1166
1280
  private executionMode(call: ToolCall): "parallel" | "exclusive" {
1167
1281
  if (call.name === "branch" && typeof call.input === "object" && call.input !== null &&
1168
1282
  (call.input as Record<string, unknown>)["writes"] !== true) return "parallel";
1169
- return toolDefinition(call.name)?.execution ?? "exclusive";
1283
+ return toolDefinition(call.name, this.config.tools)?.execution ?? "exclusive";
1170
1284
  }
1171
1285
 
1172
1286
  private async *prepareConcurrentCall(call: ToolCall, branchIndex: number, signal?: AbortSignal): AsyncGenerator<LoopEvent, () => Promise<DeferredToolResult>> {
1173
- const ctx = { ...this.config.tools, ...(signal ? { signal } : {}) };
1287
+ const ctx = { ...this.config.tools, callId: call.call_id, imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"), imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}) };
1174
1288
  if (call.name !== "branch") {
1175
1289
  const prepared = await prepareTool(ctx, call.name, call.input);
1176
1290
  return typeof prepared === "function" ? prepared : async () => prepared;
@@ -1544,11 +1658,11 @@ export class Session {
1544
1658
 
1545
1659
  const points: RestorePoint[] = [];
1546
1660
  for (const n of all) {
1547
- let open: { prompt: string; changed: number; last: string } | null = null;
1661
+ let open: { prompt: string; changed: number; last: string; userId: string } | null = null;
1548
1662
  const close = () => {
1549
1663
  if (open) {
1550
1664
  points.push({
1551
- node: n.node, atEvent: open.last, prompt: open.prompt,
1665
+ node: n.node, atEvent: open.last, prompt: open.prompt, userId: open.userId,
1552
1666
  changed: open.changed, onPath: onPath.has(open.last),
1553
1667
  });
1554
1668
  }
@@ -1564,7 +1678,7 @@ export class Session {
1564
1678
  }
1565
1679
  if (e.t === "user") {
1566
1680
  close();
1567
- open = { prompt: e.text, changed: 0, last: e.id };
1681
+ open = { prompt: e.text, changed: 0, last: e.id, userId: e.id };
1568
1682
  continue;
1569
1683
  }
1570
1684
  if (!open) continue;
@@ -1637,6 +1751,7 @@ export class Session {
1637
1751
  // what every mode needs -- to apply, or to say what it is NOT applying.
1638
1752
  const plan = await planRestoreToNode(dir, target, undefined, root, this.ignore);
1639
1753
  if (!plan.ok) return { ok: false, error: plan.error };
1754
+ const historical = await readCursorAt(dir, point.node, point.atEvent);
1640
1755
  // Preserve idle edits before moving away from them. An unobserved file is
1641
1756
  // not disposable just because the target predates it (D8/appendix H).
1642
1757
  for await (const event of this.snapshotTurn([], false, "idle")) {
@@ -1723,9 +1838,18 @@ export class Session {
1723
1838
  for await (const result of this.snapshotTurn([], true)) {
1724
1839
  if (result.kind === "snapshot_failed") throw new Error(result.reason);
1725
1840
  }
1841
+ const incompatible = historical === null || !sameModel(historical, {
1842
+ model: this.provider.config.spec.id, provider: this.provider.config.spec.provider,
1843
+ });
1844
+ if (incompatible) {
1845
+ // Persist the guard before opening the new model context, including on crash recovery.
1846
+ const current = await readCursor(dir);
1847
+ if (current) await writeCursor(dir, { ...current, node, next_node: this.state.nextNode, needs_model_checkpoint: true });
1848
+ await this.performCut("model_checkpoint", this.provider.config.spec.id);
1849
+ }
1726
1850
  await this.saveCursor();
1727
1851
 
1728
- return { ok: true, node, ...moved };
1852
+ return { ok: true, node: this.state.node, ...moved };
1729
1853
  }
1730
1854
 
1731
1855
  /**
@@ -1932,7 +2056,8 @@ export class Session {
1932
2056
  * would make a model switch wait for minNodeWorkTokens, and the new model
1933
2057
  * would see the old thinking blocks in the meantime.
1934
2058
  */
1935
- private async performCut(reason: CutReason, nextModel = this.provider.config.spec.id, retainedFrom?: string): Promise<void> {
2059
+ private async performCut(reason: CutReason, nextModel = this.provider.config.spec.id, retainedFrom?: string,
2060
+ nextProvider = this.provider.config.spec.provider, nextThinking = this.provider.config.effort, nextContextRef?: string): Promise<void> {
1936
2061
  const dir = this.config.logDir;
1937
2062
  const node = this.state.node;
1938
2063
 
@@ -1951,6 +2076,9 @@ export class Session {
1951
2076
  reason,
1952
2077
  next_node: this.state.nextNode,
1953
2078
  next_model: nextModel,
2079
+ next_provider: nextProvider,
2080
+ next_thinking: nextThinking,
2081
+ ...(nextContextRef === undefined ? {} : { next_context_ref: nextContextRef }),
1954
2082
  handoff_tokens: DEFAULT_HANDOFF_TOKENS,
1955
2083
  ...(retainedFrom === undefined ? {} : { retained_from: retainedFrom }),
1956
2084
  });
@@ -1978,6 +2106,8 @@ export class Session {
1978
2106
  // its derived state does. Relying on the default here would silently make
1979
2107
  // every boundary a fork and replay the whole history.
1980
2108
  await initNode(dir, this.state.node, self, this.clock.now(), "handoff");
1981
- await this.saveCursor();
2109
+ // The durable successor already belongs to the target provider, even if
2110
+ // the process exits before switchModel installs its in-memory adapter.
2111
+ await this.saveCursor(nextModel, nextProvider, nextThinking, nextContextRef);
1982
2112
  }
1983
2113
  }