scalix 1.5.0 → 1.5.2

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/dist/cli.js CHANGED
@@ -15,19 +15,22 @@ import SelectInput from 'ink-select-input';
15
15
  import { createAgent, messageContentToString, REPORTED_ERROR } from './agent.js';
16
16
  import { defaultTools } from './tools.js';
17
17
  import { extraTools } from './tools-extra.js';
18
+ import { memoryTools } from './tools-memory.js';
18
19
  import { webTools } from './tools-web.js';
19
20
  import { advancedTools } from './tools-advanced.js';
20
21
  import { visionTools } from './tools-vision.js';
21
22
  import { voiceTools } from './tools-voice.js';
22
23
  import { browserTools, closeBrowser } from './tools-browser.js';
23
- import { loadMcpServices } from './mcp.js';
24
+ import { startMcp, validateMcpUrl } from './mcp.js';
24
25
  import { renderMarkdown } from './markdown.js';
25
26
  import { costTracker } from './cost-tracker.js';
26
27
  import { saveSession, listSessions, loadSession, generateSessionId, saveCrashState, loadCrashState, clearCrashState, forkSession } from './sessions.js';
28
+ import { saveFeedback } from './feedback.js';
27
29
  import { logger } from './logger.js';
28
- import { VERSION, PRODUCT_NAME, DEFAULT_MODELS, DEFAULT_BASE_URL, DEFAULT_INSTRUCTIONS, ENV_API_KEY, ENV_MODEL, TOOL_DEFAULTS, HEADLESS_DEFAULTS, GIT_BOT_IDENTITY, BYOK_DEFAULT_CONTEXT_TOKENS, USER_CONFIG_DIR, fetchModelSpecs } from './constants.js';
30
+ import { VERSION, PRODUCT_NAME, DEFAULT_MODELS, DEFAULT_BASE_URL, DEFAULT_INSTRUCTIONS, ENV_API_KEY, ENV_MODEL, TOOL_DEFAULTS, HEADLESS_DEFAULTS, GIT_BOT_IDENTITY, BYOK_DEFAULT_CONTEXT_TOKENS, BYOK_DEFAULT_MAX_OUTPUT_TOKENS, USER_CONFIG_DIR, OPENROUTER_BASE_URL, getModelContextWindow, fetchModelSpecs, normalizeModelId, registerUserModels, hasExplicitModelSpec } from './constants.js';
31
+ import { buildAuthUrl, exchangeCodeForKey, generatePkcePair, openInBrowser, startLoopbackCallback } from './openrouter-oauth.js';
29
32
  import { checkForUpdate, getUpdateBanner, performUpdate } from './update-checker.js';
30
- import { loadConfig, saveConfig, isConfigured, resolveActiveProvider, DEFAULT_LIMITS, THINKING_MODES } from './settings.js';
33
+ import { loadConfig, loadGlobalConfig, saveConfig, isConfigured, resolveActiveProvider, DEFAULT_LIMITS, THINKING_MODES } from './settings.js';
31
34
  import { fetchByokModels } from './providers/openai.js';
32
35
  import { getModeDescription, PERMISSION_MODES, setSandboxRoot, checkScopedPermission, getScopedPermissionRules } from './sandbox.js';
33
36
  import { buildProjectIndex, indexToContext } from './project-index.js';
@@ -47,7 +50,8 @@ import { createWorktree, listWorktrees, removeWorktree, cleanupStaleWorktrees }
47
50
  import { sendNotification, getTerminalType } from './terminal-notify.js';
48
51
  import { stringWidth, truncateText } from './text-utils.js';
49
52
  import { isWorkspaceTrusted, trustWorkspace, hasProjectExecSurface } from './workspace-trust.js';
50
- import { fetchMe, fetchCloudTools, groupCloudTools, CONSOLE_KEYS_URL } from './cloud.js';
53
+ import { setLocalOriginApprover } from './browser-access.js';
54
+ import { fetchMe, fetchCloudTools, groupCloudTools, filterCatalogByScopes, cloudKeyRemedy, CONSOLE_KEYS_URL } from './cloud.js';
51
55
  logger.setLevel('silent');
52
56
  // Promisified async child_process helpers (keep the TUI responsive vs execSync).
53
57
  const execFileAsync = promisify(execFile);
@@ -86,7 +90,7 @@ setSandboxRoot(process.cwd());
86
90
  // Suppress noisy React 19 development-mode reconciler warnings that leak to stderr
87
91
  const _origConsoleError = console.error;
88
92
  console.error = (...args) => {
89
- if (typeof args[0] === 'string' && args[0].startsWith('Warning:') && args[0].includes('same key'))
93
+ if (args.length === 1 && typeof args[0] === 'string' && args[0].includes('same key'))
90
94
  return;
91
95
  _origConsoleError(...args);
92
96
  };
@@ -95,14 +99,12 @@ console.error = (...args) => {
95
99
  // sanitizeInput) and buffer the pasted content into the input value instead of
96
100
  // treating each embedded newline as a separate Enter (which caused many submits).
97
101
  process.stdout.write('\x1b[?2004h');
98
- // User-registered external MCP servers (mcp-services.json). These are
99
- // decorative config entries only — they are filtered out of API requests by
100
- // transformToolsForApi. Scalix Cloud platform tools do NOT use this path: they
101
- // are fetched from the account's catalog and registered as first-class
102
- // function tools via agent.refreshCloudTools() (see connectCloud below).
103
- const mcpTools = loadMcpServices();
102
+ // User-registered external MCP servers (mcp-services.json) are not in this
103
+ // list: they are connected once the agent exists and registered from the live
104
+ // connection by startMcp(), the same way Scalix Cloud platform tools arrive via
105
+ // agent.refreshCloudTools() (see connectCloud below).
104
106
  let pluginTools = [];
105
- const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools];
107
+ const allTools = [...defaultTools, ...extraTools, ...memoryTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools];
106
108
  // ─── Confirmation bridge (event-driven) ───
107
109
  import { EventEmitter } from 'node:events';
108
110
  const confirmEmitter = new EventEmitter();
@@ -134,6 +136,11 @@ const confirmToolCall = async (toolName, args) => {
134
136
  confirmEmitter.emit('pending');
135
137
  });
136
138
  };
139
+ // The browser tools refuse localhost and the LAN until the user says otherwise.
140
+ // In the TUI that question goes through the same confirmation prompt as any
141
+ // other tool call, so "Always allow" covers the rest of the session; a lasting,
142
+ // written-down approval is the IDE's job (it can show and revoke the list).
143
+ setLocalOriginApprover(async ({ origin, url }) => (await confirmToolCall('browser_open_local_address', { origin, url })) ? 'once' : 'deny');
137
144
  // ─── Agent (deferred until config is ready) ───
138
145
  let baseInstructions = DEFAULT_INSTRUCTIONS;
139
146
  let agent = null;
