@visual-z/xeno 0.5.0 → 0.7.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 (102) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +19 -4
  3. package/docs/app-server.md +32 -3
  4. package/docs/foundations.md +88 -0
  5. package/docs/install.md +23 -2
  6. package/docs/research/foundation-clients-2026-10-09.md +27 -0
  7. package/docs/research/foundation-editing-2026-10-09.md +56 -0
  8. package/docs/research/foundation-network-2026-10-09.md +37 -0
  9. package/docs/research/foundation-workflow-2026-10-09.md +37 -0
  10. package/package.json +10 -2
  11. package/src/apps/acp-server.ts +41 -0
  12. package/src/apps/agent-profiles.ts +19 -0
  13. package/src/apps/app-server.ts +4 -2
  14. package/src/apps/cli.ts +18 -2
  15. package/src/apps/commands.ts +20 -0
  16. package/src/apps/control-transport.ts +3 -2
  17. package/src/apps/model-catalog.ts +57 -31
  18. package/src/apps/question-ui.ts +27 -0
  19. package/src/apps/resources.ts +63 -25
  20. package/src/apps/review.ts +109 -0
  21. package/src/apps/session-hub.ts +95 -0
  22. package/src/apps/shell.ts +55 -1
  23. package/src/apps/startup.ts +157 -22
  24. package/src/apps/ui/app.ts +89 -38
  25. package/src/apps/ui/image-cache.ts +71 -28
  26. package/src/apps/ui/surface.ts +1 -0
  27. package/src/apps/update-check.ts +71 -0
  28. package/src/apps/workflow.ts +58 -0
  29. package/src/core/activity-status.ts +28 -0
  30. package/src/core/agent-profile.ts +130 -0
  31. package/src/core/control-protocol.ts +8 -1
  32. package/src/core/file-precondition.ts +6 -0
  33. package/src/core/git-status.ts +29 -0
  34. package/src/core/image-limits.ts +72 -0
  35. package/src/core/lsp-framing.ts +34 -0
  36. package/src/core/mcp-config.ts +68 -0
  37. package/src/core/patch.ts +130 -0
  38. package/src/core/permissions.ts +143 -4
  39. package/src/core/progress.ts +9 -0
  40. package/src/core/question.ts +94 -0
  41. package/src/core/request-budget.ts +13 -0
  42. package/src/core/review.ts +68 -0
  43. package/src/core/session-exit.ts +26 -0
  44. package/src/core/settings.ts +1 -1
  45. package/src/core/stall.ts +18 -6
  46. package/src/core/stop-report.ts +25 -0
  47. package/src/core/update-check.ts +24 -5
  48. package/src/core/web-content.ts +80 -0
  49. package/src/core/workflow-mode.ts +34 -0
  50. package/src/runtime/acp-agent.ts +289 -0
  51. package/src/runtime/agent-profiles.ts +21 -0
  52. package/src/runtime/catalog.ts +2 -2
  53. package/src/runtime/control-client.ts +149 -0
  54. package/src/runtime/control-connection.ts +39 -4
  55. package/src/runtime/exec-sessions.ts +40 -4
  56. package/src/runtime/extensions.ts +20 -2
  57. package/src/runtime/formatter-tool.ts +38 -0
  58. package/src/runtime/formatter.ts +50 -0
  59. package/src/runtime/foundation.ts +78 -0
  60. package/src/runtime/images.ts +6 -2
  61. package/src/runtime/integrations.ts +8 -2
  62. package/src/runtime/loop.ts +124 -21
  63. package/src/runtime/lsp-client.ts +201 -0
  64. package/src/runtime/mcp-client.ts +264 -0
  65. package/src/runtime/mcp-oauth.ts +168 -0
  66. package/src/runtime/mcp-schema.ts +51 -0
  67. package/src/runtime/oauth.ts +1 -1
  68. package/src/runtime/openai-responses.ts +6 -4
  69. package/src/runtime/openai.ts +6 -1
  70. package/src/runtime/output-schema.ts +26 -0
  71. package/src/runtime/patch-tool.ts +38 -0
  72. package/src/runtime/permission-resources.ts +11 -0
  73. package/src/runtime/pi-provider.ts +5 -4
  74. package/src/runtime/provider-tool-policy.ts +33 -0
  75. package/src/runtime/provider.ts +8 -1
  76. package/src/runtime/question-tool.ts +62 -0
  77. package/src/runtime/review-tool.ts +48 -0
  78. package/src/runtime/session-control.ts +61 -2
  79. package/src/runtime/session-hub.ts +85 -0
  80. package/src/runtime/tool-discovery.ts +4 -3
  81. package/src/runtime/tools.ts +44 -10
  82. package/src/runtime/update-check.ts +3 -2
  83. package/src/runtime/web-http.ts +56 -0
  84. package/src/runtime/web-tools.ts +88 -0
  85. package/src/runtime/workspace-lifecycle.ts +14 -1
  86. package/src/store/agent-profile-files.ts +13 -0
  87. package/src/store/blobs.ts +2 -1
  88. package/src/store/context-files.ts +77 -1
  89. package/src/store/file-checkpoints.ts +5 -4
  90. package/src/store/foundation-config.ts +24 -0
  91. package/src/store/git-status.ts +15 -0
  92. package/src/store/lsp-document.ts +30 -0
  93. package/src/store/mcp-credentials.ts +85 -0
  94. package/src/store/patch.ts +92 -0
  95. package/src/store/permission-paths.ts +34 -0
  96. package/src/store/review.ts +109 -0
  97. package/src/store/search.ts +48 -9
  98. package/src/tui/ui/layout.ts +7 -7
  99. package/src/tui/ui/messages.ts +1 -1
  100. package/src/tui/ui/pictures.ts +26 -9
  101. package/src/tui/ui/screen.ts +12 -8
  102. package/src/tui/ui/status.ts +32 -6
