@visual-z/xeno 0.4.0 → 0.6.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 (90) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +41 -10
  3. package/docs/app-server.md +35 -4
  4. package/docs/foundations.md +88 -0
  5. package/docs/install.md +23 -2
  6. package/docs/providers.md +4 -0
  7. package/docs/research/foundation-clients-2026-10-09.md +27 -0
  8. package/docs/research/foundation-editing-2026-10-09.md +56 -0
  9. package/docs/research/foundation-network-2026-10-09.md +37 -0
  10. package/docs/research/foundation-workflow-2026-10-09.md +37 -0
  11. package/package.json +10 -2
  12. package/src/apps/acp-server.ts +41 -0
  13. package/src/apps/agent-profiles.ts +19 -0
  14. package/src/apps/app-server.ts +4 -2
  15. package/src/apps/cli.ts +18 -2
  16. package/src/apps/commands.ts +20 -0
  17. package/src/apps/control-transport.ts +3 -2
  18. package/src/apps/model-catalog.ts +57 -31
  19. package/src/apps/preferences.ts +20 -0
  20. package/src/apps/question-ui.ts +27 -0
  21. package/src/apps/resources.ts +63 -25
  22. package/src/apps/review.ts +109 -0
  23. package/src/apps/session-hub.ts +84 -0
  24. package/src/apps/shell.ts +88 -30
  25. package/src/apps/startup-input.ts +7 -2
  26. package/src/apps/startup.ts +180 -25
  27. package/src/apps/ui/app.ts +74 -91
  28. package/src/apps/ui/surface.ts +1 -0
  29. package/src/apps/update-check.ts +71 -0
  30. package/src/apps/workflow.ts +58 -0
  31. package/src/core/agent-profile.ts +130 -0
  32. package/src/core/completion-review.ts +13 -4
  33. package/src/core/control-protocol.ts +8 -1
  34. package/src/core/file-precondition.ts +6 -0
  35. package/src/core/lsp-framing.ts +34 -0
  36. package/src/core/mcp-config.ts +68 -0
  37. package/src/core/model-preferences.ts +28 -0
  38. package/src/core/patch.ts +130 -0
  39. package/src/core/permissions.ts +156 -11
  40. package/src/core/question.ts +94 -0
  41. package/src/core/review.ts +68 -0
  42. package/src/core/settings.ts +1 -1
  43. package/src/core/update-check.ts +24 -5
  44. package/src/core/web-content.ts +80 -0
  45. package/src/core/workflow-mode.ts +34 -0
  46. package/src/runtime/acp-agent.ts +289 -0
  47. package/src/runtime/agent-profiles.ts +21 -0
  48. package/src/runtime/catalog.ts +10 -3
  49. package/src/runtime/control-client.ts +149 -0
  50. package/src/runtime/control-connection.ts +39 -4
  51. package/src/runtime/extensions.ts +20 -2
  52. package/src/runtime/formatter-tool.ts +38 -0
  53. package/src/runtime/formatter.ts +50 -0
  54. package/src/runtime/foundation.ts +78 -0
  55. package/src/runtime/integrations.ts +8 -2
  56. package/src/runtime/loop.ts +68 -10
  57. package/src/runtime/lsp-client.ts +201 -0
  58. package/src/runtime/mcp-client.ts +264 -0
  59. package/src/runtime/mcp-oauth.ts +168 -0
  60. package/src/runtime/mcp-schema.ts +51 -0
  61. package/src/runtime/oauth.ts +1 -1
  62. package/src/runtime/output-schema.ts +26 -0
  63. package/src/runtime/patch-tool.ts +38 -0
  64. package/src/runtime/permission-resources.ts +11 -0
  65. package/src/runtime/provider.ts +2 -0
  66. package/src/runtime/question-tool.ts +62 -0
  67. package/src/runtime/review-tool.ts +48 -0
  68. package/src/runtime/session-control.ts +60 -2
  69. package/src/runtime/session-hub.ts +85 -0
  70. package/src/runtime/tool-discovery.ts +4 -3
  71. package/src/runtime/tools.ts +48 -16
  72. package/src/runtime/update-check.ts +3 -2
  73. package/src/runtime/web-http.ts +56 -0
  74. package/src/runtime/web-tools.ts +88 -0
  75. package/src/runtime/workspace-lifecycle.ts +14 -1
  76. package/src/store/agent-profile-files.ts +13 -0
  77. package/src/store/context-files.ts +77 -1
  78. package/src/store/file-checkpoints.ts +5 -4
  79. package/src/store/foundation-config.ts +24 -0
  80. package/src/store/lsp-document.ts +30 -0
  81. package/src/store/mcp-credentials.ts +85 -0
  82. package/src/store/patch.ts +92 -0
  83. package/src/store/permission-paths.ts +34 -0
  84. package/src/store/resources.ts +13 -22
  85. package/src/store/review.ts +109 -0
  86. package/src/store/search.ts +48 -9
  87. package/src/store/user-settings.ts +30 -0
  88. package/src/tui/ui/control-panel.ts +7 -5
  89. package/src/tui/ui/control-sidebar.ts +49 -0
  90. package/src/tui/ui/screen.ts +107 -11
