scalix 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  All notable changes to Scalix Coder will be documented in this file.
4
4
 
5
+ ## [1.5.1] - 2026-08-08
6
+
7
+ ### Added
8
+ - **Scalix Cloud key for BYOK sessions.** The platform tools (deploy, database, storage, …) now run on their own credential pair, independent of inference: `SCALIX_CLOUD_API_KEY` / `SCALIX_CLOUD_BASE_URL` env, a keychain slot and settings row in the VS Code extension, and a wizard step in the CLI. Inference on your own endpoint, deploys still on Scalix — the BYOK key never reaches Scalix and the Scalix key never reaches your endpoint. The IPC engine now registers the cloud tools at startup; the GUI previously had none at all, and models asked to deploy invented SDKs and CLI flags instead.
9
+ - **Guided setup.** Full-page setup panel in the extension (provider presets, key/model/limits, engine and terminal-CLI status with an install button) that auto-opens on first run and lands in chat after save; `/setup` slash command in both surfaces.
10
+ - **The agent knows what is at its disposal.** While a cloud connection is live, every request carries a section listing the registered `scalix_*` tool groups and the platform's ~30s per-call execution ceiling, steering long operations to the async pairs (`scalix_build_create` → poll `scalix_build_status`) instead of one blocking call that the transport would kill.
11
+ - **Scope-aware tool catalog.** The catalog is filtered client-side by the API key's scope families, so the model only sees tools its key can plausibly call; `/cloud status` reports "N of M available to this key" and says when the key's own project binding overrides a configured project id (the platform ignores the header for API keys — the key wins).
12
+ - **Key-shape guidance.** Platform tools need a project-scoped key with the `database:query` scope; setup copy now says so everywhere a key is entered, and the two known rejections map to one-line remedies (400 = org-wide key, 403 = missing database scope) in the wizard, `/cloud status`, headless warnings and the extension banner.
13
+ - `/status` marks the context window as *assumed* when neither the user nor the API supplied a spec for the active model, and points at the `/setup` context-window override — no invented numbers.
14
+
15
+ ### Changed
16
+ - **Scalix product steering is gated on connection state.** Full platform guidance only while Scalix Cloud is connected; a single neutral pointer on the Scalix provider while disconnected; nothing Scalix-specific on pure BYOK sessions. Coder is a generic tool — the pitch arrives with the tools, not before.
17
+ - Cloud tool-call budget cut from 120s to 45s: the platform transport ends a call at ~30–35s, so the extra 75s only ever waited on a dead call.
18
+ - Request timeout raised to 300s to match the platform's own walls (env-overridable via `SCALIX_REQUEST_TIMEOUT_MS`).
19
+
20
+ ### Fixed
21
+ - **Model switching, 24 audited fixes + 3 from dogfooding videos.** `setModel` preserves the failover chain and mutates in place (sub-agents saw stale model lists), and resets the breaker; fresh sends no longer round-robin the chain; BYOK model specs are registered on every switch surface, so no more `max_tokens`-less requests that OpenRouter 400s; the TUI `/model` no longer fires mid-stream via the `/mode` prefix collision and persists chain-preservingly to the global config (project overrides no longer laundered); `/settings` reports live-vs-next-launch truthfully; a `SCALIX_MODEL` override is announced; the IPC engine survives stray rejections — a 429 used to kill the whole session.
22
+ - The system prompt forbids inventing `scalix` packages or CLI flags and explains the `scalix_*` tools, with pinned SDK names (`@scalix-world/sdk` on npm, `scalix-sdk` on PyPI) and exact model ids.
23
+
24
+ ### Fixed (VS Code extension)
25
+ - Scalix keys are verified against `/v1/me` before storing — a rejected key gets a dialog with the reason instead of failing silently later. BYOK keys are never probed.
26
+ - Model QuickPick accepts mouse clicks under Antigravity's event ordering; model/command updates broadcast to every chat surface (the editor tab used to go stale); the shared-config watcher ignores its own writes, so adopting a config no longer reverts a fresh model switch; the settings Models tab was rebuilt — stable refresh, live active badge, auto-reload on provider/endpoint/key change.
27
+ - `vsce` packaging: the extension manifest carries the `repository` field CI packaging requires.
28
+
5
29
  ## [1.5.0] - 2026-08-02
6
30
 
7
31
  ### Added
package/dist/agent.js CHANGED
@@ -3,7 +3,7 @@ import { EventEmitter } from 'eventemitter3';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { getToolPermissionWithArgs } from './sandbox.js';
5
5
  import { logger } from './logger.js';
6
- import { DEFAULT_MODELS as FALLBACK_MODELS, DEFAULT_INSTRUCTIONS, DEFAULT_BASE_URL, AGENT_DEFAULTS, TOOL_DEFAULTS, BACKOFF_DEFAULTS, CONTEXT_DEFAULTS, modelSupportsVision, getModelSpec, maxOutputTokensForRequest, hasExplicitModelSpec } from './constants.js';
6
+ import { DEFAULT_MODELS as FALLBACK_MODELS, DEFAULT_INSTRUCTIONS, DEFAULT_BASE_URL, AGENT_DEFAULTS, TOOL_DEFAULTS, BACKOFF_DEFAULTS, CONTEXT_DEFAULTS, SCALIX_PLATFORM_SECTION, SCALIX_CLOUD_RESOURCES_SECTION, SCALIX_NEUTRAL_POINTER, SCALIX_STEERING_ALWAYS_ON, modelSupportsVision, getModelSpec, maxOutputTokensForRequest, hasExplicitModelSpec } from './constants.js';
7
7
  import { estimateMessageTokens as estimateTokenCount, estimateTokens } from './token-counter.js';
8
8
  import { ContextManager } from './context-manager.js';
9
9
  import { zodToJsonSchema } from './tool-helper.js';
