@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.
- package/CHANGELOG.md +10 -0
- package/README.md +35 -8
- package/docs/app-server.md +3 -1
- package/docs/providers.md +4 -0
- package/package.json +1 -1
- package/src/apps/preferences.ts +20 -0
- package/src/apps/shell.ts +34 -30
- package/src/apps/startup-input.ts +7 -2
- package/src/apps/startup.ts +30 -10
- package/src/apps/ui/app.ts +53 -69
- package/src/core/completion-review.ts +13 -4
- package/src/core/model-preferences.ts +28 -0
- package/src/core/permissions.ts +13 -7
- package/src/core/settings.ts +1 -1
- package/src/runtime/catalog.ts +8 -1
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/tools.ts +11 -9
- package/src/store/resources.ts +13 -22
- package/src/store/user-settings.ts +30 -0
- package/src/tui/ui/control-panel.ts +7 -5
- package/src/tui/ui/control-sidebar.ts +49 -0
- package/src/tui/ui/screen.ts +107 -11
package/src/apps/ui/app.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
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
|
|
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", "
|
|
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
|
|
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
|
-
...
|
|
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
|
-
(
|
|
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
|
+
}
|
package/src/core/permissions.ts
CHANGED
|
@@ -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;
|
|
7
|
-
* which is why
|
|
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.
|
|
79
|
-
*
|
|
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
|
-
*
|
|
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(
|
|
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
|
|
118
|
+
* supplies the current process's compiled policy.
|
|
113
119
|
*/
|
|
114
120
|
export function decide(
|
|
115
121
|
rules: readonly PermissionRule[],
|
package/src/core/settings.ts
CHANGED
|
@@ -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;
|
|
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";
|
package/src/runtime/catalog.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/runtime/oauth.ts
CHANGED
|
@@ -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.
|
|
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
|
package/src/runtime/tools.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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: "
|
|
394
|
-
expected_sha256: { type: "string", description: "
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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: "
|
|
423
|
-
read_handle: { type: "string", description: "
|
|
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
|
},
|
package/src/store/resources.ts
CHANGED
|
@@ -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.
|
|
6
|
-
*
|
|
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
|
|
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
|
|
94
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|