@@ -25,7 +25,7 @@ import { pickerRows } from "../../core/model-picker.ts";
25
25
  import { modelKey } from "../../core/session-position.ts";
26
26
  import { flattenTree, type TreeFilter, type TreeNode } from "../../core/session-tree.ts";
27
27
  import { selectSessions, type SessionSort } from "../../core/session-browser.ts";
28
- import { DISPLAY_SETTINGS, mergeSettings, settingValue, settingValues } from "../../core/settings.ts";
28
+ import { DISPLAY_SETTINGS, settingValue, settingValues } from "../../core/settings.ts";
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";
@@ -38,10 +38,9 @@ import { highlightCode, transformMermaid, warmSyntaxLanguages } from "../../runt
38
38
  import { SOURCE_PROVIDERS } from "../../runtime/model-data.ts";
39
39
  import { SUBSCRIPTION_OPTIONS, subscriptionById, loginGitHubCopilot, startCallbackServer, XENO_VERSION } from "../../runtime/oauth.ts";
40
40
  import { reportedCost } from "../../runtime/usage.ts";
41
- import { fetchLatestVersion } from "../../runtime/update-check.ts";
42
- import { installKind, updateNotice, updatePlan } from "../../core/update-check.ts";
41
+ import { UpdateChecks } from "../update-check.ts";
42
+ import { updateNotice } from "../../core/update-check.ts";
43
43
  import { isUpgrade } from "../../core/version.ts";
44
- import { packageLocation, readUpdateCache, writeUpdateCache } from "../../store/update-cache.ts";
45
44
  import type { Effort } from "../../runtime/model.ts";
46
45
  import { readNode, readCursor, setSessionLabel } from "../../store/log.ts";
47
46
  import { readNodeHistory } from "../../store/restore.ts";
@@ -50,7 +49,8 @@ import { projectTrust } from "../../store/trust.ts";
50
49
  import { discoverResources, setResourceOverride } from "../../store/resource-index.ts";
51
50
  import { nextResourceOverride } from "../../core/resource-selection.ts";
52
51
  import { archiveSession, renameSession, scanSessionRoots, sessionRoots } from "../../store/session-browser.ts";
53
- import { discoverThemes, removeModelThinkingOverride, selectedThemePath, watchTheme, writeUserSettings, loadUiResources } from "../../store/resources.ts";
52
+ import { discoverThemes, removeModelThinkingOverride, selectedThemePath, watchTheme, loadUiResources } from "../../store/resources.ts";
53
+ import { commitUserSettings } from "../preferences.ts";
54
54
  import { fuzzyMatch } from "../../tui/fuzzy.ts";
55
55
  import type { DialogSpec, ListAction } from "../../tui/ui/dialog.ts";
56
56
  import { keyPress, bound, type KeyPress } from "../../tui/ui/keys.ts";
@@ -82,7 +82,7 @@ import { ImageCache } from "./image-cache.ts";
82
82
  import { ImageDraft } from "../image-draft.ts";
83
83
  import { sessionControl, type SessionControl, type ControlSnapshot } from "../../runtime/session-control.ts";
84
84
  import { controlModel } from "./control-model.ts";
85
- import { controlPanelLines, type ControlTab, type ControlFocus } from "../../tui/ui/control-panel.ts";
85
+ import type { ControlTab } from "../../tui/ui/control-panel.ts";
86
86
  import { programStatus } from "../../core/program-status.ts";
87
87
  import { ProgramStatusReporter } from "../../runtime/program-status.ts";
88
88
  import type { PictureSource } from "../../tui/ui/pictures.ts";
@@ -177,8 +177,9 @@ export class InteractiveApp implements Ui {
177
177
  };
178
178
  /** Decoded pictures shared by every node that shows them (feat-tui-inline-images). */
179
179
  private readonly images = new ImageCache(() => this.app.blobDir);
180
- /** The background update check's request, aborted on close so quitting never waits on the registry. */
181
- private readonly updateRequest = new AbortController();
180
+ /** Shared update requests are aborted on close so quitting never waits on the registry. */
181
+ private readonly updateChecks = new UpdateChecks();
182
+ private manualUpdateRequested = false;
182
183
  /** The terminal finished answering capability queries: picture support may have changed. */
183
184
  private readonly capabilitiesChanged = (): void => this.applyPictures();
184
185
  /** What the terminal reported (OSC 4/10/11); null until it answers, or forever if it never does. */
