@visual-z/xeno 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +55 -16
  3. package/docs/app-server.md +86 -0
  4. package/docs/install.md +1 -1
  5. package/docs/providers.md +20 -6
  6. package/package.json +3 -2
  7. package/src/apps/app-server.ts +28 -0
  8. package/src/apps/cli.ts +25 -10
  9. package/src/apps/commands.ts +10 -6
  10. package/src/apps/completions.ts +15 -13
  11. package/src/apps/control-transport.ts +89 -0
  12. package/src/apps/model-catalog.ts +2 -1
  13. package/src/apps/preferences.ts +20 -0
  14. package/src/apps/provider-connect.ts +4 -4
  15. package/src/apps/shell.ts +73 -50
  16. package/src/apps/startup-input.ts +7 -2
  17. package/src/apps/startup.ts +52 -19
  18. package/src/apps/ui/app.ts +210 -56
  19. package/src/apps/ui/control-model.ts +26 -0
  20. package/src/apps/ui/surface.ts +2 -0
  21. package/src/core/completion-action.ts +20 -0
  22. package/src/core/completion-review.ts +38 -29
  23. package/src/core/control-protocol.ts +62 -0
  24. package/src/core/control-view.ts +42 -0
  25. package/src/core/event-validation.ts +1 -1
  26. package/src/core/events.ts +2 -0
  27. package/src/core/harness-diagnostics.ts +206 -0
  28. package/src/core/model-preferences.ts +28 -0
  29. package/src/core/oauth-fields.ts +44 -0
  30. package/src/core/permissions.ts +13 -7
  31. package/src/core/program-status.ts +31 -0
  32. package/src/core/project.ts +25 -1
  33. package/src/core/request-progress.ts +87 -0
  34. package/src/core/request-state.ts +114 -0
  35. package/src/core/settings.ts +8 -1
  36. package/src/core/tool-input.ts +6 -0
  37. package/src/runtime/auth.ts +10 -3
  38. package/src/runtime/catalog.ts +21 -4
  39. package/src/runtime/control-connection.ts +104 -0
  40. package/src/runtime/harness-eval.ts +3 -1
  41. package/src/runtime/loop.ts +72 -19
  42. package/src/runtime/model.ts +9 -0
  43. package/src/runtime/native-eval.ts +6 -3
  44. package/src/runtime/oauth.ts +80 -3
  45. package/src/runtime/pi-provider.ts +169 -12
  46. package/src/runtime/program-status.ts +38 -0
  47. package/src/runtime/provider-connect.ts +2 -2
  48. package/src/runtime/provider-presets.ts +1 -1
  49. package/src/runtime/provider-request.ts +13 -2
  50. package/src/runtime/provider.ts +23 -9
  51. package/src/runtime/session-control.ts +198 -0
  52. package/src/runtime/tools.ts +11 -9
  53. package/src/store/resources.ts +13 -22
  54. package/src/store/user-settings.ts +30 -0
  55. package/src/tui/ui/composer.ts +3 -2
  56. package/src/tui/ui/control-panel.ts +25 -0
  57. package/src/tui/ui/control-sidebar.ts +49 -0
  58. package/src/tui/ui/mascot.ts +1 -1
  59. package/src/tui/ui/screen.ts +107 -11
  60. package/src/tui/ui/status.ts +8 -0