@@ -19,7 +19,7 @@ import { extractCodeClaims, checkGrounding, formatGroundingNudge, GROUNDING_DEFA
19
19
  import { RETRIEVAL_DEFAULTS } from './context-retriever.js';
20
20
  import fs from 'node:fs/promises';
21
21
  import path from 'node:path';
22
- import { fetchCloudTools, callCloudTool } from './cloud.js';
22
+ import { fetchCloudTools, callCloudTool, fetchMe, filterCatalogByScopes, buildCloudToolsPromptSection, cloudKeyRemedy } from './cloud.js';
23
23
  import { createChatClient, capabilitiesFor } from './providers/index.js';
24
24
  /**
25
25
  * Marks an error that Agent has already announced through the 'error' event.
@@ -177,6 +177,8 @@ export class Agent extends EventEmitter {
177
177
  cloudStatus = { state: 'no-key', toolCount: 0 };
178
178
  providerId;
179
179
  capabilities;
180
+ cloudApiKey;
181
+ cloudBaseUrl;
180
182
  streamingToolCallsResolved;
181
183
  constructor(config) {
182
184
  super();
@@ -189,6 +191,14 @@ export class Agent extends EventEmitter {
189
191
  throw new Error(`Provider '${this.providerId}' requires an explicit baseUrl — there is no default third-party endpoint.`);
190
192
  }
191
193
  this.client = createChatClient(this.providerId, config.baseUrl || DEFAULT_BASE_URL, config.apiKey);
194
+ // Cloud tools follow their own credential pair. On the Scalix gateway the
195
+ // inference creds double as the cloud creds; on BYOK `config.baseUrl` is a
196
+ // third-party host with no /v1/me, so the cloud URL falls back to the
197
+ // Scalix default rather than inheriting it.
198
+ this.cloudApiKey = config.cloudApiKey?.trim()
199
+ || (this.capabilities.platformTools ? config.apiKey : '');
200
+ this.cloudBaseUrl = config.cloudBaseUrl?.trim()
201
+ || (this.capabilities.platformTools ? (config.baseUrl || DEFAULT_BASE_URL) : DEFAULT_BASE_URL);
192
202
  this.log = logger.child();
193
203
  const resolvedModels = Array.isArray(config.model)
194
204
  ? (config.model.length > 0 ? config.model : FALLBACK_MODELS)
@@ -268,7 +278,16 @@ export class Agent extends EventEmitter {
268
278
  this.retrievalConfig = { ...RETRIEVAL_DEFAULTS, ...config.contextRetrieval };
269
279
  }
270
280
  }
271
- pickModel() {
281
+ /**
282
+ * `fresh` — a NEW user turn: always start from the front of the list (the
283
+ * user's chosen model). The idx+1 bookkeeping below exists so RETRIES
284
+ * advance to the next model in the chain; without the fresh reset it also
285
+ * rotated the STARTING model, so with a failover chain configured,
286
+ * consecutive sends silently alternated models — a switch never "stuck".
287
+ */
288
+ pickModel(fresh = false) {
289
+ if (fresh)
290
+ this.modelIndex = 0;
272
291
  const startIdx = this.modelIndex;
273
292
  for (let i = 0; i < this.config.models.length; i++) {
274
293
  const idx = (startIdx + i) % this.config.models.length;
@@ -385,12 +404,18 @@ export class Agent extends EventEmitter {
385
404
  setPermissionMode(mode) { this.config.permissionMode = mode; }
386
405
  getProvider() { return this.providerId; }
387
406
  /**
388
- * Whether this session's inference endpoint is the Scalix gateway, and so
389
- * can answer /v1/me and serve the account's platform-tool catalog. Callers
390
- * must check this before probing those endpoints: on BYOK the base URL is a
391
- * third-party host that has no such routes.
407
+ * Whether this session can reach a Scalix gateway that answers /v1/me and
408
+ * serves the account's platform-tool catalog. True when inference runs on
409
+ * the gateway, or when BYOK inference is paired with a separate Scalix Cloud
410
+ * key. False means no endpoint to probe — never probe the BYOK base URL.
392
411
  */
393
- supportsPlatformTools() { return this.capabilities.platformTools; }
412
+ supportsPlatformTools() { return this.cloudApiKey.length > 0; }
413
+ /** The Scalix Cloud endpoint platform tools talk to — the gateway, not the
414
+ * inference base URL, which under BYOK is a third-party host. */
415
+ getCloudBaseUrl() { return this.cloudBaseUrl; }
416
+ /** The key platform tools authenticate with — equal to the inference key on
417
+ * the 'scalix' provider, separate under BYOK, empty if unconfigured. */
418
+ getCloudApiKey() { return this.cloudApiKey; }
394
419
  getThinking() { return this.config.thinkingMode; }
395
420
  setThinking(mode) { this.config.thinkingMode = mode; }
396
421
  async compact() {
@@ -447,7 +472,11 @@ export class Agent extends EventEmitter {
447
472
  * itself started rejecting the oversized request. The untouched 1M default
448
473
  * isn't a choice, so it stays out of the way of the guess.
449
474
  */
450
- syncContextLimit(model = this.config.models[this.modelIndex % this.config.models.length]) {
475
+ // Default to models[0] — the model a fresh send will use. modelIndex points
476
+ // one PAST the last pick (retry bookkeeping), so dereferencing it here made
477
+ // idle-time context reads use the NEXT chain entry's budget, not the
478
+ // active model's.
479
+ syncContextLimit(model = this.config.models[0]) {
451
480
  const configured = this.configuredContextTokens;
452
481
  const userChoseLimit = configured != null
453
482
  && configured > 0
@@ -559,8 +588,8 @@ export class Agent extends EventEmitter {
559
588
  * Schema is passed to the model as-is via `parameters` (NOT `inputSchema`,
560
589
  * which transformToolsForApi would run through the Zod converter). */
561
590
  makeCloudTool(ct) {
562
- const baseUrl = this.client.baseURL;
563
- const apiKey = this.config.apiKey;
591
+ const baseUrl = this.cloudBaseUrl;
592
+ const apiKey = this.cloudApiKey;
564
593
  const projectId = this.cloudProjectId;
565
594
  return {
566
595
  source: 'scalix-cloud',
@@ -581,26 +610,41 @@ export class Agent extends EventEmitter {
581
610
  * (/cloud refresh). On failure the previously registered cloud tools are
582
611
  * removed and the agent keeps running with local tools only — the returned
583
612
  * status carries the real error for a single honest notice; no retries.
613
+ *
614
+ * `identity`: pass a CloudIdentity to scope-filter the catalog without a
615
+ * second /v1/me round-trip (the TUI already fetched one); pass `null` when
616
+ * /v1/me was tried and failed so no doomed re-fetch happens; omit entirely
617
+ * (headless, IPC) and the identity is fetched best-effort here — on any
618
+ * /v1/me failure the catalog registers unfiltered, exactly as before.
584
619
  */
585
- async refreshCloudTools() {
586
- // Platform tools live on the Scalix gateway (/v1/me, /api/v1/mcp). Never
587
- // probe a BYOK third-party endpoint for them.
588
- if (!this.capabilities.platformTools) {
589
- this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
590
- this.cloudStatus = { state: 'no-key', toolCount: 0 };
591
- return { ...this.cloudStatus };
592
- }
593
- if (!this.config.apiKey) {
620
+ async refreshCloudTools(identity) {
621
+ // Platform tools live on the Scalix gateway (/v1/me, /api/v1/mcp), which
622
+ // is `cloudBaseUrl` — never the BYOK inference endpoint. No cloud key
623
+ // means nothing to fetch: on 'scalix' the user has no key at all, on BYOK
624
+ // they have not opted into pairing a Scalix Cloud key with their provider.
625
+ if (!this.cloudApiKey) {
594
626
  this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
595
627
  this.cloudStatus = { state: 'no-key', toolCount: 0 };
596
628
  return { ...this.cloudStatus };
597
629
  }
598
630
  try {
599
- const catalog = await fetchCloudTools(this.client.baseURL, this.config.apiKey, this.cloudProjectId);
631
+ const catalog = await fetchCloudTools(this.cloudBaseUrl, this.cloudApiKey, this.cloudProjectId);
632
+ let me = identity;
633
+ if (me === undefined) {
634
+ try {
635
+ me = await fetchMe(this.cloudBaseUrl, this.cloudApiKey);
636
+ }
637
+ catch {
638
+ me = null;
639
+ }
640
+ }
641
+ const visible = me
642
+ ? filterCatalogByScopes(catalog.tools, me.permissions.scopes)
643
+ : catalog.tools;
600
644
  this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
601
645
  const localNames = new Set(this.config.tools.map((t) => t.function?.name || t.name).filter(Boolean));
602
646
  let registered = 0;
603
- for (const ct of catalog.tools) {
647
+ for (const ct of visible) {
604
648
  if (localNames.has(ct.name)) {
605
649
  this.log.warn('Cloud tool name collides with a local tool — keeping the local tool', { tool: ct.name });
606
650
  continue;
@@ -613,14 +657,26 @@ export class Agent extends EventEmitter {
613
657
  this.cloudStatus = {
614
658
  state: 'connected',
615
659
  toolCount: registered,
660
+ totalCount: catalog.tools.length,
616
661
  platformAvailable: catalog.platformAvailable,
617
662
  };
663
+ // The platform binds MCP calls to the KEY's project; a configured
664
+ // project id cannot redirect them. Say so instead of silently differing.
665
+ const keyProject = me?.identity.projectId;
666
+ if (keyProject && this.cloudProjectId && keyProject !== this.cloudProjectId) {
667
+ this.cloudStatus.note = `API key is bound to project ${keyProject}; configured project ${this.cloudProjectId} is ignored for platform tools (the key's binding wins).`;
668
+ }
618
669
  }
619
670
  catch (err) {
620
671
  this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
621
672
  if (this.toolSelector)
622
673
  this.toolSelector.index(this.config.tools);
623
- this.cloudStatus = { state: 'unavailable', toolCount: 0, error: err?.message || String(err) };
674
+ this.cloudStatus = {
675
+ state: 'unavailable',
676
+ toolCount: 0,
677
+ error: err?.message || String(err),
678
+ remedy: cloudKeyRemedy(err),
679
+ };
624
680
  this.log.warn('Scalix Cloud tool catalog unavailable', { error: this.cloudStatus.error });
625
681
  }
626
682
  return { ...this.cloudStatus };
@@ -638,12 +694,39 @@ export class Agent extends EventEmitter {
638
694
  if (!this.config.models.includes(model))
639
695
  this.config.models.push(model);
640
696
  }
641
- setModel(model) { this.config.models = [model]; }
697
+ setModel(model) {
698
+ // Mutate IN PLACE, never reassign: setAgentToolConfig captured this array
699
+ // by reference at construction, so a reassignment left sub-agents on the
700
+ // pre-switch model list forever. And keep the failover chain — replacing
701
+ // the whole list with [model] permanently destroyed a configured
702
+ // multi-model chain; the switched-to model just moves to the front.
703
+ const rest = this.config.models.filter((m) => m !== model);
704
+ this.config.models.splice(0, this.config.models.length, model, ...rest);
705
+ this.modelIndex = 0;
706
+ // A deliberate switch is a vote of confidence: clear this model's breaker
707
+ // so the next request actually tries it, and resize the context budget
708
+ // now instead of at the next lazy resync.
709
+ this.modelFailures.delete(model);
710
+ this.syncContextLimit(model);
711
+ }
642
712
  getModels() { return [...this.config.models]; }
713
+ /** Live-apply a limit. Returns true when the running agent honors the new
714
+ * value immediately, false when it only takes effect on the next launch —
715
+ * callers used to report "updated" unconditionally, which was a lie for
716
+ * every key that lives outside AgentConfig (web/git/search timeouts, …). */
643
717
  updateLimit(key, value) {
718
+ // Context keys live nested under config.contextManager and on the live
719
+ // ContextManager, not as top-level AgentConfig fields.
720
+ if (key === 'maxContextTokens') {
721
+ this.configuredContextTokens = value;
722
+ this.contextManager.setMaxContextTokens(value);
723
+ return true;
724
+ }
644
725
  if (key in this.config) {
645
726
  this.config[key] = value;
727
+ return true;
646
728
  }
729
+ return false;
647
730
  }
648
731
  getReplay() { return this.replay; }
649
732
  getChangeTracker() { return this.changeTracker; }
@@ -847,7 +930,7 @@ export class Agent extends EventEmitter {
847
930
  reqLog.info('User message received', { length: content.length });
848
931
  this.registerInRegistry();
849
932
  try {
850
- const initialModel = this.pickModel();
933
+ const initialModel = this.pickModel(true);
851
934
  reqLog.info('Starting agent loop', { model: initialModel, agentId: this.agentId });
852
935
  let result = await this.loopStreamingTurns(initialModel, reqLog);
853
936
  if (this.outputFormat !== 'text') {
@@ -946,6 +1029,24 @@ export class Agent extends EventEmitter {
946
1029
  }
947
1030
  }
948
1031
  }
1032
+ // Scalix steering + live cloud-tools awareness, assembled here — the
1033
+ // one point every frontend (TUI, headless, IPC) funnels through —
1034
+ // and rebuilt per request so a /cloud refresh shows up on the next
1035
+ // turn. Coder is a generic tool: the product steering only appears
1036
+ // while a Scalix Cloud connection is live; on the Scalix provider
1037
+ // without one a single neutral pointer; on pure BYOK nothing at all.
1038
+ if (this.cloudStatus.state === 'connected') {
1039
+ if (!SCALIX_STEERING_ALWAYS_ON) {
1040
+ effectiveInstructions += '\n\n' + SCALIX_PLATFORM_SECTION + '\n\n' + SCALIX_CLOUD_RESOURCES_SECTION;
1041
+ }
1042
+ const liveCloudTools = this.getCloudTools();
1043
+ if (liveCloudTools.length > 0) {
1044
+ effectiveInstructions += '\n\n' + buildCloudToolsPromptSection(liveCloudTools);
1045
+ }
1046
+ }
1047
+ else if (!SCALIX_STEERING_ALWAYS_ON && this.providerId === 'scalix') {
1048
+ effectiveInstructions += '\n\n' + SCALIX_NEUTRAL_POINTER;
1049
+ }
949
1050
  const vision = modelSupportsVision(currentModel);
950
1051
  const apiMessages = messagesToApi(this.messages, effectiveInstructions, vision);
951
1052
  const thinkingLevel = this.config.thinkingMode !== 'off' ? this.config.thinkingMode : undefined;
@@ -28,7 +28,15 @@ export function parseRetryAfter(value) {
28
28
  return Math.max(0, date - Date.now());
29
29
  return undefined;
30
30
  }
31
- const DEFAULT_TIMEOUT_MS = 120_000;
31
+ // Per-request abort. 300s matches the platform's own proxy walls; the old
32
+ // 120s default aborted the client before a slow first token could arrive on
33
+ // CPU-served models (the request was fine, we hung up on it). For reference,
34
+ // the OpenAI and Anthropic SDKs both default to 600s.
35
+ const DEFAULT_TIMEOUT_MS = (() => {
36
+ const v = process.env.SCALIX_REQUEST_TIMEOUT_MS;
37
+ const n = v ? parseInt(v, 10) : NaN;
38
+ return Number.isFinite(n) && n > 0 ? n : 300_000;
39
+ })();
32
40
  // The capability probe only decides whether a turn streams. It must never be
33
41
  // the reason a turn is slow to start, so it gives up quickly and falls back to
34
42
  // the conservative answer.
package/dist/cli.js CHANGED
@@ -25,9 +25,9 @@ import { renderMarkdown } from './markdown.js';
25
25
  import { costTracker } from './cost-tracker.js';
26
26
  import { saveSession, listSessions, loadSession, generateSessionId, saveCrashState, loadCrashState, clearCrashState, forkSession } from './sessions.js';
27
27
  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';
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, BYOK_DEFAULT_MAX_OUTPUT_TOKENS, USER_CONFIG_DIR, getModelContextWindow, fetchModelSpecs, normalizeModelId, registerUserModels, hasExplicitModelSpec } from './constants.js';
29
29
  import { checkForUpdate, getUpdateBanner, performUpdate } from './update-checker.js';
30
- import { loadConfig, saveConfig, isConfigured, resolveActiveProvider, DEFAULT_LIMITS, THINKING_MODES } from './settings.js';
30
+ import { loadConfig, loadGlobalConfig, saveConfig, isConfigured, resolveActiveProvider, DEFAULT_LIMITS, THINKING_MODES } from './settings.js';
31
31
  import { fetchByokModels } from './providers/openai.js';
32
32
  import { getModeDescription, PERMISSION_MODES, setSandboxRoot, checkScopedPermission, getScopedPermissionRules } from './sandbox.js';
33
33
  import { buildProjectIndex, indexToContext } from './project-index.js';
@@ -47,7 +47,7 @@ import { createWorktree, listWorktrees, removeWorktree, cleanupStaleWorktrees }
47
47
  import { sendNotification, getTerminalType } from './terminal-notify.js';
48
48
  import { stringWidth, truncateText } from './text-utils.js';
49
49
  import { isWorkspaceTrusted, trustWorkspace, hasProjectExecSurface } from './workspace-trust.js';
50
- import { fetchMe, fetchCloudTools, groupCloudTools, CONSOLE_KEYS_URL } from './cloud.js';
50
+ import { fetchMe, fetchCloudTools, groupCloudTools, filterCatalogByScopes, cloudKeyRemedy, CONSOLE_KEYS_URL } from './cloud.js';
51
51
  logger.setLevel('silent');
52
52
  // Promisified async child_process helpers (keep the TUI responsive vs execSync).
53
53
  const execFileAsync = promisify(execFile);
@@ -168,34 +168,36 @@ async function connectCloud(targetAgent) {
168
168
  // unavailable" banner on a session that never asked for Scalix Cloud.
169
169
  // refreshCloudTools() already refuses the catalog fetch for these providers;
170
170
  // 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' };
171
+ const cloudKey = targetAgent.getCloudApiKey();
172
+ if (!cloudKey) {
173
+ cloudConnection = targetAgent.getProvider() === 'scalix'
174
+ ? { connected: false, identity: null, toolCount: 0, reason: 'no-key', error: 'no API key configured' }
175
+ : { connected: false, identity: null, toolCount: 0, reason: 'byok' };
177
176
  return cloudConnection;
178
177
  }
179
178
  try {
180
- const identity = await fetchMe(targetAgent.getBaseUrl() || DEFAULT_BASE_URL, targetAgent.getApiKey());
181
- const status = await targetAgent.refreshCloudTools();
179
+ const identity = await fetchMe(targetAgent.getCloudBaseUrl() || DEFAULT_BASE_URL, cloudKey);
180
+ // Hand the identity over so the catalog is scope-filtered without a
181
+ // second /v1/me round-trip.
182
+ const status = await targetAgent.refreshCloudTools(identity);
182
183
  if (status.state === 'connected') {
183
- cloudConnection = { connected: true, identity, toolCount: status.toolCount };
184
+ cloudConnection = { connected: true, identity, toolCount: status.toolCount, totalCount: status.totalCount, note: status.note };
184
185
  }
185
186
  else {
186
- cloudConnection = { connected: false, identity, toolCount: 0, reason: 'failed', error: status.error };
187
+ cloudConnection = { connected: false, identity, toolCount: 0, reason: 'failed', error: status.error, remedy: status.remedy };
187
188
  }
188
189
  }
189
190
  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);
191
+ // /v1/me failed. Run the catalog fetch anyway (null = don't re-probe
192
+ // /v1/me): on failure it clears any stale platform tools from a previous
193
+ // connect; in the rare case it succeeds, the tools genuinely work and we
194
+ // say so (identity unavailable, catalog unfiltered).
195
+ const status = await targetAgent.refreshCloudTools(null).catch(() => null);
194
196
  if (status?.state === 'connected') {
195
- cloudConnection = { connected: true, identity: null, toolCount: status.toolCount, error: err?.message || String(err) };
197
+ cloudConnection = { connected: true, identity: null, toolCount: status.toolCount, totalCount: status.totalCount, error: err?.message || String(err) };
196
198
  }
197
199
  else {
198
- cloudConnection = { connected: false, identity: null, toolCount: 0, reason: 'failed', error: err?.message || String(err) };
200
+ cloudConnection = { connected: false, identity: null, toolCount: 0, reason: 'failed', error: err?.message || String(err), remedy: status?.remedy ?? cloudKeyRemedy(err) };
199
201
  }
200
202
  }
201
203
  return cloudConnection;
@@ -263,11 +265,38 @@ async function buildEnhancedInstructions() {
263
265
  }
264
266
  return instructions;
265
267
  }
268
+ /** The config the current agent was initialized from — lets command handlers
269
+ * (e.g. the /model picker's BYOK discovery) resolve provider/endpoint without
270
+ * threading the config through every component. */
271
+ let activeAgentConfig = null;
272
+ /** Under BYOK a model without a registered spec sends no max_tokens, and
273
+ * gateways such as OpenRouter then 400 every request — a "successful" switch
274
+ * that can never answer. Ensure any model activated from the TUI has a spec:
275
+ * the catalog-reported window when known, the standard fallbacks otherwise.
276
+ * Explicit user specs are never overwritten. */
277
+ function ensureByokSpec(config, model, contextWindow) {
278
+ if (!config || resolveActiveProvider(config).provider !== 'openai')
279
+ return;
280
+ if (hasExplicitModelSpec(model))
281
+ return;
282
+ const spec = {
283
+ contextWindow: contextWindow && contextWindow > 0 ? contextWindow : BYOK_DEFAULT_CONTEXT_TOKENS,
284
+ maxOutputTokens: BYOK_DEFAULT_MAX_OUTPUT_TOKENS,
285
+ };
286
+ registerUserModels({ [model]: spec });
287
+ loadGlobalConfig().then((cfg) => {
288
+ cfg.customModels = { ...cfg.customModels, [model]: spec };
289
+ return saveConfig(cfg);
290
+ }).catch((err) => { logger.warn('Failed to persist model spec', { error: err.message }); });
291
+ }
266
292
  function initAgent(config) {
267
293
  const envModels = process.env[ENV_MODEL]
268
- ? process.env[ENV_MODEL].split(',').map((m) => m.trim()).filter(Boolean)
294
+ ? process.env[ENV_MODEL].split(',').map((m) => m.trim()).filter(Boolean).map(normalizeModelId)
269
295
  : null;
270
- const models = envModels || config.models;
296
+ // length check, not truthiness: SCALIX_MODEL="," parses to [] which is
297
+ // truthy — the other entry points fall back to config models, so must we.
298
+ const models = envModels && envModels.length > 0 ? envModels : config.models;
299
+ activeAgentConfig = config;
271
300
  if (models.length === 0) {
272
301
  throw new Error('No models configured. Please add at least one model in setup.');
273
302
  }
@@ -277,6 +306,8 @@ function initAgent(config) {
277
306
  apiKey: active.apiKey,
278
307
  baseUrl: active.baseUrl,
279
308
  provider: active.provider,
309
+ cloudApiKey: active.cloudApiKey,
310
+ cloudBaseUrl: active.cloudBaseUrl,
280
311
  model: models.length === 1 ? models[0] : models,
281
312
  instructions: baseInstructions,
282
313
  tools: allTools,
@@ -585,7 +616,10 @@ function SetupScreen({ onComplete, existingConfig }) {
585
616
  (async () => {
586
617
  const identity = await fetchMe(baseUrl, apiKey);
587
618
  const catalog = await fetchCloudTools(baseUrl, apiKey, process.env.SCALIX_PROJECT);
588
- return { identity, toolCount: catalog.tools.length };
619
+ // Count what will actually register for this key, not the raw catalog —
620
+ // the wizard's "N tools unlocked" must match the session that follows.
621
+ const visible = filterCatalogByScopes(catalog.tools, identity.permissions.scopes);
622
+ return { identity, toolCount: visible.length };
589
623
  })().then((result) => {
590
624
  if (cancelled)
591
625
  return;
@@ -594,7 +628,7 @@ function SetupScreen({ onComplete, existingConfig }) {
594
628
  }).catch((err) => {
595
629
  if (cancelled)
596
630
  return;
597
- setCloudCheck({ phase: 'fail', error: err?.message || String(err) });
631
+ setCloudCheck({ phase: 'fail', error: err?.message || String(err), remedy: cloudKeyRemedy(err) });
598
632
  });
599
633
  return () => { cancelled = true; };
600
634
  // apiKey/baseUrl are intentionally not deps: they are frozen once the user
@@ -676,7 +710,7 @@ function SetupScreen({ onComplete, existingConfig }) {
676
710
  setByokContextWindows({});
677
711
  setContextInput(String(defaultContextTokens(p, existingConfig?.limits?.maxContextTokens)));
678
712
  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) => {
713
+ } }) })] })), 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: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(LongTextInput, { value: apiKey, onChange: (v) => { setApiKey(v); setApiKeyError(false); }, onSubmit: (v) => {
680
714
  const cleaned = v.trim();
681
715
  if (cleaned) {
682
716
  setApiKey(cleaned);
@@ -726,7 +760,7 @@ function SetupScreen({ onComplete, existingConfig }) {
726
760
  setFetchingModels(false);
727
761
  });
728
762
  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: [
763
+ }, 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.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
764
  { label: 'Re-enter API key', value: '__reenter__' },
731
765
  { label: 'Skip — continue without Scalix Cloud (local tools only)', value: '__skip__' },
732
766
  ], onSelect: (item) => {
@@ -736,7 +770,7 @@ function SetupScreen({ onComplete, existingConfig }) {
736
770
  else {
737
771
  setStep('models');
738
772
  }
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: [
773
+ } }) })] }))] })), 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' ? (_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
774
  ...fetchedModels.filter((fm) => !models.includes(fm.id)).map((fm) => ({
741
775
  label: ` ${fm.name || fm.id}`,
742
776
  value: fm.id,
@@ -831,7 +865,29 @@ function SetupScreen({ onComplete, existingConfig }) {
831
865
  setSaving(true);
832
866
  const parsed = parseInt(v.trim(), 10);
833
867
  const tokens = parsed > 0 ? parsed : defaultContextTokens(provider, existingConfig?.limits?.maxContextTokens);
834
- const limits = { ...DEFAULT_LIMITS, maxContextTokens: tokens };
868
+ // Preserve the user's /settings customizations — building
869
+ // from DEFAULT_LIMITS alone silently wiped every other
870
+ // limit each time the wizard was re-run.
871
+ const limits = { ...DEFAULT_LIMITS, ...(existingConfig?.limits || {}), maxContextTokens: tokens };
872
+ // BYOK: the endpoint's catalog told us per-model context
873
+ // windows during discovery. Persist them as specs — they
874
+ // used to be thrown away here, leaving every model
875
+ // spec-less, so no max_tokens was sent and OpenRouter-class
876
+ // gateways rejected every request with a 400. Existing
877
+ // explicit specs win.
878
+ const customModels = { ...(existingConfig?.customModels || {}) };
879
+ if (provider === 'openai') {
880
+ for (const m of models) {
881
+ if (!customModels[m]) {
882
+ customModels[m] = {
883
+ contextWindow: byokContextWindows[m] && byokContextWindows[m] > 0
884
+ ? byokContextWindows[m]
885
+ : BYOK_DEFAULT_CONTEXT_TOKENS,
886
+ maxOutputTokens: BYOK_DEFAULT_MAX_OUTPUT_TOKENS,
887
+ };
888
+ }
889
+ }
890
+ }
835
891
  // BYOK: openai creds live under providers.openai; the
836
892
  // top-level apiKey/baseUrl stay reserved for Scalix so
837
893
  // switching back never loses the Scalix connection.
@@ -839,7 +895,7 @@ function SetupScreen({ onComplete, existingConfig }) {
839
895
  apiKey: provider === 'openai' ? (existingConfig?.apiKey || '') : apiKey,
840
896
  baseUrl: provider === 'openai' ? (existingConfig?.baseUrl || DEFAULT_BASE_URL) : baseUrl,
841
897
  models,
842
- customModels: existingConfig?.customModels || {},
898
+ customModels,
843
899
  limits,
844
900
  permissionMode: existingConfig?.permissionMode || 'default',
845
901
  thinkingMode: existingConfig?.thinkingMode || 'off',
@@ -943,7 +999,10 @@ function cloudStatusLine(cloud, detail = true) {
943
999
  return '☁ Scalix Cloud not in use — this session runs on your own provider (local tools only)';
944
1000
  }
945
1001
  if (cloud.reason === 'failed') {
946
- return `☁ Scalix Cloud unavailable — local tools only${detail && cloud.error ? ` · ${cloud.error}` : ''}`;
1002
+ const extra = detail
1003
+ ? `${cloud.error ? ` · ${cloud.error}` : ''}${cloud.remedy ? ` · ${cloud.remedy}` : ''}`
1004
+ : '';
1005
+ return `☁ Scalix Cloud unavailable — local tools only${extra}`;
947
1006
  }
948
1007
  return '☁ not connected — /login to link your Scalix account';
949
1008
  }
@@ -1157,7 +1216,7 @@ function ConfirmPrompt({ toolName, args, onRespond, onScrollUp, onScrollDown })
1157
1216
  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
1217
  }), _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
1218
  }
1160
- function ModelPicker({ currentModels, onSelect, onCancel }) {
1219
+ function ModelPicker({ currentModels, availableModels = [], onSelect, onCancel }) {
1161
1220
  const [customInput, setCustomInput] = useState('');
1162
1221
  const [showCustom, setShowCustom] = useState(false);
1163
1222
  useInput((_ch, key) => { if (key.escape) {
@@ -1174,11 +1233,16 @@ function ModelPicker({ currentModels, onSelect, onCancel }) {
1174
1233
  setShowCustom(false);
1175
1234
  }, placeholder: "provider/model-name" })] }), _jsx(Text, { dimColor: true, children: "ESC to go back" })] }));
1176
1235
  }
1236
+ const catalogOnly = availableModels.filter((m) => !currentModels.includes(m));
1177
1237
  const items = [
1178
1238
  ...currentModels.map((m) => ({
1179
1239
  label: `\u25CF ${shortModel(m)}`,
1180
1240
  value: m,
1181
1241
  })),
1242
+ ...catalogOnly.map((m) => ({
1243
+ label: `\u25CB ${shortModel(m)}`,
1244
+ value: m,
1245
+ })),
1182
1246
  { label: '+ Add new model...', value: '__custom__' },
1183
1247
  { label: ' Cancel', value: '__cancel__' },
1184
1248
  ];
@@ -1809,6 +1873,9 @@ function App({ config }) {
1809
1873
  const [streamText, setStreamText] = useState('');
1810
1874
  const [reasoningText, setReasoningText] = useState('');
1811
1875
  const [selectingModel, setSelectingModel] = useState(false);
1876
+ /** BYOK catalog fetched when the /model picker opens (ids + context windows). */
1877
+ const [pickerCatalog, setPickerCatalog] = useState([]);
1878
+ const [pickerWindows, setPickerWindows] = useState({});
1812
1879
  const [confirmInfo, setConfirmInfo] = useState(null);
1813
1880
  const [thinkStart, setThinkStart] = useState(null);
1814
1881
  const [showBanner, setShowBanner] = useState(true);
@@ -2255,6 +2322,18 @@ function App({ config }) {
2255
2322
  }
2256
2323
  if (cmd === '/model') {
2257
2324
  setSelectingModel(true);
2325
+ // BYOK: the picker used to show only the already-configured list — no
2326
+ // way to discover what the endpoint actually serves. Fetch its catalog
2327
+ // in the background; the picker grows when (if) the list lands.
2328
+ if (activeAgentConfig && resolveActiveProvider(activeAgentConfig).provider === 'openai') {
2329
+ const activeNow = resolveActiveProvider(activeAgentConfig);
2330
+ fetchByokModels(activeNow.baseUrl, activeNow.apiKey).then((found) => {
2331
+ if (found.length === 0)
2332
+ return;
2333
+ setPickerCatalog(found.map((f) => f.id));
2334
+ setPickerWindows(Object.fromEntries(found.filter((f) => f.contextWindow && f.contextWindow > 0).map((f) => [f.id, f.contextWindow])));
2335
+ }).catch(() => { });
2336
+ }
2258
2337
  return true;
2259
2338
  }
2260
2339
  if (cmd === '/compact') {
@@ -2441,7 +2520,14 @@ function App({ config }) {
2441
2520
  else {
2442
2521
  add('system', `Identity unavailable: ${s.error ?? 'unknown error'}`);
2443
2522
  }
2444
- add('system', `Platform tools: ${s.toolCount} registered ${'—'} /cloud tools to list them`);
2523
+ const ofTotal = s.totalCount && s.totalCount > s.toolCount ? ` of ${s.totalCount} in the catalog (this key's scopes unlock ${s.toolCount})` : '';
2524
+ add('system', `Platform tools: ${s.toolCount} registered${ofTotal} ${'—'} /cloud tools to list them`);
2525
+ if (s.note)
2526
+ add('system', s.note);
2527
+ const cloudActiveModel = agent.getModels()[0];
2528
+ if (cloudActiveModel && !hasExplicitModelSpec(cloudActiveModel)) {
2529
+ 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.`);
2530
+ }
2445
2531
  }
2446
2532
  else {
2447
2533
  add('system', cloudStatusLine(s));
@@ -2484,9 +2570,9 @@ function App({ config }) {
2484
2570
  if (arg && PERMISSION_MODES.includes(arg)) {
2485
2571
  agent.setPermissionMode(arg);
2486
2572
  setCurrentMode(arg);
2487
- loadConfig().then(cfg => {
2573
+ loadGlobalConfig().then(cfg => {
2488
2574
  cfg.permissionMode = arg;
2489
- saveConfig(cfg);
2575
+ return saveConfig(cfg);
2490
2576
  }).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
2491
2577
  add('system', `Mode: ${arg.toUpperCase()} \u2014 ${getModeDescription(arg)}`);
2492
2578
  }
@@ -2499,9 +2585,9 @@ function App({ config }) {
2499
2585
  const arg = text.split(' ')[1]?.toLowerCase();
2500
2586
  if (arg && THINKING_MODES.includes(arg)) {
2501
2587
  agent.setThinking(arg);
2502
- loadConfig().then(cfg => {
2588
+ loadGlobalConfig().then(cfg => {
2503
2589
  cfg.thinkingMode = arg;
2504
- saveConfig(cfg);
2590
+ return saveConfig(cfg);
2505
2591
  }).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
2506
2592
  add('system', `Thinking: ${arg.toUpperCase()}${arg === 'off' ? '' : ' \u2014 model will reason before responding'}`);
2507
2593
  }
@@ -3078,7 +3164,7 @@ function App({ config }) {
3078
3164
  add('system', `${PRODUCT_NAME} v${VERSION}`);
3079
3165
  const activeProv = resolveActiveProvider(config);
3080
3166
  add('system', `Provider: ${activeProv.provider === 'openai' ? 'OpenAI-compatible (BYOK)' : 'Scalix Cloud'}`);
3081
- add('system', `Model: ${modelsStr}`);
3167
+ add('system', `Model: ${modelsStr}${process.env[ENV_MODEL] ? ' (set by SCALIX_MODEL env — overrides saved config)' : ''}`);
3082
3168
  add('system', `API: ${baseUrl}`);
3083
3169
  if (activeProv.provider === 'scalix') {
3084
3170
  add('system', `Cloud: ${cloudStatusLine(cloudConnection)}`);
@@ -3088,6 +3174,13 @@ function App({ config }) {
3088
3174
  }
3089
3175
  add('system', `Mode: ${mode}`);
3090
3176
  add('system', `Context: ${ctx.percentage}% (${formatTokens(ctx.tokens)} / ${formatTokens(ctx.maxTokens)})`);
3177
+ // Honesty over silence: when neither the user nor the API supplied a
3178
+ // spec for the active model, the context window above is our guess, and
3179
+ // saying so beats letting it read as a reported fact.
3180
+ const activeModel = agent.getModels()[0];
3181
+ if (activeModel && !hasExplicitModelSpec(activeModel)) {
3182
+ 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.`);
3183
+ }
3091
3184
  add('system', `Messages: ${msgCount}`);
3092
3185
  add('system', `Session: ${sessionId}`);
3093
3186
  add('system', `CWD: ${process.cwd()}`);
@@ -3175,9 +3268,9 @@ function App({ config }) {
3175
3268
  if (cmd === '/plan') {
3176
3269
  agent.setPermissionMode('plan');
3177
3270
  setCurrentMode('plan');
3178
- loadConfig().then(cfg => {
3271
+ loadGlobalConfig().then(cfg => {
3179
3272
  cfg.permissionMode = 'plan';
3180
- saveConfig(cfg);
3273
+ return saveConfig(cfg);
3181
3274
  }).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
3182
3275
  add('system', `Mode: PLAN — ${getModeDescription('plan')}`);
3183
3276
  return true;
@@ -3340,8 +3433,11 @@ function App({ config }) {
3340
3433
  // Reset idle nudge on any user activity
3341
3434
  lastActivityRef.current = Date.now();
3342
3435
  idleNudgedRef.current = false;
3343
- // Allow /mode even while the agent is working
3344
- if (isLoadingRef.current && trimmed.startsWith('/mode')) {
3436
+ // Allow /mode even while the agent is working. Exact match only —
3437
+ // startsWith('/mode') also matched /model and /models, letting the model
3438
+ // picker run (and switch the LIVE response's model) mid-stream while the
3439
+ // queue guard below thought it was protecting them.
3440
+ if (isLoadingRef.current && (trimmed === '/mode' || trimmed.startsWith('/mode '))) {
3345
3441
  handleCommand(trimmed);
3346
3442
  return;
3347
3443
  }
@@ -3407,8 +3503,13 @@ function App({ config }) {
3407
3503
  loadSession(session.id).then((data) => {
3408
3504
  agent.loadMessages(data.messages);
3409
3505
  sessionId = session.id;
3410
- setModels([session.model]);
3411
- agent.setModel(session.model);
3506
+ const resumeModel = normalizeModelId(session.model);
3507
+ agent.setModel(resumeModel);
3508
+ setModels(agent.getModels());
3509
+ // The session may have been recorded under another provider/spec —
3510
+ // without this a BYOK resume reactivates a spec-less model (no
3511
+ // max_tokens sent → gateways 400 every request).
3512
+ ensureByokSpec(activeAgentConfig, resumeModel);
3412
3513
  if (data.costState) {
3413
3514
  costTracker.restore(data.costState);
3414
3515
  setTotalTokens(data.costState.totalTokens);
@@ -3542,26 +3643,36 @@ function App({ config }) {
3542
3643
  if (showSetup) {
3543
3644
  return _jsx(SetupScreen, { onComplete: handleSetupDone, existingConfig: config });
3544
3645
  }
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) => {
3646
+ 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) => {
3647
+ const v = normalizeModelId(picked);
3546
3648
  agent.setModel(v);
3547
- setModels([v]);
3649
+ // Keep the whole failover chain visible, not just the pick —
3650
+ // setModel moves the model to the front and preserves the rest.
3651
+ setModels(agent.getModels());
3548
3652
  setSelectingModel(false);
3549
- loadConfig().then(cfg => {
3550
- cfg.models = [v];
3551
- saveConfig(cfg);
3653
+ ensureByokSpec(activeAgentConfig, v, pickerWindows[v]);
3654
+ // Global config only (loadConfig() merges project-local overrides
3655
+ // and round-tripping them through saveConfig laundered them into
3656
+ // ~/.scalix-coder). Chain-preserving, and the saveConfig promise
3657
+ // is returned so its failure actually hits the catch.
3658
+ loadGlobalConfig().then(cfg => {
3659
+ cfg.models = [v, ...cfg.models.filter(m => m !== v)];
3660
+ return saveConfig(cfg);
3552
3661
  }).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
3553
- add('system', `Model switched to ${shortModel(v)}`);
3662
+ add('system', `Model switched to ${shortModel(v)}${process.env[ENV_MODEL] ? ' — note: SCALIX_MODEL is set and overrides this on next launch' : ''}`);
3554
3663
  }, onCancel: () => setSelectingModel(false) })) : selectingMode ? (_jsx(ModePicker, { currentMode: currentMode, onSelect: (mode) => {
3555
3664
  agent.setPermissionMode(mode);
3556
3665
  setCurrentMode(mode);
3557
3666
  setSelectingMode(false);
3558
- loadConfig().then(cfg => {
3667
+ loadGlobalConfig().then(cfg => {
3559
3668
  cfg.permissionMode = mode;
3560
- saveConfig(cfg);
3669
+ return saveConfig(cfg);
3561
3670
  }).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
3562
3671
  add('system', `Mode: ${mode.toUpperCase()} \u2014 ${getModeDescription(mode)}`);
3563
3672
  }, onCancel: () => setSelectingMode(false) })) : editingSettings ? (_jsx(SettingsEditor, { onSave: (key, value) => {
3564
- loadConfig().then((c) => {
3673
+ // Global config only — round-tripping the override-merged view
3674
+ // laundered project-local settings into ~/.scalix-coder.
3675
+ loadGlobalConfig().then((c) => {
3565
3676
  const limits = { ...(c.limits || DEFAULT_LIMITS) };
3566
3677
  const floatKeys = new Set([
3567
3678
  'compressionThreshold', 'messageKeepRatio', 'subAgentCompressionThreshold',
@@ -3572,8 +3683,13 @@ function App({ config }) {
3572
3683
  limits[key] = parsed;
3573
3684
  const newConfig = { ...c, limits };
3574
3685
  saveConfig(newConfig).then(() => {
3575
- agent?.updateLimit(key, parsed);
3576
- add('system', `Setting updated: ${key} = ${parsed}`);
3686
+ // updateLimit says whether the RUNNING agent honors the new
3687
+ // value — claiming "updated" for keys that only apply on
3688
+ // the next launch was a lie the user had no way to detect.
3689
+ const live = agent?.updateLimit(key, parsed) ?? false;
3690
+ add('system', live
3691
+ ? `Setting updated: ${key} = ${parsed}`
3692
+ : `Setting saved: ${key} = ${parsed} — takes effect on the next launch`);
3577
3693
  }).catch(() => {
3578
3694
  add('error', `Failed to save setting: ${key}`);
3579
3695
  });
package/dist/cloud.js CHANGED
@@ -18,8 +18,12 @@ import { apiHostRoot, USER_AGENT, WEB_DEFAULTS } from './constants.js';
18
18
  export const CONSOLE_KEYS_URL = 'https://console.scalix.world/org/keys';
19
19
  export const CLOUD_DEFAULTS = {
20
20
  fetchTimeoutMs: WEB_DEFAULTS.apiFetchTimeoutMs,
21
- // Platform operations (deploys, migrations) can legitimately take a while.
22
- callTimeoutMs: 120_000,
21
+ // The platform's own transport kills a tool call at ~30-35s (gateway →
22
+ // mcp-server hop, then the inner REST callback). 45s covers that ceiling
23
+ // plus jitter; anything longer is waiting on a call the server already
24
+ // abandoned. Long operations go through async pairs (build_create →
25
+ // build_status) — see buildCloudToolsPromptSection.
26
+ callTimeoutMs: 45_000,
23
27
  };
24
28
  export class CloudError extends Error {
25
29
  status;
@@ -51,7 +55,7 @@ export function errorFromEnvelope(status, statusText, bodyText) {
51
55
  }
52
56
  catch { }
53
57
  if (status === 401) {
54
- return new CloudError(`API key rejected — create one at ${CONSOLE_KEYS_URL}`, {
58
+ return new CloudError(`API key rejected — create a project-scoped key (all scopes is simplest) at ${CONSOLE_KEYS_URL}`, {
55
59
  status,
56
60
  code: typeof envelope?.code === 'string' ? envelope.code : undefined,
57
61
  docsUrl: typeof envelope?.docs_url === 'string' ? envelope.docs_url : undefined,
@@ -182,8 +186,11 @@ export async function fetchMe(baseUrl, apiKey) {
182
186
  return parseMeResponse(await res.json());
183
187
  }
184
188
  /**
185
- * Fetch the account's MCP tool catalog from `/api/v1/mcp`. The catalog is
186
- * scope-filtered server-side for the caller's key. Throws CloudError on failure.
189
+ * Fetch the account's MCP tool catalog from `/api/v1/mcp`. The gateway
190
+ * advertises the full platform tool set to every authenticated key — only the
191
+ * gateway-native DB tools are scope-filtered server-side — so callers that
192
+ * know the key's scopes should narrow the result with filterCatalogByScopes()
193
+ * before registering. Throws CloudError on failure.
187
194
  */
188
195
  export async function fetchCloudTools(baseUrl, apiKey, projectId) {
189
196
  const url = `${apiHostRoot(baseUrl)}/api/v1/mcp`;
@@ -219,6 +226,68 @@ export async function callCloudTool(baseUrl, apiKey, name, args, projectId) {
219
226
  }
220
227
  return parseToolCallResult(await res.json());
221
228
  }
229
+ /**
230
+ * One-line, user-actionable fix for the two key-shape failures the platform
231
+ * returns on the MCP routes: 400 = org-wide key (platform tools require a
232
+ * project-scoped key), 403 = missing the database scope (database:query is
233
+ * the one that unlocks tools; the default all-scopes project key covers it).
234
+ * Anything else gets no remedy — the server's own error text stands alone.
235
+ */
236
+ export function cloudKeyRemedy(err) {
237
+ if (err?.status === 400) {
238
+ return `This looks like an org-wide key — platform tools need a project-scoped key (all scopes, or at least database:query). Create one at ${CONSOLE_KEYS_URL}`;
239
+ }
240
+ if (err?.status === 403) {
241
+ return `This key is missing the database scope — create a project key with database:query (or all scopes) at ${CONSOLE_KEYS_URL}`;
242
+ }
243
+ return undefined;
244
+ }
245
+ // Tool-name prefix → API-key scope family (the part before ':'). Mirrors the
246
+ // gateway's route enforcement, NOT /v1/me's per-tool list: that list drifts
247
+ // from enforcement in places (run_scale/run_rollback, compute_delete), so a
248
+ // per-tool intersection would hide tools a key can actually call. Family
249
+ // granularity only drops whole areas the key holds no scope for at all.
250
+ const TOOL_SCOPE_FAMILIES = [
251
+ [/^scalix_db_/, 'database'],
252
+ [/^scalix_run_/, 'services'],
253
+ [/^scalix_(compute|domain|build)_/, 'compute'],
254
+ [/^scalix_storage_/, 'storage'],
255
+ [/^scalix_kv_/, 'kv'],
256
+ [/^scalix_fn_/, 'functions'],
257
+ [/^scalix_events_/, 'events'],
258
+ [/^scalix_cron_/, 'cron'],
259
+ [/^scalix_(computer_|sandbox_run$)/, 'sandbox'],
260
+ [/^scalix_ai_/, 'ai'],
261
+ [/^scalix_search$/, 'search'],
262
+ [/^scalix_usage$/, 'metering'],
263
+ [/^scalix_(project_|auth_configure$)/, 'admin'],
264
+ ];
265
+ /** Scope family a tool belongs to, or null for tools that are never filtered
266
+ * (scalix_status, and any future tool whose prefix we don't know — fail open
267
+ * so a new platform area is not invisible until a client release). */
268
+ export function scopeFamilyForTool(name) {
269
+ for (const [re, family] of TOOL_SCOPE_FAMILIES) {
270
+ if (re.test(name))
271
+ return family;
272
+ }
273
+ return null;
274
+ }
275
+ /**
276
+ * Narrow a tool catalog to what the key's scopes can plausibly call. `*`
277
+ * keeps everything; otherwise a tool is kept when its scope family appears in
278
+ * the key's scopes (any action within the family counts) or when it has no
279
+ * known family. Call-time enforcement still has the final word — this only
280
+ * stops the model from seeing whole platform areas its key cannot touch.
281
+ */
282
+ export function filterCatalogByScopes(tools, scopes) {
283
+ if (scopes.includes('*'))
284
+ return tools;
285
+ const families = new Set(scopes.map((s) => s.split(':')[0]));
286
+ return tools.filter((t) => {
287
+ const family = scopeFamilyForTool(t.name);
288
+ return family === null || families.has(family);
289
+ });
290
+ }
222
291
  /**
223
292
  * Group tools by their platform area for display: scalix_db_query → "db",
224
293
  * scalix_run_deploy → "run", non-scalix names → "other".
@@ -234,3 +303,23 @@ export function groupCloudTools(tools) {
234
303
  }
235
304
  return groups;
236
305
  }
306
+ /**
307
+ * System-prompt section describing the live platform tools for this session.
308
+ * Appended per-request while the cloud connection is up, so the model knows
309
+ * exactly what is at its disposal — and that the platform's transport ends a
310
+ * tool call at roughly 30 seconds, which makes async pairs the only way to
311
+ * run long operations.
312
+ */
313
+ export function buildCloudToolsPromptSection(tools) {
314
+ const groups = groupCloudTools(tools);
315
+ const counts = [...groups.entries()]
316
+ .sort(([a], [b]) => a.localeCompare(b))
317
+ .map(([group, list]) => `${group} (${list.length})`)
318
+ .join(', ');
319
+ return [
320
+ '## Scalix Cloud Tools (live this session)',
321
+ `${tools.length} scalix_* tools are registered and authenticated: ${counts}. Prefer them over any CLI or SDK for platform work.`,
322
+ 'Platform tool calls have a ~30s execution ceiling — a single call cannot outlive it. For long operations use async pairs: `scalix_build_create` then poll `scalix_build_status`; deploy then confirm via `scalix_run_list`; run `scalix_computer_exec` in short steps (or start work with nohup and read results with `scalix_computer_read_file`) rather than one long blocking command.',
323
+ 'If a call fails with a timeout, do not retry the same call — switch to the async pattern.',
324
+ ].join('\n');
325
+ }
package/dist/constants.js CHANGED
@@ -38,7 +38,11 @@ export function aiEndpointUrl(baseUrl, endpoint) {
38
38
  return `${apiHostRoot(baseUrl)}/v1/ai/${endpoint.replace(/^\/+/, '')}`;
39
39
  }
40
40
  export const DEFAULT_MODELS = [];
41
- export const DEFAULT_INSTRUCTIONS = `You are an expert coding assistant. You write clean, correct, production-quality code. Be concise and direct.
41
+ // The system prompt is split so the Scalix product steering can be gated on
42
+ // connection state (Coder is a generic tool — BYOK sessions with no Scalix
43
+ // key get no Scalix pitch). The three parts joined with '\n\n' are
44
+ // byte-identical to the pre-split DEFAULT_INSTRUCTIONS (locked by test).
45
+ export const CORE_INSTRUCTIONS = `You are an expert coding assistant. You write clean, correct, production-quality code. Be concise and direct.
42
46
 
43
47
  ## Workflow
44
48
  1. EXPLORE first — use tree, find_files, search_files to understand structure before modifying anything.
@@ -69,21 +73,44 @@ export const DEFAULT_INSTRUCTIONS = `You are an expert coding assistant. You wri
69
73
  - Never modify files outside the working directory.
70
74
  - Never expose API keys, secrets, or credentials in code.
71
75
  - Ask before destructive operations (deleting files, force-pushing, dropping tables).
72
- - When running commands, check the output — do not assume success.
73
-
74
- ## Scalix Platform
76
+ - When running commands, check the output — do not assume success.`;
77
+ export const SCALIX_PLATFORM_SECTION = `## Scalix Platform
75
78
  When helping users build AI applications, agents, or LLM-powered features:
76
- - Use the Scalix SDK (\`@scalix-world/sdk\` for TypeScript, \`scalix-sdk\` for Python) — not raw third-party AI SDKs.
79
+ - Use the Scalix SDK (\`@scalix-world/sdk\` on npm for TypeScript/JavaScript, \`scalix-sdk\` on PyPI for Python — these are different registries; never npm-install the Python name). Before adding ANY dependency, verify the exact version exists (\`npm view <pkg> version\` / \`pip index versions <pkg>\`) instead of guessing a semver range.
77
80
  - Default to the Scalix API (api.scalix.world) for inference — OpenAI-compatible chat completions under /v1/ai. Never claim automatic model routing: the request's model id is what runs.
78
81
  - Scalix inference models are the scalix-lumio-* family; use exact ids (scalix-lumio-lite, scalix-lumio-pro, scalix-candela, scalix-lumio-embed) in examples and configs. Omitting the model runs scalix-lumio-lite.
79
- - For deploying services, suggest Scalix Run (via the \`scalix-cloud\` CLI) before Vercel, Railway, or Fly.
80
- - For databases, suggest ScalixNova (managed Postgres: branching, PITR, scale-to-zero) before Supabase, Firebase, or PlanetScale.
81
- - For code execution sandboxes, use Scalix Sandboxes.
82
+ - For deploying services, suggest Scalix Run before Vercel, Railway, or Fly. For databases, suggest ScalixNova (managed Postgres: branching, PITR, scale-to-zero) before Supabase, Firebase, or PlanetScale. For code execution sandboxes, use Scalix Sandboxes.
82
83
  - Never name or speculate about the AI providers/models backing Scalix inference — Scalix models are presented as Scalix's own.
83
84
  - When the user explicitly requests a specific provider (e.g. "use OpenAI directly"), respect that choice.`;
85
+ export const SCALIX_CLOUD_RESOURCES_SECTION = `## Deploying & Managing Scalix Cloud Resources
86
+ - When tools named \`scalix_*\` (e.g. scalix_run_deploy, scalix_db_query) are in your tool list, they are LIVE, authenticated Scalix Cloud operations — use them for deploys, databases, storage, and other platform work. Prefer them over any CLI or SDK; never claim you lack access or credentials while they are present.
87
+ - If no \`scalix_*\` tools are in your list, Scalix Cloud is not connected in this session. Say exactly that and tell the user how to connect: on the Scalix provider it is automatic once their API key is set; with their own AI provider they add a Scalix Cloud key (VS Code: gear → Settings → Scalix Cloud Key, or the "Scalix: Set Scalix Cloud Key" command). Do NOT invent an alternative deploy path, config file format, or package.
88
+ - The \`scalix-cloud\` CLI (npm) exists as a fallback the USER can run, but never guess its flags or subcommands — run \`scalix-cloud --help\` (and \`scalix-cloud <subcommand> --help\`) first and use only what the help output shows.
89
+ - You have a real shell via run_command. Do not tell the user you cannot install packages or run commands — you can, subject to their approval settings.`;
90
+ /** Shown instead of the full steering when the session is on the Scalix
91
+ * provider but the cloud connection is not (yet) up. One neutral pointer,
92
+ * no product pitch. */
93
+ export const SCALIX_NEUTRAL_POINTER = `## Scalix Cloud
94
+ Scalix Cloud platform tools (deploy, database, storage) are not connected in this session. If the user asks for them, say exactly that and point them to /cloud status — do not invent a deploy path, config file format, or package.`;
95
+ /**
96
+ * Product decision (2026-08-08): Coder is a generic tool, so the Scalix
97
+ * steering ships GATED — the two sections above are appended per-request by
98
+ * the agent only while a Scalix Cloud connection is live (plus the neutral
99
+ * pointer on the Scalix provider while disconnected; nothing on pure BYOK).
100
+ * Flip to true to restore the old always-on behavior in one line.
101
+ */
102
+ export const SCALIX_STEERING_ALWAYS_ON = false;
103
+ export const DEFAULT_INSTRUCTIONS = SCALIX_STEERING_ALWAYS_ON
104
+ ? `${CORE_INSTRUCTIONS}\n\n${SCALIX_PLATFORM_SECTION}\n\n${SCALIX_CLOUD_RESOURCES_SECTION}`
105
+ : CORE_INSTRUCTIONS;
84
106
  export const ENV_API_KEY = 'SCALIX_API_KEY';
85
107
  export const ENV_MODEL = 'SCALIX_MODEL';
86
108
  export const ENV_BASE_URL = 'SCALIX_BASE_URL';
109
+ /** Scalix Cloud credentials for the platform tools, independent of inference.
110
+ * Only needed under BYOK — on the 'scalix' provider the inference key above
111
+ * already authenticates the platform tools. */
112
+ export const ENV_CLOUD_API_KEY = 'SCALIX_CLOUD_API_KEY';
113
+ export const ENV_CLOUD_BASE_URL = 'SCALIX_CLOUD_BASE_URL';
87
114
  export const DEFAULT_MODEL_SPEC = { contextWindow: 200_000, maxOutputTokens: 65_536 };
88
115
  // Per-request output ceiling on the Scalix path. Matches the platform's own cap
89
116
  // (gateway AI_MAX_OUTPUT_TOKENS, mirrored by the completions schema), so a
@@ -260,6 +287,10 @@ export const AGENT_DEFAULTS = {
260
287
  * can honour, and pre-filling it was inventing a number on the user's behalf.
261
288
  */
262
289
  export const BYOK_DEFAULT_CONTEXT_TOKENS = 128_000;
290
+ /** Fallback longest-reply cap when a BYOK catalog does not state one.
291
+ * Omitting max_tokens is not a safe default — gateways such as OpenRouter
292
+ * then reserve the whole context window for output and 400 every request. */
293
+ export const BYOK_DEFAULT_MAX_OUTPUT_TOKENS = 16_384;
263
294
  export const CONTEXT_DEFAULTS = {
264
295
  maxContextTokens: 1_000_000,
265
296
  compressionThreshold: 0.80,
package/dist/headless.js CHANGED
@@ -13,8 +13,8 @@ import { loadMcpServices } from './mcp.js';
13
13
  import { costTracker } from './cost-tracker.js';
14
14
  import { saveSession, loadSession, listSessions, generateSessionId } from './sessions.js';
15
15
  import { logger } from './logger.js';
16
- import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS, ENV_MODEL, HEADLESS_DEFAULTS, fetchModelSpecs } from './constants.js';
17
- import { loadConfig, resolveActiveProvider, DEFAULT_LIMITS } from './settings.js';
16
+ import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS, ENV_MODEL, HEADLESS_DEFAULTS, fetchModelSpecs, normalizeModelId } from './constants.js';
17
+ import { loadConfig, loadGlobalConfig, saveConfig, resolveActiveProvider, DEFAULT_LIMITS } from './settings.js';
18
18
  import { checkForUpdate, getUpdateBanner } from './update-checker.js';
19
19
  import { getModeDescription, PERMISSION_MODES, setSandboxRoot } from './sandbox.js';
20
20
  import { collectGitInfo, gitInfoToContext } from './git-context.js';
@@ -155,7 +155,7 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeE
155
155
  }).catch(() => { });
156
156
  }
157
157
  const envModels = process.env[ENV_MODEL]
158
- ? process.env[ENV_MODEL].split(',').map((m) => m.trim()).filter(Boolean)
158
+ ? process.env[ENV_MODEL].split(',').map((m) => m.trim()).filter(Boolean).map(normalizeModelId)
159
159
  : [];
160
160
  const selectedModel = envModels.length > 0 ? envModels
161
161
  : config.models.length > 0 ? config.models : DEFAULT_MODELS;
@@ -194,6 +194,8 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeE
194
194
  apiKey,
195
195
  baseUrl,
196
196
  provider: active.provider,
197
+ cloudApiKey: active.cloudApiKey,
198
+ cloudBaseUrl: active.cloudBaseUrl,
197
199
  model: selectedModel,
198
200
  instructions,
199
201
  tools: allTools,
@@ -216,7 +218,8 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeE
216
218
  // is stated once and the run continues with local tools only).
217
219
  const cloudStatus = await agent.refreshCloudTools();
218
220
  if (cloudStatus.state === 'unavailable') {
219
- process.stderr.write(`\x1b[33m☁ Scalix Cloud unavailable — running with local tools only. ${cloudStatus.error ?? ''}\x1b[0m\n`);
221
+ const remedy = cloudStatus.remedy ? ` ${cloudStatus.remedy}` : '';
222
+ process.stderr.write(`\x1b[33m☁ Scalix Cloud unavailable — running with local tools only. ${cloudStatus.error ?? ''}${remedy}\x1b[0m\n`);
220
223
  }
221
224
  let fullResponse = '';
222
225
  agent.on('tool:call', (name) => process.stderr.write(`\x1b[33m▶ ${name}\x1b[0m\n`));
@@ -319,7 +322,7 @@ async function runInteractive() {
319
322
  if (active.provider === 'scalix')
320
323
  fetchModelSpecs(baseUrl, apiKey).catch(() => { });
321
324
  const envModels = process.env[ENV_MODEL]
322
- ? process.env[ENV_MODEL].split(',').map((m) => m.trim()).filter(Boolean)
325
+ ? process.env[ENV_MODEL].split(',').map((m) => m.trim()).filter(Boolean).map(normalizeModelId)
323
326
  : [];
324
327
  const selectedModel = envModels.length > 0 ? envModels
325
328
  : config.models.length > 0 ? config.models : DEFAULT_MODELS;
@@ -355,6 +358,8 @@ async function runInteractive() {
355
358
  apiKey,
356
359
  baseUrl,
357
360
  provider: active.provider,
361
+ cloudApiKey: active.cloudApiKey,
362
+ cloudBaseUrl: active.cloudBaseUrl,
358
363
  model: selectedModel,
359
364
  instructions,
360
365
  tools: allTools,
@@ -412,7 +417,8 @@ async function runInteractive() {
412
417
  // is stated once and the session continues with local tools only).
413
418
  const cloudStatus = await agent.refreshCloudTools();
414
419
  if (cloudStatus.state === 'unavailable') {
415
- console.log(`\x1b[33m☁ Scalix Cloud unavailable — running with local tools only. ${cloudStatus.error ?? ''}\x1b[0m`);
420
+ const remedy = cloudStatus.remedy ? ` ${cloudStatus.remedy}` : '';
421
+ console.log(`\x1b[33m☁ Scalix Cloud unavailable — running with local tools only. ${cloudStatus.error ?? ''}${remedy}\x1b[0m`);
416
422
  }
417
423
  const toolCount = allTools.length + cloudStatus.toolCount;
418
424
  const modelDisplay = selectedModel.map(shortModel).join(' | ');
@@ -502,8 +508,16 @@ async function runInteractive() {
502
508
  console.log(`\nInvalid model name: "${name}"\n`);
503
509
  return true;
504
510
  }
505
- agent.setModel(name);
506
- console.log(`\nSwitched to ${shortModel(name)}\n`);
511
+ const chosen = normalizeModelId(name);
512
+ agent.setModel(chosen);
513
+ // Persist like the TUI does — an in-memory-only switch silently
514
+ // reverted on the next launch. Global config only (no project-override
515
+ // laundering), chain-preserving (chosen model to the front).
516
+ loadGlobalConfig().then((cfg) => {
517
+ cfg.models = [chosen, ...cfg.models.filter((m) => m !== chosen)];
518
+ return saveConfig(cfg);
519
+ }).catch(() => { });
520
+ console.log(`\nSwitched to ${shortModel(chosen)}${process.env[ENV_MODEL] ? '\n(note: SCALIX_MODEL is set and will override this on next launch)' : ''}\n`);
507
521
  return true;
508
522
  }
509
523
  if (cmd === '/compact') {
@@ -22,7 +22,7 @@ import { loadMcpServices } from './mcp.js';
22
22
  import { loadConfig, resolveActiveProvider, DEFAULT_LIMITS } from './settings.js';
23
23
  import { discoverPlugins, getPluginTools } from './plugins.js';
24
24
  import { isWorkspaceTrusted } from './workspace-trust.js';
25
- import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS, ENV_MODEL, fetchModelSpecs, registerUserModels, VERSION } from './constants.js';
25
+ import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS, ENV_MODEL, fetchModelSpecs, registerUserModels, normalizeModelId, VERSION } from './constants.js';
26
26
  import { loadMemory, autoDetectConventions, saveMemory } from './project-memory.js';
27
27
  import { buildProjectContext, contextToInstructions } from './context-builder.js';
28
28
  const pendingConfirms = new Map();
@@ -53,6 +53,14 @@ export async function runIpcServer() {
53
53
  console.log = (...a) => { process.stderr.write(a.map(String).join(' ') + '\n'); };
54
54
  console.error = (...a) => { process.stderr.write(a.map(String).join(' ') + '\n'); };
55
55
  console.warn = (...a) => { process.stderr.write(a.map(String).join(' ') + '\n'); };
56
+ // A stray rejection from a streaming/network path must not kill the engine:
57
+ // the extension auto-reconnects, but the fresh process has lost the whole
58
+ // session, which reads as the agent "forgetting" its task mid-way. Log to
59
+ // stderr (the extension surfaces it) and keep serving.
60
+ process.on('unhandledRejection', (reason) => {
61
+ const detail = reason instanceof Error ? (reason.stack || reason.message) : String(reason);
62
+ process.stderr.write(`IPC: unhandled rejection (kept alive): ${detail}\n`);
63
+ });
56
64
  const config = await loadConfig();
57
65
  const active = resolveActiveProvider(config);
58
66
  const apiKey = active.apiKey;
@@ -69,8 +77,15 @@ export async function runIpcServer() {
69
77
  ? process.env[ENV_MODEL].split(',').map(m => m.trim()).filter(Boolean)
70
78
  : [];
71
79
  const selectedModels = envModels.length > 0
72
- ? envModels
80
+ ? envModels.map(normalizeModelId)
73
81
  : config.models.length > 0 ? config.models : DEFAULT_MODELS;
82
+ if (selectedModels.length === 0) {
83
+ // Structured, like the missing-key path above — without this the Agent
84
+ // constructor throw escaped as plain text and the extension saw garbage
85
+ // instead of a protocol-level error.
86
+ write({ type: 'event', event: 'error', data: { message: 'No models configured. Set SCALIX_MODEL or pick a model in setup.' } });
87
+ process.exit(1);
88
+ }
74
89
  const baseUrl = active.baseUrl;
75
90
  if (active.provider === 'scalix')
76
91
  fetchModelSpecs(baseUrl, apiKey).catch(() => { });
@@ -109,6 +124,8 @@ export async function runIpcServer() {
109
124
  apiKey,
110
125
  baseUrl,
111
126
  provider: active.provider,
127
+ cloudApiKey: active.cloudApiKey,
128
+ cloudBaseUrl: active.cloudBaseUrl,
112
129
  model: selectedModels,
113
130
  instructions,
114
131
  tools: allTools,
@@ -139,6 +156,14 @@ export async function runIpcServer() {
139
156
  // Signal ready with capability info. `version` lets the extension detect
140
157
  // engine/extension drift (a globally-installed CLI can lag the bundled one).
141
158
  write({ type: 'event', event: 'ready', data: { version: VERSION, tools: allTools.length, models: selectedModels, permissionMode } });
159
+ // Register the account's Scalix Cloud platform tools (deploy, database, …).
160
+ // Not awaited: the catalog fetch crosses the network and the extension's
161
+ // connect handshake must not wait on it — the tools join the set when the
162
+ // fetch lands. Without this call the GUI engine had NO scalix_* tools at
163
+ // all, and models asked to deploy invented SDKs and CLI flags instead.
164
+ void agent.refreshCloudTools()
165
+ .then(status => { write({ type: 'event', event: 'cloud:status', data: status }); })
166
+ .catch(err => { process.stderr.write(`IPC: cloud tools registration failed: ${err?.message || err}\n`); });
142
167
  // Process JSONL from stdin
143
168
  let busy = false;
144
169
  const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
@@ -196,11 +221,19 @@ export async function runIpcServer() {
196
221
  agent.clearHistory();
197
222
  write({ type: 'response', id, result: { success: true } });
198
223
  break;
199
- case 'setModel':
200
- if (params?.model)
201
- agent.setModel(params.model);
224
+ case 'setModel': {
225
+ // Same format rule as the headless REPL — the old handler accepted
226
+ // any truthy string (typos included) and answered an EMPTY model
227
+ // with success, both of which read back as "switched" in the UI.
228
+ const requested = typeof params?.model === 'string' ? params.model.trim() : '';
229
+ if (!requested || !/^[a-zA-Z0-9][\w./:@-]*$/.test(requested)) {
230
+ write({ type: 'response', id, error: { message: `Invalid model id: "${requested || '(empty)'}"` } });
231
+ break;
232
+ }
233
+ agent.setModel(normalizeModelId(requested));
202
234
  write({ type: 'response', id, result: { model: agent.getModels()[0] } });
203
235
  break;
236
+ }
204
237
  case 'compact': {
205
238
  const r = await agent.compact();
206
239
  write({ type: 'response', id, result: { message: r } });
package/dist/settings.js CHANGED
@@ -91,6 +91,10 @@ function validateConfig(parsed) {
91
91
  typeof s.contextWindow === 'number' && s.contextWindow > 0 &&
92
92
  typeof s.maxOutputTokens === 'number' && s.maxOutputTokens > 0) {
93
93
  validated[id] = { contextWindow: s.contextWindow, maxOutputTokens: s.maxOutputTokens };
94
+ // Keep the vision flag — dropping it made every user-declared vision
95
+ // model report supportsVision === false and silently strip images.
96
+ if (s.supportsVision === true)
97
+ validated[id].supportsVision = true;
94
98
  }
95
99
  }
96
100
  if (Object.keys(validated).length > 0) {
@@ -387,6 +391,22 @@ export async function loadConfig() {
387
391
  return config;
388
392
  }
389
393
  }
394
+ /**
395
+ * The GLOBAL config file only — no project-local settings.local.json, no
396
+ * managed-policy merge, no keychain resolution. This is the ONLY correct base
397
+ * for a read-modify-write persistence cycle: round-tripping loadConfig()
398
+ * (which merges overrides) through saveConfig() laundered project-local
399
+ * models/limits/customModels into ~/.scalix-coder/config.json permanently.
400
+ */
401
+ export async function loadGlobalConfig() {
402
+ try {
403
+ const raw = await fs.readFile(getConfigFile(), 'utf-8');
404
+ return { ...DEFAULTS, ...validateConfig(JSON.parse(raw)) };
405
+ }
406
+ catch {
407
+ return { ...DEFAULTS };
408
+ }
409
+ }
390
410
  export async function saveConfig(config) {
391
411
  await ensureDir();
392
412
  // Store API keys in keychain if available, strip from config file. The
@@ -436,23 +456,34 @@ export const OPENAI_KEYCHAIN_ACCOUNT = 'api-key-openai';
436
456
  * path is byte-identical to the pre-BYOK behavior. There is NO default
437
457
  * third-party URL — an openai selection without a baseUrl resolves with an
438
458
  * empty one and the caller surfaces the configuration error.
459
+ *
460
+ * Scalix Cloud credentials resolve separately so BYOK sessions keep the
461
+ * platform tools: under `openai` they come from `SCALIX_CLOUD_API_KEY` →
462
+ * `SCALIX_API_KEY` → the top-level `config.apiKey`, and are simply empty when
463
+ * the user never added one.
439
464
  */
440
465
  export function resolveActiveProvider(config, env = process.env) {
441
466
  const envProvider = env.SCALIX_PROVIDER?.trim().toLowerCase();
442
467
  const provider = isProviderId(envProvider)
443
468
  ? envProvider
444
469
  : (config.provider ?? 'scalix');
470
+ const scalixBaseUrl = env.SCALIX_BASE_URL?.trim() || config.baseUrl;
471
+ const scalixApiKey = env.SCALIX_API_KEY?.trim() || config.apiKey;
445
472
  if (provider === 'openai') {
446
473
  const creds = config.providers?.openai ?? {};
447
474
  return {
448
475
  provider,
449
476
  baseUrl: env.OPENAI_BASE_URL?.trim() || creds.baseUrl || '',
450
477
  apiKey: env.OPENAI_API_KEY?.trim() || creds.apiKey || '',
478
+ cloudBaseUrl: env.SCALIX_CLOUD_BASE_URL?.trim() || scalixBaseUrl,
479
+ cloudApiKey: env.SCALIX_CLOUD_API_KEY?.trim() || scalixApiKey || '',
451
480
  };
452
481
  }
453
482
  return {
454
483
  provider: 'scalix',
455
- baseUrl: env.SCALIX_BASE_URL?.trim() || config.baseUrl,
456
- apiKey: env.SCALIX_API_KEY?.trim() || config.apiKey,
484
+ baseUrl: scalixBaseUrl,
485
+ apiKey: scalixApiKey,
486
+ cloudBaseUrl: scalixBaseUrl,
487
+ cloudApiKey: scalixApiKey,
457
488
  };
458
489
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scalix",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "AI coding agent for the terminal — edit code, run commands, and build with Scalix",
5
5
  "main": "dist/headless.js",
6
6
  "bin": {