@@ -216,6 +217,10 @@ export class InteractiveApp implements Ui {
216
217
  this.screen = new Screen(renderer, this.colors(), () => this.keymap().byAction, {
217
218
  codeBlock: (code, language) => highlightCode(code, language, (token, text) => this.app.ui?.style.token?.(token, text) ?? ANSI.token?.(token, text) ?? text),
218
219
  });
220
+ this.screen.onControlAction = action => {
221
+ if (action === "interrupt") this.interrupt();
222
+ else this.track(async () => { if (action === "refresh") await this.refreshControl(); else this.acceptControlCriterion(); });
223
+ };
219
224
  this.imageDraft = new ImageDraft(this.screen.composer);
220
225
  this.extensions = new ExtensionHost({
221
226
  dialog: spec => this.screen.dialogs.push(spec),
@@ -320,7 +325,7 @@ export class InteractiveApp implements Ui {
320
325
  this.requestDerived();
321
326
  this.paint(true);
322
327
  // Never awaited: startup and exit do not wait on the network (feat-update-check DoD 3).
323
- void this.checkForUpdate();
328
+ void this.checkForUpdateOnStartup();
324
329
  }
325
330
 
326
331
  /**
@@ -329,25 +334,24 @@ export class InteractiveApp implements Ui {
329
334
  * and is shown now only while the session is still empty, so it never
330
335
  * interrupts work. xeno names the update command and never runs it.
331
336
  */
332
- private async checkForUpdate(): Promise<void> {
337
+ private async checkForUpdateOnStartup(): Promise<void> {
333
338
  try {
334
- const location = packageLocation();
335
- const install = installKind(location.root, location.git);
336
- const cache = await readUpdateCache();
337
- const plan = updatePlan({ current: XENO_VERSION, now: Date.now(), cache, env: process.env, offline: this.app.args.offline === true,
338
- enabled: this.app.ui?.settings.updateCheck, install });
339
- if (plan.notice && !this.disposed && !this.inflight) this.notice(plan.notice);
340
- if (!plan.check) return;
341
- const latest = await fetchLatestVersion(process.env["npm_config_registry"], { signal: this.updateRequest.signal });
342
- // Quitting cut the request short: that is no answer, so the next start asks again.
343
- if (this.disposed) return;
344
- // A failed attempt is recorded too, so a closed network is not retried on every start.
345
- const known = latest ?? cache?.latest;
346
- await writeUpdateCache({ checkedAt: Date.now(), ...(known ? { latest: known } : {}) });
347
- if (!plan.notice && latest && isUpgrade(latest, XENO_VERSION) && this.emptySession()) this.notice(updateNotice(latest, XENO_VERSION, install));
339
+ const { input, plan } = await this.updateChecks.prepare({ offline: this.app.args.offline === true, enabled: this.app.ui?.settings.updateCheck });
340
+ if (plan.notice && !this.manualUpdateRequested && !this.disposed && !this.inflight) this.notice(plan.notice);
341
+ if (!plan.check || this.manualUpdateRequested) return;
342
+ const latest = await this.updateChecks.refresh(input);
343
+ if (!this.manualUpdateRequested && !plan.notice && latest && isUpgrade(latest, XENO_VERSION) && this.emptySession()) this.notice(updateNotice(latest, XENO_VERSION, input.install));
348
344
  } catch { /* an update hint is never worth an error */ }
349
345
  }
350
346
 
347
+ checkForUpdate(): void {
348
+ // The explicit action owns its result even when it joins a startup request.
349
+ this.manualUpdateRequested = true;
350
+ void this.updateChecks.manual({ offline: this.app.args.offline === true }, text => {
351
+ if (!this.disposed) this.notice(text);
352
+ }).catch(() => { if (!this.disposed) this.notice("Could not check for xeno updates. Try /update again.", "error"); });
353
+ }
354
+
351
355
  private emptySession(): boolean {
352
356
  return !this.disposed && !this.inflight && !this.transcriptItems.some(item => item.kind === "user" || item.kind === "assistant" || item.kind === "tool" || item.kind === "shell");
353
357
  }
@@ -389,7 +393,7 @@ export class InteractiveApp implements Ui {
389
393
  this.renderer.off("palette", this.paletteChanged);
390
394
  this.renderer.off("selection", this.selectionFinished);
391
395
  this.renderer.off("capabilities", this.capabilitiesChanged);
392
- this.updateRequest.abort();
396
+ this.updateChecks.cancel();
393
397
  this.images.dispose();
394
398
  this.screen.dispose();
395
399
  delete this.app.askSlot.current;
@@ -590,7 +594,7 @@ export class InteractiveApp implements Ui {
590
594
  * per debounce window, one run at a time, and only while a surface shows it.
591
595
  */
592
596
  private requestDerived(): void {
593
- if (this.disposed || this.screen.tier === "bare") return;
597
+ if (this.disposed || (this.screen.tier === "bare" && !this.screen.controlOpen)) return;
594
598
  if (this.derivedKey === this.derivedTarget()) return;
595
599
  if (this.derivedRun || this.derivedTimer) { this.derivedAgain = true; return; }
596
600
  // Throttle, not a pure debounce: the first change after a quiet spell refreshes at once,
@@ -604,23 +608,24 @@ export class InteractiveApp implements Ui {
604
608
  this.derivedLast = Date.now();
605
609
  const target = this.derivedTarget();
606
610
  this.derivedRun = (async () => {
611
+ const session = this.app.session;
607
612
  try {
608
- const session = this.app.session;
609
613
  const derived = handoffStatus(await session.previewHandoff());
610
614
  const snapshot = await sessionControl(session).snapshot(false);
611
- if (this.app.session !== session) return;
615
+ if (this.disposed || this.app.session !== session) return;
612
616
  this.derived = derived; this.controlSnapshot = snapshot; this.derivedKey = target;
613
617
  // Idle acceptance changes have no model delta. Reconcile terminal attention
614
618
  // with the same evidence projection that refreshes the visible goal.
615
619
  if (!this.inflight) this.program?.update(programStatus({ running: false, waiting: snapshot.waiting,
616
620
  ...(snapshot.goal?.state.execution ? { outcome: snapshot.goal.state.execution } : {}),
617
621
  ...(snapshot.goal ? { goal: snapshot.goal.state.status } : {}) }));
618
- if (this.screen.dialogs.top?.title === "Control panel") this.screen.dialogs.refresh();
622
+ this.screen.setControlModel(controlModel(snapshot));
619
623
  }
620
624
  catch {
621
625
  // Unavailable evidence cannot leave a stale verified goal visible.
626
+ if (this.disposed || this.app.session !== session) return;
622
627
  this.controlSnapshot = undefined;
623
- if (this.screen.dialogs.top?.title === "Control panel") this.screen.dialogs.refresh();
628
+ this.screen.setControlModel(undefined);
624
629
  }
625
630
  })();
626
631
  await this.derivedRun;
@@ -636,6 +641,7 @@ export class InteractiveApp implements Ui {
636
641
  private onPaste(event: PasteEvent): void {
637
642
  const text = new TextDecoder().decode(event.bytes);
638
643
  if (this.screen.dialogs.open) { if (this.screen.dialogs.paste(text)) return; event.preventDefault(); return; }
644
+ this.screen.focusComposer();
639
645
  const paths = droppedImagePaths(text);
640
646
  if (paths) { event.preventDefault(); this.track(() => this.pasteClipboard({ files: paths })); }
641
647
  }
@@ -651,6 +657,7 @@ export class InteractiveApp implements Ui {
651
657
  if (bound(b, "app.exit", key) && !this.screen.dialogs.open && !this.screen.composer.text && !this.pendingImages.length) { this.requestShutdown(); return true; }
652
658
  if (bound(b, "app.suspend", key)) { this.suspend(); return true; }
653
659
  if (this.screen.dialogs.open) return this.screen.dialogs.key(key);
660
+ if (this.screen.handleControlKey(key)) return true;
654
661
  const composer = this.screen.composer;
655
662
  if (composer.completing) {
656
663
  if (key.chord === "up") { composer.moveCompletion(-1); return true; }
@@ -1039,6 +1046,7 @@ export class InteractiveApp implements Ui {
1039
1046
  const control = sessionControl(this.app.session);
1040
1047
  if (this.control === control) return;
1041
1048
  this.stopControl?.(); this.control = control; this.controlSnapshot = undefined;
1049
+ this.screen.setControlModel(undefined);
1042
1050
  const detachInput = control.attachInput(phase => this.pullQueued(phase));
1043
1051
  const unsubscribe = control.subscribe(notice => {
1044
1052
  if (this.disposed) return;
@@ -1093,40 +1101,38 @@ export class InteractiveApp implements Ui {
1093
1101
 
1094
1102
  async openControl(tab: ControlTab = "goal"): Promise<void> {
1095
1103
  this.bindControl();
1096
- this.controlSnapshot = await this.control!.snapshot();
1097
- const tabs: ControlTab[] = ["goal", "progress", "context"];
1098
- const lenses: ControlFocus[] = ["messages", "omitted", "history"];
1099
- let focus: ControlFocus = "messages", scroll = 0, total = 0;
1100
- this.screen.dialogs.push({ kind: "lines", title: "Control panel",
1101
- hint: "tab sections · ←→ lenses · ↑↓ scroll · r refresh · i interrupt · a accept",
1102
- render: width => {
1103
- if (!this.controlSnapshot) return ["Current evidence unavailable. Press r to refresh."];
1104
- const lines = controlPanelLines(controlModel(this.controlSnapshot), tab, width, focus);
1105
- const rows = Math.max(4, this.renderer.height - 14); total = lines.length;
1106
- scroll = Math.min(scroll, Math.max(0, total - rows));
1107
- return [...lines.slice(scroll, scroll + rows), ...(total > rows ? [`${scroll + 1}–${Math.min(total, scroll + rows)} / ${total} · ↑↓ scroll`] : [])];
1108
- },
1109
- handleKey: async key => {
1110
- if (key.name === "tab") { tab = tabs[(tabs.indexOf(tab) + 1) % tabs.length]!; scroll = 0; this.screen.dialogs.refresh(); return true; }
1111
- if (key.name === "up" || key.name === "down") { scroll = Math.max(0, Math.min(total - 1, scroll + (key.name === "down" ? 1 : -1))); this.screen.dialogs.refresh(); return true; }
1112
- if (tab === "context" && (key.name === "left" || key.name === "right")) {
1113
- focus = lenses[(lenses.indexOf(focus) + (key.name === "right" ? 1 : 2)) % 3]!; scroll = 0; this.screen.dialogs.refresh(); return true;
1114
- }
1115
- if (key.name === "r") { this.controlSnapshot = await sessionControl(this.app.session).snapshot(); this.screen.dialogs.refresh(); return true; }
1116
- if (key.name === "i") { this.interrupt(); return true; }
1117
- if (key.name === "a") {
1118
- const goal = this.controlSnapshot?.goal;
1119
- const criteria = goal?.state.acceptance?.criteria.filter(item => item.kind === "human" && item.status !== "pass" && item.status !== "accepted") ?? [];
1120
- if (this.inflight || !goal || !criteria.length) { this.toast("No human criterion ready for acceptance", "warning"); return true; }
1121
- this.screen.dialogs.push({ kind: "list", title: "Accept a human criterion", search: false,
1122
- items: criteria.map(item => ({ value: item.id, label: item.description, description: item.status })),
1123
- onPick: async item => {
1124
- await sessionControl(this.app.session).accept(goal.state.request.event, item.value);
1125
- this.controlSnapshot = await sessionControl(this.app.session).snapshot(); this.screen.dialogs.refresh();
1126
- } });
1127
- return true;
1128
- }
1129
- return false;
1104
+ if (!await this.refreshControl()) return;
1105
+ this.screen.showControl(controlModel(this.controlSnapshot!), tab);
1106
+ this.requestDerived();
1107
+ }
1108
+
1109
+ private async refreshControl(): Promise<boolean> {
1110
+ const control = sessionControl(this.app.session);
1111
+ try {
1112
+ const snapshot = await control.snapshot();
1113
+ if (this.disposed || control !== sessionControl(this.app.session)) return false;
1114
+ this.controlSnapshot = snapshot;
1115
+ this.screen.setControlModel(controlModel(snapshot));
1116
+ return true;
1117
+ } catch (error) {
1118
+ if (!this.disposed && control === sessionControl(this.app.session)) {
1119
+ this.controlSnapshot = undefined;
1120
+ this.screen.setControlModel(undefined);
1121
+ }
1122
+ throw error;
1123
+ }
1124
+ }
1125
+
1126
+ private acceptControlCriterion(): void {
1127
+ const control = sessionControl(this.app.session), goal = this.controlSnapshot?.goal;
1128
+ const criteria = goal?.state.acceptance?.criteria.filter(item => item.kind === "human" && item.status !== "pass" && item.status !== "accepted") ?? [];
1129
+ if (this.inflight || !goal || !criteria.length) { this.toast("No human criterion ready for acceptance", "warning"); return; }
1130
+ this.screen.dialogs.push({ kind: "list", title: "Accept a human criterion", search: false,
1131
+ items: criteria.map(item => ({ value: item.id, label: item.description, description: item.status })),
1132
+ onPick: async item => {
1133
+ if (control !== sessionControl(this.app.session)) throw Error("Session changed; refresh the control panel");
1134
+ await control.accept(goal.state.request.event, item.value);
1135
+ await this.refreshControl();
1130
1136
  } });
1131
1137
  }
1132
1138
 
@@ -1192,37 +1198,17 @@ export class InteractiveApp implements Ui {
1192
1198
  keywords: `${row.model.provider} ${row.model.providerName}`, ...(row.current ? { hint: "● current" } : {}) }));
1193
1199
  items.push({ value: "login", label: "+ Connect a provider…", section: "More", description: "subscription, API key, gateway or local server" });
1194
1200
  this.push({ kind: "list", title: `Model · ${rows.filter(row => row.model.ready).length} ready`, items, selected: current,
1195
- actions: [{ key: "ctrl+s", label: "save default", run: item => this.saveDefaultModel(item) }],
1196
1201
  onPick: async item => {
1197
1202
  if (item.value === "login") { this.openLogin(); return; }
1198
1203
  const spec = this.app.catalog.find(model => modelKey(model) === item.value);
1199
1204
  if (spec) await this.hooks.switchToModel(this.app, spec);
1200
1205
  } });
1201
1206
  }
1202
- private async saveDefaultModel(item: ListItem | undefined): Promise<void> {
1203
- const spec = item && this.app.catalog.find(model => modelKey(model) === item.value);
1204
- if (!spec || !this.app.ui) return;
1205
- const patch = { defaultModel: spec.id, defaultProvider: spec.provider };
1206
- await writeUserSettings(patch);
1207
- this.app.ui.userOverrides = { ...this.app.ui.userOverrides, ...patch };
1208
- this.app.ui.settings = { ...this.app.ui.settings, ...patch };
1209
- this.toast(`saved default model ${item.value}`, "success");
1210
- }
1211
-
1212
1207
  openThinking(): void {
1213
1208
  const spec = this.app.providerConfig.spec, current = this.app.providerConfig.effort;
1214
1209
  this.push({ kind: "list", title: "Thinking", search: false, selected: current,
1215
1210
  items: THINKING_LEVELS.map(level => ({ value: level, label: level, disabled: !this.hooks.effortSupported(spec, level),
1216
1211
  ...(level === current ? { hint: "● current" } : !this.hooks.effortSupported(spec, level) ? { description: "not supported by this model" } : {}) })),
1217
- actions: [{ key: "ctrl+s", label: "save default", run: async item => {
1218
- const effort = item && asThinkingLevel(item.value);
1219
- if (!effort || !this.hooks.effortSupported(spec, effort) || !this.app.ui) return;
1220
- const patch = { modelThinkingLevels: { [modelKey(spec)]: effort } };
1221
- await writeUserSettings(patch);
1222
- this.app.ui.userOverrides = mergeSettings({ builtin: this.app.ui.userOverrides, user: patch });
1223
- this.app.ui.settings = mergeSettings({ builtin: this.app.ui.settings, user: patch });
1224
- this.toast(`saved default thinking ${effort}`, "success");
1225
- } }],
1226
1212
  onPick: async item => { const effort = asThinkingLevel(item.value) as Effort | undefined; if (effort) await this.hooks.applyThinking(this.app, effort, this); } });
1227
1213
  }
1228
1214
 
@@ -1273,10 +1259,9 @@ export class InteractiveApp implements Ui {
1273
1259
  this.push({ kind: "list", title: "Models to cycle with alt+m", multi: true, checked, empty: "No available models. Use /provider to add a credential.",
1274
1260
  items: ready.map(spec => ({ value: modelKey(spec), label: spec.id, description: spec.provider, section: SOURCE_PROVIDERS[spec.provider]?.name ?? spec.provider })),
1275
1261
  onSubmitChecked: async values => {
1276
- await writeUserSettings({ scopedModels: values });
1277
- if (this.app.ui) { this.app.ui.settings = { ...this.app.ui.settings, scopedModels: values }; this.app.ui.userOverrides = { ...this.app.ui.userOverrides, scopedModels: values }; }
1262
+ const saved = await commitUserSettings(this.app, { scopedModels: values });
1278
1263
  this.app.args.models = ""; this.app.args.modelsExplicit = false;
1279
- this.toast(`saved ${values.length} scoped models`, "success");
1264
+ if (saved) this.toast(`saved ${values.length} scoped models`, "success");
1280
1265
  } });
1281
1266
  }
1282
1267
 
@@ -1286,7 +1271,7 @@ export class InteractiveApp implements Ui {
1286
1271
  const entries: [string, string, string][] = [
1287
1272
  ["theme", "Theme", settings.theme ?? "dark"],
1288
1273
  ["thinking", "Thinking", this.app.providerConfig.effort],
1289
- ["defaultModel", "Default model", settings.defaultModel ? `${settings.defaultProvider}/${settings.defaultModel}` : "automatic"],
1274
+ ["defaultModel", "Model", modelKey(this.app.providerConfig.spec)],
1290
1275
  ["modelThinkingLevels", "Model thinking defaults", `${Object.keys(settings.modelThinkingLevels ?? {}).length} configured`],
1291
1276
  ["steeringMode", "Steering delivery", settings.steeringMode ?? "one-at-a-time"],
1292
1277
  ["followUpMode", "Follow-up delivery", settings.followUpMode ?? "one-at-a-time"],
@@ -1334,9 +1319,7 @@ export class InteractiveApp implements Ui {
1334
1319
  ui.userOverrides = { ...ui.userOverrides, modelThinkingLevels: loaded.userOverrides.modelThinkingLevels ?? {} };
1335
1320
  } else {
1336
1321
  const patch = { modelThinkingLevels: { [item.value]: choice.value as Effort } };
1337
- await writeUserSettings(patch);
1338
- ui.settings = mergeSettings({ builtin: ui.settings, user: patch });
1339
- ui.userOverrides = mergeSettings({ builtin: ui.userOverrides, user: patch });
1322
+ await commitUserSettings(this.app, patch);
1340
1323
  }
1341
1324
  this.screen.dialogs.pop(); this.openModelThinkingDefaults();
1342
1325
  } });
@@ -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,130 @@
1
+ /** R13 / D13: explicit role layers share tool, model, workflow and budget ownership. */
2
+ import { isWorkflowMode, type WorkflowMode } from "./workflow-mode.ts";
3
+ import { isToolProfile, parsePermissionRule, type PermissionRule, type ToolSelection } from "./permissions.ts";
4
+ import { asThinkingLevel, type ThinkingLevel } from "./thinking.ts";
5
+
6
+ export interface AgentProfile {
7
+ readonly name: string;
8
+ readonly description?: string;
9
+ readonly mode?: WorkflowMode;
10
+ readonly instructions?: string;
11
+ readonly model?: { readonly provider: string; readonly id: string; readonly thinking?: ThinkingLevel };
12
+ readonly tools?: ToolSelection;
13
+ readonly permissions?: readonly PermissionRule[];
14
+ /** maxTurns is a hard cap; timeBudgetMs retains the existing advisory semantics. */
15
+ readonly budget?: { readonly maxTurns?: number; readonly timeBudgetMs?: number };
16
+ }
17
+ export type AgentProfileField = Exclude<keyof AgentProfile, "name">;
18
+ export interface AgentProfileSource { readonly kind: "builtin" | "user" | "project" | "cli"; readonly path?: string }
19
+ export interface AgentProfileLayer { readonly source: AgentProfileSource; readonly profiles: readonly AgentProfile[] }
20
+ export interface ResolvedAgentProfile {
21
+ readonly profile: AgentProfile;
22
+ readonly sources: readonly AgentProfileSource[];
23
+ readonly provenance: Readonly<Partial<Record<AgentProfileField, AgentProfileSource>>>;
24
+ }
25
+ export interface AgentProfileParse { readonly profiles: readonly AgentProfile[]; readonly diagnostics: readonly string[] }
26
+
27
+ /** Named builtins are explicit selectable presets; they never fill missing fields in another role. */
28
+ export function builtinAgentProfileLayer(): AgentProfileLayer {
29
+ return { source: { kind: "builtin" }, profiles: [{ name: "build", mode: "build", tools: { profile: "foundation" } }, { name: "plan", mode: "plan", tools: { profile: "foundation" } },
30
+ { name: "reviewer", mode: "plan", tools: { profile: "readonly" },
31
+ instructions: "Review current code for actionable defects. Cite concrete evidence and paths; do not edit the workspace." }] };
32
+ }
33
+
34
+ const FIELDS: readonly AgentProfileField[] = ["description", "mode", "instructions", "model", "tools", "permissions", "budget"];
35
+ export function isAgentProfileName(value: string): boolean { return /^[a-z][a-z0-9_-]{0,63}$/.test(value); }
36
+ function record(value: unknown, label: string, allowed: readonly string[]): Record<string, unknown> {
37
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw Error(`${label} must be an object`);
38
+ const obj = value as Record<string, unknown>;
39
+ const unknown = Object.keys(obj).find(key => !allowed.includes(key));
40
+ if (unknown) throw Error(`${label} has unknown field ${unknown}`);
41
+ return obj;
42
+ }
43
+ function text(value: unknown, label: string): string {
44
+ if (typeof value !== "string" || !value.trim() || value.includes("\0")) throw Error(`${label} must be a nonempty string`);
45
+ return value;
46
+ }
47
+ function strings(value: unknown, label: string): string[] {
48
+ if (!Array.isArray(value) || value.some(item => typeof item !== "string" || !item.trim()) || new Set(value).size !== value.length) throw Error(`${label} must contain unique nonempty names`);
49
+ return [...value] as string[];
50
+ }
51
+
52
+ export function parseAgentProfile(value: unknown): AgentProfile {
53
+ const obj = record(value, "agent profile", ["name", ...FIELDS]);
54
+ const name = text(obj["name"], "agent name");
55
+ if (!isAgentProfileName(name)) throw Error("agent name must begin with a lowercase letter and contain only letters, digits, _ or - (max 64)");
56
+ const profile: { name: string; description?: string; mode?: WorkflowMode; instructions?: string;
57
+ model?: NonNullable<AgentProfile["model"]>; tools?: ToolSelection; permissions?: PermissionRule[]; budget?: NonNullable<AgentProfile["budget"]> } = { name };
58
+ if (obj["description"] !== undefined) profile.description = text(obj["description"], "agent description");
59
+ if (obj["instructions"] !== undefined) profile.instructions = text(obj["instructions"], "agent instructions");
60
+ if (obj["mode"] !== undefined) {
61
+ if (typeof obj["mode"] !== "string" || !isWorkflowMode(obj["mode"])) throw Error("agent mode must be build or plan");
62
+ profile.mode = obj["mode"];
63
+ }
64
+ if (obj["model"] !== undefined) {
65
+ const model = record(obj["model"], "agent model", ["provider", "id", "thinking"]);
66
+ const provider = text(model["provider"], "agent model provider"), id = text(model["id"], "agent model id");
67
+ const thinking = asThinkingLevel(model["thinking"]);
68
+ if (model["thinking"] !== undefined && thinking === undefined) throw Error("agent model thinking is invalid");
69
+ profile.model = { provider, id, ...(thinking !== undefined ? { thinking } : {}) };
70
+ }
71
+ if (obj["tools"] !== undefined) {
72
+ const tools = record(obj["tools"], "agent tools", ["profile", "only", "exclude", "none", "noBuiltin"]);
73
+ const selected: { profile?: NonNullable<ToolSelection["profile"]>; only?: string[]; exclude?: string[]; none?: boolean; noBuiltin?: boolean } = {};
74
+ if (tools["profile"] !== undefined) {
75
+ if (typeof tools["profile"] !== "string" || !isToolProfile(tools["profile"])) throw Error("agent tool profile is invalid");
76
+ selected.profile = tools["profile"];
77
+ }
78
+ if (tools["only"] !== undefined) selected.only = strings(tools["only"], "agent tools.only");
79
+ if (tools["exclude"] !== undefined) selected.exclude = strings(tools["exclude"], "agent tools.exclude");
80
+ for (const key of ["none", "noBuiltin"] as const) {
81
+ if (tools[key] !== undefined) { if (typeof tools[key] !== "boolean") throw Error(`agent tools.${key} must be boolean`); selected[key] = tools[key]; }
82
+ }
83
+ profile.tools = selected;
84
+ }
85
+ if (obj["permissions"] !== undefined) {
86
+ if (!Array.isArray(obj["permissions"])) throw Error("agent permissions must be an array");
87
+ profile.permissions = obj["permissions"].map(parsePermissionRule);
88
+ }
89
+ if (obj["budget"] !== undefined) {
90
+ const budget = record(obj["budget"], "agent budget", ["maxTurns", "timeBudgetMs"]);
91
+ const selected: { maxTurns?: number; timeBudgetMs?: number } = {};
92
+ for (const key of ["maxTurns", "timeBudgetMs"] as const) {
93
+ if (budget[key] !== undefined) {
94
+ if (!Number.isSafeInteger(budget[key]) || Number(budget[key]) < 1) throw Error(`agent budget.${key} must be a positive safe integer`);
95
+ selected[key] = Number(budget[key]);
96
+ }
97
+ }
98
+ profile.budget = selected;
99
+ }
100
+ return profile;
101
+ }
102
+
103
+ /** A malformed document contributes no partial authority. */
104
+ export function parseAgentProfiles(value: unknown): AgentProfileParse {
105
+ try {
106
+ const obj = record(value, "agents document", ["profiles"]);
107
+ if (!Array.isArray(obj["profiles"])) throw Error("agents document profiles must be an array");
108
+ const profiles = obj["profiles"].map(parseAgentProfile);
109
+ if (new Set(profiles.map(profile => profile.name)).size !== profiles.length) throw Error("agents document contains duplicate profile names");
110
+ return { profiles, diagnostics: [] };
111
+ } catch (error) { return { profiles: [], diagnostics: [error instanceof Error ? error.message : "Invalid agents document"] }; }
112
+ }
113
+
114
+ /** Caller supplies low-to-high layers; whole-field replacement stays explainable. */
115
+ export function resolveAgentProfile(name: string, layers: readonly AgentProfileLayer[]): ResolvedAgentProfile | null {
116
+ let profile: AgentProfile | undefined;
117
+ const sources: AgentProfileSource[] = [], provenance: Partial<Record<AgentProfileField, AgentProfileSource>> = {};
118
+ for (const layer of layers) {
119
+ const matching = layer.profiles.find(item => item.name === name);
120
+ if (!matching) continue;
121
+ const source = structuredClone(layer.source);
122
+ sources.push(source); profile = { ...profile, ...structuredClone(matching) };
123
+ for (const field of FIELDS) if (matching[field] !== undefined) provenance[field] = structuredClone(source);
124
+ }
125
+ return profile ? { profile, sources, provenance } : null;
126
+ }
127
+
128
+ export function agentProfileNames(layers: readonly AgentProfileLayer[]): string[] {
129
+ return [...new Set(layers.flatMap(layer => layer.profiles.map(profile => profile.name)))].sort();
130
+ }