scalix 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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, 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';
@@ -19,8 +21,28 @@ import { extractCodeClaims, checkGrounding, formatGroundingNudge, GROUNDING_DEFA
19
21
  import { RETRIEVAL_DEFAULTS } from './context-retriever.js';
20
22
  import fs from 'node:fs/promises';
21
23
  import path from 'node:path';
22
- import { fetchCloudTools, callCloudTool } from './cloud.js';
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,8 +199,11 @@ 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;
205
+ cloudApiKey;
206
+ cloudBaseUrl;
180
207
  streamingToolCallsResolved;
181
208
  constructor(config) {
182
209
  super();
@@ -189,6 +216,14 @@ export class Agent extends EventEmitter {
189
216
  throw new Error(`Provider '${this.providerId}' requires an explicit baseUrl — there is no default third-party endpoint.`);
190
217
  }
191
218
  this.client = createChatClient(this.providerId, config.baseUrl || DEFAULT_BASE_URL, config.apiKey);
219
+ // Cloud tools follow their own credential pair. On the Scalix gateway the
220
+ // inference creds double as the cloud creds; on BYOK `config.baseUrl` is a
221
+ // third-party host with no /v1/me, so the cloud URL falls back to the
222
+ // Scalix default rather than inheriting it.
223
+ this.cloudApiKey = config.cloudApiKey?.trim()
224
+ || (this.capabilities.platformTools ? config.apiKey : '');
225
+ this.cloudBaseUrl = config.cloudBaseUrl?.trim()
226
+ || (this.capabilities.platformTools ? (config.baseUrl || DEFAULT_BASE_URL) : DEFAULT_BASE_URL);
192
227
  this.log = logger.child();
193
228
  const resolvedModels = Array.isArray(config.model)
194
229
  ? (config.model.length > 0 ? config.model : FALLBACK_MODELS)
@@ -223,8 +258,28 @@ export class Agent extends EventEmitter {
223
258
  this.toolSelector = new ToolSelector();
224
259
  this.toolSelector.index(this.config.tools);
225
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(() => { });
226
266
  this.replay = new AgentReplay(randomUUID().slice(0, 8), this.config.models[0]);
227
- 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
+ }
228
283
  this.instructionLoader = config.instructionLoader;
229
284
  if (config.autoVerify === true) {
230
285
  this.verifyConfig = { ...VERIFY_DEFAULTS };
@@ -268,7 +323,16 @@ export class Agent extends EventEmitter {
268
323
  this.retrievalConfig = { ...RETRIEVAL_DEFAULTS, ...config.contextRetrieval };
269
324
  }
270
325
  }
271
- pickModel() {
326
+ /**
327
+ * `fresh` — a NEW user turn: always start from the front of the list (the
328
+ * user's chosen model). The idx+1 bookkeeping below exists so RETRIES
329
+ * advance to the next model in the chain; without the fresh reset it also
330
+ * rotated the STARTING model, so with a failover chain configured,
331
+ * consecutive sends silently alternated models — a switch never "stuck".
332
+ */
333
+ pickModel(fresh = false) {
334
+ if (fresh)
335
+ this.modelIndex = 0;
272
336
  const startIdx = this.modelIndex;
273
337
  for (let i = 0; i < this.config.models.length; i++) {
274
338
  const idx = (startIdx + i) % this.config.models.length;
@@ -335,6 +399,16 @@ export class Agent extends EventEmitter {
335
399
  message.includes('stream error') ||
336
400
  message.includes('try again'));
337
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
+ }
338
412
  isModelCircuitOpen(model) {
339
413
  const state = this.modelFailures.get(model);
340
414
  if (!state)
@@ -385,12 +459,18 @@ export class Agent extends EventEmitter {
385
459
  setPermissionMode(mode) { this.config.permissionMode = mode; }
386
460
  getProvider() { return this.providerId; }
387
461
  /**
388
- * Whether this session's inference endpoint is the Scalix gateway, and so
389
- * can answer /v1/me and serve the account's platform-tool catalog. Callers
390
- * must check this before probing those endpoints: on BYOK the base URL is a
391
- * third-party host that has no such routes.
462
+ * Whether this session can reach a Scalix gateway that answers /v1/me and
463
+ * serves the account's platform-tool catalog. True when inference runs on
464
+ * the gateway, or when BYOK inference is paired with a separate Scalix Cloud
465
+ * key. False means no endpoint to probe — never probe the BYOK base URL.
392
466
  */
393
- supportsPlatformTools() { return this.capabilities.platformTools; }
467
+ supportsPlatformTools() { return this.cloudApiKey.length > 0; }
468
+ /** The Scalix Cloud endpoint platform tools talk to — the gateway, not the
469
+ * inference base URL, which under BYOK is a third-party host. */
470
+ getCloudBaseUrl() { return this.cloudBaseUrl; }
471
+ /** The key platform tools authenticate with — equal to the inference key on
472
+ * the 'scalix' provider, separate under BYOK, empty if unconfigured. */
473
+ getCloudApiKey() { return this.cloudApiKey; }
394
474
  getThinking() { return this.config.thinkingMode; }
395
475
  setThinking(mode) { this.config.thinkingMode = mode; }
396
476
  async compact() {
@@ -447,7 +527,11 @@ export class Agent extends EventEmitter {
447
527
  * itself started rejecting the oversized request. The untouched 1M default
448
528
  * isn't a choice, so it stays out of the way of the guess.
449
529
  */
450
- syncContextLimit(model = this.config.models[this.modelIndex % this.config.models.length]) {
530
+ // Default to models[0] — the model a fresh send will use. modelIndex points
531
+ // one PAST the last pick (retry bookkeeping), so dereferencing it here made
532
+ // idle-time context reads use the NEXT chain entry's budget, not the
533
+ // active model's.
534
+ syncContextLimit(model = this.config.models[0]) {
451
535
  const configured = this.configuredContextTokens;
452
536
  const userChoseLimit = configured != null
453
537
  && configured > 0
@@ -551,6 +635,64 @@ export class Agent extends EventEmitter {
551
635
  if (this.toolSelector)
552
636
  this.toolSelector.index(this.config.tools);
553
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
+ }
554
696
  // ─── Scalix Cloud platform tools ───
555
697
  isCloudTool(t) {
556
698
  return 'source' in t && t.source === 'scalix-cloud';
@@ -559,8 +701,8 @@ export class Agent extends EventEmitter {
559
701
  * Schema is passed to the model as-is via `parameters` (NOT `inputSchema`,
560
702
  * which transformToolsForApi would run through the Zod converter). */
561
703
  makeCloudTool(ct) {
562
- const baseUrl = this.client.baseURL;
563
- const apiKey = this.config.apiKey;
704
+ const baseUrl = this.cloudBaseUrl;
705
+ const apiKey = this.cloudApiKey;
564
706
  const projectId = this.cloudProjectId;
565
707
  return {
566
708
  source: 'scalix-cloud',
@@ -581,26 +723,41 @@ export class Agent extends EventEmitter {
581
723
  * (/cloud refresh). On failure the previously registered cloud tools are
582
724
  * removed and the agent keeps running with local tools only — the returned
583
725
  * status carries the real error for a single honest notice; no retries.
726
+ *
727
+ * `identity`: pass a CloudIdentity to scope-filter the catalog without a
728
+ * second /v1/me round-trip (the TUI already fetched one); pass `null` when
729
+ * /v1/me was tried and failed so no doomed re-fetch happens; omit entirely
730
+ * (headless, IPC) and the identity is fetched best-effort here — on any
731
+ * /v1/me failure the catalog registers unfiltered, exactly as before.
584
732
  */
585
- async refreshCloudTools() {
586
- // Platform tools live on the Scalix gateway (/v1/me, /api/v1/mcp). Never
587
- // probe a BYOK third-party endpoint for them.
588
- if (!this.capabilities.platformTools) {
589
- this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
590
- this.cloudStatus = { state: 'no-key', toolCount: 0 };
591
- return { ...this.cloudStatus };
592
- }
593
- if (!this.config.apiKey) {
733
+ async refreshCloudTools(identity) {
734
+ // Platform tools live on the Scalix gateway (/v1/me, /api/v1/mcp), which
735
+ // is `cloudBaseUrl` — never the BYOK inference endpoint. No cloud key
736
+ // means nothing to fetch: on 'scalix' the user has no key at all, on BYOK
737
+ // they have not opted into pairing a Scalix Cloud key with their provider.
738
+ if (!this.cloudApiKey) {
594
739
  this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
595
740
  this.cloudStatus = { state: 'no-key', toolCount: 0 };
596
741
  return { ...this.cloudStatus };
597
742
  }
598
743
  try {
599
- const catalog = await fetchCloudTools(this.client.baseURL, this.config.apiKey, this.cloudProjectId);
744
+ const catalog = await fetchCloudTools(this.cloudBaseUrl, this.cloudApiKey, this.cloudProjectId);
745
+ let me = identity;
746
+ if (me === undefined) {
747
+ try {
748
+ me = await fetchMe(this.cloudBaseUrl, this.cloudApiKey);
749
+ }
750
+ catch {
751
+ me = null;
752
+ }
753
+ }
754
+ const visible = me
755
+ ? filterCatalogByScopes(catalog.tools, me.permissions.scopes)
756
+ : catalog.tools;
600
757
  this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
601
758
  const localNames = new Set(this.config.tools.map((t) => t.function?.name || t.name).filter(Boolean));
602
759
  let registered = 0;
603
- for (const ct of catalog.tools) {
760
+ for (const ct of visible) {
604
761
  if (localNames.has(ct.name)) {
605
762
  this.log.warn('Cloud tool name collides with a local tool — keeping the local tool', { tool: ct.name });
606
763
  continue;
@@ -613,14 +770,26 @@ export class Agent extends EventEmitter {
613
770
  this.cloudStatus = {
614
771
  state: 'connected',
615
772
  toolCount: registered,
773
+ totalCount: catalog.tools.length,
616
774
  platformAvailable: catalog.platformAvailable,
617
775
  };
776
+ // The platform binds MCP calls to the KEY's project; a configured
777
+ // project id cannot redirect them. Say so instead of silently differing.
778
+ const keyProject = me?.identity.projectId;
779
+ if (keyProject && this.cloudProjectId && keyProject !== this.cloudProjectId) {
780
+ this.cloudStatus.note = `API key is bound to project ${keyProject}; configured project ${this.cloudProjectId} is ignored for platform tools (the key's binding wins).`;
781
+ }
618
782
  }
619
783
  catch (err) {
620
784
  this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
621
785
  if (this.toolSelector)
622
786
  this.toolSelector.index(this.config.tools);
623
- this.cloudStatus = { state: 'unavailable', toolCount: 0, error: err?.message || String(err) };
787
+ this.cloudStatus = {
788
+ state: 'unavailable',
789
+ toolCount: 0,
790
+ error: err?.message || String(err),
791
+ remedy: cloudKeyRemedy(err),
792
+ };
624
793
  this.log.warn('Scalix Cloud tool catalog unavailable', { error: this.cloudStatus.error });
625
794
  }
626
795
  return { ...this.cloudStatus };
@@ -638,16 +807,57 @@ export class Agent extends EventEmitter {
638
807
  if (!this.config.models.includes(model))
639
808
  this.config.models.push(model);
640
809
  }
641
- setModel(model) { this.config.models = [model]; }
810
+ setModel(model) {
811
+ // Mutate IN PLACE, never reassign: setAgentToolConfig captured this array
812
+ // by reference at construction, so a reassignment left sub-agents on the
813
+ // pre-switch model list forever. And keep the failover chain — replacing
814
+ // the whole list with [model] permanently destroyed a configured
815
+ // multi-model chain; the switched-to model just moves to the front.
816
+ const rest = this.config.models.filter((m) => m !== model);
817
+ this.config.models.splice(0, this.config.models.length, model, ...rest);
818
+ this.modelIndex = 0;
819
+ // A deliberate switch is a vote of confidence: clear this model's breaker
820
+ // so the next request actually tries it, and resize the context budget
821
+ // now instead of at the next lazy resync.
822
+ this.modelFailures.delete(model);
823
+ this.syncContextLimit(model);
824
+ }
642
825
  getModels() { return [...this.config.models]; }
826
+ /** Live-apply a limit. Returns true when the running agent honors the new
827
+ * value immediately, false when it only takes effect on the next launch —
828
+ * callers used to report "updated" unconditionally, which was a lie for
829
+ * every key that lives outside AgentConfig (web/git/search timeouts, …). */
643
830
  updateLimit(key, value) {
831
+ // Context keys live nested under config.contextManager and on the live
832
+ // ContextManager, not as top-level AgentConfig fields.
833
+ if (key === 'maxContextTokens') {
834
+ this.configuredContextTokens = value;
835
+ this.contextManager.setMaxContextTokens(value);
836
+ return true;
837
+ }
644
838
  if (key in this.config) {
645
839
  this.config[key] = value;
840
+ return true;
646
841
  }
842
+ return false;
647
843
  }
648
844
  getReplay() { return this.replay; }
649
845
  getChangeTracker() { return this.changeTracker; }
650
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
+ }
651
861
  getApiKey() { return this.config.apiKey; }
652
862
  getBaseUrl() { return this.client.baseURL; }
653
863
  getAgentId() { return this.agentId; }
@@ -820,6 +1030,8 @@ export class Agent extends EventEmitter {
820
1030
  async send(content, images) {
821
1031
  this._aborted = false;
822
1032
  this.groundingNudgeCount = 0;
1033
+ if (images?.length)
1034
+ validatePastedImages(images);
823
1035
  const hasFileRefs = /@(?:[\w./-]+\/)?[\w.-]+\.\w+/.test(content);
824
1036
  const expandedContent = hasFileRefs ? await this.expandFileReferences(content) : content;
825
1037
  let finalContent = expandedContent;
@@ -842,12 +1054,30 @@ export class Agent extends EventEmitter {
842
1054
  this.emit('thinking:start');
843
1055
  this.truncateMessages();
844
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 { }
845
1075
  const requestId = randomUUID().slice(0, 8);
846
1076
  const reqLog = logger.child(requestId);
847
1077
  reqLog.info('User message received', { length: content.length });
848
1078
  this.registerInRegistry();
849
1079
  try {
850
- const initialModel = this.pickModel();
1080
+ const initialModel = this.pickModel(true);
851
1081
  reqLog.info('Starting agent loop', { model: initialModel, agentId: this.agentId });
852
1082
  let result = await this.loopStreamingTurns(initialModel, reqLog);
853
1083
  if (this.outputFormat !== 'text') {
@@ -866,7 +1096,7 @@ export class Agent extends EventEmitter {
866
1096
  // platform key is a dead end that doesn't touch the credential that failed.
867
1097
  if (status === 401 && !error.message.includes('console.scalix.world') && !error.message.includes('/setup')) {
868
1098
  error.message += this.capabilities.platformTools
869
- ? ' — 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.'
870
1100
  : ` — API key rejected by ${this.client.baseURL}. Check the key for your provider and update it via /setup.`;
871
1101
  }
872
1102
  error.metadata = {
@@ -891,6 +1121,13 @@ export class Agent extends EventEmitter {
891
1121
  let emptyTurnCount = 0;
892
1122
  const MAX_EMPTY_TURNS = 5;
893
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;
894
1131
  let forceNonStreaming = false;
895
1132
  while (turnCount < this.config.maxSteps) {
896
1133
  if (this._aborted) {
@@ -946,6 +1183,27 @@ export class Agent extends EventEmitter {
946
1183
  }
947
1184
  }
948
1185
  }
1186
+ // Scalix steering + live cloud-tools awareness, assembled here — the
1187
+ // one point every frontend (TUI, headless, IPC) funnels through —
1188
+ // and rebuilt per request so a /cloud refresh shows up on the next
1189
+ // turn. Coder is a generic tool: the product steering only appears
1190
+ // while a Scalix Cloud connection is live; on the Scalix provider
1191
+ // without one a single neutral pointer; on pure BYOK nothing at all.
1192
+ if (this.cloudStatus.state === 'connected') {
1193
+ if (!SCALIX_STEERING_ALWAYS_ON) {
1194
+ effectiveInstructions += '\n\n' + SCALIX_PLATFORM_SECTION + '\n\n' + SCALIX_CLOUD_RESOURCES_SECTION;
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;
1199
+ const liveCloudTools = this.getCloudTools();
1200
+ if (liveCloudTools.length > 0) {
1201
+ effectiveInstructions += '\n\n' + buildCloudToolsPromptSection(liveCloudTools);
1202
+ }
1203
+ }
1204
+ else if (!SCALIX_STEERING_ALWAYS_ON && this.providerId === 'scalix') {
1205
+ effectiveInstructions += '\n\n' + SCALIX_NEUTRAL_POINTER;
1206
+ }
949
1207
  const vision = modelSupportsVision(currentModel);
950
1208
  const apiMessages = messagesToApi(this.messages, effectiveInstructions, vision);
951
1209
  const thinkingLevel = this.config.thinkingMode !== 'off' ? this.config.thinkingMode : undefined;
@@ -958,6 +1216,7 @@ export class Agent extends EventEmitter {
958
1216
  ? true
959
1217
  : (!apiTools || apiTools.length === 0) && !hasToolMessages);
960
1218
  if (useStreaming) {
1219
+ _turnLlmStartMs = Date.now();
961
1220
  const stream = await this.client.chatComplete({
962
1221
  model: currentModel,
963
1222
  messages: apiMessages,
@@ -977,6 +1236,10 @@ export class Agent extends EventEmitter {
977
1236
  if (choice?.delta) {
978
1237
  const delta = choice.delta;
979
1238
  if (delta.content) {
1239
+ if (!_firstDeltaSeen) {
1240
+ _turnTtftMs = Date.now() - _turnLlmStartMs;
1241
+ _firstDeltaSeen = true;
1242
+ }
980
1243
  fullText += delta.content;
981
1244
  this.emit('stream:delta', delta.content, fullText);
982
1245
  }
@@ -1000,6 +1263,8 @@ export class Agent extends EventEmitter {
1000
1263
  const inTok = chunk.usage.prompt_tokens ?? 0;
1001
1264
  const outTok = chunk.usage.completion_tokens ?? 0;
1002
1265
  const totalTok = chunk.usage.total_tokens ?? (inTok + outTok);
1266
+ _turnInputTokens = inTok;
1267
+ _turnOutputTokens = outTok;
1003
1268
  const cost = chunk.usage.cost ?? 0;
1004
1269
  this.emit('usage', {
1005
1270
  prompt_tokens: inTok,
@@ -1013,6 +1278,7 @@ export class Agent extends EventEmitter {
1013
1278
  }
1014
1279
  }
1015
1280
  else {
1281
+ _turnLlmStartMs = Date.now();
1016
1282
  this.emit('stream:start');
1017
1283
  const result = await this.client.chatComplete({
1018
1284
  model: currentModel,
@@ -1027,6 +1293,8 @@ export class Agent extends EventEmitter {
1027
1293
  this.recordModelSuccess(currentModel);
1028
1294
  const choice = result.choices?.[0];
1029
1295
  if (choice?.message) {
1296
+ _turnTtftMs = Date.now() - _turnLlmStartMs;
1297
+ _firstDeltaSeen = true;
1030
1298
  if (choice.message.content) {
1031
1299
  fullText = choice.message.content;
1032
1300
  this.emit('stream:delta', fullText, fullText);
@@ -1045,6 +1313,8 @@ export class Agent extends EventEmitter {
1045
1313
  const inTok = result.usage.prompt_tokens ?? 0;
1046
1314
  const outTok = result.usage.completion_tokens ?? 0;
1047
1315
  const totalTok = result.usage.total_tokens ?? (inTok + outTok);
1316
+ _turnInputTokens = inTok;
1317
+ _turnOutputTokens = outTok;
1048
1318
  const cost = result.usage.cost ?? 0;
1049
1319
  this.emit('usage', {
1050
1320
  prompt_tokens: inTok,
@@ -1219,10 +1489,13 @@ export class Agent extends EventEmitter {
1219
1489
  const execFn = tool?.function?.execute ?? tool?.execute;
1220
1490
  if (tool && typeof execFn === 'function') {
1221
1491
  try {
1222
- // Cloud platform tools get the raw args: their JSON Schemas are
1223
- // authoritative (snake_case) and the gateway validates them —
1224
- // injecting camelCase duplicates could fail server-side validation.
1225
- 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')
1226
1499
  ? parsedArgs
1227
1500
  : normalizeArgs(parsedArgs);
1228
1501
  // Validate against the very schema the model was shown. Nothing
@@ -1249,6 +1522,8 @@ export class Agent extends EventEmitter {
1249
1522
  const toolTimeout = (typeof args?.timeout === 'number' && args.timeout > 0)
1250
1523
  ? Math.min(args.timeout, TOOL_DEFAULTS.maxCommandTimeoutMs)
1251
1524
  : this.config.toolTimeoutMs;
1525
+ const toolStart = Date.now();
1526
+ this.emit('step:start', { stepIndex: _stepIndex, name: call.name, args: parsedArgs, startedAt: toolStart });
1252
1527
  result = await withTimeout(execFn(args, {
1253
1528
  context: {},
1254
1529
  workspaceRoot: this.config.workspaceRoot ?? process.cwd(),
@@ -1262,8 +1537,21 @@ export class Agent extends EventEmitter {
1262
1537
  confirmToolCall: this.config.confirmToolCall,
1263
1538
  },
1264
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.
1265
1549
  if (call.name === 'add_mcp_server' && result?.success) {
1266
- 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 } : {}) };
1267
1555
  }
1268
1556
  }
1269
1557
  catch (e) {
@@ -1272,11 +1560,19 @@ export class Agent extends EventEmitter {
1272
1560
  }
1273
1561
  }
1274
1562
  else {
1275
- const availableNames = this.config.tools
1563
+ const allNames = this.config.tools
1276
1564
  .map((t) => t.function?.name || t.name)
1277
- .filter(Boolean)
1278
- .slice(0, 20);
1279
- 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.` };
1280
1576
  }
1281
1577
  let resultStr;
1282
1578
  try {
@@ -1382,6 +1678,16 @@ export class Agent extends EventEmitter {
1382
1678
  }
1383
1679
  if (this._aborted)
1384
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
+ });
1385
1691
  continue;
1386
1692
  }
1387
1693
  if (this.groundingConfig?.enabled && this.groundingNudgeCount < this.groundingConfig.maxNudges) {
@@ -1401,6 +1707,16 @@ export class Agent extends EventEmitter {
1401
1707
  }
1402
1708
  }
1403
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
+ });
1404
1720
  return fullText;
1405
1721
  }
1406
1722
  catch (err) {