scalix 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  All notable changes to Scalix Coder will be documented in this file.
4
4
 
5
+ ## [1.4.0] - 2026-07-05
6
+
7
+ ### Added
8
+ - Scalix Cloud platform tools in the agent — your account's MCP catalog (`GET /api/v1/mcp`, scope-filtered per key) is fetched on start and registered as first-class function tools, executed via `POST /api/v1/mcp/call` with your own API key. Deploys, database queries, storage, functions and more become plain agent prompts
9
+ - `/cloud` command — `/cloud status` (identity, plan, limits, tool count), `/cloud tools` (available `scalix_*` tools grouped by platform area), `/cloud refresh` (re-fetch identity + catalog)
10
+ - `/login` command — links your Scalix account through the setup wizard
11
+ - Connected onboarding — the setup wizard validates the API key against `GET /v1/me` and shows a "Connected to Scalix Cloud" panel (org, project, plan, rate limit, platform tools unlocked) with a re-enter/skip choice on failure; a "Try:" block with real platform prompts appears after a successful connect
12
+ - Welcome/status line reflects the real connection: `☁ Scalix Cloud · <plan> · <N> platform tools` when connected, `☁ not connected — /login to link your Scalix account` otherwise
13
+ - `SCALIX_PROJECT` env var — sent as `x-scalix-project` on catalog/tool calls to scope an org-wide key to one project
14
+
15
+ ### Changed
16
+ - When the catalog is unreachable or no key is configured, the agent states it once and runs with local tools only — availability is never fabricated and there is no retry loop. Tool counts in the status bar and headless banner include only tools that actually loaded
17
+
18
+ ## [1.3.3] - 2026-07-05
19
+
20
+ ### Fixed
21
+ - API endpoints aligned to the Scalix gateway's `/v1/ai` nest: chat completions now POST to `/v1/ai/chat/completions` and the model list is fetched from `GET /v1/ai/models` (OpenAI-style `{data: [...]}`). The default base URL is now the host root `https://api.scalix.world`; a trailing `/v1` in existing configs is still accepted and normalized
22
+ - Voice input calls the single canonical `/v1/ai/audio/transcriptions` path and reports plainly when transcription isn't available on the deployment, instead of probing two dead endpoints
23
+ - Web search no longer calls a non-existent platform research endpoint; it states that web research isn't available via the Scalix API yet and uses the configured search providers (Brave/Tavily/DuckDuckGo)
24
+ - Sandbox no longer falsely blocks file access when the working directory sits behind a symlink (e.g. macOS temp dirs): symlink containment now compares real paths on both sides
25
+ - `User-Agent` header carries the real package version instead of a hardcoded `1.0`
26
+
27
+ ### Changed
28
+ - Model examples and defaults use current Scalix model names (`scalix-lumio-*`); the retired `scalix-world-ai` id removed from UI text and defaults
29
+ - Model list UI no longer shows tier/provider columns (the API does not expose them)
30
+ - README version badge matches the released package version
31
+
5
32
  ## [3.1.0] - 2026-04-20
6
33
 
7
34
  ### Added
package/README.md CHANGED
@@ -2,11 +2,19 @@
2
2
 