@@ -0,0 +1,89 @@
1
+ /** Q24: bounded local JSONL transport. No TCP listener or additional runtime dependency. */
2
+ import { createServer, type Socket } from "node:net";
3
+ import { chmodSync } from "node:fs";
4
+ import { lstat, mkdir, rm } from "node:fs/promises";
5
+ import { dirname, resolve } from "node:path";
6
+ import { CONTROL_SCHEMA } from "../core/control-protocol.ts";
7
+ import { ControlConnection, type RpcOutput } from "../runtime/control-connection.ts";
8
+ import type { SessionControl } from "../runtime/session-control.ts";
9
+
10
+ export class ControlJsonl {
11
+ private buffer = Buffer.alloc(0);
12
+ private pending = 0;
13
+ private tail = Promise.resolve();
14
+ private closed = false;
15
+ readonly connection: ControlConnection;
16
+ private readonly stop: () => void;
17
+ private readonly output: (text: string) => void;
18
+ constructor(control: () => SessionControl, output: (text: string) => void, stop: () => void, denyOnClose = false) {
19
+ this.stop = stop;
20
+ this.output = text => { try { output(text); } catch { this.close(); this.stop(); } };
21
+ this.connection = new ControlConnection(control, message => this.write(message), { denyOnClose });
22
+ }
23
+ private write(message: RpcOutput): void {
24
+ if (this.closed) return;
25
+ const text = JSON.stringify(message) + "\n";
26
+ if (Buffer.byteLength(text) > CONTROL_SCHEMA.limits.frameBytes) {
27
+ if ("id" in message) this.output(JSON.stringify({ id: message.id, error: { code: -32002, message: "response exceeds frame limit; use paginated inspectors" } }) + "\n");
28
+ else this.fail("event exceeds frame limit; reconnect and resynchronize");
29
+ } else this.output(text);
30
+ }
31
+ private fail(message: string): void {
32
+ if (this.closed) return;
33
+ this.output(JSON.stringify({ id: null, error: { code: -32001, message } }) + "\n");
34
+ this.close(); this.stop();
35
+ }
36
+ feed(chunk: Uint8Array): void {
37
+ if (this.closed) return;
38
+ this.buffer = Buffer.concat([this.buffer, chunk]);
39
+ for (;;) {
40
+ const newline = this.buffer.indexOf(10);
41
+ if (newline < 0) { if (this.buffer.length > CONTROL_SCHEMA.limits.frameBytes) this.fail("input frame exceeds limit"); return; }
42
+ if (newline > CONTROL_SCHEMA.limits.frameBytes) { this.fail("input frame exceeds limit"); return; }
43
+ const line = this.buffer.subarray(0, newline).toString("utf8"); this.buffer = this.buffer.subarray(newline + 1);
44
+ if (!line.trim()) continue;
45
+ if (++this.pending > CONTROL_SCHEMA.limits.queuedRequests) { this.fail("request queue is full"); return; }
46
+ this.tail = this.tail.then(async () => {
47
+ if (this.closed) return;
48
+ let value: unknown;
49
+ try { value = JSON.parse(line); }
50
+ catch { this.write({ id: null, error: { code: -32700, message: "invalid JSON" } }); return; }
51
+ await this.connection.receive(value);
52
+ }).finally(() => { this.pending--; });
53
+ }
54
+ }
55
+ async drained(): Promise<void> { await this.tail; }
56
+ close(): void { this.closed = true; this.buffer = Buffer.alloc(0); this.connection.close(); }
57
+ }
58
+
59
+ export async function listenControl(path: string, control: () => SessionControl): Promise<() => Promise<void>> {
60
+ const absolute = resolve(path), parent = dirname(absolute);
61
+ await mkdir(parent, { recursive: true, mode: 0o700 });
62
+ const directory = await lstat(parent);
63
+ if (!directory.isDirectory() || directory.mode & 0o077 || process.getuid && directory.uid !== process.getuid()) throw Error("control socket requires an owned private directory (mode 0700)");
64
+ try { await lstat(absolute); throw Error("control socket path already exists; refusing to replace it"); }
65
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
66
+ const clients = new Map<Socket, ControlJsonl>();
67
+ const server = createServer(socket => {
68
+ // Assumed local adapter capacity; together with per-peer bounds this caps retained transport memory.
69
+ if (clients.size >= 32) { socket.destroy(); return; }
70
+ const peer = new ControlJsonl(control, text => {
71
+ if (socket.writableLength + Buffer.byteLength(text) > CONTROL_SCHEMA.limits.outputBytes) socket.destroy(); else socket.write(text);
72
+ }, () => socket.destroy());
73
+ clients.set(socket, peer);
74
+ socket.on("data", chunk => peer.feed(typeof chunk === "string" ? Buffer.from(chunk) : chunk));
75
+ socket.on("error", () => socket.destroy());
76
+ socket.on("close", () => { peer.close(); clients.delete(socket); });
77
+ });
78
+ await new Promise<void>((resolveReady, reject) => {
79
+ server.once("error", reject);
80
+ server.listen(absolute, () => { chmodSync(absolute, 0o600); resolveReady(); });
81
+ });
82
+ const owned = await lstat(absolute);
83
+ return async () => {
84
+ for (const [socket, peer] of clients) { peer.close(); socket.destroy(); }
85
+ await new Promise<void>((resolveClosed, reject) => server.close(error => error ? reject(error) : resolveClosed()));
86
+ try { const current = await lstat(absolute); if (current.ino === owned.ino && current.dev === owned.dev) await rm(absolute); }
87
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
88
+ };
89
+ }
@@ -1,6 +1,6 @@
1
1
  import { modelKey } from "../core/session-position.ts";
2
2
  import { clampThinkingLevel } from "../core/thinking.ts";
