@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.
- package/CHANGELOG.md +20 -0
- package/README.md +41 -10
- package/docs/app-server.md +35 -4
- package/docs/foundations.md +88 -0
- package/docs/install.md +23 -2
- package/docs/providers.md +4 -0
- package/docs/research/foundation-clients-2026-10-09.md +27 -0
- package/docs/research/foundation-editing-2026-10-09.md +56 -0
- package/docs/research/foundation-network-2026-10-09.md +37 -0
- package/docs/research/foundation-workflow-2026-10-09.md +37 -0
- package/package.json +10 -2
- package/src/apps/acp-server.ts +41 -0
- package/src/apps/agent-profiles.ts +19 -0
- package/src/apps/app-server.ts +4 -2
- package/src/apps/cli.ts +18 -2
- package/src/apps/commands.ts +20 -0
- package/src/apps/control-transport.ts +3 -2
- package/src/apps/model-catalog.ts +57 -31
- package/src/apps/preferences.ts +20 -0
- package/src/apps/question-ui.ts +27 -0
- package/src/apps/resources.ts +63 -25
- package/src/apps/review.ts +109 -0
- package/src/apps/session-hub.ts +84 -0
- package/src/apps/shell.ts +88 -30
- package/src/apps/startup-input.ts +7 -2
- package/src/apps/startup.ts +180 -25
- package/src/apps/ui/app.ts +74 -91
- package/src/apps/ui/surface.ts +1 -0
- package/src/apps/update-check.ts +71 -0
- package/src/apps/workflow.ts +58 -0
- package/src/core/agent-profile.ts +130 -0
- package/src/core/completion-review.ts +13 -4
- package/src/core/control-protocol.ts +8 -1
- package/src/core/file-precondition.ts +6 -0
- package/src/core/lsp-framing.ts +34 -0
- package/src/core/mcp-config.ts +68 -0
- package/src/core/model-preferences.ts +28 -0
- package/src/core/patch.ts +130 -0
- package/src/core/permissions.ts +156 -11
- package/src/core/question.ts +94 -0
- package/src/core/review.ts +68 -0
- package/src/core/settings.ts +1 -1
- package/src/core/update-check.ts +24 -5
- package/src/core/web-content.ts +80 -0
- package/src/core/workflow-mode.ts +34 -0
- package/src/runtime/acp-agent.ts +289 -0
- package/src/runtime/agent-profiles.ts +21 -0
- package/src/runtime/catalog.ts +10 -3
- package/src/runtime/control-client.ts +149 -0
- package/src/runtime/control-connection.ts +39 -4
- package/src/runtime/extensions.ts +20 -2
- package/src/runtime/formatter-tool.ts +38 -0
- package/src/runtime/formatter.ts +50 -0
- package/src/runtime/foundation.ts +78 -0
- package/src/runtime/integrations.ts +8 -2
- package/src/runtime/loop.ts +68 -10
- package/src/runtime/lsp-client.ts +201 -0
- package/src/runtime/mcp-client.ts +264 -0
- package/src/runtime/mcp-oauth.ts +168 -0
- package/src/runtime/mcp-schema.ts +51 -0
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/output-schema.ts +26 -0
- package/src/runtime/patch-tool.ts +38 -0
- package/src/runtime/permission-resources.ts +11 -0
- package/src/runtime/provider.ts +2 -0
- package/src/runtime/question-tool.ts +62 -0
- package/src/runtime/review-tool.ts +48 -0
- package/src/runtime/session-control.ts +60 -2
- package/src/runtime/session-hub.ts +85 -0
- package/src/runtime/tool-discovery.ts +4 -3
- package/src/runtime/tools.ts +48 -16
- package/src/runtime/update-check.ts +3 -2
- package/src/runtime/web-http.ts +56 -0
- package/src/runtime/web-tools.ts +88 -0
- package/src/runtime/workspace-lifecycle.ts +14 -1
- package/src/store/agent-profile-files.ts +13 -0
- package/src/store/context-files.ts +77 -1
- package/src/store/file-checkpoints.ts +5 -4
- package/src/store/foundation-config.ts +24 -0
- package/src/store/lsp-document.ts +30 -0
- package/src/store/mcp-credentials.ts +85 -0
- package/src/store/patch.ts +92 -0
- package/src/store/permission-paths.ts +34 -0
- package/src/store/resources.ts +13 -22
- package/src/store/review.ts +109 -0
- package/src/store/search.ts +48 -9
- 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";
|
|
@@ -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 {
|
|
42
|
-
import {
|
|
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,
|
|
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 {
|
|
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
|
-
/**
|
|
181
|
-
private readonly
|
|
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.
|
|
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
|
|
337
|
+
private async checkForUpdateOnStartup(): Promise<void> {
|
|
333
338
|
try {
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const
|
|
338
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
|
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", "
|
|
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
|
|
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
|
} });
|
package/src/apps/ui/surface.ts
CHANGED
|
@@ -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
|
+
}
|