3
3
  AI-powered coding agent for the terminal and VS Code — built by [Scalix World](https://scalix.world).
4
4
 
5
- ![Version](https://img.shields.io/badge/version-1.0.0-blue)
5
+ ![Version](https://img.shields.io/badge/version-1.4.0-blue)
6
6
  ![Node](https://img.shields.io/badge/node-%3E%3D18-green)
7
7
  ![License](https://img.shields.io/badge/license-EULA-orange)
8
8
 
9
- Scalix Coder is a full-featured AI coding assistant that runs in your terminal or as a VS Code extension. It connects to the Scalix API (or any OpenAI-compatible endpoint) and gives AI direct access to your codebase through 31 built-in tools — file editing, shell execution, search, git, web access, browser automation, vision, voice input, and more.
9
+ Scalix Coder is a full-featured AI coding assistant that runs in your terminal or as a VS Code extension. It connects to the Scalix API (or your own compatible endpoint) and gives AI direct access to your codebase through 31 built-in tools — file editing, shell execution, search, git, web access, browser automation, vision, voice input, and more.
10
+
11
+ ## One agent for your whole cloud
12
+
13
+ With a Scalix account, the agent is more than a coder: on start it fetches your account's platform tool catalog and registers every tool your API key's scopes unlock — databases, deployments (Scalix Run), storage, functions, cron, domains, and more. "Deploy this app", "create a table and seed it", "upload dist/ to a bucket" become plain prompts.
14
+
15
+ - Tools reflect **your key's scopes** — the catalog is filtered server-side, and mutating tools are refused for read-only keys
16
+ - `/cloud` shows your connection, plan, and available tools; `/cloud refresh` re-fetches the catalog
17
+ - No account (or catalog unreachable)? The agent says so once and runs in **local-only mode** with the built-in tools — nothing is faked
10
18
 
11
19
  ## Features
12
20
 
@@ -55,6 +63,7 @@ First launch opens a setup wizard to configure your Scalix API key, endpoint, an
55
63
  | `SCALIX_API_KEY` | API key (overrides saved config) |
56
64
  | `SCALIX_MODEL` | Comma-separated model IDs |
57
65
  | `SCALIX_BASE_URL` | API base URL |
66
+ | `SCALIX_PROJECT` | Project ID for org-wide keys (sent as `x-scalix-project` on platform tool calls) |
58
67
  | `BRAVE_API_KEY` | Brave Search API key |
59
68
  | `TAVILY_API_KEY` | Tavily Search API key |
60
69
 
@@ -90,6 +99,8 @@ scalix run "generate a migration" --output migration.sql --format patch
90
99
  | `/replay` | Session timeline |
91
100
  | `/plugins` | Loaded plugins |
92
101
  | `/settings` | Configure limits |
102
+ | `/cloud` | Scalix Cloud status, platform tools & refresh |
103
+ | `/login` | Link your Scalix account |
93
104
  | `/setup` | Reconfigure API key & models |
94
105
  | `/delegate <task>` | Run with sub-agent |
95
106
  | `/checkpoint` | Create git checkpoint |
package/bin/scalix.js CHANGED
@@ -8,6 +8,14 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
8
8
  process.env.NODE_ENV = 'production';
9
9
 
10
10
  const args = process.argv.slice(2);
11
+
12
+ if (args.includes('--version') || args.includes('-v')) {
13
+ const { readFileSync } = await import('node:fs');
14
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8'));
15
+ console.log(pkg.version);
16
+ process.exit(0);
17
+ }
18
+
11
19
  const needsHeadless =
12
20
  args.includes('run') ||
13
21
  args.includes('--pipe') || args.includes('-p') ||
package/dist/agent.js CHANGED
@@ -1,9 +1,9 @@
1
- import { ScalixClient } from './api-client.js';
1
+ import { ScalixClient, ApiError } from './api-client.js';
2
2
  import { EventEmitter } from 'eventemitter3';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { getToolPermission } from './sandbox.js';
4
+ import { getToolPermissionWithArgs } from './sandbox.js';
5
5
  import { logger } from './logger.js';
6
- import { DEFAULT_MODELS as FALLBACK_MODELS, DEFAULT_INSTRUCTIONS, AGENT_DEFAULTS, TOOL_DEFAULTS, BACKOFF_DEFAULTS, modelSupportsVision, getModelSpec } from './constants.js';
6
+ import { DEFAULT_MODELS as FALLBACK_MODELS, DEFAULT_INSTRUCTIONS, DEFAULT_BASE_URL, AGENT_DEFAULTS, TOOL_DEFAULTS, BACKOFF_DEFAULTS, modelSupportsVision, getModelSpec } from './constants.js';
7
7
  import { estimateMessageTokens as estimateTokenCount } from './token-counter.js';
8
8
  import { ContextManager } from './context-manager.js';
9
9
  import { zodToJsonSchema } from './tool-helper.js';
@@ -19,8 +19,13 @@ 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
23
  function transformToolsForApi(tools) {
23
24
  return tools
25
+ // type:'mcp' entries are decorative config for user-registered external
26
+ // MCP servers and never reach the API. Scalix Cloud platform tools bypass
27
+ // this path entirely: they are plain function tools (CloudPlatformTool,
28
+ // registered by refreshCloudTools) whose `parameters` pass through as-is.
24
29
  .filter((t) => !('type' in t && t.type === 'mcp'))
25
30
  .map((t) => {
26
31
  const local = t;
@@ -156,12 +161,16 @@ export class Agent extends EventEmitter {
156
161
  groundingConfig = null;
157
162
  groundingNudgeCount = 0;
158
163
  retrievalConfig = null;
164
+ _sending = false;
165
+ outputFormatInstructionIndex = null;
166
+ cloudProjectId;
167
+ cloudStatus = { state: 'no-key', toolCount: 0 };
159
168
  constructor(config) {
160
169
  super();
161
170
  this.agentId = config.agentId ?? generateAgentId();
162
171
  this.parentAgentId = config.parentAgentId ?? null;
163
172
  this.agentDescription = config.agentDescription ?? 'main';
164
- this.client = new ScalixClient(config.baseUrl || 'https://api.scalix.world/v1', config.apiKey);
173
+ this.client = new ScalixClient(config.baseUrl || DEFAULT_BASE_URL, config.apiKey);
165
174
  this.log = logger.child();
166
175
  const resolvedModels = Array.isArray(config.model)
167
176
  ? (config.model.length > 0 ? config.model : FALLBACK_MODELS)
@@ -185,7 +194,9 @@ export class Agent extends EventEmitter {
185
194
  circuitBreakerResetMs: config.circuitBreakerResetMs ?? AGENT_DEFAULTS.circuitBreakerResetMs,
186
195
  fileRefTruncation: config.fileRefTruncation ?? TOOL_DEFAULTS.fileRefTruncation,
187
196
  thinkingMode: config.thinkingMode ?? 'off',
197
+ workspaceRoot: config.workspaceRoot ?? process.cwd(),
188
198
  };
199
+ this.cloudProjectId = config.cloudProjectId ?? process.env.SCALIX_PROJECT ?? undefined;
189
200
  this.contextManager = new ContextManager(config.contextManager);
190
201
  if (config.dynamicToolSelection) {
191
202
  this.toolSelector = new ToolSelector();
@@ -252,22 +263,22 @@ export class Agent extends EventEmitter {
252
263
  shouldFallbackToNext(err) {
253
264
  const e = err;
254
265
  const message = (e?.message || e?.error?.message || '').toLowerCase();
255
- const statusCode = e?.status || e?.statusCode || e?.error?.code;
256
- if (statusCode === 401 || statusCode === 403 || message.includes('unauthorized') || message.includes('forbidden') || message.includes('invalid api key')) {
266
+ // Prefer the structured status carried by ApiError; fall back to other shapes.
267
+ const status = err instanceof ApiError ? err.status : (e?.status ?? e?.statusCode ?? e?.error?.code);
268
+ if (status === 401 || status === 403 || message.includes('unauthorized') || message.includes('forbidden') || message.includes('invalid api key')) {
257
269
  return false;
258
270
  }
271
+ // Retry/fallback on transient upstream statuses.
272
+ if (status === 429 || status === 500 || status === 502 || status === 503 || status === 408 || status === 404) {
273
+ return true;
274
+ }
259
275
  return (message.includes('429') ||
260
276
  message.includes('rate limit') ||
261
277
  message.includes('provider returned error') ||
262
278
  message.includes('safety') ||
263
279
  message.includes('streaming failed') ||
264
280
  message.includes('stream error') ||
265
- message.includes('try again') ||
266
- statusCode === 429 ||
267
- statusCode === 500 ||
268
- statusCode === 502 ||
269
- statusCode === 503 ||
270
- statusCode === 404);
281
+ message.includes('try again'));
271
282
  }
272
283
  isModelCircuitOpen(model) {
273
284
  const state = this.modelFailures.get(model);
@@ -294,6 +305,10 @@ export class Agent extends EventEmitter {
294
305
  const keepCount = Math.floor(this.config.maxMessages * this.config.messageKeepRatio);
295
306
  const dropped = this.messages.length - keepCount;
296
307
  this.messages = this.messages.slice(dropped);
308
+ // Drop leading orphan tool messages — a tool result whose preceding
309
+ // assistant tool_call was sliced away would be rejected by the API.
310
+ while (this.messages[0]?.role === 'tool')
311
+ this.messages.shift();
297
312
  this.log.info(`Truncated ${dropped} oldest messages, keeping ${this.messages.length}`);
298
313
  }
299
314
  emitThinkingEnd() {
@@ -331,7 +346,7 @@ export class Agent extends EventEmitter {
331
346
  model,
332
347
  messages: apiMessages,
333
348
  stream: false,
334
- });
349
+ }, this._abortController?.signal);
335
350
  const summary = result.choices?.[0]?.message?.content?.trim() || '';
336
351
  if (!summary)
337
352
  return 'Compaction failed: no summary generated.';
@@ -386,7 +401,7 @@ export class Agent extends EventEmitter {
386
401
  { role: 'user', content: prompt },
387
402
  ],
388
403
  stream: false,
389
- });
404
+ }, this._abortController?.signal);
390
405
  const summary = result.choices?.[0]?.message?.content?.trim() || '';
391
406
  if (summary) {
392
407
  this.messages = [
@@ -415,6 +430,82 @@ export class Agent extends EventEmitter {
415
430
  if (this.toolSelector)
416
431
  this.toolSelector.index(this.config.tools);
417
432
  }
433
+ // ─── Scalix Cloud platform tools ───
434
+ isCloudTool(t) {
435
+ return 'source' in t && t.source === 'scalix-cloud';
436
+ }
437
+ /** Build a first-class function tool from a catalog entry. The server's JSON
438
+ * Schema is passed to the model as-is via `parameters` (NOT `inputSchema`,
439
+ * which transformToolsForApi would run through the Zod converter). */
440
+ makeCloudTool(ct) {
441
+ const baseUrl = this.client.baseURL;
442
+ const apiKey = this.config.apiKey;
443
+ const projectId = this.cloudProjectId;
444
+ return {
445
+ source: 'scalix-cloud',
446
+ name: ct.name,
447
+ description: ct.description,
448
+ parameters: ct.inputSchema,
449
+ execute: async (args) => {
450
+ const result = await callCloudTool(baseUrl, apiKey, ct.name, args ?? {}, projectId);
451
+ // Tool-level failures are tool results the model can react to, not
452
+ // exceptions — the "Error: " prefix is the model-facing signal.
453
+ return result.ok ? result.text : `Error: ${result.text}`;
454
+ },
455
+ };
456
+ }
457
+ /**
458
+ * Fetch the account's Scalix Cloud tool catalog and register every returned
459
+ * tool as a first-class function tool. Called on agent start and on demand
460
+ * (/cloud refresh). On failure the previously registered cloud tools are
461
+ * removed and the agent keeps running with local tools only — the returned
462
+ * status carries the real error for a single honest notice; no retries.
463
+ */
464
+ async refreshCloudTools() {
465
+ if (!this.config.apiKey) {
466
+ this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
467
+ this.cloudStatus = { state: 'no-key', toolCount: 0 };
468
+ return { ...this.cloudStatus };
469
+ }
470
+ try {
471
+ const catalog = await fetchCloudTools(this.client.baseURL, this.config.apiKey, this.cloudProjectId);
472
+ this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
473
+ const localNames = new Set(this.config.tools.map((t) => t.function?.name || t.name).filter(Boolean));
474
+ let registered = 0;
475
+ for (const ct of catalog.tools) {
476
+ if (localNames.has(ct.name)) {
477
+ this.log.warn('Cloud tool name collides with a local tool — keeping the local tool', { tool: ct.name });
478
+ continue;
479
+ }
480
+ this.config.tools.push(this.makeCloudTool(ct));
481
+ registered++;
482
+ }
483
+ if (this.toolSelector)
484
+ this.toolSelector.index(this.config.tools);
485
+ this.cloudStatus = {
486
+ state: 'connected',
487
+ toolCount: registered,
488
+ platformAvailable: catalog.platformAvailable,
489
+ };
490
+ }
491
+ catch (err) {
492
+ this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
493
+ if (this.toolSelector)
494
+ this.toolSelector.index(this.config.tools);
495
+ this.cloudStatus = { state: 'unavailable', toolCount: 0, error: err?.message || String(err) };
496
+ this.log.warn('Scalix Cloud tool catalog unavailable', { error: this.cloudStatus.error });
497
+ }
498
+ return { ...this.cloudStatus };
499
+ }
500
+ getCloudToolsStatus() {
501
+ return { ...this.cloudStatus };
502
+ }
503
+ getCloudTools() {
504
+ return this.config.tools.filter((t) => this.isCloudTool(t));
505
+ }
506
+ getCloudProjectId() {
507
+ return this.cloudProjectId;
508
+ }
418
509
  addModel(model) {
419
510
  if (!this.config.models.includes(model))
420
511
  this.config.models.push(model);
@@ -451,6 +542,9 @@ export class Agent extends EventEmitter {
451
542
  deregisterFromRegistry(status) {
452
543
  if (!this.registeredInRegistry)
453
544
  return;
545
+ // Reset so a fresh send() re-registers, and guard against double-deregister
546
+ // (e.g. an aborted turn followed by the send() catch/finally path).
547
+ this.registeredInRegistry = false;
454
548
  AgentRegistry.getInstance().deregister(this.agentId, status);
455
549
  }
456
550
  checkInbox() {
@@ -540,7 +634,15 @@ export class Agent extends EventEmitter {
540
634
  else {
541
635
  instruction = 'IMPORTANT: Your final response MUST be valid JSON. Do not include any text outside the JSON object. No markdown code fences, no explanation — only the raw JSON.';
542
636
  }
637
+ // Inject the format instruction only once per session — refresh the existing
638
+ // system message in place if it's still valid, otherwise append once.
639
+ const existing = this.outputFormatInstructionIndex;
640
+ if (existing !== null && this.messages[existing]?.role === 'system') {
641
+ this.messages[existing] = { role: 'system', content: instruction };
642
+ return;
643
+ }
543
644
  this.messages.push({ role: 'system', content: instruction });
645
+ this.outputFormatInstructionIndex = this.messages.length - 1;
544
646
  }
545
647
  extractJsonFromResponse(response) {
546
648
  const trimmed = response.trim();
@@ -868,7 +970,28 @@ export class Agent extends EventEmitter {
868
970
  continue;
869
971
  }
870
972
  }
871
- const permission = getToolPermission(call.name, this.config.permissionMode);
973
+ // Parse tool arguments once, up front, so the permission decision,
974
+ // the confirmation prompt, and execution all see the same values.
975
+ let parsedArgs;
976
+ const rawArgs = (call.arguments ?? '').trim();
977
+ if (rawArgs === '') {
978
+ parsedArgs = {};
979
+ }
980
+ else {
981
+ try {
982
+ parsedArgs = JSON.parse(call.arguments);
983
+ }
984
+ catch {
985
+ result = { error: 'Malformed JSON arguments — resend the tool call with valid JSON' };
986
+ reqLog.warn('Malformed tool arguments', { tool: call.name });
987
+ this.emit('tool:blocked', call.name, 'Malformed arguments');
988
+ this.messages.push({ role: 'tool', content: JSON.stringify(result), tool_call_id: call.id, name: call.name });
989
+ this.emit('tool:result', call.name, result);
990
+ continue;
991
+ }
992
+ }
993
+ // Args-aware permission so scoped allow/deny rules are enforced.
994
+ const permission = getToolPermissionWithArgs(call.name, this.config.permissionMode, parsedArgs);
872
995
  if (permission === 'block') {
873
996
  result = { denied: true, message: `Tool ${call.name} is blocked in ${this.config.permissionMode} mode` };
874
997
  reqLog.info('Tool blocked by permission mode', { tool: call.name, mode: this.config.permissionMode });
@@ -884,8 +1007,8 @@ export class Agent extends EventEmitter {
884
1007
  }
885
1008
  if (permission === 'ask') {
886
1009
  if (!this.config.confirmToolCall) {
887
- if (this.config.permissionMode === 'auto') {
888
- reqLog.info('Auto-approving tool (no confirmation handler in auto mode)', { tool: call.name });
1010
+ if (this.config.permissionMode === 'yolo') {
1011
+ reqLog.info('Auto-approving tool (yolo mode, no confirmation handler)', { tool: call.name });
889
1012
  }
890
1013
  else {
891
1014
  result = { denied: true, message: `Tool ${call.name} requires confirmation but no confirmation handler is available` };
@@ -902,13 +1025,7 @@ export class Agent extends EventEmitter {
902
1025
  }
903
1026
  }
904
1027
  else {
905
- let args;
906
- try {
907
- args = JSON.parse(call.arguments);
908
- }
909
- catch {
910
- args = call.arguments;
911
- }
1028
+ const args = parsedArgs;
912
1029
  const CONFIRM_TIMEOUT_MS = 300_000;
913
1030
  const confirmed = await new Promise((resolve) => {
914
1031
  const timer = setTimeout(() => resolve(false), CONFIRM_TIMEOUT_MS);
@@ -940,20 +1057,19 @@ export class Agent extends EventEmitter {
940
1057
  const execFn = tool?.function?.execute ?? tool?.execute;
941
1058
  if (tool && typeof execFn === 'function') {
942
1059
  try {
943
- let args;
944
- try {
945
- args = normalizeArgs(JSON.parse(call.arguments));
946
- }
947
- catch {
948
- args = {};
949
- }
1060
+ // Cloud platform tools get the raw args: their JSON Schemas are
1061
+ // authoritative (snake_case) and the gateway validates them —
1062
+ // injecting camelCase duplicates could fail server-side validation.
1063
+ const args = tool?.source === 'scalix-cloud'
1064
+ ? parsedArgs
1065
+ : normalizeArgs(parsedArgs);
950
1066
  reqLog.debug('Executing tool', { tool: call.name });
951
1067
  const toolTimeout = (typeof args?.timeout === 'number' && args.timeout > 0)
952
1068
  ? Math.min(args.timeout, TOOL_DEFAULTS.maxCommandTimeoutMs)
953
1069
  : this.config.toolTimeoutMs;
954
1070
  result = await withTimeout(execFn(args, {
955
1071
  context: {},
956
- workspaceRoot: process.cwd(),
1072
+ workspaceRoot: this.config.workspaceRoot ?? process.cwd(),
957
1073
  permissionMode: this.config.permissionMode,
958
1074
  agentId: this.agentId,
959
1075
  agentConfig: {
@@ -1,26 +1,44 @@
1
+ import { USER_AGENT, aiEndpointUrl } from './constants.js';
2
+ export class ApiError extends Error {
3
+ status;
4
+ body;
5
+ constructor(status, statusText, body) {
6
+ super(`API request failed: ${status} ${statusText}${body ? ` — ${body.slice(0, 200)}` : ''}`);
7
+ this.name = 'ApiError';
8
+ this.status = status;
9
+ this.body = body;
10
+ }
11
+ }
12
+ const DEFAULT_TIMEOUT_MS = 120_000;
1
13
  export class ScalixClient {
2
14
  baseURL;
3
15
  apiKey;
4
- constructor(baseURL, apiKey) {
16
+ timeoutMs;
17
+ constructor(baseURL, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS) {
5
18
  this.baseURL = baseURL.replace(/\/+$/, '');
6
19
  this.apiKey = apiKey;
20
+ this.timeoutMs = timeoutMs;
7
21
  }
8
22
  async chatComplete(req, signal) {
9
- const url = `${this.baseURL}/chat/completions`;
23
+ // baseURL is the API host root; the gateway nests AI under /v1/ai.
24
+ // aiEndpointUrl() also tolerates a legacy base ending in /v1.
25
+ const url = aiEndpointUrl(this.baseURL, 'chat/completions');
10
26
  const body = JSON.stringify(req);
27
+ const timeoutSignal = AbortSignal.timeout(this.timeoutMs);
28
+ const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
11
29
  const response = await fetch(url, {
12
30
  method: 'POST',
13
31
  headers: {
14
32
  'Content-Type': 'application/json',
15
33
  'Authorization': `Bearer ${this.apiKey}`,
16
- 'User-Agent': 'Scalix-Coder/1.0',
34
+ 'User-Agent': USER_AGENT,
17
35
  },
18
36
  body,
19
- signal,
37
+ signal: combined,
20
38
  });
21
39
  if (!response.ok) {
22
40
  const text = await response.text().catch(() => '');
23
- throw new Error(`API request failed: ${response.status} ${response.statusText}${text ? ` — ${text.slice(0, 200)}` : ''}`);
41
+ throw new ApiError(response.status, response.statusText, text);
24
42
  }
25
43
  if (req.stream) {
26
44
  return this.parseSSEStream(response);
@@ -45,32 +63,52 @@ export class ScalixClient {
45
63
  const trimmed = line.trim();
46
64
  if (!trimmed || trimmed.startsWith(':'))
47
65
  continue;
48
- if (trimmed.startsWith('data: ')) {
49
- const data = trimmed.slice(6);
66
+ if (trimmed.startsWith('data:')) {
67
+ const data = trimmed.slice(5).replace(/^ /, '');
50
68
  if (data === '[DONE]')
51
69
  return;
70
+ let parsed;
52
71
  try {
53
- yield JSON.parse(data);
72
+ parsed = JSON.parse(data);
54
73
  }
55
74
  catch {
56
75
  // skip malformed chunks
76
+ continue;
77
+ }
78
+ if (parsed && parsed.error) {
79
+ const err = parsed.error;
80
+ throw new Error(`Stream error: ${typeof err === 'string' ? err : (err?.message || JSON.stringify(err))}`);
57
81
  }
82
+ yield parsed;
58
83
  }
59
84
  }
60
85
  }
61
- if (buffer.trim().startsWith('data: ')) {
62
- const data = buffer.trim().slice(6);
86
+ const tail = buffer.trim();
87
+ if (tail.startsWith('data:')) {
88
+ const data = tail.slice(5).replace(/^ /, '');
63
89
  if (data !== '[DONE]') {
90
+ let parsed;
64
91
  try {
65
- yield JSON.parse(data);
92
+ parsed = JSON.parse(data);
66
93
  }
67
94
  catch {
68
95
  // skip
69
96
  }
97
+ if (parsed) {
98
+ if (parsed.error) {
99
+ const err = parsed.error;
100
+ throw new Error(`Stream error: ${typeof err === 'string' ? err : (err?.message || JSON.stringify(err))}`);
101
+ }
102
+ yield parsed;
103
+ }
70
104
  }
71
105
  }
72
106
  }
73
107
  finally {
108
+ try {
109
+ await reader.cancel();
110
+ }
111
+ catch { }
74
112
  reader.releaseLock();
75
113
  }
76
114
  }
@@ -74,7 +74,8 @@ async function detectChecker(filePath, projectRoot) {
74
74
  }
75
75
  // Verify the tool is available
76
76
  try {
77
- await execFileAsync('which', [checker.command[0]], { timeout: 3000 });
77
+ const probe = process.platform === 'win32' ? 'where' : 'which';
78
+ await execFileAsync(probe, [checker.command[0]], { timeout: 3000, shell: process.platform === 'win32' });
78
79
  }
79
80
  catch {
80
81
  // Tool not installed, skip
@@ -100,6 +101,7 @@ export async function runVerification(filePath, projectRoot, config = VERIFY_DEF
100
101
  cwd: projectRoot,
101
102
  timeout: config.timeoutMs,
102
103
  maxBuffer: 512 * 1024,
104
+ shell: process.platform === 'win32',
103
105
  });
104
106
  const output = (stderr || stdout).trim();
105
107
  return {