@@ -29,6 +29,7 @@ import { DISPLAY_SETTINGS, settingValue, settingValues } from "../../core/settin
29
29
  import { THINKING_LEVELS, asThinkingLevel, supportedThinkingLevels } from "../../core/thinking.ts";
30
30
  import { builtinTheme } from "../../core/theme.ts";
31
31
  import { projectTranscript, type TranscriptItem } from "../../core/transcript.ts";
32
+ import { DEFAULT_FULLSCREEN_EXIT_OUTPUT, sessionExitStatus } from "../../core/session-exit.ts";
32
33
  import { lastAssistantText } from "../../core/export.ts";
33
34
  import type { PermissionRequest } from "../../core/permissions.ts";
34
35
  import type { ApprovalChoice } from "../../runtime/tools.ts";
@@ -38,14 +39,16 @@ import { highlightCode, transformMermaid, warmSyntaxLanguages } from "../../runt
38
39
  import { SOURCE_PROVIDERS } from "../../runtime/model-data.ts";
39
40
  import { SUBSCRIPTION_OPTIONS, subscriptionById, loginGitHubCopilot, startCallbackServer, XENO_VERSION } from "../../runtime/oauth.ts";
40
41
  import { reportedCost } from "../../runtime/usage.ts";
41
- import { fetchLatestVersion } from "../../runtime/update-check.ts";
42
- import { installKind, updateNotice, updatePlan } from "../../core/update-check.ts";
42
+ import { UpdateChecks } from "../update-check.ts";
43
+ import { updateNotice } from "../../core/update-check.ts";
43
44
  import { isUpgrade } from "../../core/version.ts";
44
- import { packageLocation, readUpdateCache, writeUpdateCache } from "../../store/update-cache.ts";
45
45
  import type { Effort } from "../../runtime/model.ts";
46
46
  import { readNode, readCursor, setSessionLabel } from "../../store/log.ts";
47
47
  import { readNodeHistory } from "../../store/restore.ts";
48
- import { currentBranch } from "../../store/git-branch.ts";
48
+ import { readGitStatus } from "../../store/git-status.ts";
49
+ import type { GitStatus } from "../../core/git-status.ts";
50
+ import { activityStatus } from "../../core/activity-status.ts";
51
+ import { executionOutcome, type ExecutionOutcome } from "../../core/outcome.ts";
49
52
  import { projectTrust } from "../../store/trust.ts";
50
53
  import { discoverResources, setResourceOverride } from "../../store/resource-index.ts";
51
54
  import { nextResourceOverride } from "../../core/resource-selection.ts";
@@ -108,6 +111,11 @@ function samePalette(a: TerminalPalette, b: TerminalPalette | null): boolean {
108
111
  return b !== null && a.foreground === b.foreground && a.background === b.background && a.slots.every((slot, i) => slot === b.slots[i]);
109
112
  }
110
113
 
