@visual-z/xeno 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +41 -10
  3. package/docs/app-server.md +35 -4
  4. package/docs/foundations.md +88 -0
  5. package/docs/install.md +23 -2
  6. package/docs/providers.md +4 -0
  7. package/docs/research/foundation-clients-2026-10-09.md +27 -0
  8. package/docs/research/foundation-editing-2026-10-09.md +56 -0
  9. package/docs/research/foundation-network-2026-10-09.md +37 -0
  10. package/docs/research/foundation-workflow-2026-10-09.md +37 -0
  11. package/package.json +10 -2
  12. package/src/apps/acp-server.ts +41 -0
  13. package/src/apps/agent-profiles.ts +19 -0
  14. package/src/apps/app-server.ts +4 -2
  15. package/src/apps/cli.ts +18 -2
  16. package/src/apps/commands.ts +20 -0
  17. package/src/apps/control-transport.ts +3 -2
  18. package/src/apps/model-catalog.ts +57 -31
  19. package/src/apps/preferences.ts +20 -0
  20. package/src/apps/question-ui.ts +27 -0
  21. package/src/apps/resources.ts +63 -25
  22. package/src/apps/review.ts +109 -0
  23. package/src/apps/session-hub.ts +84 -0
  24. package/src/apps/shell.ts +88 -30
  25. package/src/apps/startup-input.ts +7 -2
  26. package/src/apps/startup.ts +180 -25
  27. package/src/apps/ui/app.ts +74 -91
  28. package/src/apps/ui/surface.ts +1 -0
  29. package/src/apps/update-check.ts +71 -0
  30. package/src/apps/workflow.ts +58 -0
  31. package/src/core/agent-profile.ts +130 -0
  32. package/src/core/completion-review.ts +13 -4
  33. package/src/core/control-protocol.ts +8 -1
  34. package/src/core/file-precondition.ts +6 -0
  35. package/src/core/lsp-framing.ts +34 -0
  36. package/src/core/mcp-config.ts +68 -0
  37. package/src/core/model-preferences.ts +28 -0
  38. package/src/core/patch.ts +130 -0
  39. package/src/core/permissions.ts +156 -11
  40. package/src/core/question.ts +94 -0
  41. package/src/core/review.ts +68 -0
  42. package/src/core/settings.ts +1 -1
  43. package/src/core/update-check.ts +24 -5
  44. package/src/core/web-content.ts +80 -0
  45. package/src/core/workflow-mode.ts +34 -0
  46. package/src/runtime/acp-agent.ts +289 -0
  47. package/src/runtime/agent-profiles.ts +21 -0
  48. package/src/runtime/catalog.ts +10 -3
  49. package/src/runtime/control-client.ts +149 -0
  50. package/src/runtime/control-connection.ts +39 -4
  51. package/src/runtime/extensions.ts +20 -2
  52. package/src/runtime/formatter-tool.ts +38 -0
  53. package/src/runtime/formatter.ts +50 -0
  54. package/src/runtime/foundation.ts +78 -0
  55. package/src/runtime/integrations.ts +8 -2
  56. package/src/runtime/loop.ts +68 -10
  57. package/src/runtime/lsp-client.ts +201 -0
  58. package/src/runtime/mcp-client.ts +264 -0
  59. package/src/runtime/mcp-oauth.ts +168 -0
  60. package/src/runtime/mcp-schema.ts +51 -0
  61. package/src/runtime/oauth.ts +1 -1
  62. package/src/runtime/output-schema.ts +26 -0
  63. package/src/runtime/patch-tool.ts +38 -0
  64. package/src/runtime/permission-resources.ts +11 -0
  65. package/src/runtime/provider.ts +2 -0
  66. package/src/runtime/question-tool.ts +62 -0
  67. package/src/runtime/review-tool.ts +48 -0
  68. package/src/runtime/session-control.ts +60 -2
  69. package/src/runtime/session-hub.ts +85 -0
  70. package/src/runtime/tool-discovery.ts +4 -3
  71. package/src/runtime/tools.ts +48 -16
  72. package/src/runtime/update-check.ts +3 -2
  73. package/src/runtime/web-http.ts +56 -0
  74. package/src/runtime/web-tools.ts +88 -0
  75. package/src/runtime/workspace-lifecycle.ts +14 -1
  76. package/src/store/agent-profile-files.ts +13 -0
  77. package/src/store/context-files.ts +77 -1
  78. package/src/store/file-checkpoints.ts +5 -4
  79. package/src/store/foundation-config.ts +24 -0
  80. package/src/store/lsp-document.ts +30 -0
  81. package/src/store/mcp-credentials.ts +85 -0
  82. package/src/store/patch.ts +92 -0
  83. package/src/store/permission-paths.ts +34 -0
  84. package/src/store/resources.ts +13 -22
  85. package/src/store/review.ts +109 -0
  86. package/src/store/search.ts +48 -9
  87. package/src/store/user-settings.ts +30 -0
  88. package/src/tui/ui/control-panel.ts +7 -5
  89. package/src/tui/ui/control-sidebar.ts +49 -0
  90. package/src/tui/ui/screen.ts +107 -11
@@ -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 { recordRecent } from "../core/model-picker.ts";
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, mergeSettings } from "../core/settings.ts";
68
- import { writeUserSettings, resolveTheme } from "../store/resources.ts";
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 (!effort || !await applyThinking(app, effort, tui)) return;
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 writeUserSettings(parsed.settings);
837
- app.ui.settings = mergeSettings({ builtin: app.ui.settings, user: parsed.settings });
838
- app.ui.userOverrides = mergeSettings({ builtin: app.ui.userOverrides, user: parsed.settings });
839
- if (key === "enableskillcommands") { await reloadAppResources(app); await tui?.refreshResources(); }
840
- if (tui) tui.toast(`${field} · ${value}`, "success");
841
- else app.io.say(C.dim(` ${field} ${value}`));
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 rememberRecentModel(app, spec);
1593
+ if (origin === "user") await rememberModelSelection(app, spec, thinking);
1558
1594
  }
1559
1595
 
1560
- /** Recency feeds the picker's first section; a write failure only costs ordering. */
1561
- async function rememberRecentModel(app: App, spec: ModelSpec): Promise<void> {
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 recentModels = recordRecent(app.ui.settings.recentModels ?? [], modelKey(spec));
1564
- app.ui.settings = { ...app.ui.settings, recentModels };
1565
- try { await writeUserSettings({ recentModels }); } catch { /* ordering only */ }
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, restore the shell's mode and replay held bytes to the next input owner. */
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();