3
- import { createProvider } from "../runtime/catalog.ts";
3
+ import { createProvider, providerSessionId } from "../runtime/catalog.ts";
4
4
  import { storedIsOAuth } from "../runtime/auth.ts";
5
5
  import { outputTokenBudget, type ModelSpec } from "../runtime/model.ts";
6
6
  import { providerAccess, setProviderAccess } from "../runtime/provider-access.ts";
@@ -29,6 +29,7 @@ export async function publishModelCatalog(app: App, models: readonly ModelSpec[]
29
29
  const config = { ...app.providerConfig, spec, maxTokens: outputTokenBudget(spec), effort: clampThinkingLevel(spec, app.providerConfig.effort) };
30
30
  const credential = app.credentialOf(spec);
31
31
  const next = createProvider(config, credential.ok ? credential.key : "", retryHandler(app.io), {
32
+ sessionId: providerSessionId(app.session.config.logDir),
32
33
  effects: app.effects, oauth: !app.args.apiKey && storedIsOAuth(app.auth, spec.provider),
33
34
  });
34
35
  const needsCut = providerReplayKey(previous) !== providerReplayKey(spec);
@@ -0,0 +1,20 @@
1
+ /** User intent owns personal defaults; CLI overrides and restored/extension state only own a session. */
2
+ import { mergeSettings, type Settings } from "../core/settings.ts";
3
+ import { writeUserSettings } from "../store/resources.ts";
4
+ import { describeError } from "./render.ts";
5
+ import type { App } from "./startup.ts";
6
+
7
+ export type PreferenceOrigin = "user" | "session";
8
+
9
+ export async function commitUserSettings(app: App, patch: Settings): Promise<boolean> {
10
+ const ui = app.ui;
11
+ if (!ui) return false;
12
+ ui.settings = mergeSettings({ builtin: ui.settings, user: patch });
13
+ try { await writeUserSettings(patch); }
14
+ catch (error) {
15
+ app.io.say(app.io.palette.yellow(` The change is active in this session, but could not save settings for the next startup: ${describeError(error)}`));
16
+ return false;
17
+ }
18
+ ui.userOverrides = mergeSettings({ builtin: ui.userOverrides, user: patch });
19
+ return true;
20
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Custom endpoints and connected-provider management, reached from `/login`.
2
+ * Custom endpoints and connected-provider management, reached from `/provider`.
3
3
  *
4
4
  * Flow (magpie's add form + OpenCode's /connect, see
5
5
  * docs/tasks/feat-provider-connect.md): choose a preset or a custom endpoint
@@ -68,7 +68,7 @@ function connectedName(id: string): string {
68
68
  }
69
69
 
70
70
  /**
71
- * Entry points from `/login`. API-key and subscription sign-in stay on the
71
+ * Entry points from `/provider`. API-key and subscription sign-in stay on the
72
72
  * login picker itself (their masked editor and OAuth flows are fixed by
73
73
  * tests); the wizard owns what login never did: custom endpoints and
74
74
  * per-provider model choice.
@@ -86,7 +86,7 @@ export function startConnect(host: ConnectHost, entry: "custom" | "manage"): voi
86
86
  }
87
87
  const connected = [...new Set(app.catalog.map(model => model.provider))].filter(id => providerReady(app, id).ready > 0).sort();
88
88
  if (connected.length === 0) {
89
- wizard.push({ kind: "message", title: "No provider is connected yet", lines: ["Choose a subscription, an API-key provider or a custom endpoint from /login first."], onDone: () => host.close() });
89
+ wizard.push({ kind: "message", title: "No provider is connected yet", lines: ["Choose a subscription, an API-key provider or a custom endpoint from /provider first."], onDone: () => host.close() });
90
90
  return;
91
91
  }
92
92
  wizard.push({
@@ -100,7 +100,7 @@ export function startConnect(host: ConnectHost, entry: "custom" | "manage"): voi
100
100
  });
101
101
  }
102
102
 
103
- /** A provider configured by hand (or by an earlier /login) that is not a preset. */
103
+ /** A provider configured by hand (or by an earlier /provider) that is not a preset. */
104
104
  function customFromCatalog(app: App, id: string): ProviderPreset | undefined {
105
105
  const model = app.catalog.find(candidate => candidate.provider === id);
106
106
  if (!model) return undefined;
package/src/apps/shell.ts CHANGED
@@ -30,14 +30,14 @@ import {
30
30
  } from "../core/context-policy.ts";
31
31
  import { anchoredCount, estimateTokens, type Anchor, type TokenCounter } from "../core/tokens.ts";
32
32
  import type { RestorePoint, RewindMode } from "../runtime/loop.ts";
33
- import { createProvider, findModelByPattern, groupedModels, loadCatalog } from "../runtime/catalog.ts";
33
+ import { createProvider, providerSessionId, findModelByPattern, groupedModels, loadCatalog } from "../runtime/catalog.ts";
34
34
  import { outputTokenBudget, type Effort, type ModelSpec } from "../runtime/model.ts";
35
35
  import { scopedModels } from "../runtime/model-scope.ts";
36
36
  import { equivalentCost, reportedCost } from "../runtime/usage.ts";
37
37
  import type { ProviderConfig } from "../runtime/provider.ts";
38
38
  import { describeMissingCredential, envLabel, ensureFreshCredential, storedIsOAuth } from "../runtime/auth.ts";
39
39
  import {
40
- SUBSCRIPTION_NOTICE, SUBSCRIPTION_OPTIONS, isSubscriptionLogin, subscriptionById,
40
+ SUBSCRIPTION_NOTICE, SUBSCRIPTION_OPTIONS, subscriptionById, loginGrok,
41
41
  loginGitHubCopilot, generatePKCE, generateState, anthropicAuthorizeUrl, openaiCodexAuthorizeUrl,
42
42
  startCallbackServer, exchangeAnthropicCode, exchangeOpenAICode, parseAuthorizationInput,
43
43
  ANTHROPIC_CALLBACK_PORT, ANTHROPIC_CALLBACK_PATH, ANTHROPIC_REDIRECT_URI,
@@ -58,18 +58,20 @@ 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";
72
73
  import { toJsonEvent } from "../core/json-event.ts";
74
+ import { sessionControl } from "../runtime/session-control.ts";
73
75
  import { COMMANDS, parseCommand, type Command } from "./commands.ts";
74
76
  import { expandInput } from "./input.ts";
75
77
  import {
@@ -84,7 +86,7 @@ import type { TurnSurface, Ui } from "./ui/surface.ts";
84
86
  * One line. Listing all ~40 commands here wrapped into a two-line wall that
85
87
  * nobody reads; typing `/` already opens the searchable command menu.
86
88
  */
87
- const HINT = `type a prompt · / for ${COMMANDS.length} commands · /login connects a provider · /model switches · /help`;
89
+ const HINT = `type a prompt · / for ${COMMANDS.length} commands · /provider connects a provider · /model switches · /help`;
88
90
  const OAUTH_PASTE = "\0oauth";
89
91
 
90
92
  const initializedExtensions = new WeakSet<ExtensionRegistry>();
@@ -143,9 +145,9 @@ async function bindExtensions(app: App, tui?: Ui): Promise<void> {
143
145
  },
144
146
  setModel: async model => {
145
147
  if (typeof model !== "object" || !model || !("provider" in model) || !("id" in model)) return false;
146
- await switchByPattern(app, String(model.provider) + "/" + String(model.id)); return app.providerConfig.spec.id === model.id && app.providerConfig.spec.provider === model.provider;
148
+ await switchByPattern(app, String(model.provider) + "/" + String(model.id), "session"); return app.providerConfig.spec.id === model.id && app.providerConfig.spec.provider === model.provider;
147
149
  },
148
- setThinking: async level => { await applyThinkingCommand(app, level, tui ?? null); },
150
+ setThinking: async level => { await applyThinkingCommand(app, level, tui ?? null, "session"); },
149
151
  exec: (command, args, options) => app.session.runExtensionCommand(command, args, options),
150
152
  });
151
153
  if (!initializedExtensions.has(registry)) {
@@ -344,7 +346,7 @@ async function runLines(app: App): Promise<void> {
344
346
  await bindExtensions(app);
345
347
  const C = app.io.palette;
346
348
  if (app.ui?.settings.quietStartup !== true || app.args.verbose) app.io.say(C.dim(HINT));
347
- /** Set by `/login <provider>`; the next line typed is the key, not a prompt. */
349
+ /** Set by `/provider <provider>`; the next line typed is the key, not a prompt. */
348
350
  let awaitingKeyFor: string | null = null;
349
351
  for await (const line of console) {
350
352
  const text = line.trim();
@@ -438,7 +440,11 @@ async function execute(app: App, command: Command, tui: Ui | null): Promise<Outc
438
440
  printUsage(app);
439
441
  return "continue";
440
442
  case "state":
441
- printState(app);
443
+ await printState(app);
444
+ return "continue";
445
+ case "control":
446
+ if (tui) await tui.openControl();
447
+ else app.io.say(JSON.stringify(await sessionControl(app.session).snapshot(), null, 2));
442
448
  return "continue";
443
449
  case "context":
444
450
  printContext(app);
@@ -514,8 +520,8 @@ async function execute(app: App, command: Command, tui: Ui | null): Promise<Outc
514
520
  if (command.provider === undefined) {
515
521
  if (tui) { tui.openLogin(); return "continue"; }
516
522
  const C = app.io.palette;
517
- const providers = [...new Set(app.catalog.map((m) => m.provider))];
518
- app.io.say(C.bold("\nlogin"));
523
+ const providers = [...new Set(app.catalog.map((m) => m.provider))].filter(provider => !subscriptionById(provider));
524
+ app.io.say(C.bold("\nproviders"));
519
525
  app.io.say(C.bold(" API keys"));
520
526
  for (const p of providers) {
521
527
  app.io.say(` ${p.padEnd(12)}${C.dim(envLabel(p))}`);
@@ -524,13 +530,15 @@ async function execute(app: App, command: Command, tui: Ui | null): Promise<Outc
524
530
  for (const s of SUBSCRIPTION_OPTIONS) {
525
531
  app.io.say(` ${s.id.padEnd(16)}${C.dim(s.label)}`);
526
532
  }
527
- app.io.say(C.dim("\n /login <provider> then paste the key. Empty line cancels."));
528
- app.io.say(C.dim(" /login claude-pro|codex|github-copilot starts a subscription login.\n"));
533
+ app.io.say(C.dim("\n /provider <provider> then paste the key. Empty line cancels."));
534
+ app.io.say(C.dim(` /provider ${SUBSCRIPTION_OPTIONS.map(option => option.id).join("|")} starts an account login.\n`));
529
535
  return "continue";
530
536
  }
531
- if (isSubscriptionLogin(command.provider)) {
532
- return startSubscriptionLogin(app, command.provider, tui);
537
+ const subscription = subscriptionById(command.provider);
538
+ if (subscription) {
539
+ return startSubscriptionLogin(app, subscription.id, tui);
533
540
  }
541
+ if (!app.catalog.some(model => model.provider === command.provider)) { app.io.say(app.io.palette.red(` unknown provider: ${command.provider}. Use /provider to choose one.`)); return "continue"; }
534
542
  if (tui) {
535
543
  const provider = command.provider;
536
544
  const key = await tui.askSecret(`API key · ${provider}`, "Key", "paste the key · $VAR reads the environment · !cmd runs a command · enter save · esc cancel");
@@ -703,12 +711,21 @@ function printUsage(app: App): void {
703
711
  app.io.say("");
704
712
  }
705
713
 
706
- function printState(app: App): void {
714
+ async function printState(app: App): Promise<void> {
707
715
  const C = app.io.palette;
708
716
  const s = app.session.state;
709
717
  app.io.say(C.bold("\nstate"));
710
718
  app.io.say(` node ${s.node} (depth ${s.depth})`);
711
719
  app.io.say(` events in node ${s.events.length}`);
720
+ const request = await app.session.requestState();
721
+ if (request) {
722
+ app.io.say(` request ${request.request.event} · ${request.status}`);
723
+ app.io.say(` execution ${request.execution?.reason ?? "in progress or unknown"}`);
724
+ app.io.say(` command evidence ${request.commands.filter(command => command.status === "pass").length} current passes / ${request.commands.length} receipts`);
725
+ app.io.say(` observations ${request.progress.observations.length} new; artifact changes ${request.progress.artifact_changes.length}; acceptance advances ${request.progress.acceptance_advances.length}`);
726
+ app.io.say(` diagnostics ${request.progress.new_diagnostics.length} new; unchanged retries ${request.progress.unchanged_retries.length}; unknown ${request.progress.unknown_diagnostics.length}`);
727
+ if (!request.acceptance) app.io.say(" task verification no declared acceptance contract; command success alone does not verify the task");
728
+ }
712
729
  if (s.handoff) {
713
730
  const h = s.handoff;
714
731
  app.io.say(` completed ${h.state.completed.length}`);
@@ -806,7 +823,8 @@ async function commitSetting(app: App, field: string, value: string, tui: Ui | n
806
823
  }
807
824
  if (key === "thinking") {
808
825
  const effort = asEffort(value);
809
- if (!effort || !await applyThinking(app, effort, tui)) return;
826
+ if (effort) await applyThinking(app, effort, tui);
827
+ return;
810
828
  }
811
829
  const parsed = settingPatch(key === "status" ? "statusBar" : field, value);
812
830
  if (parsed.diagnostics.length) { app.io.say(C.yellow(parsed.diagnostics.join("\n"))); return; }
@@ -817,12 +835,12 @@ async function commitSetting(app: App, field: string, value: string, tui: Ui | n
817
835
  app.ui.theme = theme;
818
836
  app.ui.style = colorEnabled() ? createStyle(theme.colors) : IDENTITY;
819
837
  }
820
- await writeUserSettings(parsed.settings);
821
- app.ui.settings = mergeSettings({ builtin: app.ui.settings, user: parsed.settings });
822
- app.ui.userOverrides = mergeSettings({ builtin: app.ui.userOverrides, user: parsed.settings });
823
- if (key === "enableskillcommands") { await reloadAppResources(app); await tui?.refreshResources(); }
824
- if (tui) tui.toast(`${field} · ${value}`, "success");
825
- else app.io.say(C.dim(` ${field} ${value}`));
838
+ const saved = await commitUserSettings(app, parsed.settings);
839
+ if (saved && key === "enableskillcommands") { await reloadAppResources(app); await tui?.refreshResources(); }
840
+ if (saved) {
841
+ if (tui) tui.toast(`${field} · ${value}`, "success");
842
+ else app.io.say(C.dim(` ${field} ${value}`));
843
+ }
826
844
  tui?.applyConfiguration();
827
845
  if (key === "theme") tui?.followTheme();
828
846
  tui?.redraw();
@@ -840,7 +858,7 @@ function effortSupported(spec: ModelSpec, level: Effort): boolean {
840
858
  * must not cut: a cut drops the prefix cache, and thinking blocks stay
841
859
  * valid because the model did not change.
842
860
  */
843
- async function applyThinking(app: App, level: Effort, tui: Ui | null): Promise<boolean> {
861
+ async function applyThinking(app: App, level: Effort, tui: Ui | null, origin: PreferenceOrigin = "user"): Promise<boolean> {
844
862
  const C = app.io.palette;
845
863
  if (!effortSupported(app.providerConfig.spec, level)) {
846
864
  app.io.say(
@@ -858,19 +876,20 @@ async function applyThinking(app: App, level: Effort, tui: Ui | null): Promise<b
858
876
  if (app.ui !== undefined) {
859
877
  app.ui.settings = { ...app.ui.settings, thinking: level };
860
878
  }
879
+ if (origin === "user") await commitUserSettings(app, thinkingPreferencePatch(app.providerConfig.spec, level));
861
880
  app.io.say(C.dim(` thinking ${level}`));
862
881
  tui?.redraw();
863
882
  return true;
864
883
  }
865
884
 
866
- async function applyThinkingCommand(app: App, level: string | undefined, tui: Ui | null): Promise<Outcome> {
885
+ async function applyThinkingCommand(app: App, level: string | undefined, tui: Ui | null, origin: PreferenceOrigin = "user"): Promise<Outcome> {
867
886
  if (level !== undefined) {
868
887
  const effort = asEffort(level);
869
888
  if (effort === undefined) {
870
889
  app.io.say(app.io.palette.yellow(" thinking must be " + THINKING_LEVELS.join("|")));
871
890
  return "continue";
872
891
  }
873
- await applyThinking(app, effort, tui);
892
+ await applyThinking(app, effort, tui, origin);
874
893
  return "continue";
875
894
  }
876
895
  if (tui) {
@@ -1244,7 +1263,7 @@ function providerOAuthOpts(app: App, spec: ModelSpec): { oauth: true } | undefin
1244
1263
  }
1245
1264
 
1246
1265
  function makeProvider(app: App, config: ProviderConfig, key: string) {
1247
- return createProvider(config, key, retryHandler(app.io), { ...providerOAuthOpts(app, config.spec), effects: app.effects });
1266
+ return createProvider(config, key, retryHandler(app.io), { sessionId: providerSessionId(app.session.config.logDir), ...providerOAuthOpts(app, config.spec), effects: app.effects });
1248
1267
  }
1249
1268
 
1250
1269
  interface PendingOAuth {
@@ -1361,19 +1380,20 @@ async function startSubscriptionLogin(app: App, id: string, tui: Ui | null): Pro
1361
1380
  await cancelPendingOAuth(app);
1362
1381
  const effects = tui?.oauthEffects ?? loginEffects;
1363
1382
  app.io.say(C.yellow(` ${SUBSCRIPTION_NOTICE}`));
1364
- if (sub.id === "github-copilot") {
1383
+ if (sub.id === "github-copilot" || sub.id === "grok") {
1365
1384
  const pending: { abort: AbortController; tui: Ui | null; close?: () => void } = { abort: new AbortController(), tui };
1366
1385
  pendingDevices.set(app, pending);
1367
1386
  const work = async () => { try {
1368
- const credential = await effects.device({
1387
+ const credential = await (sub.id === "grok" ? loginGrok : effects.device)({
1369
1388
  notify: (message) => {
1370
1389
  if (pendingDevices.get(app) !== pending) return;
1371
- if (tui) { pending.close?.(); pending.close = tui.waiting("GitHub Copilot", message.split("\n"), () => { void cancelPendingOAuth(app); }); }
1390
+ if (tui) { pending.close?.(); pending.close = tui.waiting(sub.label, message.split("\n"), () => { void cancelPendingOAuth(app); }); }
1372
1391
  else app.io.say(` ${message}`);
1373
1392
  },
1374
1393
  prompt: async () => "",
1375
1394
  fetch: effects.fetch,
1376
1395
  signal: pending.abort.signal,
1396
+ openBrowser: effects.openBrowser,
1377
1397
  });
1378
1398
  if (pending.abort.signal.aborted || pendingDevices.get(app) !== pending) return;
1379
1399
  await storeOAuth(app, sub.provider, credential);
@@ -1387,7 +1407,7 @@ async function startSubscriptionLogin(app: App, id: string, tui: Ui | null): Pro
1387
1407
  pending.close?.();
1388
1408
  }
1389
1409
  } };
1390
- if (tui) { pending.close = tui.waiting("GitHub Copilot", ["Requesting a device code…"], () => { void cancelPendingOAuth(app); }); tui.runExtensionAction(work); return "continue"; }
1410
+ if (tui) { pending.close = tui.waiting(sub.label, ["Requesting a device code…"], () => { void cancelPendingOAuth(app); }); tui.runExtensionAction(work); return "continue"; }
1391
1411
  await work();
1392
1412
  return "continue";
1393
1413
  }
@@ -1469,7 +1489,7 @@ async function adoptCredential(app: App, ready: readonly ModelSpec[]): Promise<v
1469
1489
  const got = app.credentialOf(spec);
1470
1490
  if (!got.ok) return;
1471
1491
  if (app.credential.ok || app.session.state.handoff !== null || app.session.state.events.some((e) => e.t === "assistant")) {
1472
- await switchToModel(app, spec);
1492
+ await switchToModel(app, spec, undefined, "session");
1473
1493
  return;
1474
1494
  }
1475
1495
  const nextConfig: ProviderConfig = {
@@ -1494,7 +1514,7 @@ async function adoptCredential(app: App, ready: readonly ModelSpec[]): Promise<v
1494
1514
  app.io.say(C.green(` now ${spec.name}`) + C.dim(` ${spec.id}`));
1495
1515
  }
1496
1516
 
1497
- async function switchByPattern(app: App, pattern: string): Promise<void> {
1517
+ async function switchByPattern(app: App, pattern: string, origin: PreferenceOrigin = "user"): Promise<void> {
1498
1518
  const C = app.io.palette;
1499
1519
  const found = scopedModels([pattern], app.catalog, true), selected = found.entries[0];
1500
1520
  if (!selected) {
@@ -1502,24 +1522,25 @@ async function switchByPattern(app: App, pattern: string): Promise<void> {
1502
1522
  app.io.say(C.red(` ${diagnostic.ok ? found.diagnostics.join("; ") : diagnostic.reason}`));
1503
1523
  return;
1504
1524
  }
1505
- await switchToModel(app, selected.model, selected.thinking);
1525
+ await switchToModel(app, selected.model, selected.thinking, origin);
1506
1526
  }
1507
1527
 
1508
- export async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Promise<void> {
1528
+ export async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort, origin: PreferenceOrigin = "user"): Promise<void> {
1509
1529
  const C = app.io.palette;
1510
- if (spec.id === app.providerConfig.spec.id && spec.provider === app.providerConfig.spec.provider) {
1511
- if (thinking) await applyThinking(app, clampThinkingLevel(spec, thinking), null);
1512
- app.io.say(C.dim(` already on ${spec.id}`));
1513
- return;
1514
- }
1515
1530
  const got = app.credentialOf(spec);
1516
1531
  if (!got.ok) {
1517
1532
  app.io.say(C.red(`\n ${got.reason}\n`));
1518
- if (!got.reason.includes("/login")) {
1533
+ if (!got.reason.includes("/provider")) {
1519
1534
  app.io.say(C.dim(` ${describeMissingCredential(spec.provider)}\n`));
1520
1535
  }
1521
1536
  return;
1522
1537
  }
1538
+ if (spec.id === app.providerConfig.spec.id && spec.provider === app.providerConfig.spec.provider) {
1539
+ if (thinking) await applyThinking(app, clampThinkingLevel(spec, thinking), null, "session");
1540
+ if (origin === "user") await rememberModelSelection(app, spec, thinking);
1541
+ app.io.say(C.dim(` already on ${spec.id}`));
1542
+ return;
1543
+ }
1523
1544
  const nextConfig: ProviderConfig = {
1524
1545
  spec,
1525
1546
  maxTokens: outputTokenBudget(spec),
@@ -1537,15 +1558,15 @@ export async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort
1537
1558
  app.providerConfig = nextConfig;
1538
1559
  app.credential = got;
1539
1560
  app.io.say(C.green(` now ${spec.name}`) + C.dim(` ${spec.id}`));
1540
- await rememberRecentModel(app, spec);
1561
+ if (origin === "user") await rememberModelSelection(app, spec, thinking);
1541
1562
  }
1542
1563
 
1543
- /** Recency feeds the picker's first section; a write failure only costs ordering. */
1544
- async function rememberRecentModel(app: App, spec: ModelSpec): Promise<void> {
1564
+ /** N.8 must accept the checkpoint before a user choice can own the next startup. */
1565
+ async function rememberModelSelection(app: App, spec: ModelSpec, explicitThinking: Effort | undefined): Promise<void> {
1545
1566
  if (!app.ui) return;
1546
- const recentModels = recordRecent(app.ui.settings.recentModels ?? [], modelKey(spec));
1547
- app.ui.settings = { ...app.ui.settings, recentModels };
1548
- try { await writeUserSettings({ recentModels }); } catch { /* ordering only */ }
1567
+ const effort = app.providerConfig.effort;
1568
+ const patch = modelPreferencePatch(spec, app.ui.settings.recentModels ?? [], effort);
1569
+ await commitUserSettings(app, explicitThinking === undefined ? patch : { ...patch, ...thinkingPreferencePatch(spec, effort) });
1549
1570
  }
1550
1571
 
1551
1572
  function sessionDirOf(app: App): string | undefined {
@@ -1750,6 +1771,7 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Ui, ima
1750
1771
  if (tui) tui.beginBash(prompt.slice(excluded ? 2 : 1));
1751
1772
  const result = await app.session.runUserBash(prompt.slice(excluded ? 2 : 1), excluded, signal,
1752
1773
  tui ? chunk => tui.pushBash(chunk) : undefined);
1774
+ tui?.observeEvent?.({ kind: "done", outcome: result.outcome });
1753
1775
  if (tui) { await tui.syncTranscript(); tui.commitStream(); }
1754
1776
  else app.io.say(`$ ${result.command}\n${result.content}`);
1755
1777
  return true;
@@ -1760,7 +1782,7 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Ui, ima
1760
1782
  const C = app.io.palette;
1761
1783
  app.io.say(C.yellow(" no credential yet -- this prompt was not sent."));
1762
1784
  app.io.say(C.dim(` ${app.credential.reason}`));
1763
- app.io.say(C.dim(" export an API key, or /login <provider>.\n"));
1785
+ app.io.say(C.dim(" export an API key, or /provider <provider>.\n"));
1764
1786
  tui?.restoreDraft(prompt, images);
1765
1787
  return false;
1766
1788
  }
@@ -1855,7 +1877,8 @@ export async function runTurn(
1855
1877
  }
1856
1878
  await registry?.emit("agent_start");
1857
1879
  await registry?.emit("turn_start", { turnIndex: 0 });
1858
- try { for await (const e of skill ? app.session.sendSkill(skill, prompt, signal) : app.session.send(prompt, signal, pull)) {
1880
+ try { for await (const e of skill ? app.session.sendSkill(skill, prompt, signal) : sessionControl(app.session).send(prompt, signal, pull)) {
1881
+ tui?.observeEvent?.(e);
1859
1882
  if (e.kind === "text_delta" || e.kind === "thinking_delta") await registry?.emit("message_update", { assistantMessageEvent: { type: e.kind, delta: e.text } });
1860
1883
  if (e.kind === "assistant_text") await registry?.emit("message_end", { message: { role: "assistant", content: [{ type: "text", text: e.text }] } });
1861
1884
  if (e.kind === "done") await registry?.emit("turn_end", { reason: e.reason });
@@ -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();