@visual-z/xeno 0.8.2 → 0.8.4

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 (60) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/package.json +1 -1
  3. package/src/apps/review.ts +59 -59
  4. package/src/apps/shell.ts +20 -5
  5. package/src/apps/startup.ts +4 -4
  6. package/src/apps/ui/app.ts +143 -20
  7. package/src/apps/ui/project.ts +1 -1
  8. package/src/apps/ui/standalone.ts +4 -2
  9. package/src/apps/ui/surface.ts +2 -0
  10. package/src/core/activity-status.ts +17 -2
  11. package/src/core/command-contract.ts +58 -16
  12. package/src/core/completion-review.ts +1 -1
  13. package/src/core/derive.ts +9 -1
  14. package/src/core/event-validation.ts +19 -3
  15. package/src/core/events.ts +38 -2
  16. package/src/core/evidence.ts +149 -7
  17. package/src/core/export.ts +2 -0
  18. package/src/core/external-effects.ts +488 -0
  19. package/src/core/handoff.ts +24 -3
  20. package/src/core/history-query.ts +1 -1
  21. package/src/core/history.ts +124 -20
  22. package/src/core/observation-view.ts +24 -0
  23. package/src/core/path-suggestions.ts +59 -0
  24. package/src/core/permissions.ts +60 -0
  25. package/src/core/prompt-policy.ts +4 -2
  26. package/src/core/secrets.ts +188 -0
  27. package/src/core/stop-report.ts +25 -3
  28. package/src/core/tool-failure.ts +11 -3
  29. package/src/core/transcript.ts +3 -0
  30. package/src/core/work-state.ts +7 -2
  31. package/src/core/working-set.ts +34 -6
  32. package/src/runtime/connection-reuse.ts +40 -0
  33. package/src/runtime/exec-sessions.ts +66 -18
  34. package/src/runtime/history-tools.ts +12 -2
  35. package/src/runtime/loop.ts +264 -73
  36. package/src/runtime/open-image.ts +42 -0
  37. package/src/runtime/openai-responses.ts +15 -7
  38. package/src/runtime/openai.ts +15 -7
  39. package/src/runtime/permission-resources.ts +11 -4
  40. package/src/runtime/pi-provider.ts +34 -9
  41. package/src/runtime/provider.ts +19 -8
  42. package/src/runtime/retry.ts +152 -9
  43. package/src/runtime/secrets.ts +79 -0
  44. package/src/runtime/session-control.ts +9 -1
  45. package/src/runtime/stream-idle.ts +80 -0
  46. package/src/runtime/tools.ts +97 -27
  47. package/src/runtime/turn-effects.ts +31 -0
  48. package/src/store/context-files.ts +5 -1
  49. package/src/store/file-checkpoints.ts +1 -1
  50. package/src/store/log.ts +20 -0
  51. package/src/store/replay.ts +4 -3
  52. package/src/store/secrets.ts +44 -0
  53. package/src/store/sessions.ts +49 -6
  54. package/src/tui/ui/branch-panel.ts +47 -0
  55. package/src/tui/ui/composer.ts +27 -28
  56. package/src/tui/ui/image-size.ts +6 -2
  57. package/src/tui/ui/items.ts +3 -1
  58. package/src/tui/ui/pictures.ts +14 -1
  59. package/src/tui/ui/prompt-history.ts +60 -0
  60. package/src/tui/ui/screen.ts +34 -3
@@ -18,11 +18,12 @@
18
18
  * request.
19
19
  */
20
20
 
21
- import type { LogEvent, AssistantBlock, NodeRef, CutReason, ToolInvocationEvent } from "../core/events.ts";
21
+ import type { LogEvent, AssistantBlock, NodeRef, CutReason, ToolInvocationEvent, FailureCause } from "../core/events.ts";
22
22
  import { userInputData, submissionImages, type UserInput, type ImageRef } from "../core/images.ts";
23
23
  import { hydrateImages } from "./images.ts";
24
24
  import type { Handoff } from "../core/handoff.ts";
25
25
  import { boundHandoff, DEFAULT_HANDOFF_TOKENS } from "../core/history.ts";
26
+ import { CURRENT_WORKING_SET_VERSION } from "../core/working-set.ts";
26
27
  import { boundaryPreparation, continuationGuidance } from "../core/continuation.ts";
27
28
  import type { FrozenPrefix, Message, ProjectInput } from "../core/project.ts";
28
29
  import { projectContext, toolCallIdentityViolations } from "../core/project.ts";
@@ -76,7 +77,11 @@ import { skillTools, type Skill } from "../core/skills.ts";
76
77
  import type { WorkItem } from "../core/work-state.ts";
77
78
  import { parseAcceptanceContract, type AcceptanceContract, type AcceptanceCriterion, type AcceptanceState } from "../core/acceptance.ts";
78
79
  import { deadlineNotice } from "../core/deadline.ts";
79
- import { statusOf } from "./retry.ts";
80
+ import { describeFailure, failureCause, statusOf } from "./retry.ts";
81
+ import { XENO_VERSION } from "./version.ts";
82
+ import { SECRET_GUIDANCE, SecretKeeper } from "./secrets.ts";
83
+ import { secretEnvironmentValues } from "../core/secrets.ts";
84
+ import { copyVault } from "../store/secrets.ts";
80
85
  import { assertNever } from "../core/exhaustive.ts";
81
86
  import { isLogEvent } from "../core/event-validation.ts";