@@ -168,34 +175,36 @@ async function connectCloud(targetAgent) {
168
175
  // unavailable" banner on a session that never asked for Scalix Cloud.
169
176
  // refreshCloudTools() already refuses the catalog fetch for these providers;
170
177
  // the identity probe has to refuse it too.
171
- if (!targetAgent.supportsPlatformTools()) {
172
- cloudConnection = { connected: false, identity: null, toolCount: 0, reason: 'byok' };
173
- return cloudConnection;
174
- }
175
- if (!targetAgent.getApiKey()) {
176
- cloudConnection = { connected: false, identity: null, toolCount: 0, reason: 'no-key', error: 'no API key configured' };
178
+ const cloudKey = targetAgent.getCloudApiKey();
179
+ if (!cloudKey) {
180
+ cloudConnection = targetAgent.getProvider() === 'scalix'
181
+ ? { connected: false, identity: null, toolCount: 0, reason: 'no-key', error: 'no API key configured' }
182
+ : { connected: false, identity: null, toolCount: 0, reason: 'byok' };
177
183
  return cloudConnection;
178
184
  }
179
185
  try {
180
- const identity = await fetchMe(targetAgent.getBaseUrl() || DEFAULT_BASE_URL, targetAgent.getApiKey());
181
- const status = await targetAgent.refreshCloudTools();
186
+ const identity = await fetchMe(targetAgent.getCloudBaseUrl() || DEFAULT_BASE_URL, cloudKey);
187
+ // Hand the identity over so the catalog is scope-filtered without a
188
+ // second /v1/me round-trip.
189
+ const status = await targetAgent.refreshCloudTools(identity);
182
190
  if (status.state === 'connected') {
183
- cloudConnection = { connected: true, identity, toolCount: status.toolCount };
191
+ cloudConnection = { connected: true, identity, toolCount: status.toolCount, totalCount: status.totalCount, note: status.note };
184
192
  }
185
193
  else {
186
- cloudConnection = { connected: false, identity, toolCount: 0, reason: 'failed', error: status.error };
194
+ cloudConnection = { connected: false, identity, toolCount: 0, reason: 'failed', error: status.error, remedy: status.remedy };
187
195
  }
188
196
  }
189
197
  catch (err) {
190
- // /v1/me failed. Run the catalog fetch anyway: on failure it clears any
191
- // stale platform tools from a previous connect; in the rare case it
192
- // succeeds, the tools genuinely work and we say so (identity unavailable).
193
- const status = await targetAgent.refreshCloudTools().catch(() => null);
198
+ // /v1/me failed. Run the catalog fetch anyway (null = don't re-probe
199
+ // /v1/me): on failure it clears any stale platform tools from a previous
200
+ // connect; in the rare case it succeeds, the tools genuinely work and we
201
+ // say so (identity unavailable, catalog unfiltered).
202
+ const status = await targetAgent.refreshCloudTools(null).catch(() => null);
194
203
  if (status?.state === 'connected') {
195
- cloudConnection = { connected: true, identity: null, toolCount: status.toolCount, error: err?.message || String(err) };
204
+ cloudConnection = { connected: true, identity: null, toolCount: status.toolCount, totalCount: status.totalCount, error: err?.message || String(err) };
196
205
  }
197
206
  else {
198
- cloudConnection = { connected: false, identity: null, toolCount: 0, reason: 'failed', error: err?.message || String(err) };
207
+ cloudConnection = { connected: false, identity: null, toolCount: 0, reason: 'failed', error: err?.message || String(err), remedy: status?.remedy ?? cloudKeyRemedy(err) };
199
208
  }
200
209
  }
201
210
  return cloudConnection;
@@ -263,11 +272,38 @@ async function buildEnhancedInstructions() {
263
272
  }
264
273
  return instructions;
265
274
  }
275
+ /** The config the current agent was initialized from — lets command handlers
276
+ * (e.g. the /model picker's BYOK discovery) resolve provider/endpoint without
277
+ * threading the config through every component. */
278
+ let activeAgentConfig = null;
279
+ /** Under BYOK a model without a registered spec sends no max_tokens, and
280
+ * gateways such as OpenRouter then 400 every request — a "successful" switch
281
+ * that can never answer. Ensure any model activated from the TUI has a spec:
282
+ * the catalog-reported window when known, the standard fallbacks otherwise.
283
+ * Explicit user specs are never overwritten. */
284
+ function ensureByokSpec(config, model, contextWindow) {
285
+ if (!config || resolveActiveProvider(config).provider !== 'openai')
286
+ return;
287
+ if (hasExplicitModelSpec(model))
288
+ return;
289
+ const spec = {
290
+ contextWindow: contextWindow && contextWindow > 0 ? contextWindow : BYOK_DEFAULT_CONTEXT_TOKENS,
291
+ maxOutputTokens: BYOK_DEFAULT_MAX_OUTPUT_TOKENS,
292
+ };
293
+ registerUserModels({ [model]: spec });
294
+ loadGlobalConfig().then((cfg) => {
295
+ cfg.customModels = { ...cfg.customModels, [model]: spec };
296
+ return saveConfig(cfg);
297
+ }).catch((err) => { logger.warn('Failed to persist model spec', { error: err.message }); });
298
+ }
266
299
  function initAgent(config) {
267
300
  const envModels = process.env[ENV_MODEL]
268
- ? process.env[ENV_MODEL].split(',').map((m) => m.trim()).filter(Boolean)
301
+ ? process.env[ENV_MODEL].split(',').map((m) => m.trim()).filter(Boolean).map(normalizeModelId)
269
302
  : null;
270
- const models = envModels || config.models;
303
+ // length check, not truthiness: SCALIX_MODEL="," parses to [] which is
304
+ // truthy — the other entry points fall back to config models, so must we.
305
+ const models = envModels && envModels.length > 0 ? envModels : config.models;
306
+ activeAgentConfig = config;
271
307
  if (models.length === 0) {
272
308
  throw new Error('No models configured. Please add at least one model in setup.');
273
309
  }
@@ -277,6 +313,8 @@ function initAgent(config) {
277
313
  apiKey: active.apiKey,
278
314
  baseUrl: active.baseUrl,
279
315
  provider: active.provider,
316
+ cloudApiKey: active.cloudApiKey,
317
+ cloudBaseUrl: active.cloudBaseUrl,
280
318
  model: models.length === 1 ? models[0] : models,
281
319
  instructions: baseInstructions,
282
320
  tools: allTools,
@@ -529,6 +567,21 @@ function formatContextSize(tokens) {
529
567
  return `${(tokens / 1_000).toFixed(tokens % 1_000 === 0 ? 0 : 1)}K`;
530
568
  return String(tokens);
531
569
  }
570
+ /**
571
+ * Paste box for the OpenRouter manual fallback: OpenRouter shows a single-use,
572
+ * 10-minute authorization code when the browser can't reach our loopback
573
+ * server (SSH/headless) or the user prefers another device.
574
+ */
575
+ function ManualCodeInput({ onSubmit }) {
576
+ const [code, setCode] = useState('');
577
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "Or paste the single-use code OpenRouter shows after you approve:" }), _jsxs(Box, { children: [_jsxs(Text, { color: "#06B6D4", bold: true, children: ['\u276F', " "] }), _jsx(LongTextInput, { value: code, onChange: setCode, onSubmit: (v) => {
578
+ const trimmed = v.trim();
579
+ if (trimmed) {
580
+ onSubmit(trimmed);
581
+ setCode('');
582
+ }
583
+ }, placeholder: "paste code..." })] })] }));
584
+ }
532
585
  /**
533
586
  * What to pre-fill the context-window box with.
534
587
  *
@@ -546,11 +599,17 @@ function defaultContextTokens(p, existing) {
546
599
  }
547
600
  function SetupScreen({ onComplete, existingConfig }) {
548
601
  const [provider, setProvider] = useState(existingConfig?.provider === 'openai' ? 'openai' : 'scalix');
602
+ // BYOK sub-flavor: a plain OpenAI-compatible endpoint (manual key + URL) or
603
+ // the OpenRouter OAuth connect. Both persist identically (providers.openai);
604
+ // the flavor only decides which steps run between provider and models.
605
+ const [byokFlavor, setByokFlavor] = useState('endpoint');
549
606
  // Scalix = zero-config default; BYOK (OpenAI-compatible) skips the
550
607
  // Scalix-only cloud step and enters models manually.
551
- const STEPS = provider === 'openai'
552
- ? ['provider', 'api-key', 'base-url', 'models', 'context-window']
553
- : ['provider', 'api-key', 'base-url', 'cloud', 'models', 'context-window'];
608
+ const STEPS = provider !== 'openai'
609
+ ? ['provider', 'api-key', 'base-url', 'cloud', 'models', 'context-window']
610
+ : byokFlavor === 'openrouter'
611
+ ? ['provider', 'openrouter-auth', 'models', 'context-window']
612
+ : ['provider', 'api-key', 'base-url', 'models', 'context-window'];
554
613
  const [step, setStep] = useState('provider');
555
614
  const [apiKey, setApiKey] = useState(existingConfig?.apiKey || process.env[ENV_API_KEY] || '');
556
615
  const [baseUrl, setBaseUrl] = useState(existingConfig?.baseUrl || DEFAULT_BASE_URL);
@@ -574,6 +633,11 @@ function SetupScreen({ onComplete, existingConfig }) {
574
633
  * borrowed default. */
575
634
  const [byokContextWindows, setByokContextWindows] = useState({});
576
635
  const [cloudCheck, setCloudCheck] = useState({ phase: 'checking' });
636
+ // ── OpenRouter connect (OAuth PKCE) ─────────────────────────────────────
637
+ // The PKCE verifier must outlive the effect that created it when the user
638
+ // falls back to manual code paste, so it lives in a ref.
639
+ const orPairRef = useRef(null);
640
+ const [orAuth, setOrAuth] = useState({ phase: 'starting' });
577
641
  const { exit } = useApp();
578
642
  // Validate the key against Scalix Cloud when the cloud step is entered:
579
643
  // /v1/me for identity/plan, then the tool catalog for the unlocked count.
@@ -585,7 +649,10 @@ function SetupScreen({ onComplete, existingConfig }) {
585
649
  (async () => {
586
650
  const identity = await fetchMe(baseUrl, apiKey);
587
651
  const catalog = await fetchCloudTools(baseUrl, apiKey, process.env.SCALIX_PROJECT);
588
- return { identity, toolCount: catalog.tools.length };
652
+ // Count what will actually register for this key, not the raw catalog —
653
+ // the wizard's "N tools unlocked" must match the session that follows.
654
+ const visible = filterCatalogByScopes(catalog.tools, identity.permissions.scopes);
655
+ return { identity, toolCount: visible.length };
589
656
  })().then((result) => {
590
657
  if (cancelled)
591
658
  return;
@@ -594,12 +661,86 @@ function SetupScreen({ onComplete, existingConfig }) {
594
661
  }).catch((err) => {
595
662
  if (cancelled)
596
663
  return;
597
- setCloudCheck({ phase: 'fail', error: err?.message || String(err) });
664
+ setCloudCheck({ phase: 'fail', error: err?.message || String(err), remedy: cloudKeyRemedy(err) });
598
665
  });
599
666
  return () => { cancelled = true; };
600
667
  // apiKey/baseUrl are intentionally not deps: they are frozen once the user
601
668
  // advances past base-url, and the step transition is the trigger.
602
669
  }, [step]);