114
+ /** Only the endpoint host is chrome; credentials, query and path stay out of it. */
115
+ function endpointHost(baseUrl: string | undefined): string | null {
116
+ try { return baseUrl ? new URL(baseUrl).host : null; } catch { return null; }
117
+ }
118
+
111
119
  export class InteractiveApp implements Ui {
112
120
  readonly app: App;
113
121
  readonly screen: Screen;
@@ -142,6 +150,11 @@ export class InteractiveApp implements Ui {
142
150
  private retryTimer: ReturnType<typeof setInterval> | undefined;
143
151
  private bashDecoder = new TextDecoder();
144
152
  private branch: string | null;
153
+ private git: GitStatus | null = null;
154
+ private gitRun: Promise<void> | undefined;
155
+ private gitAgain = false;
156
+ private attention: ControlSnapshot["waiting"] = null;
157
+ private lastOutcome: ExecutionOutcome | undefined;
145
158
  private paintTimer: ReturnType<typeof setTimeout> | undefined;
146
159
  /** Handoff-derived status (sidebar / top bar), refreshed single-flight and debounced (DoD 9). */
147
160
  private derived: ReturnType<typeof handoffStatus> | undefined;
@@ -178,8 +191,9 @@ export class InteractiveApp implements Ui {
178
191
  };
179
192
  /** Decoded pictures shared by every node that shows them (feat-tui-inline-images). */
180
193
  private readonly images = new ImageCache(() => this.app.blobDir);
181
- /** The background update check's request, aborted on close so quitting never waits on the registry. */
182
- private readonly updateRequest = new AbortController();
194
+ /** Shared update requests are aborted on close so quitting never waits on the registry. */
195
+ private readonly updateChecks = new UpdateChecks();
196
+ private manualUpdateRequested = false;
183
197
  /** The terminal finished answering capability queries: picture support may have changed. */
184
198
  private readonly capabilitiesChanged = (): void => this.applyPictures();
185
199
  /** What the terminal reported (OSC 4/10/11); null until it answers, or forever if it never does. */
@@ -211,7 +225,7 @@ export class InteractiveApp implements Ui {
211
225
  this.program = program;
212
226
  this.stopOsc = program ? renderer.subscribeOsc(sequence => program.receive(sequence)) : undefined;
213
227
  this.closed = new Promise(resolve => { this.closedResolve = resolve; });
214
- this.branch = currentBranch(app.workspace);
228
+ this.branch = null;
215
229
  this.sessionName = app.args.sessionName || null;
216
230
  this.thinkingExpanded = app.ui?.settings.hideThinkingBlock === false;
217
231
  this.screen = new Screen(renderer, this.colors(), () => this.keymap().byAction, {
@@ -325,7 +339,7 @@ export class InteractiveApp implements Ui {
325
339
  this.requestDerived();
326
340
  this.paint(true);
327
341
  // Never awaited: startup and exit do not wait on the network (feat-update-check DoD 3).
328
- void this.checkForUpdate();
342
+ void this.checkForUpdateOnStartup();
329
343
  }
330
344
 
331
345
  /**
@@ -334,25 +348,24 @@ export class InteractiveApp implements Ui {
334
348
  * and is shown now only while the session is still empty, so it never
335
349
  * interrupts work. xeno names the update command and never runs it.
336
350
  */
337
- private async checkForUpdate(): Promise<void> {
351
+ private async checkForUpdateOnStartup(): Promise<void> {
338
352
  try {
339
- const location = packageLocation();
340
- const install = installKind(location.root, location.git);
341
- const cache = await readUpdateCache();
342
- const plan = updatePlan({ current: XENO_VERSION, now: Date.now(), cache, env: process.env, offline: this.app.args.offline === true,
343
- enabled: this.app.ui?.settings.updateCheck, install });
344
- if (plan.notice && !this.disposed && !this.inflight) this.notice(plan.notice);
345
- if (!plan.check) return;
346
- const latest = await fetchLatestVersion(process.env["npm_config_registry"], { signal: this.updateRequest.signal });
347
- // Quitting cut the request short: that is no answer, so the next start asks again.
348
- if (this.disposed) return;
349
- // A failed attempt is recorded too, so a closed network is not retried on every start.
350
- const known = latest ?? cache?.latest;
351
- await writeUpdateCache({ checkedAt: Date.now(), ...(known ? { latest: known } : {}) });
352
- if (!plan.notice && latest && isUpgrade(latest, XENO_VERSION) && this.emptySession()) this.notice(updateNotice(latest, XENO_VERSION, install));
353
+ const { input, plan } = await this.updateChecks.prepare({ offline: this.app.args.offline === true, enabled: this.app.ui?.settings.updateCheck });
354
+ if (plan.notice && !this.manualUpdateRequested && !this.disposed && !this.inflight) this.notice(plan.notice);
355
+ if (!plan.check || this.manualUpdateRequested) return;
356
+ const latest = await this.updateChecks.refresh(input);
357
+ if (!this.manualUpdateRequested && !plan.notice && latest && isUpgrade(latest, XENO_VERSION) && this.emptySession()) this.notice(updateNotice(latest, XENO_VERSION, input.install));
353
358
  } catch { /* an update hint is never worth an error */ }
354
359
  }
355
360
 
361
+ checkForUpdate(): void {
362
+ // The explicit action owns its result even when it joins a startup request.
363
+ this.manualUpdateRequested = true;
364
+ void this.updateChecks.manual({ offline: this.app.args.offline === true }, text => {
365
+ if (!this.disposed) this.notice(text);
366
+ }).catch(() => { if (!this.disposed) this.notice("Could not check for xeno updates. Try /update again.", "error"); });
367
+ }
368
+
356
369
  private emptySession(): boolean {
357
370
  return !this.disposed && !this.inflight && !this.transcriptItems.some(item => item.kind === "user" || item.kind === "assistant" || item.kind === "tool" || item.kind === "shell");
358
371
  }
@@ -394,7 +407,7 @@ export class InteractiveApp implements Ui {
394
407
  this.renderer.off("palette", this.paletteChanged);
395
408
  this.renderer.off("selection", this.selectionFinished);
396
409
  this.renderer.off("capabilities", this.capabilitiesChanged);
397
- this.updateRequest.abort();
410
+ this.updateChecks.cancel();
398
411
  this.images.dispose();
399
412
  this.screen.dispose();
400
413
  delete this.app.askSlot.current;
@@ -418,7 +431,7 @@ export class InteractiveApp implements Ui {
418
431
  // A different session: its notices, queue and draft attachments do not carry over.
419
432
  if (this.historyDir) { this.notices = []; this.queue = []; this.imageDraft.reset(); this.screen.dialogs.clear(); }
420
433
  this.clearStream();
421
- this.branch = currentBranch(this.app.workspace);
434
+ void this.refreshGit();
422
435
  this.sessionName = (await readCursor(dir))?.name ?? this.sessionName;
423
436
  this.thinkingExpanded = this.app.ui?.settings.hideThinkingBlock === false;
424
437
  void this.refreshFileIndex();
@@ -459,7 +472,8 @@ export class InteractiveApp implements Ui {
459
472
  } else {
460
473
  this.stopRetry();
461
474
  if (progress.kind === "tool") this.live.tools.set(progress.id, { name: progress.name, arguments: progress.arguments });
462
- else this.live.output = `${this.live.output}${progress.text}`.slice(-64 * 1024);
475
+ else if (progress.kind === "output") this.live.output = `${this.live.output}${progress.text}`.slice(-64 * 1024);
476
+ else this.live.output = progress.active ? `Waiting for process ${progress.session} · Esc to interrupt` : "";
463
477
  }
464
478
  this.paint();
465
479
  }
@@ -543,9 +557,19 @@ export class InteractiveApp implements Ui {
543
557
  const t = app.session.totals, cost = reportedCost(t, spec.price);
544
558
  const tools = items.filter(item => item.kind === "tool");
545
559
  const extension = [this.extensions.status, app.credential.ok ? "" : "no provider · /provider"].filter(Boolean).join(" · ");
560
+ const pending = tools.find(tool => tool.status === "pending");
561
+ const phase = this.executionKind === "bash" || pending ? "tool" : this.live.text ? "streaming" : this.live.thinking ? "thinking" : "connecting";
562
+ const activity = activityStatus({ running: this.inflight, waiting: this.attention ?? (!app.credential.ok ? "auth" : null), queued: this.queue.length, phase,
563
+ ...(pending ? { tool: pending.name } : this.executionKind === "bash" ? { tool: "bash" } : {}),
564
+ ...(this.lastOutcome ? { outcome: this.lastOutcome } : this.controlSnapshot?.goal?.state.execution ? { outcome: this.controlSnapshot.goal.state.execution } : {}),
565
+ ...(this.controlSnapshot?.goal ? { goal: this.controlSnapshot.goal.state.status } : {}),
566
+ ...(this.live.retryUntil ? { retry: { attempt: this.live.retryAttempt, seconds: (this.live.retryUntil - Date.now()) / 1000 } } : {}) });
546
567
  return {
547
568
  version: XENO_VERSION, title: this.sessionName ?? this.prompt, workspace: displayPath(app.workspace, homedir()),
548
569
  workspaceShort: shortPath(displayPath(app.workspace, homedir())), branch: this.branch,
570
+ mode: { yolo: app.args.yolo, workflow: app.session.config.tools.workflowMode ?? "build" },
571
+ provider: { name: spec.provider, api: spec.api, host: endpointHost(spec.baseUrl), role: app.args.role?.profile.name ?? app.args.agent ?? null, ready: app.credential.ok },
572
+ activity, git: this.git,
549
573
  ...(budget ? { context: { used: budget.used, trigger: budget.trigger, estimated: budget.estimated, segment: app.session.state.node, ...(spec.contextWindow ? { window: spec.contextWindow } : {}) } } : {}),
550
574
  ...(t.requests ? { usage: { input: t.prompt_tokens, output: t.output_tokens, cacheRate: t.cache_hit_rate,
551
575
  // Under half a cent reads as $0.00, which says nothing (OpenCode #15903; Grok's status line hides it the same way).
@@ -569,6 +593,18 @@ export class InteractiveApp implements Ui {
569
593
  this.renderer.setTerminalTitle(title);
570
594
  }
571
595
 
596
+ private async refreshGit(): Promise<void> {
597
+ if (this.disposed) return;
598
+ if (this.gitRun) { this.gitAgain = true; return; }
599
+ const workspace = this.app.workspace;
600
+ this.gitRun = (async () => {
601
+ const status = await readGitStatus(workspace);
602
+ if (!this.disposed && this.app.workspace === workspace) { this.git = status; this.branch = status?.branch ?? null; this.paint(); }
603
+ })();
604
+ try { await this.gitRun; }
605
+ finally { this.gitRun = undefined; if (this.gitAgain) { this.gitAgain = false; void this.refreshGit(); } }
606
+ }
607
+
572
608
  private liveStream(): LiveStream | undefined {
573
609
  const l = this.live;
574
610
  if (!l.thinking && !l.text && !l.tools.size && !l.output && l.bash === null && !l.retryUntil) return undefined;
@@ -794,7 +830,8 @@ export class InteractiveApp implements Ui {
794
830
  const shell = command.text.startsWith("!");
795
831
  const images = shell ? [] : input.images;
796
832
  if (shell) this.imageDraft.insertImages(input.images);
797
- if (this.inflight) { this.queue.push({ kind: shell ? "followup" : kind, text: command.text, ...(images.length ? { images } : {}) }); this.paint(); return; }
833
+ if (this.inflight) { this.queue.push({ kind: shell ? "followup" : kind, text: command.text, ...(images.length ? { images } : {}) });
834
+ if (!shell && kind === "steering") this.app.session.wakeProcessWaits(); this.paint(); return; }
798
835
  this.turn = this.runPromptTurn(command.text, images, command.skill).catch(error => this.notice(describeError(error), "error"));
799
836
  this.paint();
800
837
  }
@@ -802,6 +839,7 @@ export class InteractiveApp implements Ui {
802
839
  private async runPromptTurn(text: string, images: readonly ImageRef[] = [], skill?: import("../../core/skills.ts").Skill): Promise<void> {
803
840
  this.abort = new AbortController();
804
841
  this.inflight = true;
842
+ this.lastOutcome = undefined;
805
843
  this.programResult = false;
806
844
  this.program?.update(programStatus({ running: true }));
807
845
  this.executionKind = text.startsWith("!") ? "bash" : "model";
@@ -814,16 +852,18 @@ export class InteractiveApp implements Ui {
814
852
  if (sent && !this.abort.signal.aborted) await this.drainFollowUps();
815
853
  } catch (error) {
816
854
  this.programResult = true;
855
+ this.lastOutcome = executionOutcome("execution_error");
817
856
  this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
818
857
  throw error;
819
858
  } finally {
859
+ if (this.abort?.signal.aborted && !this.lastOutcome) this.lastOutcome = executionOutcome("cancelled");
820
860
  if (!this.programResult) this.program?.update(programStatus({ running: false }));
821
861
  await this.syncTranscript();
822
862
  this.abort = null;
823
863
  this.hooks.dropFileIndex(this.app.workspace);
824
864
  this.inflight = false;
825
865
  this.executionKind = "idle";
826
- this.branch = currentBranch(this.app.workspace);
866
+ void this.refreshGit();
827
867
  this.clearStream();
828
868
  this.requestDerived();
829
869
  this.paint();
@@ -955,7 +995,7 @@ export class InteractiveApp implements Ui {
955
995
  this.runExtensionAction(async () => { try { await action(); } finally { this.sessionTransition = false; } });
956
996
  }
957
997
  deliverExtension(text: string, kind: QueueKind): void {
958
- if (this.inflight) { this.queue.push({ kind, text }); this.paint(); }
998
+ if (this.inflight) { this.queue.push({ kind, text }); if (kind === "steering") this.app.session.wakeProcessWaits(); this.paint(); }
959
999
  else this.turn = this.runPromptTurn(text).catch(error => this.notice(describeError(error), "error"));
960
1000
  }
961
1001
 
@@ -994,10 +1034,12 @@ export class InteractiveApp implements Ui {
994
1034
  const requested = terminal?.imageProtocol ?? "auto";
995
1035
  const resolution = this.renderer.resolution;
996
1036
  const effective = resolveImageRenderProtocol(requested, this.renderer.capabilities, resolution !== null);
997
- const show = terminal?.showImages !== false && (requested !== "auto" || effective !== "blocks");
1037
+ const show = terminal?.showImages !== false && effective !== "blocks";
1038
+ const reason = terminal?.showImages === false ? "preview off" : requested === "sixel" && resolution === null ? "waiting for terminal resolution" : "terminal graphics unavailable";
998
1039
  const cell = resolution && this.renderer.terminalWidth > 0 && this.renderer.terminalHeight > 0
999
1040
  ? { width: resolution.width / this.renderer.terminalWidth, height: resolution.height / this.renderer.terminalHeight } : DEFAULT_CELL;
1000
- return { mode: show ? "picture" : "chip", protocol: requested, cell, widthCells: terminal?.imageWidthCells ?? 60, load: sha => this.images.load(sha) };
1041
+ return { mode: show ? "picture" : "chip", protocol: effective, cell, widthCells: terminal?.imageWidthCells ?? 60,
1042
+ ...(!show ? { reason } : {}), load: sha => this.images.loadResult(sha) };
1001
1043
  }
1002
1044
  private applyPictures(): void {
1003
1045
  if (this.disposed) return;
@@ -1047,18 +1089,23 @@ export class InteractiveApp implements Ui {
1047
1089
  const control = sessionControl(this.app.session);
1048
1090
  if (this.control === control) return;
1049
1091
  this.stopControl?.(); this.control = control; this.controlSnapshot = undefined;
1092
+ this.attention = null; this.lastOutcome = undefined;
1050
1093
  this.screen.setControlModel(undefined);
1051
1094
  const detachInput = control.attachInput(phase => this.pullQueued(phase));
1052
1095
  const unsubscribe = control.subscribe(notice => {
1053
1096
  if (this.disposed) return;
1054
- if (notice.method === "turn/started") this.program?.update(programStatus({ running: true }));
1097
+ if (notice.method === "turn/started") { this.lastOutcome = undefined; this.program?.update(programStatus({ running: true })); }
1055
1098
  if (notice.method === "turn/error") {
1099
+ this.lastOutcome = executionOutcome("execution_error");
1056
1100
  this.programResult = true; this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
1057
1101
  this.track(() => this.syncTranscript());
1058
1102
  }
1059
1103
  if (notice.method === "approval/request") this.program?.update(programStatus({ running: true, waiting: "permission" }));
1060
- if (notice.method === "thread/statusChanged" && "waiting" in notice.params)
1104
+ if (notice.method === "thread/statusChanged" && "waiting" in notice.params) {
1105
+ this.attention = notice.params["waiting"] as ControlSnapshot["waiting"];
1061
1106
  this.program?.update(programStatus({ running: control.busy, waiting: notice.params["waiting"] as ControlSnapshot["waiting"] }));
1107
+ }
1108
+ if (notice.method === "turn/completed") this.lastOutcome = notice.params["outcome"] as ExecutionOutcome | undefined;
1062
1109
  // A remote client drives the same Session; local turns already have the shell's stream adapter.
1063
1110
  if (!this.abort) {
1064
1111
  if (notice.method === "turn/started") { this.inflight = true; this.executionKind = "model"; }
@@ -1094,6 +1141,7 @@ export class InteractiveApp implements Ui {
1094
1141
  }
1095
1142
 
1096
1143
  observeEvent(event: import("../../runtime/loop.ts").LoopEvent): void {
1144
+ if (event.outcome) this.lastOutcome = event.outcome;
1097
1145
  if (event.kind === "done" || event.kind === "stop_and_ask") this.programResult = true;
1098
1146
  if (event.kind === "done") this.program?.update(programStatus({ running: false,
1099
1147
  ...(event.outcome ? { outcome: event.outcome } : {}), ...(event.request ? { goal: event.request.status } : {}) }));
@@ -1502,20 +1550,23 @@ export async function runInteractive(app: App, hooks: Hooks, releaseEarlyTty?: (
1502
1550
  app.io.say = previousSay;
1503
1551
  process.off("SIGTERM", terminate); process.off("SIGHUP", hangup); process.off("uncaughtException", crash); process.off("unhandledRejection", crash); process.off("exit", lastResort);
1504
1552
  }
1505
- if (!renderer && fullscreen) printExit(app);
1553
+ if (!renderer && fullscreen) await printExit(app);
1506
1554
  }
1507
1555
  }
1508
1556
 
1509
- /** After leaving the alternate screen: the conversation as plain text, then how to come back. */
1510
- function printExit(app: App): void {
1511
- const mode = app.ui?.settings.fullscreenExitOutput ?? "transcript";
1557
+ /** Restore the shell with a bounded status and resume entry; full replay is explicit. */
1558
+ async function printExit(app: App): Promise<void> {
1559
+ const history = await readNodeHistory(app.logDir, app.session.state.node);
1560
+ const events = history.ok ? history.nodes.flatMap(part => part.events) : app.session.state.events;
1561
+ const mode = app.ui?.settings.fullscreenExitOutput ?? DEFAULT_FULLSCREEN_EXIT_OUTPUT;
1512
1562
  if (mode === "transcript") {
1513
- for (const item of projectTranscript(app.session.state.events)) {
1563
+ for (const item of projectTranscript(events)) {
1514
1564
  if (item.kind === "user") process.stdout.write(`\n❯ ${item.text}\n`);
1515
1565
  else if (item.kind === "assistant") process.stdout.write(`\n${item.text}\n`);
1516
1566
  else if (item.kind === "tool") process.stdout.write(` ⏺ ${item.name}${item.result ? item.result.ok ? "" : " (failed)" : " (pending)"}\n`);
1517
1567
  }
1518
1568
  }
1569
+ process.stdout.write(`\n ${history.ok ? sessionExitStatus(events) : "session saved · history unavailable; inspect before continuing"}\n`);
1519
1570
  process.stdout.write(`\n resume with: xeno --session ${JSON.stringify(app.logDir)}\n`);
1520
1571
  }
1521
1572
 
@@ -8,20 +8,28 @@
8
8
  * its pixels, and memory is returned once the last node goes.
9
9
  *
10
10
  * Bytes come from the blob store, which checks the hash; a missing or
11
- * corrupt blob, or one that will not decode, resolves to null and the view
12
- * names the picture instead of drawing it.
11
+ * corrupt blob, or one that will not decode, has a typed failure so the view
12
+ * retains the caption and shows the actual preparation error.
13
13
  */
14
- import { NativeImage } from "@opentui/core";
14
+ import { NativeImage, imageInfo } from "@opentui/core";
15
15
  import { getBlobBytes } from "../../store/blobs.ts";
16
+ import { modelImageDimensions, MAX_IMAGE_SOURCE_BYTES, MAX_IMAGE_SOURCE_PIXELS } from "../../core/image-limits.ts";
17
+ import type { PictureLoad } from "../../tui/ui/pictures.ts";
16
18
 
17
- /** Decoded RGBA kept for reuse. 64 MiB is in line with Grok's and pi's image budgets; assumed, not measured here. */
19
+ /** Two full previews fit; mounted ImageRenderables retain their own references. */
18
20
  export const IMAGE_CACHE_BYTES = 64 * 1024 * 1024;
19
-
20
- interface Entry { readonly image: Promise<NativeImage | null>; bytes: number }
21
+ /** Bounds failed entries and queued requests as well as successful pixels. */
22
+ export const IMAGE_CACHE_ENTRIES = 128;
23
+ /** Limit simultaneous native allocations; remaining admitted loads wait in the bounded cache. */
24
+ const IMAGE_DECODE_CONCURRENCY = 2;
25
+ interface Entry { readonly result: Promise<PictureLoad>; bytes: number; settled: boolean }
21
26
 
22
27
  export class ImageCache {
23
28
  private readonly entries = new Map<string, Entry>();
24
29
  private used = 0;
30
+ private active = 0;
31
+ private closed = false;
32
+ private readonly waiting: (() => void)[] = [];
25
33
  private readonly blobDir: () => string;
26
34
  private readonly budget: number;
27
35
 
@@ -34,45 +42,80 @@ export class ImageCache {
34
42
  get size(): number { return this.used; }
35
43
  get count(): number { return this.entries.size; }
36
44
 
37
- load(sha256: string): Promise<NativeImage | null> {
45
+ async load(sha256: string): Promise<NativeImage | null> {
46
+ const result = await this.loadResult(sha256);
47
+ return result.kind === "ready" ? result.image : null;
48
+ }
49
+
50
+ loadResult(sha256: string): Promise<PictureLoad> {
51
+ if (this.closed) return Promise.resolve({ kind: "error", reason: "closed" });
38
52
  const hit = this.entries.get(sha256);
39
53
  if (hit) {
40
54
  // Most recently used goes last; eviction starts from the front.
41
55
  this.entries.delete(sha256); this.entries.set(sha256, hit);
42
- return hit.image;
56
+ return hit.result;
43
57
  }
44
- const entry: Entry = { image: this.decode(sha256), bytes: 0 };
58
+ if (this.entries.size >= IMAGE_CACHE_ENTRIES) {
59
+ const oldest = [...this.entries].find(([, entry]) => entry.settled);
60
+ if (oldest) this.drop(oldest[0], oldest[1]);
61
+ else return Promise.resolve({ kind: "error", reason: "busy" });
62
+ }
63
+ // Capture the blob owner before awaiting: session switches cannot redirect an in-flight read.
64
+ const entry: Entry = { result: this.decode(this.blobDir(), sha256), bytes: 0, settled: false };
45
65
  this.entries.set(sha256, entry);
46
- void entry.image.then(native => {
47
- if (this.entries.get(sha256) !== entry) return;
48
- entry.bytes = native ? native.width * native.height * 4 : 0;
66
+ void entry.result.then(result => {
67
+ entry.settled = true;
68
+ if (this.entries.get(sha256) !== entry) { if (result.kind === "ready") result.image.dispose(); return; }
69
+ entry.bytes = result.kind === "ready" ? result.image.width * result.image.height * 4 : 0;
49
70
  this.used += entry.bytes;
50
- this.evict(sha256);
71
+ for (const [sha, old] of this.entries) {
72
+ if (this.used <= this.budget) break;
73
+ if (sha !== sha256 && old.settled) this.drop(sha, old);
74
+ }
51
75
  });
52
- return entry.image;
76
+ return entry.result;
53
77
  }
54
78
 
55
- private async decode(sha256: string): Promise<NativeImage | null> {
79
+ private async decode(dir: string, sha: string): Promise<PictureLoad> {
80
+ if (this.active >= IMAGE_DECODE_CONCURRENCY) await new Promise<void>(resolve => this.waiting.push(resolve));
81
+ else this.active++;
82
+ if (this.closed) return { kind: "error", reason: "closed" };
83
+ let native: NativeImage | undefined;
56
84
  try {
57
- const bytes = await getBlobBytes(this.blobDir(), sha256);
58
- return bytes ? await NativeImage.load(bytes) : null;
59
- } catch { return null; }
85
+ let bytes: Uint8Array | null;
86
+ try { bytes = await getBlobBytes(dir, sha, MAX_IMAGE_SOURCE_BYTES); }
87
+ catch (error) { return { kind: "error", reason: error instanceof RangeError ? "limit" : "corrupt" }; }
88
+ if (!bytes) return { kind: "error", reason: "missing" };
89
+ if (bytes.length > MAX_IMAGE_SOURCE_BYTES) return { kind: "error", reason: "limit" };
90
+ const info = imageInfo(bytes);
91
+ if (info.width * info.height > MAX_IMAGE_SOURCE_PIXELS) return { kind: "error", reason: "limit" };
92
+ native = await NativeImage.load(bytes);
93
+ const target = modelImageDimensions(native);
94
+ if (target.width !== native.width || target.height !== native.height) {
95
+ const resized = native.resize(target); native.dispose(); native = resized;
96
+ }
97
+ if (this.closed) return { kind: "error", reason: "closed" };
98
+ if (native.width * native.height * 4 > this.budget) return { kind: "error", reason: "limit" };
99
+ const image = native; native = undefined;
100
+ return { kind: "ready", image };
101
+ } catch { return { kind: "error", reason: "decode" }; }
102
+ finally {
103
+ native?.dispose();
104
+ const next = this.waiting.shift();
105
+ if (next) next(); else this.active--;
106
+ }
60
107
  }
61
108
 
62
- /** Drop least recently used entries until the budget holds, never the one just loaded. */
63
- private evict(keep: string): void {
64
- for (const [sha, entry] of this.entries) {
65
- if (this.used <= this.budget) break;
66
- if (sha === keep) continue;
67
- this.entries.delete(sha);
68
- this.used -= entry.bytes;
69
- void entry.image.then(native => native?.dispose());
70
- }
109
+ private drop(sha: string, entry: Entry): void {
110
+ this.entries.delete(sha); this.used -= entry.bytes;
111
+ void entry.result.then(result => { if (result.kind === "ready") result.image.dispose(); });
71
112
  }
72
113
 
73
114
  dispose(): void {
74
- for (const entry of this.entries.values()) void entry.image.then(native => native?.dispose());
115
+ this.closed = true;
116
+ for (const entry of this.entries.values()) if (entry.settled) void entry.result.then(result => { if (result.kind === "ready") result.image.dispose(); });
75
117
  this.entries.clear();
76
118
  this.used = 0;
119
+ this.waiting.splice(0).forEach(resolve => resolve());
77
120
  }
78
121
  }
@@ -34,6 +34,7 @@ export interface Ui extends TurnSurface {
34
34
  /** Notices and documents in the message stream. */
35
35
  notice(text: string, tone?: "info" | "success" | "warning" | "error"): void;
36
36
  showDocument(title: string, text: string): void;
37
+ checkForUpdate(): void;
37
38
  toast(text: string, tone?: "info" | "success" | "warning" | "error"): void;
38
39
  redraw(): void;
39
40
 
@@ -0,0 +1,71 @@
1
+ /** Shared lifecycle for automatic hints and explicit checks; policy remains in core (D16). */
2
+ import { installKind, manualUpdateResult, updateDisabled, updatePlan, type UpdateInput } from "../core/update-check.ts";
3
+ import { packageLocation, readUpdateCache, writeUpdateCache } from "../store/update-cache.ts";
4
+ import { fetchLatestVersion } from "../runtime/update-check.ts";
5
+ import { XENO_VERSION } from "../runtime/oauth.ts";
6
+
7
+ export interface UpdateOptions {
8
+ readonly offline?: boolean;
9
+ readonly enabled?: boolean | undefined;
10
+ }
11
+
12
+ export class UpdateChecks {
13
+ private readonly controller = new AbortController();
14
+ private pending: Promise<string | null> | undefined;
15
+ private manualTask: Promise<number> | undefined;
16
+
17
+ async prepare(options: UpdateOptions, manual = false) {
18
+ const location = packageLocation();
19
+ const input: UpdateInput = { current: XENO_VERSION, now: Date.now(), cache: await readUpdateCache(), env: process.env,
20
+ offline: options.offline === true, enabled: options.enabled, install: installKind(location.root, location.git), manual };
21
+ return { input, plan: updatePlan(input), disabled: updateDisabled(input) };
22
+ }
23
+
24
+ /** One request and one cache write even when a manual check joins startup's request. */
25
+ refresh(input: UpdateInput): Promise<string | null> {
26
+ if (this.pending) return this.pending;
27
+ const pending = (async () => {
28
+ const latest = await fetchLatestVersion(input.env["npm_config_registry"], { signal: this.controller.signal });
29
+ if (!this.controller.signal.aborted) {
30
+ const known = latest ?? input.cache?.latest;
31
+ await writeUpdateCache({ checkedAt: Date.now(), ...(known ? { latest: known } : {}) });
32
+ }
33
+ return latest;
34
+ })().finally(() => { if (this.pending === pending) this.pending = undefined; });
35
+ this.pending = pending;
36
+ return pending;
37
+ }
38
+
39
+ manual(options: UpdateOptions, say: (text: string) => void): Promise<number> {
40
+ if (this.manualTask) return this.manualTask;
41
+ const task = this.runManual(options, say).finally(() => { if (this.manualTask === task) this.manualTask = undefined; });
42
+ this.manualTask = task;
43
+ return task;
44
+ }
45
+
46
+ private async runManual(options: UpdateOptions, say: (text: string) => void): Promise<number> {
47
+ const { input, plan, disabled } = await this.prepare(options, true);
48
+ if (this.controller.signal.aborted) return 130;
49
+ if (!plan.check) {
50
+ say(input.install === "checkout" ? `Source checkout: xeno ${input.current}. Use git pull to update this checkout.`
51
+ : `Update check skipped: ${disabled ?? "check unavailable"} (xeno ${input.current}).`);
52
+ return input.install === "checkout" ? 0 : 1;
53
+ }
54
+ say(`Checking for xeno updates (current ${input.current})...`);
55
+ const latest = await this.refresh(input);
56
+ if (this.controller.signal.aborted) return 130;
57
+ const result = manualUpdateResult(latest, input.current, input.install);
58
+ say(result.text);
59
+ return result.status === "failed" ? 1 : 0;
60
+ }
61
+
62
+ cancel(): void { this.controller.abort(); }
63
+ }
64
+
65
+ /** A standalone command has no UI lifecycle to own cancellation. */
66
+ export async function runManualUpdate(options: UpdateOptions, say: (text: string) => void): Promise<number> {
67
+ const checks = new UpdateChecks(), cancel = () => checks.cancel();
68
+ process.once("SIGINT", cancel); process.once("SIGTERM", cancel);
69
+ try { return await checks.manual(options, say); }
70
+ finally { checks.cancel(); process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
71
+ }
@@ -0,0 +1,58 @@
1
+ /** D5/D6: the app changes capabilities only through the live session's checkpoint owner. */
2
+ import { isWorkflowMode, workflowInstructions, type WorkflowMode } from "../core/workflow-mode.ts";
3
+ import { sessionControl } from "../runtime/session-control.ts";
4
+ import { sessionToolCatalog, type App } from "./startup.ts";
5
+
6
+ export type WorkflowApp = Pick<App, "args" | "session" | "extensions" | "system">;
7
+
8
+ export async function setAppWorkflow(app: WorkflowApp, mode: WorkflowMode): Promise<boolean> {
9
+ if (!isWorkflowMode(mode)) throw Error("workflow mode must be build or plan");
10
+ const session = app.session, previousMode = session.config.tools.workflowMode;
11
+ const current = previousMode ?? app.args.workflow ?? "build";
12
+ return sessionControl(session).withIdleChange(async () => {
13
+ const previousProfile = session.config.agentProfile, name = previousProfile?.name ?? app.args.agent;
14
+ const nextProfile = name ? { name, workflowExplicit: true } : undefined;
15
+ const args = structuredClone(app.args), previousScope = session.config.workflowToolProfile;
16
+ const promote = mode === "plan" && args.tools === undefined && !args.noTools && args.toolProfile === undefined;
17
+ if (promote) args.toolProfile = "foundation";
18
+ const nextScope = args.toolProfile === "foundation" ? "foundation" as const : undefined;
19
+ const metadataChanged = JSON.stringify(previousProfile) !== JSON.stringify(nextProfile) || previousScope !== nextScope;
20
+ if (mode === current && !metadataChanged && !promote) {
21
+ app.args.workflow = mode; app.args.cliFlags = [...new Set([...app.args.cliFlags ?? [], "--workflow"])];
22
+ return false;
23
+ }
24
+ const oldInstruction = workflowInstructions(current), nextInstruction = workflowInstructions(mode);
25
+ // Replace only a complete harness paragraph: user context and all other frozen bytes survive.
26
+ const paragraphs = session.config.frozen.system.split("\n\n");
27
+ const index = paragraphs.indexOf(oldInstruction);
28
+ if (index >= 0) paragraphs[index] = nextInstruction; else paragraphs.push(nextInstruction);
29
+ const system = mode === current ? session.config.frozen.system : paragraphs.join("\n\n");
30
+ const catalog = sessionToolCatalog({ ...args, workflow: mode }, app.extensions, session.config.frozen.tools);
31
+ // The persisted checkpoint owns the admission mode as well as declarations, including cold restore.
32
+ session.config.tools.workflowMode = mode;
33
+ if (nextProfile) session.config.agentProfile = nextProfile;
34
+ if (nextScope) session.config.workflowToolProfile = nextScope; else delete session.config.workflowToolProfile;
35
+ try {
36
+ const changed = await session.reloadResources(mode === current && !promote ? session.config.frozen : { ...session.config.frozen, system, tools: catalog.schemas }, undefined, undefined, metadataChanged);
37
+ if (mode !== current || promote) session.config.tools.deferredTools = catalog.deferred;
38
+ args.workflow = mode; args.cliFlags = [...new Set([...args.cliFlags ?? [], "--workflow"])]; app.args = args; app.system = system;
39
+ return changed;
40
+ } catch (error) {
41
+ if (previousMode === undefined) delete session.config.tools.workflowMode;
42
+ else session.config.tools.workflowMode = previousMode;
43
+ if (previousProfile === undefined) delete session.config.agentProfile;
44
+ else session.config.agentProfile = previousProfile;
45
+ if (previousScope === undefined) delete session.config.workflowToolProfile;
46
+ else session.config.workflowToolProfile = previousScope;
47
+ throw error;
48
+ }
49
+ });
50
+ }
51
+
52
+ export function bindAppWorkflow(app: WorkflowApp): void {
53
+ const session = app.session;
54
+ sessionControl(session).configureWorkflow = async mode => {
55
+ if (app.session !== session) throw Error("this session is no longer attached to the app");
56
+ await setAppWorkflow(app, mode);
57
+ };
58
+ }
@@ -0,0 +1,28 @@
1
+ /** A finer view of OSC status, derived from the same runtime facts (D11). */
2
+ import { programStatus, type ProgramStatus } from "./program-status.ts";
3
+ import type { ExecutionOutcome } from "./outcome.ts";
4
+
5
+ export interface ActivityFacts {
6
+ readonly running: boolean;
7
+ readonly waiting?: "permission" | "question" | "auth" | null;
8
+ readonly outcome?: ExecutionOutcome;
9
+ readonly goal?: string;
10
+ readonly phase?: "connecting" | "thinking" | "streaming" | "tool";
11
+ readonly tool?: string;
12
+ readonly retry?: { readonly attempt: number; readonly seconds: number };
13
+ readonly queued: number;
14
+ }
15
+ export interface ActivityStatus { readonly state: ProgramStatus["state"]; readonly label: string; readonly detail: string; readonly queued: number }
16
+
17
+ export function activityStatus(facts: ActivityFacts): ActivityStatus {
18
+ const status = programStatus(facts), queued = Math.max(0, Math.floor(facts.queued));
19
+ const view = (label: string, detail = status.msg ?? ""): ActivityStatus => ({ state: status.state, label, detail, queued });
20
+ if (status.state === "blocked") return view(status.kind === "auth" ? "Sign in required" : status.kind === "permission" ? "Awaiting approval" : "Awaiting input");
21
+ if (status.state === "error") return view("Failed");
22
+ if (!facts.running) return view(status.state === "done" ? "Turn finished" : facts.outcome?.status === "cancelled" ? "Cancelled" : "Ready");
23
+ if (facts.retry) return view("Retrying", `attempt ${facts.retry.attempt} · ${Math.max(0, Math.ceil(facts.retry.seconds))}s`);
24
+ if (facts.phase === "tool") return view("Running tool", facts.tool ?? "Tool execution");
25
+ if (facts.phase === "thinking") return view("Thinking");
26
+ if (facts.phase === "streaming") return view("Responding");
27
+ return view("Connecting", "Waiting for the first response");
28
+ }