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/dist/cloud.js CHANGED
@@ -15,7 +15,7 @@
15
15
  // Dependency-free: global fetch only. Reuses apiHostRoot() so legacy configs
16
16
  // with a trailing /v1 keep working for these non-AI endpoints too.
17
17
  import { apiHostRoot, USER_AGENT, WEB_DEFAULTS } from './constants.js';
18
- export const CONSOLE_KEYS_URL = 'https://console.scalix.world/org/keys';
18
+ export const CONSOLE_KEYS_URL = 'https://console.scalix.world/org/keys?utm_source=scalix-coder&utm_medium=cli';
19
19
  export const CLOUD_DEFAULTS = {
20
20
  fetchTimeoutMs: WEB_DEFAULTS.apiFetchTimeoutMs,
21
21
  // The platform's own transport kills a tool call at ~30-35s (gateway →
package/dist/config.js CHANGED
@@ -1,16 +1,55 @@
1
1
  import { z } from 'zod';
2
- export const McpServiceSchema = z.object({
2
+ /** Fields every MCP server entry carries, whichever transport it uses. */
3
+ const McpCommonFields = {
3
4
  serverLabel: z.string().min(1, 'serverLabel is required'),
4
- serverUrl: z.string().url('serverUrl must be a valid URL'),
5
5
  serverDescription: z.string().optional(),
6
- authorization: z.string().optional(),
7
- authorizationEnv: z.string().optional(),
8
- headers: z.record(z.string(), z.string()).optional(),
6
+ /** Skip this server without deleting the entry. */
7
+ disabled: z.boolean().optional(),
9
8
  allowedTools: z.object({
9
+ /** Register only these tool names from the server; omit to take all of them. */
10
10
  toolNames: z.array(z.string()).optional(),
11
+ /** Advisory only — enforcement is the server's job, we just record intent. */
11
12
  readOnly: z.boolean().optional(),
12
13
  }).optional(),
14
+ };
15
+ /**
16
+ * A remote MCP server reached over Streamable HTTP (falling back to the older
17
+ * HTTP+SSE transport, which is what `transport: "sse"` pins).
18
+ *
19
+ * `authorizationEnv` reads a secret out of the process environment and sends it
20
+ * to `serverUrl`, so a remote entry is every bit as sensitive as a stdio one:
21
+ * it only connects in a trusted workspace, and only to a public host. See
22
+ * McpHub.connect and validateMcpUrl in mcp.ts.
23
+ */
24
+ export const McpHttpServiceSchema = z.object({
25
+ ...McpCommonFields,
26
+ serverUrl: z.string().url('serverUrl must be a valid URL'),
27
+ transport: z.enum(['http', 'sse']).optional(),
28
+ authorization: z.string().optional(),
29
+ authorizationEnv: z.string().optional(),
30
+ headers: z.record(z.string(), z.string()).optional(),
31
+ });
32
+ /**
33
+ * A local MCP server the agent spawns and talks to over stdio — the shape
34
+ * Claude Desktop, VS Code and Cursor all use, so a `{command, args}` block
35
+ * copied from a server's README works here unchanged.
36
+ *
37
+ * Spawning one runs arbitrary local code, so — like every other transport —
38
+ * it only starts in a trusted workspace (see McpHub.connect in mcp.ts).
39
+ */
40
+ export const McpStdioServiceSchema = z.object({
41
+ ...McpCommonFields,
42
+ command: z.string().min(1, 'command is required'),
43
+ args: z.array(z.string()).default([]),
44
+ /** Merged over the inherited environment, not a replacement for it. */
45
+ env: z.record(z.string(), z.string()).optional(),
46
+ cwd: z.string().optional(),
13
47
  });
48
+ /** Either transport. `command` vs `serverUrl` is what tells them apart. */
49
+ export const McpServiceSchema = z.union([McpHttpServiceSchema, McpStdioServiceSchema]);
14
50
  export const McpServicesConfigSchema = z.object({
15
51
  mcpServers: z.array(McpServiceSchema).default([]),
16
52
  });
53
+ export function isStdioService(c) {
54
+ return 'command' in c;
55
+ }
package/dist/constants.js CHANGED
@@ -25,6 +25,13 @@ export const USER_AGENT = `${PRODUCT_NAME}/${VERSION}`;
25
25
  // pre-1.3.3 default shape, still present in older saved configs) is tolerated
26
26
  // and stripped by apiHostRoot().
27
27
  export const DEFAULT_BASE_URL = process.env.SCALIX_BASE_URL || 'https://api.scalix.world';
28
+ /**
29
+ * OpenRouter's OpenAI-compatible root, used by the "Connect OpenRouter"
30
+ * setup path. The key arrives via the OAuth PKCE exchange (openrouter-oauth.ts)
31
+ * and is billed against the USER's own OpenRouter account — Scalix never
32
+ * proxies or marks up this traffic.
33
+ */
34
+ export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
28
35
  /**
29
36
  * Normalize a configured base URL to the API host root: trailing slashes are
30
37
  * removed, and a trailing `/v1` or `/v1/ai` segment (legacy config shape) is
@@ -78,7 +85,7 @@ export const SCALIX_PLATFORM_SECTION = `## Scalix Platform
78
85
  When helping users build AI applications, agents, or LLM-powered features:
79
86
  - 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.
80
87
  - 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.
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.
88
+ - Scalix inference is catalog-driven. Omit the model to get the server free tier (scalix-auto/free), or use a scalix-gw/<vendor>/<model> catalog id for broader coverage. Browse ids via the catalog endpoint. Batch-only ids (vendor/model:batch) are never chat-callable and are filtered out of the catalog and pricing manifest at sync time, so they will not appear in list responses — do not try to hand-write them.
82
89
  - 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.
83
90
  - Never name or speculate about the AI providers/models backing Scalix inference — Scalix models are presented as Scalix's own.
84
91
  - When the user explicitly requests a specific provider (e.g. "use OpenAI directly"), respect that choice.`;
@@ -87,6 +94,41 @@ export const SCALIX_CLOUD_RESOURCES_SECTION = `## Deploying & Managing Scalix Cl
87
94
  - 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
95
  - 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
96
  - 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.`;
97
+ /** Deploy-path knowledge for connected sessions, appended by the agent
98
+ * alongside the steering sections. Kept OUT of the locked pre-split join
99
+ * (tests/constants.test.ts, 4675-char lock) so it can evolve freely.
100
+ * Facts verified against scalix-cloud docs/gateway 2026-08-10 — update
101
+ * alongside the platform, not from memory. */
102
+ export const SCALIX_DEPLOY_PLAYBOOK_SECTION = `## Scalix Deploy Playbook
103
+ The real deploy paths on Scalix Cloud — pick from these, never invent one:
104
+ - Run service (long-running container: autoscale, revisions, health-gated rollouts) is the standard target for web apps and APIs. Deploy with scalix_run_deploy. Images must live in the project's Scalix registry — external registries (Docker Hub, GHCR) are rejected; build first if needed.
105
+ - From source: scalix_build_create (git URL only; frameworks and static sites are auto-detected) → poll scalix_build_status → deploy the resulting image with scalix_run_deploy. Services go live at https://<name>.run.scalix.world. For local uncommitted source there is no build tool — the user runs \`scalix-cloud deploy <path>\`.
106
+ - Run services get \`PORT\` injected — the app must bind 0.0.0.0:$PORT, never localhost. Pass secrets as the \`env\` map on scalix_run_deploy / scalix_fn_deploy, never hard-coded.
107
+ - Functions: scalix_fn_deploy — Node or Python, either a single entry file inline or a container image. Multi-file source requires the image path.
108
+ - Terminal one-shot: \`scalix-cloud deploy <path>\` packages, builds, and deploys a directory as a Run service in one command — offer it when the user wants to deploy from their own shell.
109
+ - Continuous deploys: git integrations (GitHub, GitLab, Bitbucket) auto-build and auto-deploy on push, with PR preview deploys — set up on the console's Integrations page or via the Scalix GitHub App.
110
+ - Roll back with scalix_run_rollback (no revision argument lists the revisions). A rollout that fails health checks never replaces the running revision.
111
+ - Not available yet — do not offer: named deploy environments (everything ships to production) and the /v1/compute/deployments API (returns 501).`;
112
+ /** Whole-platform facts for connected sessions — the things models guess
113
+ * wrong when left to generic cloud instincts. Same regime as the deploy
114
+ * playbook: outside the locked join, verified against scalix-cloud
115
+ * docs/gateway/mcp-server 2026-08-10. */
116
+ export const SCALIX_PLATFORM_PLAYBOOK_SECTION = `## Scalix Platform Playbook
117
+ Area-by-area rules for the rest of the platform — follow these over generic cloud instincts:
118
+ - Database (ScalixNova): destructive SQL (DROP/TRUNCATE/bulk DELETE) via scalix_db_query returns CONFIRMATION_REQUIRED with a confirm_token — show the user what will run, then re-call with the token. Never wrap this in invented transactions.
119
+ - DB branches: scalix_db_branch_create makes a copy-on-write branch with a TTL (default 1h) and returns a branch-scoped token; drop with scalix_db_branch_drop or let the TTL expire. The main timeline cannot be deleted. Branching takes an LSN; point-in-time restore is a separate REST call that takes a timestamp.
120
+ - DB connections are plain Postgres: postgresql://<tenant>:<API_KEY>@db.scalix.world:6432/<db>?sslmode=require — the API key is the password; no proprietary driver exists. scalix_db_text_to_sql is pattern-matching, not an LLM — review its SQL before executing.
121
+ - Secrets & env vars: there are NO scalix_* tools for them. Manage via the /v1/env REST API or the CLI, or pass an env map at deploy time. Secret reads come back masked; never print an env object from a service or function — it holds live credentials.
122
+ - Storage: presigned URLs (REST POST /v1/storage/presign, max 7-day expiry) are the way to share private objects — no tool exists for them. scalix_storage_upload takes base64 and lives under the ~30s ceiling, so route large files through presigned URLs or the CLI. public=true buckets are world-readable; default private.
123
+ - Functions: Node and Python only, 300s max timeout. Go/Rust/anything else goes to Run (Dockerfile) or a Computer. scalix_fn_invoke takes the function_id, not the name.
124
+ - Cron: only scalix_cron_create exists — standard 5-field minute-first expressions, action function|webhook with config {function_id} or {url,...}. Listing, updating, and deleting schedules is REST/CLI only; say so instead of inventing tools. A scheduled function = deploy the function, then a cron pointing at its id.
125
+ - Custom domains: add with scalix_domain_add → user creates CNAME to cname.scalix.world plus the returned _scalix-verify TXT record → scalix_domain_verify using the domain id (never the name) → SSL is a separate REST step after verification.
126
+ - Computers: every mutation takes the machine id, never the name. Create is idempotent by name (retry-safe). Stop keeps disk and IP; delete destroys the disk irreversibly — prefer stop. Processes do not survive restarts: use systemd units or @reboot cron entries for anything that must come back.
127
+ - KV: TTL is seconds (0 = never); expired keys read as not-found; discover keys with list-by-prefix.
128
+ - Events: resolve the topic_id with scalix_events_topics before scalix_events_publish — publishing to a nonexistent topic fails, and there is no topic-create tool.
129
+ - scalix_search is a curated original-source news index (newsrooms, wires, regulators) — NOT open-web search. Do not use it as a general web tool.
130
+ - No tools exist for: logs (use REST/CLI), email sending (/v1/email REST), vectors, realtime, VPC/LB/WAF. When asked, name the REST path or CLI rather than inventing a scalix_* tool.
131
+ - Platform keys are project-scoped: a 400 from platform tools usually means an org-wide key, a 403 means a missing scope. Switching projects means switching keys.`;
90
132
  /** Shown instead of the full steering when the session is on the Scalix
91
133
  * provider but the cloud connection is not (yet) up. One neutral pointer,
92
134
  * no product pitch. */
@@ -130,16 +172,16 @@ export function maxOutputTokensForRequest(model) {
130
172
  return Math.min(getModelSpec(model).maxOutputTokens, AI_MAX_OUTPUT_TOKENS);
131
173
  }
132
174
  // Pre-Lumio public model ids → current ids (mirrors the platform's own alias
133
- // table). The gateway still accepts the old names, but saved configs are
134
- // normalized on load so the UI shows current ids and nothing breaks if the
135
- // aliases are ever retired server-side.
175
+ // table). Saved configs are normalized on load so the UI shows current ids.
176
+ // The whole Lumio family (Lite/Pro/Embed) and Candela were retired 2026-08
177
+ // (billing mig 113, gateway ai_rate_limit.rs); the gateway collapses every
178
+ // retired house id to the free default, so the client mirrors that here.
136
179
  export const LEGACY_MODEL_ALIASES = {
137
- 'scalix-lite': 'scalix-lumio-lite',
138
- 'scalix-balanced': 'scalix-lumio-pro',
139
- 'scalix-max': 'scalix-candela',
140
- 'scalix-embed': 'scalix-lumio-embed',
141
- 'scalix-world-ai': 'scalix-lumio-pro',
142
- 'scalix-advanced': 'scalix-candela',
180
+ 'scalix-embed': 'scalix-auto/free',
181
+ 'scalix-lumio-lite': 'scalix-auto/free',
182
+ 'scalix-lumio-pro': 'scalix-auto/free',
183
+ 'scalix-lumio-embed': 'scalix-auto/free',
184
+ 'scalix-candela': 'scalix-auto/free',
143
185
  };
144
186
  export function normalizeModelId(model) {
145
187
  return LEGACY_MODEL_ALIASES[model] ?? model;
@@ -191,7 +233,10 @@ export function getModelSpec(model) {
191
233
  }
192
234
  export function modelSupportsVision(model) {
193
235
  const spec = getModelSpec(model);
194
- return spec.supportsVision ?? false;
236
+ // OpenAI-compatible model-list endpoints commonly omit this capability.
237
+ // Unknown must remain usable: only suppress images when vision is
238
+ // explicitly disabled by the API or user configuration.
239
+ return spec.supportsVision !== false;
195
240
  }
196
241
  /** True when the model has a user-configured or API-fetched spec (i.e. its
197
242
  * maxOutputTokens is real, not the DEFAULT_MODEL_SPEC guess). BYOK providers
@@ -410,6 +455,10 @@ export const PRIVATE_NETWORK_HOSTNAMES = new Set([
410
455
  ]);
411
456
  export const PRIVATE_NETWORK_SUFFIXES = ['.local', '.internal', '.localhost'];
412
457
  export const PRIVATE_NETWORK_CIDRS = [
458
+ // All of 127.0.0.0/8 is loopback, not just 127.0.0.1: a service bound to
459
+ // 127.0.0.2 was reachable because only the literal .1 was in the hostname
460
+ // set above and no CIDR covered the rest of the range.
461
+ /^127\./,
413
462
  /^10\./,
414
463
  /^172\.(1[6-9]|2\d|3[01])\./,
415
464
  /^192\.168\./,
@@ -493,12 +542,34 @@ export function canonicalizeIpv4(host) {
493
542
  export function isPrivateIp(ip) {
494
543
  const canon = canonicalizeIpv4(ip);
495
544
  const candidate = (canon ?? ip).toLowerCase();
545
+ // Honour the same overrides isPrivateHost does. Without this the two
546
+ // functions disagreed: a host added to `security.allowedPrivateHosts` passed
547
+ // the hostname check and was then blocked by the post-DNS check anyway, so
548
+ // the setting did nothing for the internal hosts it exists to permit.
549
+ if (_networkOverrides.allowAll)
550
+ return false;
551
+ if (_networkOverrides.allowedHosts?.has(candidate)
552
+ || _networkOverrides.allowedHosts?.has(ip.toLowerCase()))
553
+ return false;
554
+ if (_networkOverrides.blockedHosts?.has(candidate))
555
+ return true;
496
556
  if (PRIVATE_NETWORK_HOSTNAMES.has(candidate))
497
557
  return true;
498
558
  if (PRIVATE_NETWORK_CIDRS.some(re => re.test(candidate)))
499
559
  return true;
500
560
  return false;
501
561
  }
562
+ /**
563
+ * Whether this exact hostname was named in `security.allowedPrivateHosts` (or
564
+ * network security was turned off wholesale). Callers that resolve DNS and
565
+ * re-check the resulting address need this: the operator allowed a *name*, and
566
+ * that name is expected to resolve into a private range.
567
+ */
568
+ export function isHostExplicitlyAllowed(hostname) {
569
+ if (_networkOverrides.allowAll)
570
+ return true;
571
+ return _networkOverrides.allowedHosts?.has(hostname.toLowerCase()) ?? false;
572
+ }
502
573
  export function isPrivateHost(hostname) {
503
574
  const lower = hostname.toLowerCase();
504
575
  if (_networkOverrides.allowAll)
@@ -4,6 +4,7 @@ import { collectGitInfo, gitInfoToContext } from './git-context.js';
4
4
  import { loadMemory, memoryToContext } from './project-memory.js';
5
5
  import { buildProjectIndex, indexToContext } from './project-index.js';
6
6
  import { loadScopedRules, rulesToContext } from './scoped-rules.js';
7
+ import { readBoardFile, boardToContext } from './board-context.js';
7
8
  import { retrieveContext, snippetsToContext, RETRIEVAL_DEFAULTS } from './context-retriever.js';
8
9
  async function readJsonSafe(filePath) {
9
10
  try {
@@ -102,11 +103,12 @@ export async function buildProjectContext(cwd) {
102
103
  const scripts = await detectAvailableScripts(cwd);
103
104
  const projectType = await detectProjectType(cwd);
104
105
  // Gather context in parallel
105
- const [gitCtx, memory, rules, projectConfig] = await Promise.all([
106
+ const [gitCtx, memory, rules, projectConfig, boardCtx] = await Promise.all([
106
107
  collectGitInfo(cwd).then(gitInfoToContext).catch(() => ''),
107
108
  loadMemory(cwd).then(memoryToContext).catch(() => ''),
108
109
  loadScopedRules(cwd).then(rulesToContext).catch(() => ''),
109
110
  loadProjectConfig(cwd),
111
+ readBoardFile(cwd).then(boardToContext).catch(() => ''),
110
112
  ]);
111
113
  // Project index (uses cache)
112
114
  let indexCtx = '';
@@ -121,6 +123,7 @@ export async function buildProjectContext(cwd) {
121
123
  projectIndex: indexCtx,
122
124
  projectMemory: memory,
123
125
  scopedRules: rules,
126
+ boardContext: boardCtx,
124
127
  availableScripts: scripts,
125
128
  projectConfig,
126
129
  };
@@ -145,6 +148,11 @@ export function contextToInstructions(baseInstructions, ctx) {
145
148
  if (ctx.projectMemory) {
146
149
  sections.push(ctx.projectMemory);
147
150
  }
151
+ // After memory (durable knowledge), before rules: the board is the freshest
152
+ // section and the one a member should act on first when they conflict.
153
+ if (ctx.boardContext) {
154
+ sections.push(ctx.boardContext);
155
+ }
148
156
  if (ctx.scopedRules) {
149
157
  sections.push(ctx.scopedRules);
150
158
  }
@@ -0,0 +1,51 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { USER_CONFIG_DIR, VERSION } from './constants.js';
6
+ import { logger } from './logger.js';
7
+ const FEEDBACK_FILE = path.join(process.env.HOME || process.env.USERPROFILE || '.', USER_CONFIG_DIR, 'feedback.json');
8
+ export async function saveFeedback(item) {
9
+ const fullItem = {
10
+ id: randomUUID(),
11
+ timestamp: new Date().toISOString(),
12
+ systemInfo: item.systemInfo || {
13
+ version: VERSION,
14
+ nodeVersion: process.version,
15
+ platform: os.platform(),
16
+ arch: os.arch(),
17
+ },
18
+ ...item,
19
+ };
20
+ try {
21
+ await fs.mkdir(path.dirname(FEEDBACK_FILE), { recursive: true });
22
+ let existing = [];
23
+ try {
24
+ const raw = await fs.readFile(FEEDBACK_FILE, 'utf-8');
25
+ const parsed = JSON.parse(raw);
26
+ if (Array.isArray(parsed))
27
+ existing = parsed;
28
+ }
29
+ catch { }
30
+ existing.unshift(fullItem);
31
+ // Keep last 100 feedback submissions locally
32
+ if (existing.length > 100)
33
+ existing = existing.slice(0, 100);
34
+ await fs.writeFile(FEEDBACK_FILE, JSON.stringify(existing, null, 2), { encoding: 'utf-8', mode: 0o600 });
35
+ logger.info('Feedback recorded locally', { id: fullItem.id, category: fullItem.category });
36
+ }
37
+ catch (err) {
38
+ logger.warn('Failed to persist feedback locally', { error: err.message });
39
+ }
40
+ return fullItem;
41
+ }
42
+ export async function listFeedback() {
43
+ try {
44
+ const raw = await fs.readFile(FEEDBACK_FILE, 'utf-8');
45
+ const parsed = JSON.parse(raw);
46
+ return Array.isArray(parsed) ? parsed : [];
47
+ }
48
+ catch {
49
+ return [];
50
+ }
51
+ }
package/dist/headless.js CHANGED
@@ -4,12 +4,13 @@ import { execSync } from 'node:child_process';
4
4
  import { createAgent } from './agent.js';
5
5
  import { defaultTools } from './tools.js';
6
6
  import { extraTools } from './tools-extra.js';
7
+ import { memoryTools } from './tools-memory.js';
7
8
  import { webTools } from './tools-web.js';
8
9
  import { advancedTools } from './tools-advanced.js';
9
10
  import { visionTools } from './tools-vision.js';
10
11
  import { voiceTools } from './tools-voice.js';
11
12
  import { browserTools, closeBrowser } from './tools-browser.js';
12
- import { loadMcpServices } from './mcp.js';
13
+ import { startMcp } from './mcp.js';
13
14
  import { costTracker } from './cost-tracker.js';
14
15
  import { saveSession, loadSession, listSessions, generateSessionId } from './sessions.js';
15
16
  import { logger } from './logger.js';
@@ -163,7 +164,6 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeE
163
164
  console.error('Error: No models configured.\nSet SCALIX_MODEL env var or run interactive setup: scalix');
164
165
  process.exit(1);
165
166
  }
166
- const mcpTools = loadMcpServices();
167
167
  // Only discover/load project-local plugins in a trusted workspace — plugins
168
168
  // dynamically import and execute code from the project directory.
169
169
  const plugins = isWorkspaceTrusted() ? await discoverPlugins(process.cwd()) : [];
@@ -171,7 +171,7 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeE
171
171
  process.stderr.write('Project plugins skipped (untrusted workspace).\n');
172
172
  }
173
173
  const pluginTools = getPluginTools(plugins);
174
- const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools, ...pluginTools];
174
+ const allTools = [...defaultTools, ...extraTools, ...memoryTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...pluginTools];
175
175
  const instructions = await buildEnhancedInstructions();
176
176
  let outputSchema;
177
177
  if (outputSchemaPath) {
@@ -214,6 +214,12 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeE
214
214
  groundingCheck: true,
215
215
  contextRetrieval: true,
216
216
  });
217
+ if (agent.getSkippedHookCount() > 0) {
218
+ process.stderr.write(`Project hooks skipped (untrusted workspace): ${agent.getSkippedHookCount()}\n`);
219
+ }
220
+ // Connect configured MCP servers and register their tools. Warnings only:
221
+ // a server that will not start must not take the whole run down with it.
222
+ const mcpHub = await startMcp(agent, (m) => process.stderr.write(`\x1b[33m⚠ ${m}\x1b[0m\n`));
217
223
  // Register the account's Scalix Cloud platform tools (one attempt; failure
218
224
  // is stated once and the run continues with local tools only).
219
225
  const cloudStatus = await agent.refreshCloudTools();
@@ -271,6 +277,7 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeE
271
277
  process.stdout.write(formatted + '\n');
272
278
  }
273
279
  await closeBrowser();
280
+ await mcpHub.close(); // stdio servers are child processes; don't orphan them
274
281
  if (beforeExit)
275
282
  await beforeExit();
276
283
  process.exit(0);
@@ -329,7 +336,6 @@ async function runInteractive() {
329
336
  if (selectedModel.length === 0) {
330
337
  throw new Error('No models configured. Set SCALIX_MODEL env var or run interactive setup.');
331
338
  }
332
- const mcpTools = loadMcpServices();
333
339
  // Only discover/load project-local plugins in a trusted workspace — plugins
334
340
  // dynamically import and execute code from the project directory.
335
341
  const plugins = isWorkspaceTrusted() ? await discoverPlugins(process.cwd()) : [];
@@ -337,7 +343,7 @@ async function runInteractive() {
337
343
  process.stderr.write('Project plugins skipped (untrusted workspace).\n');
338
344
  }
339
345
  const pluginTools = getPluginTools(plugins);
340
- const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools, ...pluginTools];
346
+ const allTools = [...defaultTools, ...extraTools, ...memoryTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...pluginTools];
341
347
  const instructions = await buildEnhancedInstructions();
342
348
  const rl = readline.createInterface({
343
349
  input: process.stdin,
@@ -383,6 +389,9 @@ async function runInteractive() {
383
389
  return answer.trim().toLowerCase() === 'y';
384
390
  },
385
391
  });
392
+ if (agent.getSkippedHookCount() > 0) {
393
+ process.stderr.write(`Project hooks skipped (untrusted workspace): ${agent.getSkippedHookCount()}\n`);
394
+ }
386
395
  let sessionId = generateSessionId();
387
396
  agent.on('thinking:start', () => process.stdout.write('\n\x1b[33mThinking...\x1b[0m'));
388
397
  agent.on('tool:call', (name, args) => {
@@ -413,6 +422,9 @@ async function runInteractive() {
413
422
  console.log(`\x1b[33m\u26A0 Context usage: ${info.percentage}% (${info.tokens}/${info.maxTokens} tokens)\x1b[0m`);
414
423
  }
415
424
  });
425
+ // Connect configured MCP servers and register their tools. Warnings only:
426
+ // a server that will not start must not take the whole run down with it.
427
+ const mcpHub = await startMcp(agent, (m) => process.stderr.write(`\x1b[33m⚠ ${m}\x1b[0m\n`));
416
428
  // Register the account's Scalix Cloud platform tools (one attempt; failure
417
429
  // is stated once and the session continues with local tools only).
418
430
  const cloudStatus = await agent.refreshCloudTools();
@@ -445,6 +457,7 @@ async function runInteractive() {
445
457
  console.log('Goodbye!');
446
458
  rl.close();
447
459
  await closeBrowser().catch(() => { });
460
+ await mcpHub.close().catch(() => { }); // stdio servers are our children
448
461
  process.exit(0);
449
462
  }
450
463
  process.on('SIGINT', () => { shutdown().catch(() => process.exit(1)); });
@@ -610,6 +623,10 @@ async function runInteractive() {
610
623
  return true;
611
624
  }
612
625
  if (cmd === '/hooks') {
626
+ const skipped = agent.getSkippedHookCount();
627
+ if (skipped > 0) {
628
+ console.log(`\n${skipped} project hook(s) skipped — untrusted workspace.`);
629
+ }
613
630
  console.log('\n' + hooksSummary(agent.getHooks()) + '\n');
614
631
  return true;
615
632
  }