670
+ // Shared tail of BOTH OpenRouter paths (browser callback and manual paste):
671
+ // persist the exchanged key, point the endpoint at OpenRouter, and rejoin
672
+ // the ordinary BYOK model-discovery flow exactly as the base-url step does.
673
+ const finishOpenRouterAuth = useCallback((key) => {
674
+ setApiKey(key);
675
+ setApiKeyError(false);
676
+ setBaseUrl(OPENROUTER_BASE_URL);
677
+ setBaseUrlError(false);
678
+ setFetchingModels(true);
679
+ fetchByokModels(OPENROUTER_BASE_URL, key).then((found) => {
680
+ setFetchedModels(found.map((m) => ({ id: m.id })));
681
+ setByokContextWindows(Object.fromEntries(found.filter((m) => m.contextWindow).map((m) => [m.id, m.contextWindow])));
682
+ }).catch((err) => { logger.warn('Failed to list endpoint models', { error: err.message }); })
683
+ .finally(() => { setFetchingModels(false); });
684
+ setStep('models');
685
+ }, []);
686
+ // The whole connect lives in one effect keyed on the step: pair → loopback
687
+ // bind → browser open → await code → exchange. Cleanup closes the server so
688
+ // a wizard abandoned mid-flow never leaks a socket or a pending promise.
689
+ useEffect(() => {
690
+ if (step !== 'openrouter-auth')
691
+ return;
692
+ let cancelled = false;
693
+ let handle = null;
694
+ setOrAuth({ phase: 'starting' });
695
+ (async () => {
696
+ const pair = generatePkcePair();
697
+ orPairRef.current = pair;
698
+ handle = await startLoopbackCallback();
699
+ if (cancelled) {
700
+ handle.close();
701
+ return;
702
+ }
703
+ const authUrl = buildAuthUrl(handle.url, pair);
704
+ const autoOpened = await openInBrowser(authUrl);
705
+ if (!cancelled)
706
+ setOrAuth({ phase: 'waiting', url: authUrl, autoOpened });
707
+ try {
708
+ const code = await handle.code;
709
+ if (cancelled)
710
+ return;
711
+ setOrAuth({ phase: 'exchanging' });
712
+ const key = await exchangeCodeForKey(code, pair.verifier);
713
+ if (!cancelled)
714
+ finishOpenRouterAuth(key);
715
+ }
716
+ catch (err) {
717
+ if (!cancelled)
718
+ setOrAuth({ phase: 'fail', error: err?.message || String(err), url: authUrl });
719
+ }
720
+ })().catch((err) => {
721
+ // Bind/spawn failure lands here — manual paste remains available.
722
+ if (!cancelled)
723
+ setOrAuth({ phase: 'fail', error: err?.message || String(err) });
724
+ });
725
+ return () => {
726
+ cancelled = true;
727
+ handle?.close();
728
+ };
729
+ }, [step, finishOpenRouterAuth]);
730
+ const submitManualOpenRouterCode = useCallback(async (raw) => {
731
+ const code = raw.trim();
732
+ const pair = orPairRef.current;
733
+ if (!pair || !code)
734
+ return;
735
+ setOrAuth({ phase: 'exchanging' });
736
+ try {
737
+ const key = await exchangeCodeForKey(code, pair.verifier);
738
+ finishOpenRouterAuth(key);
739
+ }
740
+ catch (err) {
741
+ setOrAuth({ phase: 'fail', error: err?.message || String(err) });
742
+ }
743
+ }, [finishOpenRouterAuth]);
603
744
  // The smallest window across the picked models, because compression has to
604
745
  // fit the tightest one — a session that fits the 128K model and not the 8K
605
746
  // fallback fails the moment it falls back. null when the endpoint reported
@@ -651,32 +792,45 @@ function SetupScreen({ onComplete, existingConfig }) {
651
792
  }
652
793
  }
653
794
  });
654
- return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsx(Banner, {}), _jsxs(Box, { flexDirection: "column", paddingX: 3, paddingY: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u2501'.repeat(4), " First-time Setup ", '\u2501'.repeat(4)] }) }), _jsxs(Box, { marginBottom: 1, gap: 1, children: [_jsx(Text, { color: "#20a0a0", children: STEPS.map((_, i) => i < stepNumber ? '\u25CF' : '\u25CB').join('') }), _jsxs(Text, { dimColor: true, children: ["Step ", stepNumber, " of ", totalSteps] })] }), step === 'provider' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Provider" }), _jsx(Text, { dimColor: true, children: "Where should the agent send inference?" }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
795
+ return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsx(Banner, {}), _jsxs(Box, { flexDirection: "column", paddingX: 3, paddingY: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "#06B6D4", bold: true, children: ['\u2501'.repeat(4), " First-time Setup ", '\u2501'.repeat(4)] }) }), _jsxs(Box, { marginBottom: 1, gap: 1, children: [_jsx(Text, { color: "#06B6D4", children: STEPS.map((_, i) => i < stepNumber ? '\u25CF' : '\u25CB').join('') }), _jsxs(Text, { dimColor: true, children: ["Step ", stepNumber, " of ", totalSteps] })] }), step === 'provider' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Provider" }), _jsx(Text, { dimColor: true, children: "Where should the agent send inference?" }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
655
796
  { label: 'Scalix Cloud (recommended) \u2014 api.scalix.world, platform tools included', value: 'scalix' },
