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 +24 -0
- package/dist/agent.js +126 -25
- package/dist/api-client.js +9 -1
- package/dist/cli.js +169 -53
- package/dist/cloud.js +94 -5
- package/dist/constants.js +39 -8
- package/dist/headless.js +22 -8
- package/dist/ipc-server.js +38 -5
- package/dist/settings.js +33 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
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.
|
|
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
|
-
|
|
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.
|
|
563
|
-
const apiKey = this.
|
|
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)
|
|
587
|
-
//
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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.
|
|
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
|
|
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 = {
|
|
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) {
|
|
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;
|
package/dist/api-client.js
CHANGED
|
@@ -28,7 +28,15 @@ export function parseRetryAfter(value) {
|
|
|
28
28
|
return Math.max(0, date - Date.now());
|
|
29
29
|
return undefined;
|
|
30
30
|
}
|
|
31
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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.
|
|
181
|
-
|
|
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
|
|
191
|
-
// stale platform tools from a previous
|
|
192
|
-
// succeeds, the tools genuinely work and we
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3411
|
-
agent.setModel(
|
|
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: (
|
|
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
|
-
|
|
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
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3576
|
-
|
|
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
|
-
//
|
|
22
|
-
|
|
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
|
|
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
|
|
186
|
-
*
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
506
|
-
|
|
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') {
|
package/dist/ipc-server.js
CHANGED
|
@@ -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
|
-
|
|
201
|
-
|
|
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:
|
|
456
|
-
apiKey:
|
|
484
|
+
baseUrl: scalixBaseUrl,
|
|
485
|
+
apiKey: scalixApiKey,
|
|
486
|
+
cloudBaseUrl: scalixBaseUrl,
|
|
487
|
+
cloudApiKey: scalixApiKey,
|
|
457
488
|
};
|
|
458
489
|
}
|