scalix 1.5.1 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -1,8 +1,10 @@
1
1
  # Required — your Scalix API key
2
- SCALIX_API_KEY=sk-...
2
+ SCALIX_API_KEY=scalix_sk_...
3
3
 
4
- # Optional — custom API base URL (defaults to Scalix cloud)
5
- # SCALIX_BASE_URL=https://api.scalix.world/v1
4
+ # Optional — API base URL. This is the host root, NOT the /v1 endpoint: the
5
+ # client appends /v1/ai/... itself. Point it at a self-hosted gateway if you
6
+ # have one; the default is Scalix cloud.
7
+ # SCALIX_BASE_URL=https://api.scalix.world
6
8
 
7
9
  # Optional — override the default model
8
10
  # SCALIX_MODEL=
package/LICENSE CHANGED
@@ -2,7 +2,6 @@ SCALIX WORLD PRIVATE LIMITED
2
2
  END USER LICENSE AGREEMENT (EULA)
3
3
 
4
4
  Copyright (c) 2025-2026 Scalix World Pvt Ltd. All rights reserved.
5
- A subsidiary of Energy FW Ltd (UK Company No. 14600160)
6
5
 
7
6
  IMPORTANT — READ CAREFULLY BEFORE USING THIS SOFTWARE.
8
7
 
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  AI-powered coding agent for the terminal and [VS Code](https://marketplace.visualstudio.com/items?itemName=scalix.scalix) — built by [Scalix World](https://scalix.world).
4
4
 
5
- ![Version](https://img.shields.io/badge/version-1.4.0-blue)
5
+ [![npm](https://img.shields.io/npm/v/scalix?color=blue)](https://www.npmjs.com/package/scalix)
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
 
@@ -61,12 +61,28 @@ Notes:
61
61
 
62
62
  ### Install
63
63
 
64
+ ```bash
65
+ npm install -g scalix
66
+ ```
67
+
68
+ Requires Node 18 or newer. If `npm install -g` needs `sudo` on your machine, that
69
+ is a sign npm's global prefix points somewhere root-owned — point it at your home
70
+ directory (`npm config set prefix ~/.npm-global`) rather than installing as root.
71
+
72
+ <details>
73
+ <summary>From source (contributors)</summary>
74
+
75
+ The repository is private; you need access before this will clone.
76
+
64
77
  ```bash
65
78
  git clone https://github.com/scalixworld/scalix-coder.git && cd scalix-coder
66
79
  npm install
67
- sudo npm link
80
+ npm run build
81
+ npm link
68
82
  ```
69
83
 
84
+ </details>
85
+
70
86
  ### Run
71
87
 
72
88
  ```bash
@@ -239,6 +255,35 @@ Config is stored in `~/.scalix-coder/config.json`.
239
255
  | `.scalix/rules/*.md` | Scoped rules |
240
256
  | `mcp-services.json` | MCP server config |
241
257
 
258
+ ### MCP servers
259
+
260
+ Servers listed in `mcp-services.json` (project root, or `.scalix/` as a
261
+ fallback) are connected at startup and their tools are offered to the model as
262
+ `mcp__<server>__<tool>`. Both transports are supported:
263
+
264
+ ```json
265
+ {
266
+ "mcpServers": [
267
+ { "serverLabel": "github", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"],
268
+ "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_…" } },
269
+ { "serverLabel": "docs", "serverUrl": "https://docs.example.com/mcp", "authorizationEnv": "DOCS_TOKEN" }
270
+ ]
271
+ }
272
+ ```
273
+
274
+ The Claude Desktop / VS Code object-map dialect (`"mcpServers": {"github": {…}}`)
275
+ is accepted too, so an existing config can be dropped in unchanged.
276
+
277
+ - `disabled: true` skips an entry without deleting it; `allowedTools.toolNames`
278
+ registers only the named tools from that server.
279
+ - A server that fails to start is logged and skipped — it never takes the run
280
+ down with it. `/mcp` lists the status of each.
281
+ - **Command (stdio) servers only start in a trusted workspace**, because
282
+ spawning one runs arbitrary local code. They get a clean environment, not the
283
+ agent's, so they cannot read its API keys.
284
+ - MCP tools are external, so they require confirmation under the default
285
+ permission mode.
286
+
242
287
  ## VS Code Extension
243
288
 
244
289
  Install from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=scalix.scalix) — or from [Open VSX](https://open-vsx.org/extension/scalix/scalix) if you use Cursor, Windsurf, or VSCodium:
@@ -0,0 +1,33 @@
1
+ export const AGENT_PRESETS = [
2
+ {
3
+ id: 'cloud-architect',
4
+ name: 'Cloud Architect',
5
+ description: 'Full Scalix Cloud tools access with architecture-first planning',
6
+ systemPromptAdditions: 'You are an expert Scalix Cloud Architect. Prioritize robust, scalable infrastructure. Plan changes carefully.',
7
+ permissionMode: 'default'
8
+ },
9
+ {
10
+ id: 'dba',
11
+ name: 'Database Administrator',
12
+ description: 'Focused on database migrations and SQL optimization',
13
+ systemPromptAdditions: 'You are a Senior DBA. Prioritize data integrity and query performance. Always verify schemas before altering.',
14
+ permissionMode: 'default'
15
+ },
16
+ {
17
+ id: 'devops',
18
+ name: 'DevOps Engineer',
19
+ description: 'CI/CD, deployments, and infrastructure management',
20
+ systemPromptAdditions: 'You are a DevOps engineer focused on CI/CD pipelines, containerization, and safe deployments.',
21
+ permissionMode: 'default'
22
+ },
23
+ {
24
+ id: 'full-access',
25
+ name: 'Full Access (Auto)',
26
+ description: 'All tools enabled, autonomous execution mode',
27
+ systemPromptAdditions: 'You are fully autonomous. Execute the required steps directly.',
28
+ permissionMode: 'auto'
29
+ }
30
+ ];
31
+ export function getPreset(id) {
32
+ return AGENT_PRESETS.find(p => p.id === id);
33
+ }
package/dist/agent.js CHANGED
@@ -1,17 +1,19 @@
1
1
  import { ApiError } from './api-client.js';
2
2
  import { EventEmitter } from 'eventemitter3';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { getToolPermissionWithArgs } from './sandbox.js';
4
+ import { getToolPermissionWithArgs, getMaxImageSize } 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, SCALIX_PLATFORM_SECTION, SCALIX_CLOUD_RESOURCES_SECTION, SCALIX_NEUTRAL_POINTER, SCALIX_STEERING_ALWAYS_ON, 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_DEPLOY_PLAYBOOK_SECTION, SCALIX_PLATFORM_PLAYBOOK_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';
10
10
  import { ToolSelector } from './tool-selector.js';
11
11
  import { AgentReplay } from './agent-replay.js';
12
12
  import { loadHooks, runHooks } from './hooks.js';
13
+ import { isWorkspaceTrusted } from './workspace-trust.js';
13
14
  import { setAgentToolConfig } from './tools-advanced.js';
14
15
  import { AgentRegistry, generateAgentId } from './agent-registry.js';
16
+ import { getPreset } from './agent-presets.js';
15
17
  import { runVerification, VERIFY_DEFAULTS } from './auto-verify.js';
16
18
  import { ChangeTracker } from './change-tracker.js';
17
19
  import { detectPromptInjection, formatInjectionWarning } from './injection-detector.js';
@@ -21,6 +23,26 @@ import fs from 'node:fs/promises';
21
23
  import path from 'node:path';
22
24
  import { fetchCloudTools, callCloudTool, fetchMe, filterCatalogByScopes, buildCloudToolsPromptSection, cloudKeyRemedy } from './cloud.js';
23
25
  import { createChatClient, capabilitiesFor } from './providers/index.js';
26
+ import { McpHub } from './mcp.js';
27
+ import { BoardTurnRefresher } from './board-context.js';
28
+ const SUPPORTED_PASTED_IMAGE_MIMES = new Set([
29
+ 'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp', 'image/svg+xml',
30
+ ]);
31
+ export function validatePastedImages(images) {
32
+ const maxSize = getMaxImageSize();
33
+ for (const image of images) {
34
+ const match = /^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=\s]+)$/i.exec(image);
35
+ if (!match || !SUPPORTED_PASTED_IMAGE_MIMES.has(match[1].toLowerCase())) {
36
+ throw new Error('Unsupported pasted image. Use PNG, JPEG, GIF, WebP, BMP, or SVG.');
37
+ }
38
+ const payload = match[2].replace(/\s/g, '');
39
+ const bytes = Math.floor(payload.length * 3 / 4)
40
+ - (payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0);
41
+ if (bytes > maxSize) {
42
+ throw new Error(`Pasted image is too large (${Math.ceil(bytes / 1024 / 1024)}MB). Maximum is ${Math.floor(maxSize / 1024 / 1024)}MB.`);
43
+ }
44
+ }
45
+ }
24
46
  /**
25
47
  * Marks an error that Agent has already announced through the 'error' event.
26
48
  * `send()` emits and then rethrows, so a caller that also prints from its catch
@@ -30,11 +52,10 @@ import { createChatClient, capabilitiesFor } from './providers/index.js';
30
52
  export const REPORTED_ERROR = Symbol.for('scalix.coder.reportedError');
31
53
  function transformToolsForApi(tools) {
32
54
  return tools
33
- // type:'mcp' entries are decorative config for user-registered external
34
- // MCP servers and never reach the API. Scalix Cloud platform tools bypass
35
- // this path entirely: they are plain function tools (CloudPlatformTool,
36
- // registered by refreshCloudTools) whose `parameters` pass through as-is.
37
- .filter((t) => !('type' in t && t.type === 'mcp'))
55
+ // Everything here is a function tool. Remotely-backed ones — Scalix Cloud
56
+ // platform tools and MCP tool proxies — carry a ready-made JSON Schema in
57
+ // `parameters` that passes through as-is; local tools carry a Zod
58
+ // `inputSchema` that gets converted below.
38
59
  .map((t) => {
39
60
  const local = t;
40
61
  const fn = local.function ?? local;
@@ -151,7 +172,10 @@ export class Agent extends EventEmitter {
151
172
  _aborted = false;
152
173
  _thinkingActive = false;
153
174
  replay;
175
+ boardRefresher;
176
+ boardPrimed;
154
177
  hooks = [];
178
+ hooksSkipped = 0;
155
179
  instructionLoader;
156
180
  _abortController = null;
157
181
  modelFailures = new Map();
@@ -175,6 +199,7 @@ export class Agent extends EventEmitter {
175
199
  outputFormatInstructionIndex = null;
176
200
  cloudProjectId;
177
201
  cloudStatus = { state: 'no-key', toolCount: 0 };
202
+ mcpHub = null;
178
203
  providerId;
179
204
  capabilities;
180
205
  cloudApiKey;
@@ -233,8 +258,28 @@ export class Agent extends EventEmitter {
233
258
  this.toolSelector = new ToolSelector();
234
259
  this.toolSelector.index(this.config.tools);
235
260
  }
261
+ // Crew board freshness (board-context.ts): baseline taken here, right
262
+ // after the system prompt embedded the board, so the first turnNote()
263
+ // only fires on real change. Fire-and-forget; send() awaits it.
264
+ this.boardRefresher = new BoardTurnRefresher(this.config.workspaceRoot ?? process.cwd());
265
+ this.boardPrimed = this.boardRefresher.prime().catch(() => { });
236
266
  this.replay = new AgentReplay(randomUUID().slice(0, 8), this.config.models[0]);
237
- this.hooks = loadHooks();
267
+ // Hooks are shell commands read out of the project's own .scalix/hooks.json
268
+ // — exec surface, exactly like plugins and scoped rules, and named as such
269
+ // in workspace-trust.ts's own docstring. The gate existed; this call site
270
+ // just never asked. Parsing the file while untrusted is still fine and is
271
+ // what lets us say "3 hooks were skipped" instead of going quietly inert.
272
+ const projectHooks = loadHooks();
273
+ if (isWorkspaceTrusted()) {
274
+ this.hooks = projectHooks;
275
+ }
276
+ else {
277
+ this.hooks = [];
278
+ this.hooksSkipped = projectHooks.length;
279
+ if (projectHooks.length > 0) {
280
+ this.log.warn('Project hooks skipped — untrusted workspace', { count: projectHooks.length });
281
+ }
282
+ }
238
283
  this.instructionLoader = config.instructionLoader;
239
284
  if (config.autoVerify === true) {
240
285
  this.verifyConfig = { ...VERIFY_DEFAULTS };
@@ -354,6 +399,16 @@ export class Agent extends EventEmitter {
354
399
  message.includes('stream error') ||
355
400
  message.includes('try again'));
356
401
  }
402
+ setPreset(presetId) {
403
+ const preset = getPreset(presetId);
404
+ if (!preset)
405
+ throw new Error(`Unknown preset: ${presetId}`);
406
+ this.config.permissionMode = preset.permissionMode;
407
+ // We append the prompt to the base instructions
408
+ if (preset.systemPromptAdditions && !this.config.instructions.includes(preset.systemPromptAdditions)) {
409
+ this.config.instructions += '\n\n' + preset.systemPromptAdditions;
410
+ }
411
+ }
357
412
  isModelCircuitOpen(model) {
358
413
  const state = this.modelFailures.get(model);
359
414
  if (!state)
@@ -580,6 +635,64 @@ export class Agent extends EventEmitter {
580
635
  if (this.toolSelector)
581
636
  this.toolSelector.index(this.config.tools);
582
637
  }
638
+ // ─── MCP servers ───
639
+ isMcpTool(t) {
640
+ return 'source' in t && t.source === 'mcp';
641
+ }
642
+ /**
643
+ * Take ownership of a connected hub and register its tools. The entrypoint
644
+ * connects (so a slow server delays startup, not the first message) and hands
645
+ * the hub over; the agent needs it so `add_mcp_server` can connect at runtime
646
+ * and so `close()` can shut the child processes down.
647
+ */
648
+ attachMcpHub(hub) {
649
+ this.mcpHub = hub;
650
+ this.syncMcpTools();
651
+ }
652
+ /** Replace the registered MCP proxies with whatever the hub currently holds. */
653
+ syncMcpTools() {
654
+ this.config.tools = this.config.tools.filter((t) => !this.isMcpTool(t));
655
+ if (this.mcpHub) {
656
+ const taken = new Set(this.config.tools.map((t) => t.function?.name || t.name).filter(Boolean));
657
+ for (const t of this.mcpHub.tools()) {
658
+ // A namespaced mcp__ name colliding with a local tool means two servers
659
+ // share a label; keep the first and say so rather than shadowing.
660
+ if (taken.has(t.name)) {
661
+ this.log.warn('MCP tool name already taken — skipping', { tool: t.name, server: t.serverLabel });
662
+ continue;
663
+ }
664
+ taken.add(t.name);
665
+ this.config.tools.push(t);
666
+ }
667
+ }
668
+ if (this.toolSelector)
669
+ this.toolSelector.index(this.config.tools);
670
+ }
671
+ /**
672
+ * Connect one server and register its tools immediately. Creates the hub if
673
+ * the entrypoint never attached one, so a runtime `add_mcp_server` works even
674
+ * when no config file existed at startup.
675
+ */
676
+ async connectMcpServer(config) {
677
+ if (!this.mcpHub)
678
+ this.mcpHub = new McpHub();
679
+ const [status] = await this.mcpHub.connect([config]);
680
+ this.syncMcpTools();
681
+ return status;
682
+ }
683
+ getMcpStatus() {
684
+ return this.mcpHub?.status() ?? [];
685
+ }
686
+ getMcpTools() {
687
+ return this.config.tools.filter((t) => this.isMcpTool(t));
688
+ }
689
+ /** Disconnect every MCP server. Call on shutdown — stdio servers are children. */
690
+ async closeMcp() {
691
+ const hub = this.mcpHub;
692
+ this.mcpHub = null;
693
+ this.config.tools = this.config.tools.filter((t) => !this.isMcpTool(t));
694
+ await hub?.close();
695
+ }
583
696
  // ─── Scalix Cloud platform tools ───
584
697
  isCloudTool(t) {
585
698
  return 'source' in t && t.source === 'scalix-cloud';
@@ -731,6 +844,20 @@ export class Agent extends EventEmitter {
731
844
  getReplay() { return this.replay; }
732
845
  getChangeTracker() { return this.changeTracker; }
733
846
  getHooks() { return this.hooks; }
847
+ /** How many project hooks the trust gate withheld. 0 when trusted. */
848
+ getSkippedHookCount() { return this.hooksSkipped; }
849
+ /**
850
+ * Re-read project hooks after the user trusts the workspace, so /trust picks
851
+ * them up in the same session rather than only after a restart. Returns the
852
+ * number now active.
853
+ */
854
+ reloadHooks() {
855
+ if (!isWorkspaceTrusted())
856
+ return 0;
857
+ this.hooks = loadHooks();
858
+ this.hooksSkipped = 0;
859
+ return this.hooks.length;
860
+ }
734
861
  getApiKey() { return this.config.apiKey; }
735
862
  getBaseUrl() { return this.client.baseURL; }
736
863
  getAgentId() { return this.agentId; }
@@ -903,6 +1030,8 @@ export class Agent extends EventEmitter {
903
1030
  async send(content, images) {
904
1031
  this._aborted = false;
905
1032
  this.groundingNudgeCount = 0;
1033
+ if (images?.length)
1034
+ validatePastedImages(images);
906
1035
  const hasFileRefs = /@(?:[\w./-]+\/)?[\w.-]+\.\w+/.test(content);
907
1036
  const expandedContent = hasFileRefs ? await this.expandFileReferences(content) : content;
908
1037
  let finalContent = expandedContent;
@@ -925,6 +1054,24 @@ export class Agent extends EventEmitter {
925
1054
  this.emit('thinking:start');
926
1055
  this.truncateMessages();
927
1056
  this.replay.record('user_message', { content });
1057
+ // Crew board turn-refresh: the system prompt is assembled once per
1058
+ // session, so a board that moved since then would go stale mid-thread.
1059
+ // Runs AFTER the push — send()'s synchronous contract (user message in
1060
+ // history and message:user emitted before the first await) is what the
1061
+ // IDE paints on — with the note spliced in BEFORE the user message, so
1062
+ // the member still reads the news before the ask. One file read per
1063
+ // turn; a workspace with no BOARD.md never produces a note.
1064
+ try {
1065
+ await this.boardPrimed;
1066
+ const boardNote = await this.boardRefresher.turnNote();
1067
+ if (boardNote) {
1068
+ const boardMessage = { role: 'system', content: boardNote };
1069
+ const idx = this.messages.lastIndexOf(userMessage);
1070
+ this.messages.splice(idx < 0 ? this.messages.length : idx, 0, boardMessage);
1071
+ this.replay.record('board_refresh', { chars: boardNote.length });
1072
+ }
1073
+ }
1074
+ catch { }
928
1075
  const requestId = randomUUID().slice(0, 8);
929
1076
  const reqLog = logger.child(requestId);
930
1077
  reqLog.info('User message received', { length: content.length });
@@ -949,7 +1096,7 @@ export class Agent extends EventEmitter {
949
1096
  // platform key is a dead end that doesn't touch the credential that failed.
950
1097
  if (status === 401 && !error.message.includes('console.scalix.world') && !error.message.includes('/setup')) {
951
1098
  error.message += this.capabilities.platformTools
952
- ? ' — API key rejected. Create a new key at https://console.scalix.world/org/keys and update it via /setup.'
1099
+ ? ' — API key rejected. Create a new key at https://console.scalix.world/org/keys?utm_source=scalix-coder&utm_medium=cli and update it via /setup.'
953
1100
  : ` — API key rejected by ${this.client.baseURL}. Check the key for your provider and update it via /setup.`;
954
1101
  }
955
1102
  error.metadata = {
@@ -974,6 +1121,13 @@ export class Agent extends EventEmitter {
974
1121
  let emptyTurnCount = 0;
975
1122
  const MAX_EMPTY_TURNS = 5;
976
1123
  let disableTools = false;
1124
+ let _stepIndex = 0;
1125
+ let _turnToolDurationMs = 0;
1126
+ let _turnLlmStartMs = 0;
1127
+ let _turnTtftMs = 0;
1128
+ let _firstDeltaSeen = false;
1129
+ let _turnInputTokens = 0;
1130
+ let _turnOutputTokens = 0;
977
1131
  let forceNonStreaming = false;
978
1132
  while (turnCount < this.config.maxSteps) {
979
1133
  if (this._aborted) {
@@ -1039,6 +1193,9 @@ export class Agent extends EventEmitter {
1039
1193
  if (!SCALIX_STEERING_ALWAYS_ON) {
1040
1194
  effectiveInstructions += '\n\n' + SCALIX_PLATFORM_SECTION + '\n\n' + SCALIX_CLOUD_RESOURCES_SECTION;
1041
1195
  }
1196
+ // Deploy-path and platform facts ride with the connection too —
1197
+ // outside the ALWAYS_ON guard so flipping that flag never drops them.
1198
+ effectiveInstructions += '\n\n' + SCALIX_DEPLOY_PLAYBOOK_SECTION + '\n\n' + SCALIX_PLATFORM_PLAYBOOK_SECTION;
1042
1199
  const liveCloudTools = this.getCloudTools();
1043
1200
  if (liveCloudTools.length > 0) {
1044
1201
  effectiveInstructions += '\n\n' + buildCloudToolsPromptSection(liveCloudTools);
@@ -1059,6 +1216,7 @@ export class Agent extends EventEmitter {
1059
1216
  ? true
1060
1217
  : (!apiTools || apiTools.length === 0) && !hasToolMessages);
1061
1218
  if (useStreaming) {
1219
+ _turnLlmStartMs = Date.now();
1062
1220
  const stream = await this.client.chatComplete({
1063
1221
  model: currentModel,
1064
1222
  messages: apiMessages,
@@ -1078,6 +1236,10 @@ export class Agent extends EventEmitter {
1078
1236
  if (choice?.delta) {
1079
1237
  const delta = choice.delta;
1080
1238
  if (delta.content) {
1239
+ if (!_firstDeltaSeen) {
1240
+ _turnTtftMs = Date.now() - _turnLlmStartMs;
1241
+ _firstDeltaSeen = true;
1242
+ }
1081
1243
  fullText += delta.content;
1082
1244
  this.emit('stream:delta', delta.content, fullText);
1083
1245
  }
@@ -1101,6 +1263,8 @@ export class Agent extends EventEmitter {
1101
1263
  const inTok = chunk.usage.prompt_tokens ?? 0;
1102
1264
  const outTok = chunk.usage.completion_tokens ?? 0;
1103
1265
  const totalTok = chunk.usage.total_tokens ?? (inTok + outTok);
1266
+ _turnInputTokens = inTok;
1267
+ _turnOutputTokens = outTok;
1104
1268
  const cost = chunk.usage.cost ?? 0;
1105
1269
  this.emit('usage', {
1106
1270
  prompt_tokens: inTok,
@@ -1114,6 +1278,7 @@ export class Agent extends EventEmitter {
1114
1278
  }
1115
1279
  }
1116
1280
  else {
1281
+ _turnLlmStartMs = Date.now();
1117
1282
  this.emit('stream:start');
1118
1283
  const result = await this.client.chatComplete({
1119
1284
  model: currentModel,
@@ -1128,6 +1293,8 @@ export class Agent extends EventEmitter {
1128
1293
  this.recordModelSuccess(currentModel);
1129
1294
  const choice = result.choices?.[0];
1130
1295
  if (choice?.message) {
1296
+ _turnTtftMs = Date.now() - _turnLlmStartMs;
1297
+ _firstDeltaSeen = true;
1131
1298
  if (choice.message.content) {
1132
1299
  fullText = choice.message.content;
1133
1300
  this.emit('stream:delta', fullText, fullText);
@@ -1146,6 +1313,8 @@ export class Agent extends EventEmitter {
1146
1313
  const inTok = result.usage.prompt_tokens ?? 0;
1147
1314
  const outTok = result.usage.completion_tokens ?? 0;
1148
1315
  const totalTok = result.usage.total_tokens ?? (inTok + outTok);
1316
+ _turnInputTokens = inTok;
1317
+ _turnOutputTokens = outTok;
1149
1318
  const cost = result.usage.cost ?? 0;
1150
1319
  this.emit('usage', {
1151
1320
  prompt_tokens: inTok,
@@ -1320,10 +1489,13 @@ export class Agent extends EventEmitter {
1320
1489
  const execFn = tool?.function?.execute ?? tool?.execute;
1321
1490
  if (tool && typeof execFn === 'function') {
1322
1491
  try {
1323
- // Cloud platform tools get the raw args: their JSON Schemas are
1324
- // authoritative (snake_case) and the gateway validates them —
1325
- // injecting camelCase duplicates could fail server-side validation.
1326
- const args = tool?.source === 'scalix-cloud'
1492
+ // Remotely-schema'd tools get the raw args: their JSON Schemas
1493
+ // are authoritative (snake_case) and the far side validates
1494
+ // them — injecting camelCase duplicates could fail that
1495
+ // validation. True for the Scalix Cloud gateway and equally for
1496
+ // any MCP server, which validates against its own inputSchema.
1497
+ const src = tool?.source;
1498
+ const args = (src === 'scalix-cloud' || src === 'mcp')
1327
1499
  ? parsedArgs
1328
1500
  : normalizeArgs(parsedArgs);
1329
1501
  // Validate against the very schema the model was shown. Nothing
@@ -1350,6 +1522,8 @@ export class Agent extends EventEmitter {
1350
1522
  const toolTimeout = (typeof args?.timeout === 'number' && args.timeout > 0)
1351
1523
  ? Math.min(args.timeout, TOOL_DEFAULTS.maxCommandTimeoutMs)
1352
1524
  : this.config.toolTimeoutMs;
1525
+ const toolStart = Date.now();
1526
+ this.emit('step:start', { stepIndex: _stepIndex, name: call.name, args: parsedArgs, startedAt: toolStart });
1353
1527
  result = await withTimeout(execFn(args, {
1354
1528
  context: {},
1355
1529
  workspaceRoot: this.config.workspaceRoot ?? process.cwd(),
@@ -1363,8 +1537,21 @@ export class Agent extends EventEmitter {
1363
1537
  confirmToolCall: this.config.confirmToolCall,
1364
1538
  },
1365
1539
  }), toolTimeout, call.name);
1540
+ const toolDurationMs = Date.now() - toolStart;
1541
+ _turnToolDurationMs += toolDurationMs;
1542
+ this.emit('step:done', { stepIndex: _stepIndex, name: call.name, durationMs: toolDurationMs, success: !result?.error });
1543
+ _stepIndex++;
1544
+ // The tool only persists the entry to mcp-services.json. Connect
1545
+ // it here too, so the server's tools are usable in this same
1546
+ // session instead of only after a restart — and report the
1547
+ // outcome back through the tool result, since a server that
1548
+ // won't answer is something the model needs to know now.
1366
1549
  if (call.name === 'add_mcp_server' && result?.success) {
1367
- this.addTool({ type: 'mcp', serverLabel: args.label, serverUrl: args.url });
1550
+ const connected = await this.connectMcpServer({
1551
+ serverLabel: args.label,
1552
+ serverUrl: args.url,
1553
+ });
1554
+ result = { ...result, connected: connected.state === 'connected', tools: connected.toolCount, ...(connected.error ? { connectError: connected.error } : {}) };
1368
1555
  }
1369
1556
  }
1370
1557
  catch (e) {
@@ -1373,11 +1560,19 @@ export class Agent extends EventEmitter {
1373
1560
  }
1374
1561
  }
1375
1562
  else {
1376
- const availableNames = this.config.tools
1563
+ const allNames = this.config.tools
1377
1564
  .map((t) => t.function?.name || t.name)
1378
- .filter(Boolean)
1379
- .slice(0, 20);
1380
- result = { error: `Tool "${call.name}" does not exist. Available tools: ${availableNames.join(', ')}. Do not invent tool names — only use tools from the list above, or respond with text directly.` };
1565
+ .filter(Boolean);
1566
+ // Near-matches first: an invented "scalix_build_list" must surface
1567
+ // the real scalix_* names. Blindly listing the first 20 registry
1568
+ // entries hid every platform tool and read as "those don't exist" —
1569
+ // the model then invented workarounds instead of correcting the name
1570
+ // (seen 2026-08-09: a deploy derailed into "make the repo public").
1571
+ const stem = call.name.toLowerCase().split(/[_-]/)[0];
1572
+ const near = stem.length >= 3 ? allNames.filter((n) => n.toLowerCase().includes(stem)) : [];
1573
+ const shown = [...new Set([...near, ...allNames])].slice(0, Math.max(20, Math.min(near.length, 40)));
1574
+ const omitted = allNames.length - shown.length;
1575
+ result = { error: `Tool "${call.name}" does not exist.${near.length ? ` Did you mean one of: ${near.slice(0, 40).join(', ')}?` : ''} Available tools: ${shown.join(', ')}${omitted > 0 ? `, plus ${omitted} more registered but not listed here` : ''}. Do not invent tool names — only use tools from the list above, or respond with text directly.` };
1381
1576
  }
1382
1577
  let resultStr;
1383
1578
  try {
@@ -1483,6 +1678,16 @@ export class Agent extends EventEmitter {
1483
1678
  }
1484
1679
  if (this._aborted)
1485
1680
  return '[interrupted]';
1681
+ this.emit('turn:summary', {
1682
+ turnIndex: turnCount,
1683
+ steps: _stepIndex,
1684
+ llmDurationMs: Date.now() - _turnLlmStartMs,
1685
+ toolDurationMs: _turnToolDurationMs,
1686
+ ttftMs: _turnTtftMs,
1687
+ tokensPerSec: (Date.now() - _turnLlmStartMs) > 0 ? _turnOutputTokens / ((Date.now() - _turnLlmStartMs) / 1000) : 0,
1688
+ inputTokens: _turnInputTokens,
1689
+ outputTokens: _turnOutputTokens,
1690
+ });
1486
1691
  continue;
1487
1692
  }
1488
1693
  if (this.groundingConfig?.enabled && this.groundingNudgeCount < this.groundingConfig.maxNudges) {
@@ -1502,6 +1707,16 @@ export class Agent extends EventEmitter {
1502
1707
  }
1503
1708
  }
1504
1709
  }
1710
+ this.emit('turn:summary', {
1711
+ turnIndex: turnCount,
1712
+ steps: _stepIndex,
1713
+ llmDurationMs: Date.now() - _turnLlmStartMs,
1714
+ toolDurationMs: _turnToolDurationMs,
1715
+ ttftMs: _turnTtftMs,
1716
+ tokensPerSec: (Date.now() - _turnLlmStartMs) > 0 ? _turnOutputTokens / ((Date.now() - _turnLlmStartMs) / 1000) : 0,
1717
+ inputTokens: _turnInputTokens,
1718
+ outputTokens: _turnOutputTokens,
1719
+ });
1505
1720
  return fullText;
1506
1721
  }
1507
1722
  catch (err) {
@@ -35,7 +35,7 @@ export function parseRetryAfter(value) {
35
35
  const DEFAULT_TIMEOUT_MS = (() => {
36
36
  const v = process.env.SCALIX_REQUEST_TIMEOUT_MS;
37
37
  const n = v ? parseInt(v, 10) : NaN;
38
- return Number.isFinite(n) && n > 0 ? n : 300_000;
38
+ return Number.isFinite(n) ? n : 0;
39
39
  })();
40
40
  // The capability probe only decides whether a turn streams. It must never be
41
41
  // the reason a turn is slow to start, so it gives up quickly and falls back to
@@ -124,8 +124,8 @@ export class ScalixClient {
124
124
  for (;;) {
125
125
  const prepared = this.prepareRequest(req);
126
126
  const body = JSON.stringify(prepared);
127
- const timeoutSignal = AbortSignal.timeout(this.timeoutMs);
128
- const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
127
+ const timeoutSignal = this.timeoutMs > 0 ? AbortSignal.timeout(this.timeoutMs) : undefined;
128
+ const combined = signal && timeoutSignal ? AbortSignal.any([signal, timeoutSignal]) : (signal || timeoutSignal);
129
129
  const response = await fetch(url, {
130
130
  method: 'POST',
131
131
  headers: {
@@ -135,7 +135,7 @@ export class ScalixClient {
135
135
  'x-client-type': 'cli',
136
136
  },
137
137
  body,
138
- signal: combined,
138
+ signal: combined || undefined,
139
139
  });
140
140
  if (!response.ok) {
141
141
  const text = await response.text().catch(() => '');