656
- { label: 'OpenAI-compatible endpoint \u2014 bring your own key (OpenAI, OpenRouter, Ollama, vLLM, ...)', value: 'openai' },
797
+ { label: 'OpenRouter \u2014 connect your account in the browser (400+ models, billed to you)', value: 'openrouter' },
798
+ { label: 'OpenAI-compatible endpoint \u2014 bring your own key (OpenAI, Ollama, vLLM, ...)', value: 'openai' },
657
799
  ], onSelect: (item) => {
658
- const p = item.value === 'openai' ? 'openai' : 'scalix';
659
- setProvider(p);
660
- if (p === 'openai') {
800
+ if (item.value === 'openrouter') {
801
+ // OpenRouter IS an OpenAI-compatible endpoint under the
802
+ // hood — config plumbing is identical; only the wizard
803
+ // steps differ (key arrives via OAuth, URL is fixed).
804
+ setProvider('openai');
805
+ setByokFlavor('openrouter');
661
806
  setApiKey(existingConfig?.providers?.openai?.apiKey || '');
662
- setBaseUrl(existingConfig?.providers?.openai?.baseUrl || '');
807
+ setBaseUrl(existingConfig?.providers?.openai?.baseUrl || OPENROUTER_BASE_URL);
663
808
  }
664
809
  else {
665
- setApiKey(existingConfig?.apiKey || process.env[ENV_API_KEY] || '');
666
- setBaseUrl(existingConfig?.baseUrl || DEFAULT_BASE_URL);
810
+ const p = item.value === 'openai' ? 'openai' : 'scalix';
811
+ setProvider(p);
812
+ setByokFlavor('endpoint');
813
+ if (p === 'openai') {
814
+ setApiKey(existingConfig?.providers?.openai?.apiKey || '');
815
+ setBaseUrl(existingConfig?.providers?.openai?.baseUrl || '');
816
+ }
817
+ else {
818
+ setApiKey(existingConfig?.apiKey || process.env[ENV_API_KEY] || '');
819
+ setBaseUrl(existingConfig?.baseUrl || DEFAULT_BASE_URL);
820
+ }
667
821
  }
668
822
  // Model ids do not transfer between endpoints: keeping the
669
823
  // configured list after a switch pre-fills the next step with
670
824
  // ids the new endpoint has never heard of, and the wizard
671
825
  // finishes "successfully" onto a 404 on the first turn.
672
- const configured = (existingConfig?.provider === 'openai' ? 'openai' : 'scalix') === p;
826
+ const configured = (existingConfig?.provider === 'openai' ? 'openai' : 'scalix') === (item.value === 'scalix' ? 'scalix' : 'openai');
673
827
  setModels(configured && existingConfig?.models?.length ? [...existingConfig.models] : []);
674
828
  setModelsMode('custom');
675
829
  setFetchedModels([]);
676
830
  setByokContextWindows({});
677
- setContextInput(String(defaultContextTokens(p, existingConfig?.limits?.maxContextTokens)));
678
- setStep('api-key');
679
- } }) })] })), step === 'api-key' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "API Key" }), _jsx(Text, { dimColor: true, children: provider === 'openai' ? 'The API key for your endpoint. Press Enter to continue.' : 'Paste your Scalix API key (console.scalix.world/org/keys) and press Enter.' }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(LongTextInput, { value: apiKey, onChange: (v) => { setApiKey(v); setApiKeyError(false); }, onSubmit: (v) => {
831
+ setContextInput(String(defaultContextTokens(item.value === 'scalix' ? 'scalix' : 'openai', existingConfig?.limits?.maxContextTokens)));
832
+ setStep(item.value === 'openrouter' ? 'openrouter-auth' : item.value === 'openai' ? 'api-key' : 'api-key');
833
+ } }) })] })), step === 'api-key' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "API Key" }), _jsx(Text, { dimColor: true, children: provider === 'openai' ? 'The API key for your endpoint. Press Enter to continue.' : 'Paste your Scalix API key (console.scalix.world/org/keys) and press Enter. Use a project-scoped key — the default all-scopes project key unlocks the platform tools.' }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#06B6D4", bold: true, children: ['\u276F', " "] }), _jsx(LongTextInput, { value: apiKey, onChange: (v) => { setApiKey(v); setApiKeyError(false); }, onSubmit: (v) => {
680
834
  const cleaned = v.trim();
681
835
  if (cleaned) {
682
836
  setApiKey(cleaned);
@@ -685,7 +839,22 @@ function SetupScreen({ onComplete, existingConfig }) {
685
839
  else {
686
840
  setApiKeyError(true);
687
841
  }
688
- }, placeholder: provider === 'openai' ? 'sk-...' : 'scalix_sk_...' })] }), apiKeyError && (_jsx(Box, { marginTop: 0, paddingLeft: 2, children: _jsx(Text, { color: "yellow", dimColor: true, children: "API key is required." }) }))] })), step === 'base-url' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: provider === 'openai' ? 'Endpoint URL' : 'Base URL' }), _jsx(Text, { dimColor: true, children: provider === 'openai' ? 'Your OpenAI-compatible endpoint root, with or without /v1 (required).' : 'Scalix API endpoint (or your self-hosted gateway)' }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: baseUrl, onChange: (v) => { setBaseUrl(sanitizeInput(v)); setBaseUrlError(false); }, onSubmit: (v) => {
842
+ }, placeholder: provider === 'openai' ? 'sk-...' : 'scalix_sk_...' })] }), apiKeyError && (_jsx(Box, { marginTop: 0, paddingLeft: 2, children: _jsx(Text, { color: "yellow", dimColor: true, children: "API key is required." }) }))] })), step === 'openrouter-auth' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Connect OpenRouter" }), _jsx(Text, { dimColor: true, children: "Sign in to YOUR OpenRouter account \u2014 models are billed to you, not to Scalix." }), orAuth.phase === 'starting' && (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#06B6D4", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Starting the connection..." })] })), orAuth.phase === 'waiting' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { children: orAuth.autoOpened
843
+ ? 'Your browser should have opened. Approve access there.'
844
+ : 'Could not open a browser here — open the URL below on any device:' }), _jsx(Box, { marginTop: 1, flexDirection: "column", borderStyle: "round", borderColor: "#06B6D4", paddingX: 2, alignSelf: "flex-start", children: _jsx(Text, { color: "#06B6D4", children: orAuth.url }) }), !orAuth.autoOpened && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "The page shows a code \u2014 paste it below when it does." }) })), _jsx(ManualCodeInput, { onSubmit: (code) => { void submitManualOpenRouterCode(code); } })] })), orAuth.phase === 'exchanging' && (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#06B6D4", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Exchanging the authorization for your API key..." })] })), orAuth.phase === 'fail' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: "red", children: ['✘', " ", orAuth.error] }), orAuth.url && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "You can also open this URL manually and paste the shown code:" }) }), _jsx(Box, { marginTop: 1, flexDirection: "column", borderStyle: "round", borderColor: "#06B6D4", paddingX: 2, alignSelf: "flex-start", children: _jsx(Text, { color: "#06B6D4", children: orAuth.url }) }), _jsx(ManualCodeInput, { onSubmit: (code) => { void submitManualOpenRouterCode(code); } })] })), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
845
+ { label: '↻ Retry the browser flow', value: '__retry__' },
846
+ ...(existingConfig ? [{ label: 'Back', value: '__back__' }] : []),
847
+ ], onSelect: (item) => {
848
+ if (item.value === '__retry__') {
849
+ // Remount the effect: fresh pair, fresh loopback port.
850
+ setOrAuth({ phase: 'starting' });
851
+ setStep('provider');
852
+ setTimeout(() => setStep('openrouter-auth'), 0);
853
+ }
854
+ else {
855
+ setStep('provider');
856
+ }
857
+ } }) })] }))] })), step === 'base-url' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: provider === 'openai' ? 'Endpoint URL' : 'Base URL' }), _jsx(Text, { dimColor: true, children: provider === 'openai' ? 'Your OpenAI-compatible endpoint root, with or without /v1 (required).' : 'Scalix API endpoint (or your self-hosted gateway)' }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#06B6D4", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: baseUrl, onChange: (v) => { setBaseUrl(sanitizeInput(v)); setBaseUrlError(false); }, onSubmit: (v) => {
689
858
  const typed = sanitizeInput(v).trim();
690
859
  if (provider === 'openai') {
691
860
  // BYOK has no default third-party URL \u2014 the endpoint is required.
@@ -726,7 +895,7 @@ function SetupScreen({ onComplete, existingConfig }) {
726
895
  setFetchingModels(false);
727
896
  });
728
897
  setStep('cloud');
729
- }, placeholder: provider === 'openai' ? 'e.g. http://localhost:11434/v1' : DEFAULT_BASE_URL })] }), baseUrlError && (_jsx(Box, { marginTop: 0, paddingLeft: 2, children: _jsx(Text, { color: "yellow", dimColor: true, children: "An endpoint URL is required for a BYOK provider." }) })), fetchingModels && (_jsx(Box, { paddingLeft: 2, marginTop: 1, children: _jsx(Text, { color: "#20a0a0", dimColor: true, children: "Fetching available models..." }) }))] })), step === 'cloud' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Scalix Cloud" }), cloudCheck.phase === 'checking' && (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#20a0a0", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Validating your API key with Scalix Cloud..." })] })), cloudCheck.phase === 'ok' && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "#30c090", paddingX: 2, marginTop: 1, alignSelf: "flex-start", children: [_jsxs(Text, { color: "#30c090", bold: true, children: ['✓', " Connected to Scalix Cloud"] }), _jsxs(Text, { children: ["Org: ", _jsx(Text, { color: "#20a0a0", children: cloudCheck.identity.identity.orgId })] }), _jsxs(Text, { children: ["Project: ", _jsx(Text, { color: "#20a0a0", children: cloudCheck.identity.identity.projectId || 'org-wide' })] }), _jsxs(Text, { children: ["Plan: ", _jsx(Text, { color: "#20a0a0", children: cloudCheck.identity.limits.plan })] }), _jsxs(Text, { children: ["Rate limit: ", _jsxs(Text, { color: "#20a0a0", children: [cloudCheck.identity.limits.rateLimitRps, " req/s"] })] }), _jsxs(Text, { color: "#30c090", bold: true, children: ['●', " ", cloudCheck.toolCount, " platform tools unlocked"] })] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [{ label: '→ Continue', value: '__continue__' }], onSelect: () => setStep('models') }) })] })), cloudCheck.phase === 'fail' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ['✘', " ", cloudCheck.error] }) }), !cloudCheck.error.includes(CONSOLE_KEYS_URL) && (_jsxs(Text, { dimColor: true, children: ["Create or check your key at ", CONSOLE_KEYS_URL] })), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
898
+ }, placeholder: provider === 'openai' ? 'e.g. http://localhost:11434/v1' : DEFAULT_BASE_URL })] }), baseUrlError && (_jsx(Box, { marginTop: 0, paddingLeft: 2, children: _jsx(Text, { color: "yellow", dimColor: true, children: "An endpoint URL is required for a BYOK provider." }) })), fetchingModels && (_jsx(Box, { paddingLeft: 2, marginTop: 1, children: _jsx(Text, { color: "#06B6D4", dimColor: true, children: "Fetching available models..." }) }))] })), step === 'cloud' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Scalix Cloud" }), cloudCheck.phase === 'checking' && (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#06B6D4", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Validating your API key with Scalix Cloud..." })] })), cloudCheck.phase === 'ok' && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "#22C55E", paddingX: 2, marginTop: 1, alignSelf: "flex-start", children: [_jsxs(Text, { color: "#22C55E", bold: true, children: ['✓', " Connected to Scalix Cloud"] }), _jsxs(Text, { children: ["Org: ", _jsx(Text, { color: "#06B6D4", children: cloudCheck.identity.identity.orgId })] }), _jsxs(Text, { children: ["Project: ", _jsx(Text, { color: "#06B6D4", children: cloudCheck.identity.identity.projectId || 'org-wide' })] }), _jsxs(Text, { children: ["Plan: ", _jsx(Text, { color: "#06B6D4", children: cloudCheck.identity.limits.plan })] }), _jsxs(Text, { children: ["Rate limit: ", _jsxs(Text, { color: "#06B6D4", children: [cloudCheck.identity.limits.rateLimitRps, " req/s"] })] }), _jsxs(Text, { color: "#22C55E", bold: true, children: ['●', " ", cloudCheck.toolCount, " platform tools unlocked"] })] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [{ label: '→ Continue', value: '__continue__' }], onSelect: () => setStep('models') }) })] })), cloudCheck.phase === 'fail' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ['✘', " ", cloudCheck.error] }) }), cloudCheck.remedy && (_jsx(Text, { dimColor: true, children: cloudCheck.remedy })), !cloudCheck.remedy && !cloudCheck.error.includes(CONSOLE_KEYS_URL) && (_jsxs(Text, { dimColor: true, children: ["Create or check your key at ", CONSOLE_KEYS_URL] })), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
730
899
  { label: 'Re-enter API key', value: '__reenter__' },
731
900
  { label: 'Skip — continue without Scalix Cloud (local tools only)', value: '__skip__' },