82
87
  import {
@@ -177,6 +182,8 @@ export interface LoopEvent {
177
182
  /** branch_start: wall-clock milliseconds, for asserting the stagger. */
178
183
  at?: number;
179
184
  outcome?: ExecutionOutcome;
185
+ /** done: credential-free cause of a failed request, recorded on its outcome. */
186
+ failure?: FailureCause;
180
187
  /** Q23: task evidence is separate from the execution outcome; rebuilt from original receipts. */
181
188
  request?: RequestState;
182
189
  progress?: Extract<StreamDelta, { kind: "tool" | "retry" }> | { kind: "output"; text: string; name: string }
@@ -214,6 +221,11 @@ export const DEFAULT_BRANCH_LIMITS: BranchLimits = {
214
221
  };
215
222
 
216
223
  export interface SessionConfig {
224
+ /**
225
+ * Where secret-named environment values are read from (S.9). Defaults to
226
+ * process.env; a test supplies its own so it never depends on the machine.
227
+ */
228
+ secretEnvironment?: Readonly<Record<string, string | undefined>>;
217
229
  /** Runtime-only output contract, explicitly selected by the current caller. */
218
230
  validateOutput?: (text: string) => { ok: boolean; error?: string };
219
231
  /** Persist a selector, never persisted profile code or authorization (D6/D13). */
@@ -365,6 +377,15 @@ export type CommittedItem = { node: number; id: string; type: LogEvent["t"] };
365
377
  export class Session {
366
378
  private readonly execOwner = randomUUID();
367
379
  private readonly jobs = new BranchJobs<DeferredToolResult>();
380
+ /**
381
+ * Branches the user started (/review-code), owned apart from `jobs`: an Esc
382
+ * on a parent turn cancels the model's branches, never these. Their joins
383
+ * wait in `pendingJoins` until the parent is idle and pairable.
384
+ */
385
+ private readonly userJobs = new BranchJobs<Extract<LogEvent, { t: "joined" }>>();
386
+ private readonly pendingJoins: { joined: LogEvent; note: string; usage: readonly UsageSample[] }[] = [];
387
+ /** Spawned but not yet handed to `userJobs` (its prelude is still recording): already running to an observer. */
388
+ private readonly startingUserBranches = new Set<number>();
368
389
  private readonly children = new Map<number, Session>();
369
390
  private cleanup: Promise<void> | null = null;
370
391
  private closing: Promise<void> | null = null;
@@ -381,12 +402,19 @@ export class Session {
381
402
  private stall: StallState | null = null;
382
403
  private recovery: RecoveryState | null = null;
383
404
  private processWake = new AbortController();
405
+ /**
406
+ * The exception the last provider request ended with, compared by identity
407
+ * in send()'s catch. Wrapping the error instead would break every check that
408
+ * reads it (FailedResponseError, statusOf, context overflow).
409
+ */
410
+ private providerFailure: { error: unknown; retries: number } | undefined;
384
411
  private pendingAdoption: number | null = null;
385
412
  /** A worktree enter/exit moves the root only once its tool round is paired, like adoption. */
386
413
  private pendingRoot: string | null = null;
387
414
  private readonly allocatedEventIds = new Set<string>();
388
415
  private recordTail: Promise<void> = Promise.resolve();
389
416
  private readonly recordListeners = new Set<(item: CommittedItem) => void>();
417
+ private readonly branchRecordListeners = new Set<(item: CommittedItem) => void>();
390
418
  readonly config: SessionConfig;
391
419
  /**
392
420
  * Replaced by switchModel. Not readonly: a live session can change which
@@ -397,6 +425,8 @@ export class Session {
397
425
  readonly clock: Clock;
398
426
  state: SessionState;
399
427
  private readonly allocation: { next: number };
428
+ /** S.9: the vault behind placeholders. A branch is handed its parent's, like `allocation`. */
429
+ private secrets: SecretKeeper;
400
430
 
401
431
  constructor(config: SessionConfig, provider: Provider, clock: Clock = systemClock(), allocation = { next: 1 }) {
402
432
  if (config.acceptanceContract) parseAcceptanceContract(config.acceptanceContract);
@@ -406,6 +436,7 @@ export class Session {
406
436
  schedulingWindow([], 0, config.maxParallelTools ?? 3);
407
437
  validateRequestBudget(config.maxTurns, config.stallLimit ?? DEFAULT_STALL_LIMIT);
408
438
  this.allocation = allocation;
439
+ this.secrets = new SecretKeeper(config.logDir, secretEnvironmentValues(config.secretEnvironment ?? process.env));
409
440
  this.config = { ...config, ...(config.acceptanceContract ? { acceptanceContract: parseAcceptanceContract(config.acceptanceContract) } : {}), tools: { ...config.tools, execOwner: this.execOwner, historyCache: new HistoryCache(),
410
441
  processWait: { wake: () => this.processWake.signal } } };
411
442
  // A child or restored Session must never inherit another live invocation scope.
@@ -419,6 +450,8 @@ export class Session {
419
450
  // Branch capability sets are owned by branchTools; a parent's optional catalog is not a grant.
420
451
  if ((config.branchDepth ?? 0) > 0) delete this.config.tools.deferredTools;
421
452
  this.config.tools.outputStore ??= join(config.logDir, "artifacts");
453
+ // Read at call time, so a branch that is handed its parent's keeper expands the parent's values.
454
+ this.config.tools.expandSecrets = input => this.secrets.expand(input);
422
455
  this.config.tools.historyEvents = async () => {
423
456
  const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
424
457
  if (!history.ok) throw Error(history.error);
@@ -498,6 +531,7 @@ export class Session {
498
531
  async start(): Promise<void> {
499
532
  this.resetDerivedToolState();
500
533
  const dir = this.config.logDir;
534
+ await this.secrets.load();
501
535
  const model = this.provider.config.spec.id;
502
536
  const existing = await listNodes(dir);
503
537
  const recordedCursor = await readCursor(dir);
@@ -607,6 +641,7 @@ export class Session {
607
641
  */
608
642
  async fork(destLogDir: string, untilEvent?: string, position: "at" | "before" = "at"): Promise<void> {
609
643
  await copyAncestryTo(this.config.logDir, destLogDir, this.clock.now(), untilEvent, position);
644
+ await copyVault(this.config.logDir, destLogDir);
610
645
  if (untilEvent !== undefined) {
611
646
  const cursor = await readCursor(destLogDir);
612
647
  if (!cursor) throw new Error("missing fork cursor");
@@ -749,8 +784,18 @@ export class Session {
749
784
  * must see that a person changed models, not that the node filled up.
750
785
  * boundary.ts already produces this reason when checkpointRequested is
751
786
  * set; switchModel is the other caller that wants it.
787
+ *
788
+ * An empty projection is the exception (fix-switch-model-empty-segment).
789
+ * With no events there is no old model's block to keep away from the new
790
+ * one, and the frozen prefix does not change here, so the cursor alone
791
+ * records the switch. Cutting anyway sealed an empty segment and opened
792
+ * the next from an empty handoff, which the model read as earlier work to
793
+ * resume. The check is the projection, not the node file: an inherit-basis
794
+ * node can be empty on disk while still projecting ancestor thinking.
795
+ *
796
+ * Returns whether a segment was cut, so the caller can say so truthfully.
752
797
  */
753
- async switchModel(next: Provider): Promise<void> {
798
+ async switchModel(next: Provider): Promise<boolean> {
754
799
  if (this.running || this.isHalfRound()) {
755
800
  throw new Error(
756
801
  "cannot switch models mid-turn: a tool_use is still unpaired. " +
@@ -758,9 +803,12 @@ export class Session {
758
803
  );
759
804
  }
760
805
 
761
- await this.performCut("model_checkpoint", next.config.spec.id, undefined, next.config.spec.provider, next.config.effort);
806
+ const cut = this.state.events.length > 0;
807
+ if (cut) await this.performCut("model_checkpoint", next.config.spec.id, undefined, next.config.spec.provider, next.config.effort);
808
+ else this.state.anchor = null;
762
809
  this.provider = next;
763
810
  await this.saveCursor();
811
+ return cut;
764
812
  }
765
813
 
766
814
  /** D6: changing the frozen prefix is an explicit checkpoint, never in-place prompt surgery. */
@@ -890,10 +938,15 @@ export class Session {
890
938
  updated_at: this.clock.now(),
891
939
  workspace: this.config.tools.root,
892
940
  context_ref: contextRef ?? await this.persistContext(),
941
+ xeno_version: XENO_VERSION,
893
942
  });
894
943
  }
895
944
 
896
945
  /** Q24: publish identities only after durable append, including non-model user actions. */
946
+ /** Commits inside this session's branches (their node numbers), for a client showing one live. */
947
+ subscribeBranchRecords(listener: (item: CommittedItem) => void): () => void {
948
+ this.branchRecordListeners.add(listener); return () => { this.branchRecordListeners.delete(listener); };
949
+ }
897
950
  subscribeRecords(listener: (item: CommittedItem) => void): () => void {
898
951
  this.recordListeners.add(listener); return () => { this.recordListeners.delete(listener); };
899
952
  }
@@ -902,6 +955,10 @@ export class Session {
902
955
  // D8: runtime callers may retain mutable results. The in-memory projection
903
956
  // must own the same JSON value that was appended, independent of later edits.
904
957
  event = JSON.parse(JSON.stringify(event)) as LogEvent;
958
+ // S.9: credentials become placeholders before anything is written, so no
959
+ // projection, handoff, export or history page can carry the value.
960
+ const redacted = await this.secrets.redact(event, this.lastAssistantText());
961
+ event = redacted.event;
905
962
  const saved = this.recordTail.then(async () => {
906
963
  await appendEvent(this.config.logDir, this.state.node, event);
907
964
  this.state.events.push(event);
@@ -913,6 +970,9 @@ export class Session {
913
970
  });
914
971
  this.recordTail = saved.catch(() => {});
915
972
  await saved;
973
+ if (redacted.added > 0 && !this.state.events.some(item => item.t === "guidance" && item.reason === "secret")) {
974
+ await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "secret", text: SECRET_GUIDANCE, sources: [event.id] });
975
+ }
916
976
  if (event.t === "tool_result" && event.ok && event.effect?.kind === "read") {
917
977
  const path = resolve(this.config.tools.root, event.effect.path);
918
978
  const skill = this.config.skills?.find(item => item.filePath === path);
@@ -925,6 +985,19 @@ export class Session {
925
985
  }
926
986
  }
927
987
 
988
+ /** The text the user is answering, for recognising a solicited credential. */
989
+ private lastAssistantText(): string | undefined {
990
+ for (let i = this.state.events.length - 1; i >= 0; i--) {
991
+ const event = this.state.events[i]!;
992
+ if (event.t === "user") return undefined;
993
+ if (event.t === "assistant") {
994
+ const text = event.blocks.flatMap(block => block.type === "text" ? [block.text] : []).join("\n");
995
+ if (text.trim()) return text;
996
+ }
997
+ }
998
+ return undefined;
999
+ }
1000
+
928
1001
  private allowedNames(): string[] {
929
1002
  return this.config.frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" &&
930
1003
  (this.config.tools.allow === undefined || this.config.tools.allow.includes(tool.name)) ? [tool.name] : []);
@@ -1000,7 +1073,7 @@ export class Session {
1000
1073
 
1001
1074
  private async boundCarried(full: Handoff, tools = this.adviceNames()): Promise<Handoff> {
1002
1075
  return carriedHandoff(this.config.logDir, this.state.node, full, { handoff_tokens: DEFAULT_HANDOFF_TOKENS,
1003
- ...(this.config.workingSet ? { working_set_version: 2 as const } : {}), guidance_tools: tools });
1076
+ ...(this.config.workingSet ? { working_set_version: CURRENT_WORKING_SET_VERSION } : {}), guidance_tools: tools });
1004
1077
  }
1005
1078
 
1006
1079
  async acceptanceState(): Promise<AcceptanceState | undefined> {
@@ -1152,6 +1225,69 @@ export class Session {
1152
1225
  } finally { owned.finish(); }
1153
1226
  }
1154
1227
 
1228
+ /**
1229
+ * D14 for a branch the user opened rather than the model. Returns once
1230
+ * `spawn` is recorded; the child runs in the background with the caller's
1231
+ * explicit config (its own prompt, tools and roots: nothing of the parent's
1232
+ * transcript, so a review stays independent of the work it judges). When it
1233
+ * ends, `joined` plus one guidance line reach the parent at its next idle,
1234
+ * pairable point -- the line is how the parent model learns to read it.
1235
+ */
1236
+ async startUserBranch(spec: {
1237
+ purpose: string; prompt: string; config: SessionConfig; provider?: Provider;
1238
+ /** Events the branch records before its first request (a review's premise receipt). */
1239
+ prelude?: (branch: Session) => Promise<void>;
1240
+ /** The guidance the parent model gets once the branch joined. */
1241
+ note: (node: number, joined: Extract<LogEvent, { t: "joined" }>) => string;
1242
+ }): Promise<number> {
1243
+ if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
1244
+ const node = this.state.nextNode++;
1245
+ this.startingUserBranches.add(node);
1246
+ try { return await this.launchUserBranch(node, spec); } finally { this.startingUserBranches.delete(node); }
1247
+ }
1248
+ private async launchUserBranch(node: number, spec: Parameters<Session["startUserBranch"]>[0]): Promise<number> {
1249
+ const sourceEvent = this.state.events.findLast(event => event.t === "user")?.id;
1250
+ const { ref, branch } = await this.openBranch({ node, purpose: spec.purpose, background: true, ...(sourceEvent ? { sourceEvent } : {}),
1251
+ inputWorkspace: { kind: "shared_readonly" }, worktree: null, config: spec.config, ...(spec.provider ? { provider: spec.provider } : {}) });
1252
+ // Not in `children`: the parent's cancel() disposes those, and an Esc must not stop this one.
1253
+ try { await spec.prelude?.(branch); await this.saveCursor(); }
1254
+ catch (error) { await branch.dispose(); throw error; }
1255
+ const controller = new AbortController();
1256
+ this.userJobs.start(node, controller, async () => {
1257
+ const { outcome, text } = await this.runChild(node, branch, spec.prompt, true, controller.signal, true);
1258
+ const { joined } = await this.closeBranch(ref, branch, outcome, text);
1259
+ if (joined.t !== "joined") throw new Error("a closed branch must produce a joined event");
1260
+ this.pendingJoins.push({ joined, note: spec.note(node, joined), usage: [...branch.state.usage] });
1261
+ await this.flushUserJoins();
1262
+ return joined;
1263
+ }, () => {});
1264
+ return node;
1265
+ }
1266
+ /** User branches still running, so a client can show and stop them. */
1267
+ get userBranches(): readonly number[] {
1268
+ return [...this.startingUserBranches, ...this.userJobs.nodes().filter(node => this.userJobs.state(node) === "running")];
1269
+ }
1270
+ /** Every branch with a live child here: the model's (foreground or background) and the user's. */
1271
+ get runningBranches(): readonly number[] { return [...new Set([...this.children.keys(), ...this.userBranches])]; }
1272
+ cancelUserBranch(node: number): void { this.userJobs.cancel(node); }
1273
+ /**
1274
+ * The branch's join once it ended -- recorded, or still waiting for the
1275
+ * parent to be idle and pairable. Undefined if its finalization failed.
1276
+ */
1277
+ async whenUserBranch(node: number): Promise<Extract<LogEvent, { t: "joined" }> | undefined> {
1278
+ await this.userJobs.drain(node); return (await this.userJobs.wait(node, 0))?.value;
1279
+ }
1280
+ /** Appends joins that waited for an idle, pairable parent. Never mid-turn: the guidance enters model context. */
1281
+ private async flushUserJoins(): Promise<void> {
1282
+ while (this.pendingJoins.length > 0 && !this.running && !this.cleanup && !this.isHalfRound()) {
1283
+ const item = this.pendingJoins.shift()!;
1284
+ this.state.usage.push(...item.usage);
1285
+ await this.record(item.joined);
1286
+ await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "branch", text: item.note, sources: [item.joined.id] });
1287
+ await this.saveCursor();
1288
+ }
1289
+ }
1290
+
1155
1291
  private async drainProcesses(): Promise<void> {
1156
1292
  const results = await disposeExecSessions(this.execOwner, this.config.tools.outputStore!, this.config.tools.maxOutputBytes);
1157
1293
  await this.config.tools.workspace?.abandonCommands();
@@ -1170,7 +1306,9 @@ export class Session {
1170
1306
  dispose(): Promise<void> {
1171
1307
  if (this.closing) return this.closing;
1172
1308
  this.cancel();
1173
- this.closing = this.whenIdle().then(() => this.rootTools?.close());
1309
+ // User branches outlive interrupts, not the session. Their joins are still recorded.
1310
+ this.userJobs.cancel();
1311
+ this.closing = this.whenIdle().then(async () => { await this.userJobs.drain(); await this.flushUserJoins(); }).then(() => this.rootTools?.close());
1174
1312
  return this.closing;
1175
1313
  }
1176
1314
 
@@ -1256,6 +1394,7 @@ export class Session {
1256
1394
  if (item.value.outcome && item.value.outcome.reason !== "model_end") await this.record({
1257
1395
  t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
1258
1396
  reason: item.value.reason ?? item.value.outcome.reason, outcome: item.value.outcome,
1397
+ ...(item.value.failure ? { failure: item.value.failure } : {}),
1259
1398
  });
1260
1399
  const request = await this.requestState();
1261
1400
  if (request) item.value.request = request;
@@ -1270,9 +1409,14 @@ export class Session {
1270
1409
  yield { kind: "done", reason: "stopped: request ended without a terminal outcome", outcome, ...(request ? { request } : {}) };
1271
1410
  }
1272
1411
  } catch (error) {
1273
- // Raw provider exceptions can contain credentials; persist only the execution fact.
1412
+ // Raw exceptions can contain credentials; persist the category, never the message.
1413
+ // Only the provider request's own exception is a provider error: this catch
1414
+ // also sees failures in history reads and in the harness itself.
1415
+ const provider = this.providerFailure?.error === error ? this.providerFailure : undefined;
1416
+ const failure = failureCause(error, provider);
1274
1417
  await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
1275
- reason: "request failed before completion; no complete response was committed", outcome: executionOutcome("provider_error") });
1418
+ reason: `request failed before completion (${describeFailure(failure)}); no complete response was committed`,
1419
+ outcome: executionOutcome(provider ? "provider_error" : "execution_error"), failure });
1276
1420
  throw error;
1277
1421
  } finally {
1278
1422
  // Closing a consumer is not a user cancellation. Preserve the existing
@@ -1287,6 +1431,8 @@ export class Session {
1287
1431
  if (owned.signal.aborted) this.cancel();
1288
1432
  owned.finish();
1289
1433
  if (owned.signal.aborted) await this.whenIdle();
1434
+ // A user branch that ended mid-turn joins now that the parent is idle.
1435
+ if (this.pendingJoins.length > 0) await this.flushUserJoins().catch(() => {});
1290
1436
  }
1291
1437
  }
1292
1438
  }
@@ -1404,7 +1550,9 @@ export class Session {
1404
1550
  }
1405
1551
  if (phase === "finalize") {
1406
1552
  yield { kind: "discard" };
1407
- yield { kind: "done", reason: await report("final response failed; no additional request was made", turn + 1), outcome: executionOutcome("provider_error") };
1553
+ const failure = failureCause(error, this.providerFailure?.error === error ? this.providerFailure : { retries: 0 });
1554
+ yield { kind: "done", reason: await report(`final response failed (${describeFailure(failure)}); no additional request was made`, turn + 1),
1555
+ outcome: executionOutcome("provider_error"), failure };
1408
1556
  return;
1409
1557
  }
1410
1558
  if (!(error instanceof Error) || !isContextOverflow(error.message, statusOf(error))) throw error;
@@ -1737,8 +1885,9 @@ export class Session {
1737
1885
  ): AsyncGenerator<LoopEvent, Turn> {
1738
1886
  const hydrated = await hydrateImages(messages, this.config.blobDir ?? join(this.config.logDir, "blobs"),
1739
1887
  this.provider.config.spec.input?.includes("image") ?? false);
1740
- return yield* withProgress<Turn, LoopEvent>(emit => this.provider.send(
1741
- this.config.frozen, hydrated, this.state.node, signal, (delta: StreamDelta): void => {
1888
+ let retries = 0;
1889
+ this.providerFailure = undefined;
1890
+ const progress = (emit: (event: LoopEvent) => void) => (delta: StreamDelta): void => {
1742
1891
  switch (delta.kind) {
1743
1892
  case "text":
1744
1893
  emit({ kind: "text_delta", text: delta.text });
@@ -1749,13 +1898,25 @@ export class Session {
1749
1898
  case "discard":
1750
1899
  emit({ kind: "discard" });
1751
1900
  break;
1752
- case "tool": case "retry":
1901
+ case "retry":
1902
+ retries = Math.max(retries, delta.attempt);
1903
+ emit({ kind: "progress", progress: delta });
1904
+ break;
1905
+ case "tool":
1753
1906
  emit({ kind: "progress", progress: delta });
1754
1907
  break;
1755
1908
  default:
1756
1909
  return assertNever(delta, "onDelta");
1757
1910
  }
1758
- }, options));
1911
+ };
1912
+ try {
1913
+ return yield* withProgress<Turn, LoopEvent>(emit => this.provider.send(
1914
+ this.config.frozen, hydrated, this.state.node, signal, progress(emit), options));
1915
+ } catch (error) {
1916
+ // The outcome needs to know this exception came from the request, and after how many retries.
1917
+ this.providerFailure = { error, retries };
1918
+ throw error;
1919
+ }
1759
1920
  }
1760
1921
 
1761
1922
  /**
@@ -1829,7 +1990,10 @@ export class Session {
1829
1990
  let context: ToolContext = { ...this.config.tools, ...(this.readOnlyRequest ? { workflowMode: "plan" as const, planHint: READ_ONLY_HINT } : {}),
1830
1991
  imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"),
1831
1992
  advice: this.allowedNames(),
1832
- imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}) };
1993
+ imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}),
1994
+ // feat-approval-events: the broker has already recorded this call's
1995
+ // invocation, and its result comes after execution, so the order is fixed.
1996
+ onApproval: approval => this.record({ t: "approval", id: this.clock.nextId(), at: this.clock.now(), ...approval }) };
1833
1997
  if (native) context = nativeCommandContext(context);
1834
1998
  const decoder = new TextDecoder();
1835
1999
  context.onOutput = chunk => emit({ kind: "progress", progress: { kind: "output", name: call.name, text: decoder.decode(chunk, { stream: true }) } });
@@ -2036,7 +2200,6 @@ export class Session {
2036
2200
 
2037
2201
  const node = this.state.nextNode++;
2038
2202
  this.state.branchesOpened += 1;
2039
- const ref = await this.nodeRefFor(node);
2040
2203
  // A fork point must include any preceding tool effects in this batch.
2041
2204
 
2042
2205
  // A writing branch needs its own checkout, or parallel branches overwrite
@@ -2056,20 +2219,9 @@ export class Session {
2056
2219
  this.state.worktrees.set(node, worktree);
2057
2220
  }
2058
2221
 
2059
- await this.record({
2060
- t: "spawn", id: this.clock.nextId(), at: this.clock.now(), branch: ref, purpose,
2061
- ...(args["background"] === true ? { background: true as const } : {}),
2062
- input: { source_event: sourceEvent, tools: allowed, max_turns: Math.min(this.config.maxTurns, 15), workspace: inputWorkspace },
2063
- ...(worktree ? { workspace: worktree } : {}),
2064
- });
2065
-
2066
- // basis "none": the branch starts fresh. The parent pointer is provenance.
2067
- await initNode(this.config.logDir, node, {
2068
- ...await this.nodeRefFor(this.state.node), at_event: this.state.events.at(-1)!.id,
2069
- }, this.clock.now(), "none");
2070
-
2071
- const branch = new Session(
2072
- {
2222
+ const { ref, branch } = await this.openBranch({
2223
+ node, purpose, background: args["background"] === true, sourceEvent, inputWorkspace, worktree,
2224
+ config: {
2073
2225
  ...this.config,
2074
2226
  acceptanceContract: null,
2075
2227
  frozen: { system: this.config.frozen.system, tools: branchTools(writes, allowed) },
@@ -2086,40 +2238,14 @@ export class Session {
2086
2238
  workNamespace: `branch-${node}`,
2087
2239
  maxTurns: Math.min(this.config.maxTurns, 15),
2088
2240
  },
2089
- this.config.branchProvider ? this.config.branchProvider() : this.provider,
2090
- this.clock,
2091
- this.allocation,
2092
- );
2093
- branch.state.node = node;
2094
- branch.state.nextNode = this.state.nextNode;
2241
+ });
2095
2242
  if (skill) await branch.recordSkill(skill, "fork", skillOrigin);
2096
2243
  this.children.set(node, branch);
2097
2244
  yield { kind: "branch_start", node, reason: purpose, at: Date.now() };
2098
2245
 
2099
- let finalParts: string[] = [];
2100
- let outcome = executionOutcome("unknown");
2101
- try {
2102
- for await (const e of branch.send(prompt, signal)) {
2103
- if (e.kind === "turn_start" || e.kind === "discard") finalParts = [];
2104
- if (e.kind === "assistant_text") finalParts.push(e.text ?? "");
2105
- if (e.kind === "done") outcome = e.outcome ?? executionOutcome("unknown");
2106
- }
2107
- } catch {
2108
- // Persist a bounded failure classification, never a provider's raw error body.
2109
- outcome = executionOutcome(signal?.aborted ? "cancelled" : "execution_error");
2110
- await branch.answerUnpairedTools("the branch execution failed; this tool's outcome is unknown; inspect before retrying");
2111
- } finally { try { await branch.dispose(); } finally { this.children.delete(node); } }
2112
- if (args["background"] === true) await branch.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), outcome, reason: "background branch execution ended" });
2113
- // Node numbers the branch allocated are taken.
2114
-
2115
- const tip = branch.state.node;
2116
- const branchContext = await replayContext(this.config.logDir, tip);
2117
- // Re-digest now: `ref` was taken at spawn, before this node had events.
2118
- const handoff = deriveHandoff({
2119
- prev: branchContext.fullHandoff, depth: branchContext.depth, events: branchContext.events, self: await this.nodeRefFor(tip),
2120
- config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity },
2121
- });
2122
- await closeNode(this.config.logDir, tip, boundHandoff(handoff), (await readNode(this.config.logDir, tip)).events.length, this.clock.now());
2246
+ const { outcome, text } = await this.runChild(node, branch, prompt, args["background"] === true, signal);
2247
+ const closed = await this.closeBranch(ref, branch, outcome, text);
2248
+ const { tip, summary, receipt, receiptRef, joined } = closed;
2123
2249
 
2124
2250
  // The worktree stays until the branch is resolved: its uncommitted work
2125
2251
  // is the branch's result, and removing it here would discard exactly
@@ -2128,19 +2254,6 @@ export class Session {
2128
2254
  ? `\n\nIts changes are in ${worktree.path} on branch ${worktree.branch}, ` +
2129
2255
  "not in your files. Adopting continues in that checkout without merging it into this one."
2130
2256
  : "";
2131
- const text = finalParts.join("\n");
2132
- const summary = text.trim() || "(the branch produced no closing message)";
2133
- const receiptHistory = await readNodeHistory(this.config.logDir, tip, undefined, "context");
2134
- if (!receiptHistory.ok) throw new Error(receiptHistory.error);
2135
- const receipt = deriveBranchReceipt({ source: await this.nodeRefFor(tip), outcome, handoff, nodes: receiptHistory.nodes });
2136
- const receiptRef = await putBlob(join(this.config.logDir, "artifacts"), JSON.stringify(receipt));
2137
- const joined: LogEvent = {
2138
- t: "joined", id: this.clock.nextId(), at: this.clock.now(),
2139
- branch: ref, tip: await this.nodeRefFor(tip), handoff_ref: handoffPath(this.config.logDir, tip), summary,
2140
- model: branch.provider.config.spec.id,
2141
- outcome,
2142
- receipt_ref: receiptRef,
2143
- };
2144
2257
 
2145
2258
  // Only the summary crosses back. The branch's own calls stay in its node,
2146
2259
  // which is the entire reason to branch rather than read in place.
@@ -2169,6 +2282,84 @@ export class Session {
2169
2282
  };
2170
2283
  }
2171
2284
 
2285
+ /**
2286
+ * D14: the parent records `spawn`, the branch gets its own node file with
2287
+ * basis "none" (it starts fresh; the parent pointer is provenance only).
2288
+ * Shared by model-spawned branches and user-started ones (/review-code).
2289
+ */
2290
+ private async openBranch(spec: {
2291
+ /** Omitted for a user command in an empty session: the spawn is then its own request. */
2292
+ node: number; purpose: string; background: boolean; sourceEvent?: string;
2293
+ inputWorkspace: { kind: "shared_readonly" } | { kind: "git_tree"; head: string; tree: string };
2294
+ worktree: { path: string; branch: string } | null; config: SessionConfig; provider?: Provider;
2295
+ }): Promise<{ ref: NodeRef; branch: Session }> {
2296
+ const { node, purpose, worktree } = spec;
2297
+ const ref = await this.nodeRefFor(node), id = this.clock.nextId();
2298
+ await this.record({
2299
+ t: "spawn", id, at: this.clock.now(), branch: ref, purpose,
2300
+ ...(spec.background ? { background: true as const } : {}),
2301
+ input: { source_event: spec.sourceEvent ?? id, tools: spec.config.tools.allow ?? [], max_turns: spec.config.maxTurns, workspace: spec.inputWorkspace },
2302
+ ...(worktree ? { workspace: worktree } : {}),
2303
+ });
2304
+ await initNode(this.config.logDir, node, {
2305
+ ...await this.nodeRefFor(this.state.node), at_event: this.state.events.at(-1)!.id,
2306
+ }, this.clock.now(), "none");
2307
+ const branch = new Session(spec.config, spec.provider ?? (this.config.branchProvider ? this.config.branchProvider() : this.provider), this.clock, this.allocation);
2308
+ branch.state.node = node;
2309
+ branch.state.nextNode = this.state.nextNode;
2310
+ branch.secrets = this.secrets;
2311
+ branch.subscribeRecords(item => { for (const listener of this.branchRecordListeners) { try { listener(item); } catch { /* observers never own the append */ } } });
2312
+ return { ref, branch };
2313
+ }
2314
+
2315
+ /** Runs the child to its end. Its closing text is the last turn's text; a throw becomes a bounded outcome. */
2316
+ private async runChild(node: number, branch: Session, prompt: string, background: boolean, signal?: AbortSignal, keepRecorded = false): Promise<{ outcome: ExecutionOutcome; text: string }> {
2317
+ let finalParts: string[] = [];
2318
+ let outcome = executionOutcome("unknown");
2319
+ try {
2320
+ for await (const e of branch.send(prompt, signal)) {
2321
+ if (e.kind === "turn_start" || e.kind === "discard") finalParts = [];
2322
+ if (e.kind === "assistant_text") finalParts.push(e.text ?? "");
2323
+ if (e.kind === "done") outcome = e.outcome ?? executionOutcome("unknown");
2324
+ }
2325
+ } catch {
2326
+ // Persist a bounded failure classification, never a provider's raw error body.
2327
+ // A model's branch joins as execution_error (its caller sees a failed tool). A user branch keeps the
2328
+ // classification its own send recorded (provider_error vs execution_error), as the detached review did.
2329
+ const recorded = keepRecorded ? branch.state.events.findLast(event => event.t === "outcome") : undefined;
2330
+ outcome = signal?.aborted ? executionOutcome("cancelled") : recorded?.t === "outcome" ? recorded.outcome : executionOutcome("execution_error");
2331
+ await branch.answerUnpairedTools("the branch execution failed; this tool's outcome is unknown; inspect before retrying");
2332
+ } finally { try { await branch.dispose(); } finally { this.children.delete(node); } }
2333
+ if (background) await branch.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), outcome, reason: "background branch execution ended" });
2334
+ return { outcome, text: finalParts.join("\n") };
2335
+ }
2336
+
2337
+ /** Closes the branch's tip with its derived handoff and builds the `joined` the parent will record (D15). */
2338
+ private async closeBranch(ref: NodeRef, branch: Session, outcome: ExecutionOutcome, text: string) {
2339
+ // Node numbers the branch allocated are taken.
2340
+ const tip = branch.state.node;
2341
+ const branchContext = await replayContext(this.config.logDir, tip);
2342
+ // Re-digest now: `ref` was taken at spawn, before this node had events.
2343
+ const handoff = deriveHandoff({
2344
+ prev: branchContext.fullHandoff, depth: branchContext.depth, events: branchContext.events, self: await this.nodeRefFor(tip),
2345
+ config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity },
2346
+ });
2347
+ await closeNode(this.config.logDir, tip, boundHandoff(handoff), (await readNode(this.config.logDir, tip)).events.length, this.clock.now());
2348
+ const summary = text.trim() || "(the branch produced no closing message)";
2349
+ const receiptHistory = await readNodeHistory(this.config.logDir, tip, undefined, "context");
2350
+ if (!receiptHistory.ok) throw new Error(receiptHistory.error);
2351
+ const receipt = deriveBranchReceipt({ source: await this.nodeRefFor(tip), outcome, handoff, nodes: receiptHistory.nodes });
2352
+ const receiptRef = await putBlob(join(this.config.logDir, "artifacts"), JSON.stringify(receipt));
2353
+ const joined: LogEvent = {
2354
+ t: "joined", id: this.clock.nextId(), at: this.clock.now(),
2355
+ branch: ref, tip: await this.nodeRefFor(tip), handoff_ref: handoffPath(this.config.logDir, tip), summary,
2356
+ model: branch.provider.config.spec.id,
2357
+ outcome,
2358
+ receipt_ref: receiptRef,
2359
+ };
2360
+ return { tip, summary, receipt, receiptRef, joined };
2361
+ }
2362
+
2172
2363
  private async runWorktree(input: unknown, pathRoot?: string): Promise<ToolResult> {
2173
2364
  const args = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
2174
2365
  const root = this.config.tools.root;
@@ -2881,7 +3072,7 @@ export class Session {
2881
3072
  next_thinking: nextThinking,
2882
3073
  ...(nextContextRef === undefined ? {} : { next_context_ref: nextContextRef }),
2883
3074
  handoff_tokens: DEFAULT_HANDOFF_TOKENS,
2884
- ...(this.config.workingSet ? { working_set_version: 2 as const } : {}),
3075
+ ...(this.config.workingSet ? { working_set_version: CURRENT_WORKING_SET_VERSION } : {}),
2885
3076
  guidance_tools: guidanceTools,
2886
3077
  ...(retainedFrom === undefined ? {} : { retained_from: retainedFrom }),
2887
3078
  });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Open a stored image in the system viewer (docs/tasks/feat-tui-image-full-size.md).
3
+ *
4
+ * Blobs are content-addressed and have no extension, and viewers recognise a
5
+ * file by its extension, so the verified bytes are copied once to
6
+ * `<tmpdir>/xeno-images/<sha256>.<ext>` and that file is opened. The name is
7
+ * the hash: opening the same picture again reuses the file, and a file under
8
+ * that name can only hold those bytes because we never write it twice.
9
+ */
10
+ import { mkdir, writeFile } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { getBlobBytes } from "../store/blobs.ts";
14
+ import { MAX_IMAGE_SOURCE_BYTES } from "../core/image-limits.ts";
15
+ import { detectSupportedImageMimeType } from "../vendor/pi-rich/mime.ts";
16
+
17
+ const EXTENSIONS: Readonly<Record<string, string>> = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", "image/bmp": "bmp" };
18
+
19
+ /** The platform's "open with the default application" command. */
20
+ function launchViewer(path: string): void {
21
+ Bun.spawn([process.platform === "darwin" ? "open" : "xdg-open", path], { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
22
+ }
23
+
24
+ /** Opens the blob; returns why it could not, or null once the viewer was started. */
25
+ export async function openImageFile(blobDir: string, sha256: string,
26
+ options: { dir?: string; launch?: (path: string) => void } = {}): Promise<string | null> {
27
+ let bytes: Uint8Array | null;
28
+ try { bytes = await getBlobBytes(blobDir, sha256, MAX_IMAGE_SOURCE_BYTES); } catch (error) { return `Cannot open image: ${error instanceof Error ? error.message : String(error)}`; }
29
+ if (bytes === null) return "Cannot open image: it is no longer in the session's blob store";
30
+ const mime = detectSupportedImageMimeType(bytes);
31
+ const extension = mime === null ? undefined : EXTENSIONS[mime];
32
+ if (extension === undefined) return "Cannot open image: unrecognised image format";
33
+ const dir = options.dir ?? join(tmpdir(), "xeno-images");
34
+ const path = join(dir, `${sha256}.${extension}`);
35
+ try {
36
+ await mkdir(dir, { recursive: true, mode: 0o700 });
37
+ // `wx`: an existing file is a previous copy of these same bytes.
38
+ await writeFile(path, bytes, { flag: "wx", mode: 0o600 }).catch((error: NodeJS.ErrnoException) => { if (error.code !== "EEXIST") throw error; });
39
+ (options.launch ?? launchViewer)(path);
40
+ } catch (error) { return `Cannot open image: ${error instanceof Error ? error.message : String(error)}`; }
41
+ return null;
42
+ }