@visual-z/xeno 0.4.0 → 0.5.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.
@@ -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";
@@ -50,7 +50,8 @@ import { projectTrust } from "../../store/trust.ts";
50
50
  import { discoverResources, setResourceOverride } from "../../store/resource-index.ts";
51
51
  import { nextResourceOverride } from "../../core/resource-selection.ts";
52
52
  import { archiveSession, renameSession, scanSessionRoots, sessionRoots } from "../../store/session-browser.ts";
53
- import { discoverThemes, removeModelThinkingOverride, selectedThemePath, watchTheme, writeUserSettings, loadUiResources } from "../../store/resources.ts";
53
+ import { discoverThemes, removeModelThinkingOverride, selectedThemePath, watchTheme, loadUiResources } from "../../store/resources.ts";
54
+ import { commitUserSettings } from "../preferences.ts";
54
55
  import { fuzzyMatch } from "../../tui/fuzzy.ts";
55
56
  import type { DialogSpec, ListAction } from "../../tui/ui/dialog.ts";
56
57
  import { keyPress, bound, type KeyPress } from "../../tui/ui/keys.ts";
@@ -82,7 +83,7 @@ import { ImageCache } from "./image-cache.ts";
82
83
  import { ImageDraft } from "../image-draft.ts";
83
84
  import { sessionControl, type SessionControl, type ControlSnapshot } from "../../runtime/session-control.ts";
84
85
  import { controlModel } from "./control-model.ts";
85
- import { controlPanelLines, type ControlTab, type ControlFocus } from "../../tui/ui/control-panel.ts";
86
+ import type { ControlTab } from "../../tui/ui/control-panel.ts";
86
87
  import { programStatus } from "../../core/program-status.ts";
87
88
  import { ProgramStatusReporter } from "../../runtime/program-status.ts";
88
89
  import type { PictureSource } from "../../tui/ui/pictures.ts";