732
901
  ], onSelect: (item) => {
@@ -736,7 +905,7 @@ function SetupScreen({ onComplete, existingConfig }) {
736
905
  else {
737
906
  setStep('models');
738
907
  }
739
- } }) })] }))] })), step === 'models' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Models" }), fetchingModels && fetchedModels.length === 0 && models.length === 0 ? (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#20a0a0", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Asking your endpoint which models it serves..." })] })) : fetchedModels.length > 0 && modelsMode === 'custom' && models.length === 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Select models from your API or type a custom model ID." }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(SelectInput, { items: [
908
+ } }) })] }))] })), step === 'models' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Models" }), fetchingModels && fetchedModels.length === 0 && models.length === 0 ? (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#06B6D4", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Asking your endpoint which models it serves..." })] })) : fetchedModels.length > 0 && modelsMode === 'custom' ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Select models from your API or type a custom model ID." }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(SelectInput, { items: [
740
909
  ...fetchedModels.filter((fm) => !models.includes(fm.id)).map((fm) => ({
741
910
  label: ` ${fm.name || fm.id}`,
742
911
  value: fm.id,
@@ -757,7 +926,7 @@ function SetupScreen({ onComplete, existingConfig }) {
757
926
  setModels((prev) => [...prev, item.value]);
758
927
  }
759
928
  }
760
- } }) }), models.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: "Selected:" }), models.map((m) => (_jsxs(Text, { color: "#30c090", children: ['\u25CF', " ", m] }, m)))] }))] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "Type a model ID and press Enter to add. Add at least one." }), models.length > 0 && modelsMode === 'list' ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["Added models (", '↑', "/", '↓', " to navigate, Enter on a model removes it):"] }), _jsx(SelectInput
929
+ } }) }), models.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: "Selected:" }), models.map((m) => (_jsxs(Text, { color: "#22C55E", children: ['\u25CF', " ", m] }, m)))] }))] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "Type a model ID and press Enter to add. Add at least one." }), models.length > 0 && modelsMode === 'list' ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["Added models (", '↑', "/", '↓', " to navigate, Enter on a model removes it):"] }), _jsx(SelectInput
761
930
  // Open on Continue. The list's first entries are the
762
931
  // models themselves and choosing one deletes it, so the
763
932
  // cursor used to start on a destructive action: the
@@ -796,7 +965,7 @@ function SetupScreen({ onComplete, existingConfig }) {
796
965
  return next;
797
966
  });
798
967
  }
799
- } })] })) : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [models.length > 0 && (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: models.map((m) => (_jsxs(Text, { color: "#30c090", children: ['\u25CF', " ", m] }, m))) })), _jsxs(Box, { children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: modelInput, onChange: (v) => setModelInput(sanitizeInput(v)), onSubmit: (v) => {
968
+ } })] })) : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [models.length > 0 && (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: models.map((m) => (_jsxs(Text, { color: "#22C55E", children: ['\u25CF', " ", m] }, m))) })), _jsxs(Box, { children: [_jsxs(Text, { color: "#06B6D4", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: modelInput, onChange: (v) => setModelInput(sanitizeInput(v)), onSubmit: (v) => {
800
969
  const trimmed = sanitizeInput(v).trim();
801
970
  if (trimmed) {
802
971
  if (!models.includes(trimmed)) {
@@ -819,8 +988,8 @@ function SetupScreen({ onComplete, existingConfig }) {
819
988
  reportedContextWindow !== null ? (_jsxs(Text, { dimColor: true, children: ["Pre-filled with ", formatContextSize(reportedContextWindow), " \u2014 reported by your endpoint", models.length > 1 ? ', the smallest across the models you picked' : '', "."] })) : (_jsxs(Text, { dimColor: true, children: ["Pre-filled with a safe ", formatContextSize(BYOK_DEFAULT_CONTEXT_TOKENS), " \u2014 we can't read your endpoint's real limit."] }))), (() => {
820
989
  const parsed = parseInt(contextInput, 10);
821
990
  const valid = parsed > 0;
822
- return valid ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "#30c090", children: ['\u25CF', " ", formatContextSize(parsed), " tokens (", parsed.toLocaleString(), ")"] }) })) : null;
823
- })(), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: contextInput, onChange: setContextInput, onSubmit: (v) => {
991
+ return valid ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "#22C55E", children: ['\u25CF', " ", formatContextSize(parsed), " tokens (", parsed.toLocaleString(), ")"] }) })) : null;
992
+ })(), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#06B6D4", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: contextInput, onChange: setContextInput, onSubmit: (v) => {
824
993
  // saveConfig() writes the key to the OS keychain, and that
825
994
  // subprocess is not always quick — a locked keychain or a
826
995
  // pending "allow access?" dialog made one measured save take
@@ -831,7 +1000,29 @@ function SetupScreen({ onComplete, existingConfig }) {
831
1000
  setSaving(true);
832
1001
  const parsed = parseInt(v.trim(), 10);
833
1002
  const tokens = parsed > 0 ? parsed : defaultContextTokens(provider, existingConfig?.limits?.maxContextTokens);
834
- const limits = { ...DEFAULT_LIMITS, maxContextTokens: tokens };
1003
+ // Preserve the user's /settings customizations — building
1004
+ // from DEFAULT_LIMITS alone silently wiped every other
1005
+ // limit each time the wizard was re-run.
1006
+ const limits = { ...DEFAULT_LIMITS, ...(existingConfig?.limits || {}), maxContextTokens: tokens };
1007
+ // BYOK: the endpoint's catalog told us per-model context
1008
+ // windows during discovery. Persist them as specs — they
1009
+ // used to be thrown away here, leaving every model
1010
+ // spec-less, so no max_tokens was sent and OpenRouter-class
1011
+ // gateways rejected every request with a 400. Existing
1012
+ // explicit specs win.
1013
+ const customModels = { ...(existingConfig?.customModels || {}) };
1014
+ if (provider === 'openai') {
1015
+ for (const m of models) {
1016
+ if (!customModels[m]) {
1017
+ customModels[m] = {
1018
+ contextWindow: byokContextWindows[m] && byokContextWindows[m] > 0
1019
+ ? byokContextWindows[m]
1020
+ : BYOK_DEFAULT_CONTEXT_TOKENS,
1021
+ maxOutputTokens: BYOK_DEFAULT_MAX_OUTPUT_TOKENS,
1022
+ };
1023
+ }
1024
+ }
1025
+ }
835
1026
  // BYOK: openai creds live under providers.openai; the
836
1027
  // top-level apiKey/baseUrl stay reserved for Scalix so
837
1028
  // switching back never loses the Scalix connection.
@@ -839,7 +1030,7 @@ function SetupScreen({ onComplete, existingConfig }) {
839
1030
  apiKey: provider === 'openai' ? (existingConfig?.apiKey || '') : apiKey,
840
1031
  baseUrl: provider === 'openai' ? (existingConfig?.baseUrl || DEFAULT_BASE_URL) : baseUrl,
841
1032
  models,
842
- customModels: existingConfig?.customModels || {},
1033
+ customModels,
843
1034
  limits,
844
1035
  permissionMode: existingConfig?.permissionMode || 'default',
845
1036
  thinkingMode: existingConfig?.thinkingMode || 'off',
@@ -857,7 +1048,7 @@ function SetupScreen({ onComplete, existingConfig }) {
857
1048
  }).catch(() => {
858
1049
  onComplete(newConfig);
859
1050
  });
860
- }, placeholder: String(defaultContextTokens(provider, existingConfig?.limits?.maxContextTokens)), focus: !saving })] }), saving && (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#20a0a0", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Saving configuration (storing your key in the OS keychain)..." })] }))] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, italic: true, children: [stepNumber > 1 ? 'Backspace (empty) to go back \u2022 ' : '', "ESC to quit"] }) })] })] }));
1051
+ }, placeholder: String(defaultContextTokens(provider, existingConfig?.limits?.maxContextTokens)), focus: !saving })] }), saving && (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#06B6D4", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Saving configuration (storing your key in the OS keychain)..." })] }))] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, italic: true, children: [stepNumber > 1 ? 'Backspace (empty) to go back \u2022 ' : '', "ESC to quit"] }) })] })] }));
861
1052
  }
862
1053
  const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart, tokens, mode, contextPercent, rateLimitWarn }) {
863
1054
  const { cols } = useTermSize();
@@ -943,7 +1134,10 @@ function cloudStatusLine(cloud, detail = true) {
943
1134
  return '☁ Scalix Cloud not in use — this session runs on your own provider (local tools only)';
944
1135
  }
945
1136
  if (cloud.reason === 'failed') {
946
- return `☁ Scalix Cloud unavailable — local tools only${detail && cloud.error ? ` · ${cloud.error}` : ''}`;
1137
+ const extra = detail
1138
+ ? `${cloud.error ? ` · ${cloud.error}` : ''}${cloud.remedy ? ` · ${cloud.remedy}` : ''}`
1139
+ : '';
1140
+ return `☁ Scalix Cloud unavailable — local tools only${extra}`;
947
1141
  }
948
1142
  return '☁ not connected — /login to link your Scalix account';
949
1143
  }
@@ -979,7 +1173,7 @@ function toolNamePreview(cols) {
979
1173
  }
