@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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** Local clients open sessions through the same resource/credential loader as the terminal. */
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { createSession, copyAncestryTo, listSessions } from "../store/sessions.ts";
|
|
5
|
+
import { readCursor, writeCursor } from "../store/log.ts";
|
|
6
|
+
import { SessionHub } from "../runtime/session-hub.ts";
|
|
7
|
+
import { sessionControl, type SessionControl } from "../runtime/session-control.ts";
|
|
8
|
+
import { openSessionFor, sessionDirFor, type App } from "./startup.ts";
|
|
9
|
+
import { interactiveHooks } from "./shell.ts";
|
|
10
|
+
import { parseMcpServers } from "../core/mcp-config.ts";
|
|
11
|
+
import { reloadAppResources } from "./resources.ts";
|
|
12
|
+
|
|
13
|
+
export function createAppSessionHub(initial: App): SessionHub {
|
|
14
|
+
const apps = new Map<string, App>([[sessionControl(initial.session).threadId, initial]]);
|
|
15
|
+
// Aliases resolve to one durable log before construction: two cold resumes
|
|
16
|
+
// must never append through independent Session owners (D5/D6).
|
|
17
|
+
const opening = new Map<string, { result: Promise<SessionControl>; configuration: string }>();
|
|
18
|
+
return new SessionHub(sessionControl(initial.session), {
|
|
19
|
+
list: async () => ({ ...await listSessions(initial.workspace, sessionDirFor(initial.args, initial.workspace)), live: [...apps.keys()] }),
|
|
20
|
+
open: async (kind, params) => {
|
|
21
|
+
const clientMcp = params["acpMcpServers"] === undefined ? undefined : parseMcpServers(params["acpMcpServers"]);
|
|
22
|
+
const configuration = JSON.stringify(clientMcp ?? initial.args.clientMcp ?? null);
|
|
23
|
+
let source: string | undefined;
|
|
24
|
+
if (kind !== "new") {
|
|
25
|
+
const requested = String(params["session"]);
|
|
26
|
+
const live = [...apps.values()].find(app => sessionControl(app.session).threadId === requested || app.logDir === requested || basename(app.logDir) === requested);
|
|
27
|
+
if (kind === "resume" && live) {
|
|
28
|
+
if (clientMcp !== undefined) await sessionControl(live.session).withIdleChange(async () => {
|
|
29
|
+
const args = structuredClone(live.args); args.clientMcp = clientMcp;
|
|
30
|
+
await reloadAppResources(live, { ownerLocked: true, args }); await interactiveHooks.bindExtensions(live);
|
|
31
|
+
});
|
|
32
|
+
return sessionControl(live.session);
|
|
33
|
+
}
|
|
34
|
+
const found = (await listSessions(initial.workspace, sessionDirFor(initial.args, initial.workspace))).sessions
|
|
35
|
+
.find(item => item.ok && (createHash("sha256").update(item.path).digest("hex") === requested || item.path === requested || item.id === requested));
|
|
36
|
+
if (!live && !found) throw Error("session must belong to this app-server workspace");
|
|
37
|
+
source = live?.logDir ?? found!.path;
|
|
38
|
+
if (kind === "fork" && live?.session.running) throw Error("fork requires an idle source session");
|
|
39
|
+
}
|
|
40
|
+
if (kind === "resume") {
|
|
41
|
+
const pending = opening.get(source!);
|
|
42
|
+
if (pending) {
|
|
43
|
+
if (pending.configuration !== configuration) throw Error("a session resume with different MCP configuration is already opening; retry after it settles");
|
|
44
|
+
return pending.result;
|
|
45
|
+
}
|
|
46
|
+
const live = [...apps.values()].find(app => app.logDir === source);
|
|
47
|
+
if (live) return sessionControl(live.session);
|
|
48
|
+
}
|
|
49
|
+
const created = kind === "resume" ? undefined : await createSession(initial.workspace, undefined, sessionDirFor(initial.args, initial.workspace));
|
|
50
|
+
const logDir = created?.path ?? source!;
|
|
51
|
+
const construct = async () => {
|
|
52
|
+
if (kind === "fork") await copyAncestryTo(source!, logDir, new Date().toISOString());
|
|
53
|
+
const args = { ...structuredClone(initial.args), workspace: initial.workspace, logDir, prompt: "", print: true, fresh: kind === "new" };
|
|
54
|
+
if (clientMcp !== undefined) args.clientMcp = clientMcp;
|
|
55
|
+
delete args.resume; delete args.continueLatest; delete args.session; delete args.forkFrom;
|
|
56
|
+
const opened = await openSessionFor({ args, workspace: initial.workspace, logDir, blobDir: initial.blobDir, io: initial.io,
|
|
57
|
+
askSlot: {}, catalog: initial.catalog, auth: initial.auth, credentialOf: initial.credentialOf, effects: initial.effects,
|
|
58
|
+
interactive: false, projectTrusted: initial.projectTrusted === true, settings: initial.ui?.settings ?? {},
|
|
59
|
+
fallback: initial.providerConfig });
|
|
60
|
+
const app: App = { ...initial, args, ...opened, loadedContext: opened.resources.loadedContext, ui: opened.resources.ui,
|
|
61
|
+
slash: opened.resources.slash, extensions: opened.resources.extensions, resources: opened.resources.index,
|
|
62
|
+
foundation: opened.resources.foundation, projectTrusted: opened.resources.trusted, askSlot: opened.session.config.tools.askSlot ?? {} };
|
|
63
|
+
const control: SessionControl = sessionControl(app.session);
|
|
64
|
+
try {
|
|
65
|
+
app.askSlot.current = request => control.ask(request);
|
|
66
|
+
if (typeof params["name"] === "string") { const cursor = await readCursor(logDir); if (cursor) await writeCursor(logDir, { ...cursor, name: params["name"] }); }
|
|
67
|
+
await interactiveHooks.bindExtensions(app);
|
|
68
|
+
apps.set(control.threadId, app);
|
|
69
|
+
return control;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
await app.session.dispose().catch(() => {}); await app.extensions?.dispose().catch(() => {}); throw error;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const result = construct();
|
|
75
|
+
if (kind === "resume") opening.set(logDir, { result, configuration });
|
|
76
|
+
try { return await result; }
|
|
77
|
+
finally { if (opening.get(logDir)?.result === result) opening.delete(logDir); }
|
|
78
|
+
},
|
|
79
|
+
dispose: async control => {
|
|
80
|
+
const app = apps.get(control.threadId);
|
|
81
|
+
await control.session.dispose(); await app?.extensions?.dispose(); apps.delete(control.threadId);
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
package/src/apps/shell.ts
CHANGED
|
@@ -58,14 +58,15 @@ import { registerSessionRoot } from "../store/session-browser.ts";
|
|
|
58
58
|
import { createSession, findSessionByPrefix, listSessions, truncationNote, sessionsDir } from "../store/sessions.ts";
|
|
59
59
|
import { exportSessionArchive, importSessionArchive } from "../store/session-archive.ts";
|
|
60
60
|
import { resolveTuiMode } from "../core/tui-mode.ts";
|
|
61
|
-
import {
|
|
61
|
+
import { modelPreferencePatch, thinkingPreferencePatch } from "../core/model-preferences.ts";
|
|
62
62
|
import { flattenTree, treeFromPoints, LEFT_BRANCH_MARK, EMPTY_TREE_MESSAGE, type TreeNode, type NodeParent, type TreePoint } from "../core/session-tree.ts";
|
|
63
63
|
import { cycle } from "../core/cycle.ts";
|
|
64
64
|
import { THINKING_LEVELS, asThinkingLevel as asEffort, supportedThinkingLevels, clampThinkingLevel } from "../core/thinking.ts";
|
|
65
65
|
import { modelKey } from "../core/session-position.ts";
|
|
66
66
|
import { defaultKeymap, listBindings } from "../core/keymap.ts";
|
|
67
|
-
import { settingPatch
|
|
68
|
-
import {
|
|
67
|
+
import { settingPatch } from "../core/settings.ts";
|
|
68
|
+
import { resolveTheme } from "../store/resources.ts";
|
|
69
|
+
import { commitUserSettings, type PreferenceOrigin } from "./preferences.ts";
|
|
69
70
|
import { colorEnabled } from "./render.ts";
|
|
70
71
|
import { createStyle, IDENTITY } from "../tui/style.ts";
|
|
71
72
|
import { lastAssistantText, toHtml } from "../core/export.ts";
|
|
@@ -80,6 +81,11 @@ import {
|
|
|
80
81
|
openSessionFor, sessionDirFor, retryHandler, whyNot, splitModelPatterns, runModelsRefresh, sessionToolCatalog, type App,
|
|
81
82
|
} from "./startup.ts";
|
|
82
83
|
import type { TurnSurface, Ui } from "./ui/surface.ts";
|
|
84
|
+
import { runManualUpdate } from "./update-check.ts";
|
|
85
|
+
import { presentQuestions } from "./question-ui.ts";
|
|
86
|
+
import { bindAppWorkflow, setAppWorkflow } from "./workflow.ts";
|
|
87
|
+
import { appReviewSnapshot, runReview, reviewScopeFromText } from "./review.ts";
|
|
88
|
+
import { selectAppAgent, listAppAgents } from "./agent-profiles.ts";
|
|
83
89
|
|
|
84
90
|
/**
|
|
85
91
|
* One line. Listing all ~40 commands here wrapped into a two-line wall that
|
|
@@ -112,6 +118,11 @@ function extensionContext(app: App, tui?: Ui): ExtensionContext {
|
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
async function bindExtensions(app: App, tui?: Ui): Promise<void> {
|
|
121
|
+
bindAppWorkflow(app);
|
|
122
|
+
if (tui) {
|
|
123
|
+
const control = sessionControl(app.session);
|
|
124
|
+
control.questionSlot.current = (request, signal) => control.askQuestion(request, signal, current => presentQuestions(tui.extensionUi, current, signal));
|
|
125
|
+
}
|
|
115
126
|
tui?.bindExtensionRegistry();
|
|
116
127
|
const registry = app.extensions; if (!registry) return;
|
|
117
128
|
const history = await readNodeHistory(app.logDir, app.session.state.node);
|
|
@@ -144,9 +155,9 @@ async function bindExtensions(app: App, tui?: Ui): Promise<void> {
|
|
|
144
155
|
},
|
|
145
156
|
setModel: async model => {
|
|
146
157
|
if (typeof model !== "object" || !model || !("provider" in model) || !("id" in model)) return false;
|
|
147
|
-
await switchByPattern(app, String(model.provider) + "/" + String(model.id)); return app.providerConfig.spec.id === model.id && app.providerConfig.spec.provider === model.provider;
|
|
158
|
+
await switchByPattern(app, String(model.provider) + "/" + String(model.id), "session"); return app.providerConfig.spec.id === model.id && app.providerConfig.spec.provider === model.provider;
|
|
148
159
|
},
|
|
149
|
-
setThinking: async level => { await applyThinkingCommand(app, level, tui ?? null); },
|
|
160
|
+
setThinking: async level => { await applyThinkingCommand(app, level, tui ?? null, "session"); },
|
|
150
161
|
exec: (command, args, options) => app.session.runExtensionCommand(command, args, options),
|
|
151
162
|
});
|
|
152
163
|
if (!initializedExtensions.has(registry)) {
|
|
@@ -425,6 +436,24 @@ type Outcome = "continue" | "quit" | { login: string };
|
|
|
425
436
|
|
|
426
437
|
async function execute(app: App, command: Command, tui: Ui | null): Promise<Outcome> {
|
|
427
438
|
switch (command.kind) {
|
|
439
|
+
case "workflow":
|
|
440
|
+
await setAppWorkflow(app, command.mode); app.io.say(` Workflow: ${command.mode}`); await tui?.refreshResources(); return "continue";
|
|
441
|
+
case "agent":
|
|
442
|
+
if (command.name) { await selectAppAgent(app, command.name); await bindExtensions(app, tui ?? undefined); await tui?.refreshResources(); }
|
|
443
|
+
else app.io.say(await listAppAgents(app)); return "continue";
|
|
444
|
+
case "mcp": {
|
|
445
|
+
const manager = app.foundation?.mcp; if (!manager) throw Error("MCP resources are unavailable");
|
|
446
|
+
if (command.action === "status") app.io.say(JSON.stringify(manager.status(), null, 2));
|
|
447
|
+
else {
|
|
448
|
+
if (!command.name) throw Error(`Use /mcp ${command.action} <server>`);
|
|
449
|
+
if (command.action === "login") await manager.login(command.name, url => { app.io.say(` Complete MCP login: ${url.href}`); }, tui?.signal);
|
|
450
|
+
else await manager.logout(command.name);
|
|
451
|
+
await reloadAppResources(app); await bindExtensions(app, tui ?? undefined); await tui?.refreshResources();
|
|
452
|
+
}
|
|
453
|
+
return "continue";
|
|
454
|
+
}
|
|
455
|
+
case "diff": { const snapshot = await appReviewSnapshot(app, reviewScopeFromText(command.args), tui?.signal); app.io.say(`${snapshot.label}\n${snapshot.diff || "(no captured changes)"}\n${snapshot.limitations.join("\n")}`); return "continue"; }
|
|
456
|
+
case "review": await runReview(app, reviewScopeFromText(command.args), tui?.signal); await tui?.syncTranscript(); return "continue";
|
|
428
457
|
case "extension":
|
|
429
458
|
if (tui) tui.runExtensionCommand(command.name, command.args);
|
|
430
459
|
else { await bindExtensions(app); await app.extensions?.command(command.name, command.args); }
|
|
@@ -625,6 +654,10 @@ async function execute(app: App, command: Command, tui: Ui | null): Promise<Outc
|
|
|
625
654
|
if (tui) tui.showDocument("Changelog", text); else app.io.say(text);
|
|
626
655
|
return "continue";
|
|
627
656
|
}
|
|
657
|
+
case "update":
|
|
658
|
+
if (tui) tui.checkForUpdate();
|
|
659
|
+
else await runManualUpdate({ offline: app.args.offline === true }, app.io.say);
|
|
660
|
+
return "continue";
|
|
628
661
|
case "copy":
|
|
629
662
|
await copyLastReply(app, tui ?? undefined);
|
|
630
663
|
return "continue";
|
|
@@ -822,7 +855,8 @@ async function commitSetting(app: App, field: string, value: string, tui: Ui | n
|
|
|
822
855
|
}
|
|
823
856
|
if (key === "thinking") {
|
|
824
857
|
const effort = asEffort(value);
|
|
825
|
-
if (
|
|
858
|
+
if (effort) await applyThinking(app, effort, tui);
|
|
859
|
+
return;
|
|
826
860
|
}
|
|
827
861
|
const parsed = settingPatch(key === "status" ? "statusBar" : field, value);
|
|
828
862
|
if (parsed.diagnostics.length) { app.io.say(C.yellow(parsed.diagnostics.join("\n"))); return; }
|
|
@@ -833,12 +867,12 @@ async function commitSetting(app: App, field: string, value: string, tui: Ui | n
|
|
|
833
867
|
app.ui.theme = theme;
|
|
834
868
|
app.ui.style = colorEnabled() ? createStyle(theme.colors) : IDENTITY;
|
|
835
869
|
}
|
|
836
|
-
await
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
870
|
+
const saved = await commitUserSettings(app, parsed.settings);
|
|
871
|
+
if (saved && key === "enableskillcommands") { await reloadAppResources(app); await tui?.refreshResources(); }
|
|
872
|
+
if (saved) {
|
|
873
|
+
if (tui) tui.toast(`${field} · ${value}`, "success");
|
|
874
|
+
else app.io.say(C.dim(` ${field} ${value}`));
|
|
875
|
+
}
|
|
842
876
|
tui?.applyConfiguration();
|
|
843
877
|
if (key === "theme") tui?.followTheme();
|
|
844
878
|
tui?.redraw();
|
|
@@ -856,7 +890,7 @@ function effortSupported(spec: ModelSpec, level: Effort): boolean {
|
|
|
856
890
|
* must not cut: a cut drops the prefix cache, and thinking blocks stay
|
|
857
891
|
* valid because the model did not change.
|
|
858
892
|
*/
|
|
859
|
-
async function applyThinking(app: App, level: Effort, tui: Ui | null): Promise<boolean> {
|
|
893
|
+
async function applyThinking(app: App, level: Effort, tui: Ui | null, origin: PreferenceOrigin = "user"): Promise<boolean> {
|
|
860
894
|
const C = app.io.palette;
|
|
861
895
|
if (!effortSupported(app.providerConfig.spec, level)) {
|
|
862
896
|
app.io.say(
|
|
@@ -874,19 +908,20 @@ async function applyThinking(app: App, level: Effort, tui: Ui | null): Promise<b
|
|
|
874
908
|
if (app.ui !== undefined) {
|
|
875
909
|
app.ui.settings = { ...app.ui.settings, thinking: level };
|
|
876
910
|
}
|
|
911
|
+
if (origin === "user") await commitUserSettings(app, thinkingPreferencePatch(app.providerConfig.spec, level));
|
|
877
912
|
app.io.say(C.dim(` thinking ${level}`));
|
|
878
913
|
tui?.redraw();
|
|
879
914
|
return true;
|
|
880
915
|
}
|
|
881
916
|
|
|
882
|
-
async function applyThinkingCommand(app: App, level: string | undefined, tui: Ui | null): Promise<Outcome> {
|
|
917
|
+
async function applyThinkingCommand(app: App, level: string | undefined, tui: Ui | null, origin: PreferenceOrigin = "user"): Promise<Outcome> {
|
|
883
918
|
if (level !== undefined) {
|
|
884
919
|
const effort = asEffort(level);
|
|
885
920
|
if (effort === undefined) {
|
|
886
921
|
app.io.say(app.io.palette.yellow(" thinking must be " + THINKING_LEVELS.join("|")));
|
|
887
922
|
return "continue";
|
|
888
923
|
}
|
|
889
|
-
await applyThinking(app, effort, tui);
|
|
924
|
+
await applyThinking(app, effort, tui, origin);
|
|
890
925
|
return "continue";
|
|
891
926
|
}
|
|
892
927
|
if (tui) {
|
|
@@ -1486,7 +1521,7 @@ async function adoptCredential(app: App, ready: readonly ModelSpec[]): Promise<v
|
|
|
1486
1521
|
const got = app.credentialOf(spec);
|
|
1487
1522
|
if (!got.ok) return;
|
|
1488
1523
|
if (app.credential.ok || app.session.state.handoff !== null || app.session.state.events.some((e) => e.t === "assistant")) {
|
|
1489
|
-
await switchToModel(app, spec);
|
|
1524
|
+
await switchToModel(app, spec, undefined, "session");
|
|
1490
1525
|
return;
|
|
1491
1526
|
}
|
|
1492
1527
|
const nextConfig: ProviderConfig = {
|
|
@@ -1511,7 +1546,7 @@ async function adoptCredential(app: App, ready: readonly ModelSpec[]): Promise<v
|
|
|
1511
1546
|
app.io.say(C.green(` now ${spec.name}`) + C.dim(` ${spec.id}`));
|
|
1512
1547
|
}
|
|
1513
1548
|
|
|
1514
|
-
async function switchByPattern(app: App, pattern: string): Promise<void> {
|
|
1549
|
+
async function switchByPattern(app: App, pattern: string, origin: PreferenceOrigin = "user"): Promise<void> {
|
|
1515
1550
|
const C = app.io.palette;
|
|
1516
1551
|
const found = scopedModels([pattern], app.catalog, true), selected = found.entries[0];
|
|
1517
1552
|
if (!selected) {
|
|
@@ -1519,16 +1554,11 @@ async function switchByPattern(app: App, pattern: string): Promise<void> {
|
|
|
1519
1554
|
app.io.say(C.red(` ${diagnostic.ok ? found.diagnostics.join("; ") : diagnostic.reason}`));
|
|
1520
1555
|
return;
|
|
1521
1556
|
}
|
|
1522
|
-
await switchToModel(app, selected.model, selected.thinking);
|
|
1557
|
+
await switchToModel(app, selected.model, selected.thinking, origin);
|
|
1523
1558
|
}
|
|
1524
1559
|
|
|
1525
|
-
export async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Promise<void> {
|
|
1560
|
+
export async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort, origin: PreferenceOrigin = "user"): Promise<void> {
|
|
1526
1561
|
const C = app.io.palette;
|
|
1527
|
-
if (spec.id === app.providerConfig.spec.id && spec.provider === app.providerConfig.spec.provider) {
|
|
1528
|
-
if (thinking) await applyThinking(app, clampThinkingLevel(spec, thinking), null);
|
|
1529
|
-
app.io.say(C.dim(` already on ${spec.id}`));
|
|
1530
|
-
return;
|
|
1531
|
-
}
|
|
1532
1562
|
const got = app.credentialOf(spec);
|
|
1533
1563
|
if (!got.ok) {
|
|
1534
1564
|
app.io.say(C.red(`\n ${got.reason}\n`));
|
|
@@ -1537,6 +1567,12 @@ export async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort
|
|
|
1537
1567
|
}
|
|
1538
1568
|
return;
|
|
1539
1569
|
}
|
|
1570
|
+
if (spec.id === app.providerConfig.spec.id && spec.provider === app.providerConfig.spec.provider) {
|
|
1571
|
+
if (thinking) await applyThinking(app, clampThinkingLevel(spec, thinking), null, "session");
|
|
1572
|
+
if (origin === "user") await rememberModelSelection(app, spec, thinking);
|
|
1573
|
+
app.io.say(C.dim(` already on ${spec.id}`));
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1540
1576
|
const nextConfig: ProviderConfig = {
|
|
1541
1577
|
spec,
|
|
1542
1578
|
maxTokens: outputTokenBudget(spec),
|
|
@@ -1554,15 +1590,15 @@ export async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort
|
|
|
1554
1590
|
app.providerConfig = nextConfig;
|
|
1555
1591
|
app.credential = got;
|
|
1556
1592
|
app.io.say(C.green(` now ${spec.name}`) + C.dim(` ${spec.id}`));
|
|
1557
|
-
await
|
|
1593
|
+
if (origin === "user") await rememberModelSelection(app, spec, thinking);
|
|
1558
1594
|
}
|
|
1559
1595
|
|
|
1560
|
-
/**
|
|
1561
|
-
async function
|
|
1596
|
+
/** N.8 must accept the checkpoint before a user choice can own the next startup. */
|
|
1597
|
+
async function rememberModelSelection(app: App, spec: ModelSpec, explicitThinking: Effort | undefined): Promise<void> {
|
|
1562
1598
|
if (!app.ui) return;
|
|
1563
|
-
const
|
|
1564
|
-
|
|
1565
|
-
|
|
1599
|
+
const effort = app.providerConfig.effort;
|
|
1600
|
+
const patch = modelPreferencePatch(spec, app.ui.settings.recentModels ?? [], effort);
|
|
1601
|
+
await commitUserSettings(app, explicitThinking === undefined ? patch : { ...patch, ...thinkingPreferencePatch(spec, effort) });
|
|
1566
1602
|
}
|
|
1567
1603
|
|
|
1568
1604
|
function sessionDirOf(app: App): string | undefined {
|
|
@@ -1621,6 +1657,7 @@ async function adoptLogDir(app: App, logDir: string): Promise<boolean> {
|
|
|
1621
1657
|
app.resources = opened.resources.index;
|
|
1622
1658
|
app.projectTrusted = opened.resources.trusted;
|
|
1623
1659
|
app.extensions = opened.resources.extensions;
|
|
1660
|
+
app.foundation = opened.resources.foundation;
|
|
1624
1661
|
} catch (err) {
|
|
1625
1662
|
app.io.say(app.io.palette.red(` ${describeError(err)}`));
|
|
1626
1663
|
return false;
|
|
@@ -1864,6 +1901,10 @@ export async function runTurn(
|
|
|
1864
1901
|
let live = false;
|
|
1865
1902
|
let skipFinal = false;
|
|
1866
1903
|
const json = app.args?.mode === "json";
|
|
1904
|
+
const checkedOutput = app.session.config.validateOutput !== undefined;
|
|
1905
|
+
let finalText: string[] = [];
|
|
1906
|
+
let completed = false;
|
|
1907
|
+
let failure: string | undefined;
|
|
1867
1908
|
const pull = tui?.pullQueued?.bind(tui);
|
|
1868
1909
|
const registry = app.extensions;
|
|
1869
1910
|
const before = await registry?.emit("before_agent_start", { prompt: userInputData(prompt).text, systemPrompt: app.session.config.frozen.system }) ?? [];
|
|
@@ -1874,6 +1915,14 @@ export async function runTurn(
|
|
|
1874
1915
|
await registry?.emit("agent_start");
|
|
1875
1916
|
await registry?.emit("turn_start", { turnIndex: 0 });
|
|
1876
1917
|
try { for await (const e of skill ? app.session.sendSkill(skill, prompt, signal) : sessionControl(app.session).send(prompt, signal, pull)) {
|
|
1918
|
+
// The log retains every response, but a script's final answer must be the
|
|
1919
|
+
// accepted last response. A rejected JSON document cannot be unwritten.
|
|
1920
|
+
if (e.kind === "turn_start" || e.kind === "discard") finalText = [];
|
|
1921
|
+
if (e.kind === "assistant_text") finalText.push(e.text ?? "");
|
|
1922
|
+
if (e.kind === "done") {
|
|
1923
|
+
completed = e.outcome?.status === "completed";
|
|
1924
|
+
if (checkedOutput && !completed) failure = e.reason ?? "output schema validation did not complete";
|
|
1925
|
+
}
|
|
1877
1926
|
tui?.observeEvent?.(e);
|
|
1878
1927
|
if (e.kind === "text_delta" || e.kind === "thinking_delta") await registry?.emit("message_update", { assistantMessageEvent: { type: e.kind, delta: e.text } });
|
|
1879
1928
|
if (e.kind === "assistant_text") await registry?.emit("message_end", { message: { role: "assistant", content: [{ type: "text", text: e.text }] } });
|
|
@@ -1883,6 +1932,7 @@ export async function runTurn(
|
|
|
1883
1932
|
if (payload !== null) process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
1884
1933
|
continue;
|
|
1885
1934
|
}
|
|
1935
|
+
if (checkedOutput && !tui && e.kind === "assistant_text") continue;
|
|
1886
1936
|
if (e.kind === "progress") { if (e.progress) tui?.pushProgress?.(e.progress); continue; }
|
|
1887
1937
|
if (tui?.syncTranscript) {
|
|
1888
1938
|
if (e.kind === "usage" && e.ok === false && e.prefix && app.ui?.settings.showCacheMissNotices === true)
|
|
@@ -1936,6 +1986,14 @@ export async function runTurn(
|
|
|
1936
1986
|
app.io.say(line);
|
|
1937
1987
|
}
|
|
1938
1988
|
} } finally { await registry?.emit("agent_end", { messages: structuredClone(app.session.state.events) }); }
|
|
1989
|
+
if (failure) throw Error(failure);
|
|
1990
|
+
if (completed) {
|
|
1991
|
+
const text = finalText.join("\n");
|
|
1992
|
+
if (checkedOutput && !tui && !json) {
|
|
1993
|
+
if (app.io.answer) app.io.answer(text); else app.io.say(text);
|
|
1994
|
+
}
|
|
1995
|
+
if (app.args?.outputLastMessage) await writeFile(resolve(app.args.outputLastMessage), text.endsWith("\n") ? text : text + "\n", "utf8");
|
|
1996
|
+
}
|
|
1939
1997
|
}
|
|
1940
1998
|
|
|
1941
1999
|
// ---------------------------------------------------------------------------
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
type StartupStdin = Pick<NodeJS.ReadStream, "isTTY" | "isRaw" | "setRawMode" | "setEncoding" | "on" | "off" | "resume" | "pause" | "unshift">;
|
|
8
8
|
export interface StartupInput {
|
|
9
9
|
phase(name: string): void;
|
|
10
|
-
/** Pause
|
|
10
|
+
/** Pause and replay held bytes while keeping raw mode for the next input owner. */
|
|
11
11
|
handoff(): void;
|
|
12
12
|
/** Final cleanup discards unsubmitted input instead of leaking it to the shell. */
|
|
13
13
|
restore(): void;
|
|
@@ -45,9 +45,14 @@ export function createStartupInput(opts: {
|
|
|
45
45
|
stdin.off("data", receive);
|
|
46
46
|
signals.off("SIGINT", interrupt); signals.off("SIGTERM", terminate); signals.off("SIGHUP", hangup);
|
|
47
47
|
pasting = false; partial = "";
|
|
48
|
+
};
|
|
49
|
+
const restore = () => {
|
|
50
|
+
if (closed) return;
|
|
51
|
+
closed = true; release(); pending = [];
|
|
52
|
+
// D20: ownership transfer must not expose a cooked-mode gap that turns CR into LF.
|
|
53
|
+
// Final cleanup also restores termios when the next renderer never mounted.
|
|
48
54
|
stdin.setRawMode(wasRaw);
|
|
49
55
|
};
|
|
50
|
-
const restore = () => { closed = true; release(); pending = []; };
|
|
51
56
|
const cancel = (code: number) => {
|
|
52
57
|
if (!active) return;
|
|
53
58
|
restore();
|