@@ -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),
@@ -590,7 +595,7 @@ export class InteractiveApp implements Ui {
590
595
  * per debounce window, one run at a time, and only while a surface shows it.
591
596
  */
592
597
  private requestDerived(): void {
593
- if (this.disposed || this.screen.tier === "bare") return;
598
+ if (this.disposed || (this.screen.tier === "bare" && !this.screen.controlOpen)) return;
594
599
  if (this.derivedKey === this.derivedTarget()) return;
595
600
  if (this.derivedRun || this.derivedTimer) { this.derivedAgain = true; return; }
596
601
  // Throttle, not a pure debounce: the first change after a quiet spell refreshes at once,
@@ -604,23 +609,24 @@ export class InteractiveApp implements Ui {
604
609
  this.derivedLast = Date.now();
605
610
  const target = this.derivedTarget();
606
611
  this.derivedRun = (async () => {
612
+ const session = this.app.session;
607
613
  try {
608
- const session = this.app.session;
609
614
  const derived = handoffStatus(await session.previewHandoff());
610
615
  const snapshot = await sessionControl(session).snapshot(false);
611
- if (this.app.session !== session) return;
616
+ if (this.disposed || this.app.session !== session) return;
612
617
  this.derived = derived; this.controlSnapshot = snapshot; this.derivedKey = target;
613
618
  // Idle acceptance changes have no model delta. Reconcile terminal attention
614
619
  // with the same evidence projection that refreshes the visible goal.
615
620
  if (!this.inflight) this.program?.update(programStatus({ running: false, waiting: snapshot.waiting,
616
621
  ...(snapshot.goal?.state.execution ? { outcome: snapshot.goal.state.execution } : {}),
617
622
  ...(snapshot.goal ? { goal: snapshot.goal.state.status } : {}) }));
618
- if (this.screen.dialogs.top?.title === "Control panel") this.screen.dialogs.refresh();
623
+ this.screen.setControlModel(controlModel(snapshot));
619
624
  }
620
625
  catch {
621
626
  // Unavailable evidence cannot leave a stale verified goal visible.
627
+ if (this.disposed || this.app.session !== session) return;
622
628
  this.controlSnapshot = undefined;
623
- if (this.screen.dialogs.top?.title === "Control panel") this.screen.dialogs.refresh();
629
+ this.screen.setControlModel(undefined);
624
630
  }
625
631
  })();
626
632
  await this.derivedRun;
@@ -636,6 +642,7 @@ export class InteractiveApp implements Ui {
636
642
  private onPaste(event: PasteEvent): void {
637
643
  const text = new TextDecoder().decode(event.bytes);
638
644
  if (this.screen.dialogs.open) { if (this.screen.dialogs.paste(text)) return; event.preventDefault(); return; }
645
+ this.screen.focusComposer();
639
646
  const paths = droppedImagePaths(text);
640
647
  if (paths) { event.preventDefault(); this.track(() => this.pasteClipboard({ files: paths })); }
641
648
  }
@@ -651,6 +658,7 @@ export class InteractiveApp implements Ui {
651
658
  if (bound(b, "app.exit", key) && !this.screen.dialogs.open && !this.screen.composer.text && !this.pendingImages.length) { this.requestShutdown(); return true; }
652
659
  if (bound(b, "app.suspend", key)) { this.suspend(); return true; }
653
660
  if (this.screen.dialogs.open) return this.screen.dialogs.key(key);
661
+ if (this.screen.handleControlKey(key)) return true;
654
662
  const composer = this.screen.composer;
655
663
  if (composer.completing) {
656
664
  if (key.chord === "up") { composer.moveCompletion(-1); return true; }
@@ -1039,6 +1047,7 @@ export class InteractiveApp implements Ui {
1039
1047
  const control = sessionControl(this.app.session);
1040
1048
  if (this.control === control) return;
1041
1049
  this.stopControl?.(); this.control = control; this.controlSnapshot = undefined;
1050
+ this.screen.setControlModel(undefined);
1042
1051
  const detachInput = control.attachInput(phase => this.pullQueued(phase));
1043
1052
  const unsubscribe = control.subscribe(notice => {
1044
1053
  if (this.disposed) return;
@@ -1093,40 +1102,38 @@ export class InteractiveApp implements Ui {
1093
1102
 
1094
1103
  async openControl(tab: ControlTab = "goal"): Promise<void> {
1095
1104
  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;
1105
+ if (!await this.refreshControl()) return;
1106
+ this.screen.showControl(controlModel(this.controlSnapshot!), tab);
1107
+ this.requestDerived();
1108
+ }
1109
+
1110
+ private async refreshControl(): Promise<boolean> {
1111
+ const control = sessionControl(this.app.session);
1112
+ try {
1113
+ const snapshot = await control.snapshot();
1114
+ if (this.disposed || control !== sessionControl(this.app.session)) return false;
1115
+ this.controlSnapshot = snapshot;
1116
+ this.screen.setControlModel(controlModel(snapshot));
1117
+ return true;
1118
+ } catch (error) {
1119
+ if (!this.disposed && control === sessionControl(this.app.session)) {
1120
+ this.controlSnapshot = undefined;
1121
+ this.screen.setControlModel(undefined);
1122
+ }
1123
+ throw error;
1124
+ }
1125
+ }
1126
+
1127
+ private acceptControlCriterion(): void {
1128
+ const control = sessionControl(this.app.session), goal = this.controlSnapshot?.goal;
1129
+ const criteria = goal?.state.acceptance?.criteria.filter(item => item.kind === "human" && item.status !== "pass" && item.status !== "accepted") ?? [];
1130
+ if (this.inflight || !goal || !criteria.length) { this.toast("No human criterion ready for acceptance", "warning"); return; }
1131
+ this.screen.dialogs.push({ kind: "list", title: "Accept a human criterion", search: false,
1132
+ items: criteria.map(item => ({ value: item.id, label: item.description, description: item.status })),
1133
+ onPick: async item => {
1134
+ if (control !== sessionControl(this.app.session)) throw Error("Session changed; refresh the control panel");
1135
+ await control.accept(goal.state.request.event, item.value);
1136
+ await this.refreshControl();
1130
1137
  } });
1131
1138
  }
1132
1139
 
@@ -1192,37 +1199,17 @@ export class InteractiveApp implements Ui {
1192
1199
  keywords: `${row.model.provider} ${row.model.providerName}`, ...(row.current ? { hint: "● current" } : {}) }));
1193
1200
  items.push({ value: "login", label: "+ Connect a provider…", section: "More", description: "subscription, API key, gateway or local server" });
1194
1201
  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
1202
  onPick: async item => {
1197
1203
  if (item.value === "login") { this.openLogin(); return; }
1198
1204
  const spec = this.app.catalog.find(model => modelKey(model) === item.value);
1199
1205
  if (spec) await this.hooks.switchToModel(this.app, spec);
1200
1206
  } });
1201
1207
  }
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
1208
  openThinking(): void {
1213
1209
  const spec = this.app.providerConfig.spec, current = this.app.providerConfig.effort;
1214
1210
  this.push({ kind: "list", title: "Thinking", search: false, selected: current,
1215
1211
  items: THINKING_LEVELS.map(level => ({ value: level, label: level, disabled: !this.hooks.effortSupported(spec, level),
1216
1212
  ...(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
1213
  onPick: async item => { const effort = asThinkingLevel(item.value) as Effort | undefined; if (effort) await this.hooks.applyThinking(this.app, effort, this); } });
1227
1214
  }
1228
1215
 
@@ -1273,10 +1260,9 @@ export class InteractiveApp implements Ui {
1273
1260
  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
1261
  items: ready.map(spec => ({ value: modelKey(spec), label: spec.id, description: spec.provider, section: SOURCE_PROVIDERS[spec.provider]?.name ?? spec.provider })),
1275
1262
  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 }; }
1263
+ const saved = await commitUserSettings(this.app, { scopedModels: values });
1278
1264
  this.app.args.models = ""; this.app.args.modelsExplicit = false;
1279
- this.toast(`saved ${values.length} scoped models`, "success");
1265
+ if (saved) this.toast(`saved ${values.length} scoped models`, "success");
1280
1266
  } });
1281
1267
  }
1282
1268
 
@@ -1286,7 +1272,7 @@ export class InteractiveApp implements Ui {
1286
1272
  const entries: [string, string, string][] = [
1287
1273
  ["theme", "Theme", settings.theme ?? "dark"],
1288
1274
  ["thinking", "Thinking", this.app.providerConfig.effort],
1289
- ["defaultModel", "Default model", settings.defaultModel ? `${settings.defaultProvider}/${settings.defaultModel}` : "automatic"],
1275
+ ["defaultModel", "Model", modelKey(this.app.providerConfig.spec)],
1290
1276
  ["modelThinkingLevels", "Model thinking defaults", `${Object.keys(settings.modelThinkingLevels ?? {}).length} configured`],
1291
1277
  ["steeringMode", "Steering delivery", settings.steeringMode ?? "one-at-a-time"],
1292
1278
  ["followUpMode", "Follow-up delivery", settings.followUpMode ?? "one-at-a-time"],
@@ -1334,9 +1320,7 @@ export class InteractiveApp implements Ui {
1334
1320
  ui.userOverrides = { ...ui.userOverrides, modelThinkingLevels: loaded.userOverrides.modelThinkingLevels ?? {} };
1335
1321
  } else {
1336
1322
  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 });
1323
+ await commitUserSettings(this.app, patch);
1340
1324
  }
1341
1325
  this.screen.dialogs.pop(); this.openModelThinkingDefaults();
1342
1326
  } });
@@ -59,24 +59,33 @@ export function requestCompletion(state: RequestState, remainingRequests: number
59
59
 
60
60
  const gaps: string[] = [];
61
61
  const acceptance = options?.acceptance ?? state.acceptance;
62
+ const missingCommands = acceptance?.status === "needs_work" ? acceptance.criteria.flatMap(item =>
63
+ item.kind === "command" && item.status !== "pass" ? [{ id: item.id, command: item.command, status: item.status }] : []) : [];
62
64
  if (state.used_tools && text.length === 0) gaps.push("the final reply is empty");
63
65
  if (state.changed && !verifiedAfter && !acceptance) gaps.push("files were changed but no command has succeeded since the last edit");
64
66
  if (acceptance?.status === "needs_work") gaps.push(`declared acceptance criteria need evidence: ${[
65
- ...acceptance.criteria.filter(item => item.kind === "command" && item.status !== "pass").map(item => `${item.id}=${item.status}`),
67
+ ...missingCommands.map(item => `${item.id}=${item.status}`),
66
68
  ...(acceptance.targets ?? []).filter(target => target.status !== "ready").map(target => `target ${target.id}=${target.status}`),
67
69
  ].join(", ")}`);
68
70
  if (text.length > 0 && looksLikeHandBack(text) && acceptance?.status !== "awaiting_human") gaps.push(options?.taskAware
69
71
  ? "the reply deferred the request; check whether authorized work remains or a specific blocker requires the user"
70
72
  : "the reply handed the task back instead of finishing it; no one will answer");
71
73
  if (gaps.length === 0) return finish;
74
+ // D11 / O.11: a declared command needs its exact receipt even when the
75
+ // request has no recorded edit. Compound shell success is not that receipt.
76
+ const next = missingCommands.length > 0
77
+ ? "Obtain a fresh successful receipt for each missing command. Run each command exactly as a separate invocation without adding other commands:\n" +
78
+ missingCommands.map(item => `- ${item.id}: ${JSON.stringify(item.command)}`).join("\n") +
79
+ "\nIf verification cannot run, report the specific blocker. "
80
+ : options?.taskAware && !state.changed
81
+ ? "Use original sources for research and full inputs for calculations; do not invent command evidence. Finish with the result or the specific blocker. "
82
+ : "Run the command that verifies the result, or finish with the specific blocker that prevents verification. ";
72
83
  return { action: "continue", guidance: {
73
84
  reason: "completion_review",
74
85
  sources: [state.request.event, final.source.event],
75
86
  text: `Harness completion check: ${gaps.join("; ")}. ` +
76
87
  `Re-read the original request (event ${state.request.event}) and check each requirement against evidence in this session. ` +
77
- (options?.taskAware && !state.changed
78
- ? "Use original sources for research and full inputs for calculations; do not invent command evidence. Required human acceptance remains pending. Finish with the result or the specific blocker. "
79
- : "Run the command that verifies the result, or finish with the specific blocker that prevents verification. ") +
88
+ next + (acceptance?.criteria.some(item => item.kind === "human" && item.status !== "accepted") ? "Required human acceptance remains pending. " : "") +
80
89
  "This check runs once per request.",
81
90
  } };
82
91
  }
@@ -0,0 +1,28 @@
1
+ /** User selections become startup preferences; restored session state stays in the log (D8/Q.25). */
2
+ import { recordRecent } from "./model-picker.ts";
3
+ import { modelKey } from "./session-position.ts";
4
+ import { settingsToWrite, type Settings } from "./settings.ts";
5
+ import type { ThinkingLevel } from "./thinking.ts";
6
+
7
+ interface ModelIdentity { readonly provider: string; readonly id: string }
8
+
9
+ /** Keep the effective effort with its model, without changing another model's saved effort. */
10
+ export function modelPreferencePatch(model: ModelIdentity, recent: readonly string[], effort: ThinkingLevel): Settings {
11
+ return { defaultProvider: model.provider, defaultModel: model.id, recentModels: recordRecent(recent, modelKey(model)),
12
+ modelThinkingLevels: { [modelKey(model)]: effort } };
13
+ }
14
+
15
+ export function thinkingPreferencePatch(model: ModelIdentity, effort: ThinkingLevel): Settings {
16
+ return { defaultThinking: effort, modelThinkingLevels: { [modelKey(model)]: effort } };
17
+ }
18
+
19
+ /** Patch only known preferences; retain future keys and nested fields not owned by this selection. */
20
+ export function mergePreferenceJson(previous: Readonly<Record<string, unknown>>, patch: Settings): Record<string, unknown> {
21
+ const next = { ...previous, ...settingsToWrite(patch) };
22
+ for (const key of ["terminal", "images", "markdown", "modelThinkingLevels"] as const) {
23
+ if (patch[key] === undefined) continue;
24
+ const old = previous[key];
25
+ next[key] = { ...(old !== null && typeof old === "object" && !Array.isArray(old) ? old : {}), ...patch[key] };
26
+ }
27
+ return next;
28
+ }
@@ -3,8 +3,8 @@
3
3
  * call is allow / ask / deny.
4
4
  *
5
5
  * Approval is the last look at what is about to run, not a boundary.
6
- * Path tools are already confined to the workspace root; bash is not,
7
- * which is why bash defaults to ask and everything else to allow.
6
+ * Path tools are already confined to the workspace root; host commands
7
+ * are not, which is why shell entry points default to ask.
8
8
  *
9
9
  * This module must stay free of I/O, clocks, and randomness. The CLI
10
10
  * compiles flags and session memory into a rule list; this file only
@@ -75,8 +75,8 @@ export interface ToolSelection {
75
75
  export const ALLOW_TOOLS_FLAG = "--allow-tools";
76
76
 
77
77
  /**
78
- * Product default. bash asks because it is not confined to the workspace
79
- * root; every other built-in is allow because the path tools are.
78
+ * Product default. Shell entry points ask because their effects are not
79
+ * confined to the workspace root; path tools keep their own confinement.
80
80
  */
81
81
  export function defaultRules(): readonly PermissionRule[] {
82
82
  return [
@@ -91,9 +91,15 @@ export function defaultRules(): readonly PermissionRule[] {
91
91
  }
92
92
 
93
93
  /**
94
- * `--allow-tools` compiled to rules, in front of the defaults so they win.
94
+ * CLI opt-ins compiled to one process-scoped rule list. YOLO permits every
95
+ * tool; capability admission and path confinement still apply (D13).
96
+ * Neither this flag nor remembered approvals become a persisted policy.
95
97
  */
96
- export function rulesWithAllowTools(allowTools: readonly string[]): PermissionRule[] {
98
+ export function rulesWithAllowTools(
99
+ allowTools: readonly string[],
100
+ options: { readonly yolo?: boolean } = {},
101
+ ): PermissionRule[] {
102
+ if (options.yolo === true) return [{ action: "allow" }];
97
103
  return [
98
104
  ...allowTools.map((tool) => ({ action: "allow" as const, tool })),
99
105
  ...defaultRules(),
@@ -109,7 +115,7 @@ export function ruleMatches(rule: PermissionRule, request: PermissionRequest): b
109
115
  /**
110
116
  * First matching rule wins. An empty list allows: the tool tests pass no
111
117
  * rules because they are testing the tool, not the gate. The CLI always
112
- * supplies defaultRules().
118
+ * supplies the current process's compiled policy.
113
119
  */
114
120
  export function decide(
115
121
  rules: readonly PermissionRule[],
@@ -17,7 +17,7 @@ export interface Settings {
17
17
  readonly defaultThinking?: string;
18
18
  readonly modelThinkingLevels?: Readonly<Record<string, ThinkingLevel>>;
19
19
  readonly scopedModels?: readonly string[];
20
- /** provider/model keys, most recent first; the /model picker's first section. */
20
+ /** provider/model keys, most recent first; picker ordering and startup fallback for older settings. */
21
21
  readonly recentModels?: readonly string[];
22
22
  readonly steeringMode?: "all" | "one-at-a-time";
23
23
  readonly followUpMode?: "all" | "one-at-a-time";
@@ -186,7 +186,8 @@ export type Selection =
186
186
 
187
187
  /**
188
188
  * `--model` wins, then `--provider`'s first model, then the settings file,
189
- * then the first model in the catalog that has a credential.
189
+ * then a usable recent selection from older settings, then the first model
190
+ * in the catalog that has a credential.
190
191
  *
191
192
  * The last step is what makes a fresh install work: someone who exported one
192
193
  * API key should get a working session without also having to learn the name
@@ -229,6 +230,12 @@ export function selectModel(
229
230
  if (preferred !== undefined) return { ok: true, spec: preferred };
230
231
  }
231
232
 
233
+ // Old pickers only persisted recent identities. Reuse one exactly, without fuzzy guesses (Q.25).
234
+ for (const key of settings.recentModels ?? []) {
235
+ const recent = catalog.find(model => modelKey(model) === key);
236
+ if (recent !== undefined && hasCredential(recent)) return { ok: true, spec: recent };
237
+ }
238
+
232
239
  const authenticated = catalog.find(hasCredential);
233
240
  if (authenticated !== undefined) return { ok: true, spec: authenticated };
234
241
 
@@ -18,7 +18,7 @@ import { pollDeviceCode } from "./oauth-device.ts";
18
18
  import { parseDeviceAuthorization, parseOAuthTokens } from "../core/oauth-fields.ts";
19
19
 
20
20
  /** Matches package.json. Sent as User-Agent so the server can see who we are (D18). */
21
- export const XENO_VERSION = "0.4.0";
21
+ export const XENO_VERSION = "0.5.0";
22
22
 
23
23
  /**
24
24
  * Minutes subtracted from server expiry so a request issued at the edge
@@ -370,7 +370,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
370
370
  description:
371
371
  "Read a file. Returns the contents with 1-indexed line numbers. " +
372
372
  "Optionally limit to an inclusive line range. Reports total lines and the next range; end beyond EOF is clamped. " +
373
- "Returns read_handle and whole-file sha256 for version checks. Long lines and large previews retain an immutable artifact. Line ranges describe the selection; the observation receipt describes exactly what was visible. An unavailable artifact-recovery capability leaves a gap, not proof of full coverage.",
373
+ "Session remembers the whole-file version for later changes; no digest copying is needed. Returns read_handle and whole-file sha256 for selecting an explicit observation. Long lines and large previews retain an immutable artifact. Line ranges describe the selection; the observation receipt describes exactly what was visible. An unavailable artifact-recovery capability leaves a gap, not proof of full coverage.",
374
374
  input_schema: {
375
375
  type: "object",
376
376
  properties: {
@@ -384,14 +384,14 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
384
384
  {
385
385
  name: "write",
386
386
  access: "write", execution: "exclusive", branch: "write", run: toolWrite,
387
- description: "Write a file, creating parent directories. Replaces existing content. Pass read_handle or expected_sha256 for an explicit version check; otherwise Session uses its last observed version. create_only requires absence. A conflict requires a fresh read. Identical content makes no change.",
387
+ description: "Write a file, creating parent directories. Replaces existing content. Normally omit read_handle and expected_sha256: Session checks its latest observed version. Without a prior observation, omission has no stale-version protection. To select a specific observation instead, pass its exact version receipt. create_only requires absence. A conflict requires a fresh read. Identical content makes no change.",
388
388
  input_schema: {
389
389
  type: "object",
390
390
  properties: {
391
391
  path: { type: "string" },
392
392
  content: { type: "string" },
393
- read_handle: { type: "string", description: "Descriptor SHA from an observation of this file, workspace and host" },
394
- expected_sha256: { type: "string", description: "Expected whole-file raw-byte SHA-256" },
393
+ read_handle: { type: "string", description: "Optional explicit descriptor SHA for this file, workspace and host; normally omit" },
394
+ expected_sha256: { type: "string", description: "Optional explicit whole-file raw-byte SHA-256; normally omit" },
395
395
  create_only: { type: "boolean", description: "Require that the target does not exist; excludes read_handle and expected_sha256" },
396
396
  },
397
397
  required: ["path", "content"],
@@ -400,14 +400,16 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
400
400
  {
401
401
  name: "edit",
402
402
  example: { path: "src/main.ts", old_string: "const size = 1;", new_string: "const size = 2;" },
403
- guidance: [{ requires: ["read"], text: "Use read to obtain current text and a version receipt before an edit." }],
403
+ guidance: [{ requires: ["read"], text: "Use read to obtain current text before an edit; Session remembers its version automatically." }],
404
404
  access: "write", execution: "exclusive", branch: "write", run: toolEdit,
405
405
  description:
406
- "Replace exact text in a file. old_string must match once unless replace_all is set; copy it from observed file content, " +
406
+ "Replace exact text in a file. Normally omit read_handle and expected_sha256: Session checks its latest observed version. " +
407
+ "Without a prior observation, omission has no stale-version protection. To select a specific observation instead, pass its exact version receipt. " +
408
+ "old_string must match once unless replace_all is set; copy it from observed file content, " +
407
409
  "including indentation (line numbers are not part of the file). For several changes to one file pass edits[], " +
408
410
  "each matched against the original; all apply or none do. If there is no exact match, a unique match that differs only in " +
409
411
  "trailing whitespace, quote or dash style is applied and flagged fuzzy. A failure shows the closest text in the file. " +
410
- "Pass read_handle or expected_sha256 from a file observation to reject stale edits; when omitted, Session uses its last observed version. A conflict requires a fresh observation. A partial read handle checks the whole file version, not permission to edit unseen lines.",
412
+ "A conflict requires a fresh observation. A partial read handle checks the whole file version, not permission to edit unseen lines.",
411
413
  input_schema: {
412
414
  type: "object",
413
415
  properties: {
@@ -419,8 +421,8 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
419
421
  type: "object", required: ["old_string", "new_string"], properties: {
420
422
  old_string: { type: "string", minLength: 1 }, new_string: { type: "string" }, replace_all: { type: "boolean" },
421
423
  } } },
422
- expected_sha256: { type: "string", description: "Observed whole-file version; mismatch leaves the file unchanged" },
423
- read_handle: { type: "string", description: "Descriptor SHA from an observation of this file, workspace and host" },
424
+ expected_sha256: { type: "string", description: "Optional explicit whole-file raw-byte SHA-256; normally omit" },
425
+ read_handle: { type: "string", description: "Optional explicit descriptor SHA for this file, workspace and host; normally omit" },
424
426
  },
425
427
  required: ["path"],
426
428
  },
@@ -2,8 +2,8 @@
2
2
  * Discover and read UI resources: settings, theme, keybindings, skills,
3
3
  * prompt templates.
4
4
  *
5
- * I/O lives here; validation is core. A corrupt file is a diagnostic plus
6
- * a fallback, never a throw -- startup has to survive a missing comma.
5
+ * I/O lives here; validation is core. Reads diagnose corrupt files and fall
6
+ * back so startup survives a missing comma. Writes preserve them and report failure.
7
7
  *
8
8
  * Project-level files follow the same trust rule as `.xeno/settings.json`.
9
9
  * Theme and keymap are UI state and are not logged.
@@ -12,7 +12,9 @@
12
12
  import { readdir, readFile } from "node:fs/promises";
13
13
  import { watch } from "node:fs";
14
14
  import { basename, dirname, join } from "node:path";
15
- import { configDir, readJsonFile, writeJsonFile, settingsPath } from "./config.ts";
15
+ import { configDir, readJsonFile } from "./config.ts";
16
+ import { editUserSettings } from "./user-settings.ts";
17
+ import { mergePreferenceJson } from "../core/model-preferences.ts";
16
18
  import { discoverResources, themeResourcePath, type ResourceIndex } from "./resource-index.ts";
17
19
  import { parseSkill, type Skill } from "../core/skills.ts";
18
20
  import { parsePromptTemplate, type PromptTemplate } from "../core/prompts.ts";
@@ -29,7 +31,6 @@ import {
29
31
  import {
30
32
  defaultSettings,
31
33
  mergeSettings,
32
- settingsToWrite,
33
34
  type Settings,
34
35
  } from "../core/settings.ts";
35
36
 
@@ -90,28 +91,18 @@ export function selectedThemePath(index: ResourceIndex, name: string, noThemes =
90
91
  }
91
92
 
92
93
  export async function writeUserSettings(overrides: Partial<Settings>): Promise<void> {
93
- const { value, error } = await readJsonFile<unknown>(settingsPath());
94
- const prev =
95
- error === null && typeof value === "object" && value !== null && !Array.isArray(value)
96
- ? (value as { readonly [k: string]: unknown })
97
- : {};
98
- const next = { ...prev, ...settingsToWrite(overrides),
99
- ...(overrides.terminal ? { terminal: { ...(typeof prev["terminal"] === "object" ? prev["terminal"] : {}), ...overrides.terminal } } : {}),
100
- ...(overrides.modelThinkingLevels ? { modelThinkingLevels: { ...(typeof prev["modelThinkingLevels"] === "object" ? prev["modelThinkingLevels"] : {}), ...overrides.modelThinkingLevels } } : {}),
101
- ...Object.fromEntries((["images", "markdown"] as const).filter(key => overrides[key]).map(key => [key, { ...(typeof prev[key] === "object" ? prev[key] : {}), ...overrides[key] }])),
102
- };
103
- await writeJsonFile(settingsPath(), next, false);
94
+ const patch = structuredClone(overrides);
95
+ await editUserSettings(previous => mergePreferenceJson(previous, patch));
104
96
  }
105
97
 
106
98
  /** Remove only the user's override; project defaults and unrelated keys retain their ownership. */
107
99
  export async function removeModelThinkingOverride(identity: string): Promise<void> {
108
- const { value, error } = await readJsonFile<Record<string, unknown>>(settingsPath());
109
- if (error) throw new Error(error);
110
- if (!value || typeof value !== "object" || Array.isArray(value)) return;
111
- const levels = value["modelThinkingLevels"];
112
- if (!levels || typeof levels !== "object" || Array.isArray(levels)) return;
113
- const next = Object.fromEntries(Object.entries(levels).filter(([key]) => key !== identity));
114
- await writeJsonFile(settingsPath(), { ...value, modelThinkingLevels: next }, false);
100
+ await editUserSettings(previous => {
101
+ const levels = previous["modelThinkingLevels"];
102
+ if (!levels || typeof levels !== "object" || Array.isArray(levels)) return null;
103
+ const next = Object.fromEntries(Object.entries(levels).filter(([key]) => key !== identity));
104
+ return { ...previous, modelThinkingLevels: next };
105
+ });
115
106
  }
116
107
 
117
108
  export async function resolveTheme(spec: string, diagnostics: string[]): Promise<Theme> {
@@ -0,0 +1,30 @@
1
+ /** One read/modify/replace owner prevents automatic selections from racing other settings (Q.25). */
2
+ import { readFile } from "node:fs/promises";
3
+ import { settingsPath } from "./config.ts";
4
+ import { atomicWrite } from "./atomic.ts";
5
+ import { serialized } from "./serial.ts";
6
+
7
+ async function readSettings(path: string): Promise<Record<string, unknown>> {
8
+ let text: string;
9
+ try { text = await readFile(path, "utf8"); }
10
+ catch (error) {
11
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return {};
12
+ throw error;
13
+ }
14
+ let value: unknown;
15
+ try { value = JSON.parse(text); }
16
+ catch { throw new Error(`${path} is not valid JSON; repair it before saving settings`); }
17
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must contain a settings object`);
18
+ return value as Record<string, unknown>;
19
+ }
20
+
21
+ export function editUserSettings(edit: (previous: Record<string, unknown>) => Record<string, unknown> | null): Promise<void> {
22
+ // Capture the destination before any await, including when another profile changes configDir().
23
+ const path = settingsPath();
24
+ // Packages and resource overrides already coordinate this file through serialized().
25
+ return serialized(path, async () => {
26
+ const next = edit(await readSettings(path));
27
+ if (next === null) return;
28
+ await atomicWrite(path, `${JSON.stringify(next, null, 2)}\n`);
29
+ });
30
+ }
@@ -1,5 +1,5 @@
1
1
  /** View-only data: apps owns the projection and actions, as required by D20. */
2
- import { clipText } from "./edge.ts";
2
+ import { wrapPlain } from "../diff.ts";
3
3
 
4
4
  export type ControlPanelModel = {
5
5
  thread: string; turn: string | null; goal: string; status: string; execution: string; gaps: readonly string[];
@@ -10,14 +10,16 @@ export type ControlTab = "goal" | "progress" | "context";
10
10
  export type ControlFocus = "messages" | "omitted" | "history";
11
11
 
12
12
  export function controlPanelLines(model: ControlPanelModel, tab: ControlTab, width: number, focus: ControlFocus = "messages"): string[] {
13
- const lines = [`${tab === "goal" ? "●" : "○"} Goal ${tab === "progress" ? "●" : "○"} Progress ${tab === "context" ? "●" : "○"} Context Lens`, "",
14
- ...(tab === "goal" ? [model.goal, `Goal: ${model.status}`, `Execution: ${model.execution}`, "", ...model.gaps,
13
+ const lines = tab === "goal" ? [model.goal, `Goal: ${model.status}`, `Execution: ${model.execution}`, "", ...model.gaps,
15
14
  ...model.checks, "", `Thread ${model.thread}`, `Turn ${model.turn ?? "none"}`]
16
15
  : tab === "progress" ? ["Evidence for this request", ...model.progress.map(item => `${item.label}: ${item.count}`), "",
17
16
  "Counts describe observations, not a completion percentage.", ...model.checks,
18
17
  ...model.progress.flatMap(item => item.sources.map(source => `${item.label}: ${source}`))]
19
18
  : focus === "messages" ? ["Actual model context · ←/→ change lens", ...model.context]
20
19
  : focus === "omitted" ? ["Not replayed as messages · ←/→ change lens", ...(model.omitted.length ? model.omitted : ["No omitted events in this segment."])]
21
- : ["Handoff retention and recovery · ←/→ change lens", ...(model.history.length ? model.history : ["No carried history in this segment."])])];
22
- return lines.map(line => clipText(line.replace(/[\x00-\x1f\x7f-\x9f]/g, " "), Math.max(1, width)));
20
+ : ["Handoff retention and recovery · ←/→ change lens", ...(model.history.length ? model.history : ["No carried history in this segment."])];
21
+ // Unlike status chrome, evidence is inspectable content: every character must
22
+ // remain reachable by scrolling, including long source paths and CJK (Q24).
23
+ // A one-cell pane cannot show a wide glyph; its owner shows resize feedback.
24
+ return lines.flatMap(line => wrapPlain(line.replace(/[\x00-\x1f\x7f-\x9f]/g, " "), Math.max(2, width)));
23
25
  }