980
1174
  const WelcomeScreen = memo(function WelcomeScreen({ cloud }) {
981
1175
  const { cols } = useTermSize();
982
- return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 0, children: [_jsx(Text, { dimColor: true, children: " Type a message or use /help for commands. Press Esc to exit." }), _jsxs(Box, { marginTop: 0, paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "Tools:" }), _jsx(Text, { dimColor: true, bold: true, wrap: "truncate-end", children: toolNamePreview(cols) })] }), _jsx(Box, { paddingLeft: 2, children: cloud.connected ? (_jsxs(Text, { color: "#20a0a0", children: ['☁', " Scalix Cloud ", '·', " ", cloud.identity ? `${cloud.identity.limits.plan} ${'·'} ` : '', cloud.toolCount, " platform tools"] })) : (_jsx(Text, { dimColor: true, children: cloudStatusLine(cloud, false) })) })] }));
1176
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 0, children: [_jsx(Text, { dimColor: true, children: " Type a message or use /help for commands. Press Esc to exit." }), _jsxs(Box, { marginTop: 0, paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "Tools:" }), _jsx(Text, { dimColor: true, bold: true, wrap: "truncate-end", children: toolNamePreview(cols) })] }), _jsx(Box, { paddingLeft: 2, children: cloud.connected ? (_jsxs(Text, { color: "#06B6D4", children: ['☁', " Scalix Cloud ", '·', " ", cloud.identity ? `${cloud.identity.limits.plan} ${'·'} ` : '', cloud.toolCount, " platform tools"] })) : (_jsx(Text, { dimColor: true, children: cloudStatusLine(cloud, false) })) })] }));
983
1177
  });
984
1178
  const UserMessage = memo(function UserMessage({ text }) {
985
1179
  return (_jsx(Box, { paddingX: 2, marginTop: 1, children: _jsxs(Text, { wrap: "wrap", bold: true, children: [_jsx(Text, { color: "magentaBright", children: '\u276F ' }), text] }) }));
@@ -1157,7 +1351,7 @@ function ConfirmPrompt({ toolName, args, onRespond, onScrollUp, onScrollDown })
1157
1351
  return (_jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsxs(Text, { dimColor: true, children: [k, ":"] }), " ", _jsx(Text, { children: val }), _jsx(Text, { children: pad(k.length + 3 + stringWidth(val)) }), '\u2502'] }, k));
1158
1352
  }), _jsxs(Text, { color: "yellow", children: ['\u251C', border, '\u2524'] }), boxWidth - 2 >= 42 ? (_jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsx(Text, { color: "green", bold: true, children: "[Y]" }), _jsx(Text, { children: " Allow " }), _jsx(Text, { color: "cyan", bold: true, children: "[A]" }), _jsx(Text, { children: " Always " }), _jsx(Text, { color: "red", bold: true, children: "[N]" }), _jsx(Text, { children: " Deny " }), _jsx(Text, { dimColor: true, children: "ESC deny" }), _jsx(Text, { children: pad(42) }), '\u2502'] })) : (_jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsx(Text, { color: "green", bold: true, children: "[Y]" }), _jsx(Text, { children: "es " }), _jsx(Text, { color: "cyan", bold: true, children: "[A]" }), _jsx(Text, { children: "lways " }), _jsx(Text, { color: "red", bold: true, children: "[N]" }), _jsx(Text, { children: "o" }), _jsx(Text, { children: pad(20) }), '\u2502'] })), _jsxs(Text, { color: "yellow", children: ['\u2514', border, '\u2518'] })] }));
1159
1353
  }
1160
- function ModelPicker({ currentModels, onSelect, onCancel }) {
1354
+ function ModelPicker({ currentModels, availableModels = [], onSelect, onCancel }) {
1161
1355
  const [customInput, setCustomInput] = useState('');
1162
1356
  const [showCustom, setShowCustom] = useState(false);
1163
1357
  useInput((_ch, key) => { if (key.escape) {
@@ -1174,11 +1368,16 @@ function ModelPicker({ currentModels, onSelect, onCancel }) {
1174
1368
  setShowCustom(false);
1175
1369
  }, placeholder: "provider/model-name" })] }), _jsx(Text, { dimColor: true, children: "ESC to go back" })] }));
1176
1370
  }
1371
+ const catalogOnly = availableModels.filter((m) => !currentModels.includes(m));
1177
1372
  const items = [
1178
1373
  ...currentModels.map((m) => ({
1179
1374
  label: `\u25CF ${shortModel(m)}`,
1180
1375
  value: m,
1181
1376
  })),
1377
+ ...catalogOnly.map((m) => ({
1378
+ label: `\u25CB ${shortModel(m)}`,
1379
+ value: m,
1380
+ })),
1182
1381
  { label: '+ Add new model...', value: '__custom__' },
1183
1382
  { label: ' Cancel', value: '__cancel__' },
1184
1383
  ];
@@ -1809,6 +2008,9 @@ function App({ config }) {
1809
2008
  const [streamText, setStreamText] = useState('');
1810
2009
  const [reasoningText, setReasoningText] = useState('');
1811
2010
  const [selectingModel, setSelectingModel] = useState(false);
2011
+ /** BYOK catalog fetched when the /model picker opens (ids + context windows). */
2012
+ const [pickerCatalog, setPickerCatalog] = useState([]);
2013
+ const [pickerWindows, setPickerWindows] = useState({});
1812
2014
  const [confirmInfo, setConfirmInfo] = useState(null);
1813
2015
  const [thinkStart, setThinkStart] = useState(null);
1814
2016
  const [showBanner, setShowBanner] = useState(true);
@@ -2102,6 +2304,22 @@ function App({ config }) {
2102
2304
  add('system', 'This workspace has project plugins/hooks/rules that were skipped because the workspace is not trusted. Run /trust to load them.');
2103
2305
  }
2104
2306
  }, [add]);
2307
+ // MCP: connect every configured server once the agent exists and register
2308
+ // the tools it reports. Only failures are announced — a working setup should
2309
+ // not narrate itself at every launch.
2310
+ useEffect(() => {
2311
+ if (!agent)
2312
+ return;
2313
+ let hub = null;
2314
+ let cancelled = false;
2315
+ startMcp(agent, (m) => { if (!cancelled)
2316
+ add('system', `⚠ ${m}`); })
2317
+ .then((h) => { hub = h; if (cancelled)
2318
+ void h.close(); })
2319
+ .catch(() => { });
2320
+ // stdio servers are child processes; a re-init must not leave them running.
2321
+ return () => { cancelled = true; void hub?.close(); };
2322
+ }, [agent]);
2105
2323
  // Scalix Cloud: on agent start (and after /setup or /login re-init), fetch
2106
2324
  // the account's tool catalog and register it. Failure or no key → local
2107
2325
  // tools only, with exactly one honest notice. No retry loop.
@@ -2255,6 +2473,18 @@ function App({ config }) {
2255
2473
  }
2256
2474
  if (cmd === '/model') {
2257
2475
  setSelectingModel(true);
2476
+ // BYOK: the picker used to show only the already-configured list — no
2477
+ // way to discover what the endpoint actually serves. Fetch its catalog
2478
+ // in the background; the picker grows when (if) the list lands.
2479
+ if (activeAgentConfig && resolveActiveProvider(activeAgentConfig).provider === 'openai') {
2480
+ const activeNow = resolveActiveProvider(activeAgentConfig);
2481
+ fetchByokModels(activeNow.baseUrl, activeNow.apiKey).then((found) => {
2482
+ if (found.length === 0)
2483
+ return;
2484
+ setPickerCatalog(found.map((f) => f.id));
2485
+ setPickerWindows(Object.fromEntries(found.filter((f) => f.contextWindow && f.contextWindow > 0).map((f) => [f.id, f.contextWindow])));
2486
+ }).catch(() => { });
2487
+ }
2258
2488
  return true;
2259
2489
  }
2260
2490
  if (cmd === '/compact') {
@@ -2298,6 +2528,43 @@ function App({ config }) {
2298
2528
  });
2299
2529
  return true;
2300
2530
  }
2531
+ if (cmd === '/rename') {
2532
+ const newName = text.slice(7).trim();
2533
+ if (!newName) {
2534
+ add('system', 'Usage: /rename <new session name>');
2535
+ return true;
2536
+ }
2537
+ const msgs = agent.getMessages();
2538
+ saveSession(sessionId, msgs, models[0] || 'unknown', newName, costTracker.getState()).then((meta) => {
2539
+ add('system', `Session renamed to: "${meta.name}"`);
2540
+ }).catch((err) => {
2541
+ add('error', `Failed to rename session: ${err.message}`);
2542
+ });
2543
+ return true;
2544
+ }
2545
+ if (cmd === '/feedback') {
2546
+ const feedbackMsg = text.slice(9).trim();
2547
+ if (!feedbackMsg) {
2548
+ add('system', 'Usage: /feedback <your feedback, issue report, or suggestion>');
2549
+ return true;
2550
+ }
2551
+ saveFeedback({
2552
+ category: 'general',
2553
+ message: feedbackMsg,
2554
+ systemInfo: {
2555
+ version: VERSION,
2556
+ nodeVersion: process.version,
2557
+ platform: os.platform(),
2558
+ arch: os.arch(),
2559
+ model: models[0] || 'unknown',
2560
+ },
2561
+ }).then(() => {
2562
+ add('system', 'Thank you! Your feedback has been recorded and submitted to Scalix.');
2563
+ }).catch((err) => {
2564
+ add('error', `Failed to record feedback: ${err.message}`);
2565
+ });
2566
+ return true;
2567
+ }
2301
2568
  if (cmd === '/load' || cmd === '/resume') {
2302
2569
  if (cmd === '/resume') {
2303
2570
  loadCrashState().then(async (state) => {
@@ -2441,7 +2708,14 @@ function App({ config }) {
2441
2708
  else {
2442
2709
  add('system', `Identity unavailable: ${s.error ?? 'unknown error'}`);
2443
2710
  }
2444
- add('system', `Platform tools: ${s.toolCount} registered ${'—'} /cloud tools to list them`);
2711
+ const ofTotal = s.totalCount && s.totalCount > s.toolCount ? ` of ${s.totalCount} in the catalog (this key's scopes unlock ${s.toolCount})` : '';
2712
+ add('system', `Platform tools: ${s.toolCount} registered${ofTotal} ${'—'} /cloud tools to list them`);
2713
+ if (s.note)
2714
+ add('system', s.note);
2715
+ const cloudActiveModel = agent.getModels()[0];
2716
+ if (cloudActiveModel && !hasExplicitModelSpec(cloudActiveModel)) {
2717
+ add('system', `Model spec: assumed (${formatTokens(getModelContextWindow(cloudActiveModel))} context) ${'—'} the API did not report one for ${shortModel(cloudActiveModel)}. Set the real value in /setup's context-window step.`);
2718
+ }
2445
2719
  }
2446
2720
  else {
2447
2721
  add('system', cloudStatusLine(s));
@@ -2484,9 +2758,9 @@ function App({ config }) {
2484
2758
  if (arg && PERMISSION_MODES.includes(arg)) {
2485
2759
  agent.setPermissionMode(arg);
2486
2760
  setCurrentMode(arg);
2487
- loadConfig().then(cfg => {
2761
+ loadGlobalConfig().then(cfg => {
2488
2762
  cfg.permissionMode = arg;
2489
- saveConfig(cfg);
2763
+ return saveConfig(cfg);
2490
2764
  }).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
2491
2765
  add('system', `Mode: ${arg.toUpperCase()} \u2014 ${getModeDescription(arg)}`);
2492
2766
  }
@@ -2495,13 +2769,31 @@ function App({ config }) {
2495
2769
  }
2496
2770
  return true;
2497
2771
  }
2772
+ if (cmd === '/preset') {
2773
+ const arg = text.split(' ')[1]?.toLowerCase();
2774
+ if (arg) {
2775
+ try {
2776
+ agent.setPreset(arg);
2777
+ // When a preset switches mode, sync the TUI state
2778
+ setCurrentMode(agent.getPermissionMode());
2779
+ add('system', `Preset active: ${arg} — mode is now ${agent.getPermissionMode()}`);
2780
+ }
2781
+ catch (e) {
2782
+ add('system', `Error: ${e.message}\nAvailable presets: cloud-architect, dba, devops, full-access`);
2783
+ }
2784
+ }
2785
+ else {
2786
+ add('system', `Usage: /preset <id>\nAvailable presets: cloud-architect, dba, devops, full-access`);
2787
+ }
2788
+ return true;
2789
+ }
2498
2790
  if (cmd === '/thinking') {
2499
2791
  const arg = text.split(' ')[1]?.toLowerCase();
2500
2792
  if (arg && THINKING_MODES.includes(arg)) {
2501
2793
  agent.setThinking(arg);
2502
- loadConfig().then(cfg => {
2794
+ loadGlobalConfig().then(cfg => {
2503
2795
  cfg.thinkingMode = arg;
2504
- saveConfig(cfg);
2796
+ return saveConfig(cfg);
2505
2797
  }).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
2506
2798
  add('system', `Thinking: ${arg.toUpperCase()}${arg === 'off' ? '' : ' \u2014 model will reason before responding'}`);
2507
2799
  }
@@ -2512,21 +2804,31 @@ function App({ config }) {
2512
2804
  }
2513
2805
  if (cmd === '/mcp') {
2514
2806
  const p = text.split(' ').filter(Boolean);
2807
+ // Bare /mcp reports what is connected \u2014 the question people actually
2808
+ // have, now that connecting is something that can fail.
2809
+ if (p.length === 1) {
2810
+ const st = agent.getMcpStatus();
2811
+ add('system', st.length
2812
+ ? st.map(s => `${s.state === 'connected' ? '\u25cf' : '\u25cb'} ${s.label} (${s.transport}) \u2014 ${s.state === 'connected' ? `${s.toolCount} tool${s.toolCount === 1 ? '' : 's'}` : s.error ?? s.state}`).join('\n')
2813
+ : 'No MCP servers configured. Add one to mcp-services.json, or: /mcp <label> <url>');
2814
+ return true;
2815
+ }
2515
2816
  if (p.length < 3) {
2516
- add('system', 'Usage: /mcp <label> <url>');
2817
+ add('system', 'Usage: /mcp \u2014 show server status\n /mcp <label> <url>');
2517
2818
  return true;
2518
2819
  }
2519
- {
2520
- try {
2521
- new URL(p[2]);
2522
- }
2523
- catch {
2524
- add('error', `Invalid URL: ${p[2]}`);
2525
- return true;
2526
- }
2527
- agent.addTool({ type: 'mcp', serverLabel: p[1], serverUrl: p[2] });
2528
- add('system', `MCP registered: ${p[1]} \u2192 ${p[2]}`);
2820
+ // Same validator add_mcp_server and the config loader use \u2014 a typed URL
2821
+ // gets no more benefit of the doubt than a repo-supplied one.
2822
+ const badUrl = validateMcpUrl(p[2]);
2823
+ if (badUrl) {
2824
+ add('error', `Invalid MCP server URL: ${badUrl}`);
2825
+ return true;
2529
2826
  }
2827
+ agent.connectMcpServer({ serverLabel: p[1], serverUrl: p[2] }).then((s) => {
2828
+ add(s.state === 'connected' ? 'system' : 'error', s.state === 'connected'
2829
+ ? `MCP connected: ${p[1]} \u2192 ${p[2]} (${s.toolCount} tool${s.toolCount === 1 ? '' : 's'})`
2830
+ : `MCP ${p[1]} failed: ${s.error ?? 'unknown error'}`);
2831
+ });
2530
2832
  return true;
2531
2833
  }
2532
2834
  if (cmd === '/index') {
@@ -2576,6 +2878,9 @@ function App({ config }) {
2576
2878
  discoverPluginsForAgent(agent).then(() => {
2577
2879
  add('system', `Loaded ${loadedPlugins.length} plugin(s).`);
2578
2880
  }).catch(() => { });
2881
+ const hookCount = agent.reloadHooks();
2882
+ if (hookCount > 0)
2883
+ add('system', `Loaded ${hookCount} project hook(s).`);
2579
2884
  buildEnhancedInstructions().then((enhanced) => {
2580
2885
  agent.setInstructions(enhanced);
2581
2886
  add('system', 'Reloaded project rules into context.');
@@ -2627,6 +2932,12 @@ function App({ config }) {
2627
2932
  }
2628
2933
  if (cmd === '/hooks') {
2629
2934
  const hooks = agent.getHooks();
2935
+ const skipped = agent.getSkippedHookCount();
2936
+ // Hooks that exist but were withheld are worth a line of their own —
2937
+ // "No hooks configured" would be a lie the user could act on wrongly.
2938
+ if (skipped > 0) {
2939
+ add('system', `${skipped} project hook(s) skipped — this workspace is untrusted. Run /trust to enable them.`);
2940
+ }
2630
2941
  hooksSummary(hooks).split('\n').forEach((l) => add('system', l));
2631
2942
  return true;
2632
2943
  }
@@ -3078,7 +3389,7 @@ function App({ config }) {
3078
3389
  add('system', `${PRODUCT_NAME} v${VERSION}`);
3079
3390
  const activeProv = resolveActiveProvider(config);
3080
3391
  add('system', `Provider: ${activeProv.provider === 'openai' ? 'OpenAI-compatible (BYOK)' : 'Scalix Cloud'}`);
3081
- add('system', `Model: ${modelsStr}`);
3392
+ add('system', `Model: ${modelsStr}${process.env[ENV_MODEL] ? ' (set by SCALIX_MODEL env — overrides saved config)' : ''}`);
3082
3393
  add('system', `API: ${baseUrl}`);
3083
3394
  if (activeProv.provider === 'scalix') {
3084
3395
  add('system', `Cloud: ${cloudStatusLine(cloudConnection)}`);
@@ -3088,6 +3399,13 @@ function App({ config }) {
3088
3399
  }
3089
3400
  add('system', `Mode: ${mode}`);
3090
3401
  add('system', `Context: ${ctx.percentage}% (${formatTokens(ctx.tokens)} / ${formatTokens(ctx.maxTokens)})`);
3402
+ // Honesty over silence: when neither the user nor the API supplied a
3403
+ // spec for the active model, the context window above is our guess, and
3404
+ // saying so beats letting it read as a reported fact.
3405
+ const activeModel = agent.getModels()[0];
3406
+ if (activeModel && !hasExplicitModelSpec(activeModel)) {
3407
+ add('system', `Model spec: assumed (${formatTokens(getModelContextWindow(activeModel))} context) ${'—'} the API did not report one for ${shortModel(activeModel)}. Set the real value in /setup's context-window step or via customModels in ~/${USER_CONFIG_DIR}/config.json.`);
3408
+ }
3091
3409
  add('system', `Messages: ${msgCount}`);
3092
3410
  add('system', `Session: ${sessionId}`);
3093
3411
  add('system', `CWD: ${process.cwd()}`);
@@ -3175,9 +3493,9 @@ function App({ config }) {
3175
3493
  if (cmd === '/plan') {
3176
3494
  agent.setPermissionMode('plan');
3177
3495
  setCurrentMode('plan');
3178
- loadConfig().then(cfg => {
3496
+ loadGlobalConfig().then(cfg => {
3179
3497
  cfg.permissionMode = 'plan';
3180
- saveConfig(cfg);
3498
+ return saveConfig(cfg);
3181
3499
  }).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
3182
3500
  add('system', `Mode: PLAN — ${getModeDescription('plan')}`);
3183
3501
  return true;
@@ -3340,8 +3658,11 @@ function App({ config }) {
3340
3658
  // Reset idle nudge on any user activity
3341
3659
  lastActivityRef.current = Date.now();
3342
3660
  idleNudgedRef.current = false;
3343
- // Allow /mode even while the agent is working
3344
- if (isLoadingRef.current && trimmed.startsWith('/mode')) {
3661
+ // Allow /mode even while the agent is working. Exact match only —
3662
+ // startsWith('/mode') also matched /model and /models, letting the model
3663
+ // picker run (and switch the LIVE response's model) mid-stream while the
3664
+ // queue guard below thought it was protecting them.
3665
+ if (isLoadingRef.current && (trimmed === '/mode' || trimmed.startsWith('/mode '))) {
3345
3666
  handleCommand(trimmed);
3346
3667
  return;
3347
3668
  }
@@ -3407,8 +3728,13 @@ function App({ config }) {
3407
3728
  loadSession(session.id).then((data) => {
3408
3729
  agent.loadMessages(data.messages);
3409
3730
  sessionId = session.id;
3410
- setModels([session.model]);
3411
- agent.setModel(session.model);
3731
+ const resumeModel = normalizeModelId(session.model);
3732
+ agent.setModel(resumeModel);
3733
+ setModels(agent.getModels());
3734
+ // The session may have been recorded under another provider/spec —
3735
+ // without this a BYOK resume reactivates a spec-less model (no
3736
+ // max_tokens sent → gateways 400 every request).
3737
+ ensureByokSpec(activeAgentConfig, resumeModel);
3412
3738
  if (data.costState) {
3413
3739
  costTracker.restore(data.costState);
3414
3740
  setTotalTokens(data.costState.totalTokens);
@@ -3542,26 +3868,36 @@ function App({ config }) {
3542
3868
  if (showSetup) {
3543
3869
  return _jsx(SetupScreen, { onComplete: handleSetupDone, existingConfig: config });
3544
3870
  }
3545
- return (_jsxs(Box, { flexDirection: "column", width: "100%", height: rows, children: [showBanner && _jsx(Banner, {}), _jsx(StatusBar, { status: status, model: models[0] || 'no model', toolCount: allTools.length + pluginTools.length + cloudInfo.toolCount, thinkStart: thinkStart, tokens: totalTokens, mode: currentMode, contextPercent: contextPercent, rateLimitWarn: rateLimitWarn }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: Math.min(6, chatHeight), overflowY: "hidden", children: [showBanner && !isLoading && _jsx(WelcomeScreen, { cloud: cloudInfo }), !isAtTop && !showBanner && (_jsx(Box, { justifyContent: "center", children: _jsxs(Text, { dimColor: true, children: [" ", '\u2191', " more above ", '\u2014', " Shift+Up "] }) })), visibleItems.map((item) => _jsx(RenderItem, { item: item }, item.id)), isLoading && _jsx(StreamingArea, { text: streamText, reasoning: reasoningText, model: shortModel(models[0] || ''), waiting: !!confirmInfo }), !isAtBottom && (_jsx(Box, { justifyContent: "center", children: _jsxs(Text, { dimColor: true, children: [" ", '\u2193', " more below ", '\u2014', " Shift+Down "] }) }))] }), _jsx(Box, { flexShrink: 0, flexDirection: "column", children: confirmInfo ? (_jsx(ConfirmPrompt, { toolName: confirmInfo.toolName, args: confirmInfo.args, onRespond: handleConfirm, onScrollUp: scrollUp, onScrollDown: scrollDown })) : selectingModel ? (_jsx(ModelPicker, { currentModels: models, onSelect: (v) => {
3871
+ return (_jsxs(Box, { flexDirection: "column", width: "100%", height: rows, children: [showBanner && _jsx(Banner, {}), _jsx(StatusBar, { status: status, model: models[0] || 'no model', toolCount: allTools.length + pluginTools.length + cloudInfo.toolCount, thinkStart: thinkStart, tokens: totalTokens, mode: currentMode, contextPercent: contextPercent, rateLimitWarn: rateLimitWarn }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: Math.min(6, chatHeight), overflowY: "hidden", children: [showBanner && !isLoading && _jsx(WelcomeScreen, { cloud: cloudInfo }), !isAtTop && !showBanner && (_jsx(Box, { justifyContent: "center", children: _jsxs(Text, { dimColor: true, children: [" ", '\u2191', " more above ", '\u2014', " Shift+Up "] }) })), visibleItems.map((item) => _jsx(RenderItem, { item: item }, item.id)), isLoading && _jsx(StreamingArea, { text: streamText, reasoning: reasoningText, model: shortModel(models[0] || ''), waiting: !!confirmInfo }), !isAtBottom && (_jsx(Box, { justifyContent: "center", children: _jsxs(Text, { dimColor: true, children: [" ", '\u2193', " more below ", '\u2014', " Shift+Down "] }) }))] }), _jsx(Box, { flexShrink: 0, flexDirection: "column", children: confirmInfo ? (_jsx(ConfirmPrompt, { toolName: confirmInfo.toolName, args: confirmInfo.args, onRespond: handleConfirm, onScrollUp: scrollUp, onScrollDown: scrollDown })) : selectingModel ? (_jsx(ModelPicker, { currentModels: models, availableModels: pickerCatalog, onSelect: (picked) => {
3872
+ const v = normalizeModelId(picked);
3546
3873
  agent.setModel(v);
3547
- setModels([v]);
3874
+ // Keep the whole failover chain visible, not just the pick —
3875
+ // setModel moves the model to the front and preserves the rest.
3876
+ setModels(agent.getModels());
3548
3877
  setSelectingModel(false);
3549
- loadConfig().then(cfg => {
3550
- cfg.models = [v];
3551
- saveConfig(cfg);
3878
+ ensureByokSpec(activeAgentConfig, v, pickerWindows[v]);
3879
+ // Global config only (loadConfig() merges project-local overrides
3880
+ // and round-tripping them through saveConfig laundered them into
3881
+ // ~/.scalix-coder). Chain-preserving, and the saveConfig promise
3882
+ // is returned so its failure actually hits the catch.
3883
+ loadGlobalConfig().then(cfg => {
3884
+ cfg.models = [v, ...cfg.models.filter(m => m !== v)];
3885
+ return saveConfig(cfg);
3552
3886
  }).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
3553
- add('system', `Model switched to ${shortModel(v)}`);
3887
+ add('system', `Model switched to ${shortModel(v)}${process.env[ENV_MODEL] ? ' — note: SCALIX_MODEL is set and overrides this on next launch' : ''}`);
3554
3888
  }, onCancel: () => setSelectingModel(false) })) : selectingMode ? (_jsx(ModePicker, { currentMode: currentMode, onSelect: (mode) => {
3555
3889
  agent.setPermissionMode(mode);
3556
3890
  setCurrentMode(mode);
3557
3891
  setSelectingMode(false);
3558
- loadConfig().then(cfg => {
3892
+ loadGlobalConfig().then(cfg => {
3559
3893
  cfg.permissionMode = mode;
3560
- saveConfig(cfg);
3894
+ return saveConfig(cfg);
3561
3895
  }).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
3562
3896
  add('system', `Mode: ${mode.toUpperCase()} \u2014 ${getModeDescription(mode)}`);
3563
3897
  }, onCancel: () => setSelectingMode(false) })) : editingSettings ? (_jsx(SettingsEditor, { onSave: (key, value) => {
3564
- loadConfig().then((c) => {
3898
+ // Global config only — round-tripping the override-merged view
3899
+ // laundered project-local settings into ~/.scalix-coder.
3900
+ loadGlobalConfig().then((c) => {
3565
3901
  const limits = { ...(c.limits || DEFAULT_LIMITS) };
3566
3902
  const floatKeys = new Set([
3567
3903
  'compressionThreshold', 'messageKeepRatio', 'subAgentCompressionThreshold',
@@ -3572,8 +3908,13 @@ function App({ config }) {
3572
3908
  limits[key] = parsed;
3573
3909
  const newConfig = { ...c, limits };
3574
3910
  saveConfig(newConfig).then(() => {
3575
- agent?.updateLimit(key, parsed);
3576
- add('system', `Setting updated: ${key} = ${parsed}`);
3911
+ // updateLimit says whether the RUNNING agent honors the new
3912
+ // value — claiming "updated" for keys that only apply on
3913
+ // the next launch was a lie the user had no way to detect.
3914
+ const live = agent?.updateLimit(key, parsed) ?? false;
3915
+ add('system', live
3916
+ ? `Setting updated: ${key} = ${parsed}`
3917
+ : `Setting saved: ${key} = ${parsed} — takes effect on the next launch`);
3577
3918
  }).catch(() => {
3578
3919
  add('error', `Failed to save setting: ${key}`);
3579
3920
  });