scalix 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -33,6 +33,8 @@ import { checkForUpdate, getUpdateBanner, performUpdate } from './update-checker
33
33
  import { loadConfig, loadGlobalConfig, saveConfig, isConfigured, resolveActiveProvider, DEFAULT_LIMITS, THINKING_MODES } from './settings.js';
34
34
  import { fetchByokModels } from './providers/openai.js';
35
35
  import { getModeDescription, PERMISSION_MODES, setSandboxRoot, checkScopedPermission, getScopedPermissionRules } from './sandbox.js';
36
+ import { getCommandSandboxMode, setCommandSandboxMode } from './tools.js';
37
+ import { COMMAND_SANDBOX_MODES } from './command-sandbox.js';
36
38
  import { buildProjectIndex, indexToContext } from './project-index.js';
37
39
  import { collectGitInfo, gitInfoToContext } from './git-context.js';
38
40
  import { loadMemory, autoDetectConventions, saveMemory, memoryToContext, addNote, detectCorrection } from './project-memory.js';
@@ -52,6 +54,7 @@ import { stringWidth, truncateText } from './text-utils.js';
52
54
  import { isWorkspaceTrusted, trustWorkspace, hasProjectExecSurface } from './workspace-trust.js';
53
55
  import { setLocalOriginApprover } from './browser-access.js';
54
56
  import { fetchMe, fetchCloudTools, groupCloudTools, filterCatalogByScopes, cloudKeyRemedy, CONSOLE_KEYS_URL } from './cloud.js';
57
+ import { listMarketplaceListings, getMarketplaceListing, installMarketplaceListing } from './marketplace.js';
55
58
  logger.setLevel('silent');
56
59
  // Promisified async child_process helpers (keep the TUI responsive vs execSync).
57
60
  const execFileAsync = promisify(execFile);
@@ -342,9 +345,13 @@ function initAgent(config) {
342
345
  }).catch((err) => {
343
346
  logger.warn('Failed to build enhanced instructions', { error: err.message });
344
347
  });
345
- loadCustomCommands(process.cwd()).then((cmds) => {
346
- customCommands = cmds;
347
- }).catch((err) => { logger.warn('Failed to load custom commands', { error: err.message }); });
348
+ // Custom command templates can carry !`cmd` inline-bash, which executes
349
+ // shell — only load them from a trusted workspace, like plugins/hooks.
350
+ if (isWorkspaceTrusted(process.cwd())) {
351
+ loadCustomCommands(process.cwd()).then((cmds) => {
352
+ customCommands = cmds;
353
+ }).catch((err) => { logger.warn('Failed to load custom commands', { error: err.message }); });
354
+ }
348
355
  loadSkills(process.cwd()).then((skills) => {
349
356
  loadedSkills = skills;
350
357
  }).catch((err) => { logger.warn('Failed to load skills', { error: err.message }); });
@@ -2769,6 +2776,30 @@ function App({ config }) {
2769
2776
  }
2770
2777
  return true;
2771
2778
  }
2779
+ if (cmd === '/sandbox') {
2780
+ const arg = text.split(' ')[1]?.toLowerCase();
2781
+ const current = getCommandSandboxMode();
2782
+ if (arg && COMMAND_SANDBOX_MODES.includes(arg)) {
2783
+ setCommandSandboxMode(arg);
2784
+ loadGlobalConfig().then(cfg => {
2785
+ cfg.commandSandbox = arg;
2786
+ return saveConfig(cfg);
2787
+ }).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
2788
+ const why = arg === 'off'
2789
+ ? 'commands run unsandboxed'
2790
+ : arg === 'filesystem'
2791
+ ? 'writes confined to the workspace + temp; network allowed'
2792
+ : 'writes confined to the workspace + temp; network denied';
2793
+ add('system', `Command sandbox: ${arg.toUpperCase()} — ${why}. Applies to new run_command calls immediately.`);
2794
+ }
2795
+ else if (!arg) {
2796
+ add('system', `Command sandbox: ${current.toUpperCase()} — usage: /sandbox <off|filesystem|strict>. filesystem = workspace-only writes, strict = also no network.`);
2797
+ }
2798
+ else {
2799
+ add('system', `Unknown sandbox mode "${arg}". Options: off, filesystem, strict.`);
2800
+ }
2801
+ return true;
2802
+ }
2772
2803
  if (cmd === '/preset') {
2773
2804
  const arg = text.split(' ')[1]?.toLowerCase();
2774
2805
  if (arg) {
@@ -2826,11 +2857,107 @@ function App({ config }) {
2826
2857
  }
2827
2858
  agent.connectMcpServer({ serverLabel: p[1], serverUrl: p[2] }).then((s) => {
2828
2859
  add(s.state === 'connected' ? 'system' : 'error', s.state === 'connected'
2829
- ? `MCP connected: ${p[1]} \u2192 ${p[2]} (${s.toolCount} tool${s.toolCount === 1 ? '' : 's'})`
2860
+ ? `MCP connected: ${p[1]} → ${p[2]} (${s.toolCount} tool${s.toolCount === 1 ? '' : 's'})`
2830
2861
  : `MCP ${p[1]} failed: ${s.error ?? 'unknown error'}`);
2831
2862
  });
2832
2863
  return true;
2833
2864
  }
2865
+ if (cmd === '/marketplace') {
2866
+ // The Scalix curated catalog (docs/MARKETPLACE.md). Server data is
2867
+ // untrusted input: the client module re-validates every listing before
2868
+ // it reaches disk, and a stdio listing never installs without an
2869
+ // explicit --confirm flag (it spawns a local process).
2870
+ const p = text.split(' ').filter(Boolean);
2871
+ const trustBadge = (t) => t === 'official' ? '★' : t === 'verified' ? '✔' : '·';
2872
+ const kindBadge = (k) => k === 'mcp' ? 'MCP' : k === 'skill' ? 'skill' : k === 'rule' ? 'rule' : 'agent';
2873
+ const renderList = (list) => list.length
2874
+ ? list.map(l => `${trustBadge(l.trust)} ${l.id} [${kindBadge(l.kind)} v${l.version || '?'}] — ${l.summary}`).join('\n')
2875
+ : 'No listings matched.';
2876
+ if (p.length === 1) {
2877
+ setIsLoading(true);
2878
+ setStatus('thinking');
2879
+ listMarketplaceListings({ limit: 12 }).then((list) => {
2880
+ add('system', 'Scalix Marketplace — featured catalog:\n' + renderList(list) +
2881
+ '\n\n/marketplace <query> to search · /marketplace info <id> · /marketplace install <id>');
2882
+ }).catch((err) => {
2883
+ add('error', `Marketplace unavailable: ${err.message}`);
2884
+ }).finally(() => { setIsLoading(false); setStatus('ready'); });
2885
+ return true;
2886
+ }
2887
+ if (p[1] === 'install') {
2888
+ const id = p[2];
2889
+ if (!id) {
2890
+ add('system', 'Usage: /marketplace install <id> [--confirm]');
2891
+ return true;
2892
+ }
2893
+ const confirmStdio = p.includes('--confirm');
2894
+ setIsLoading(true);
2895
+ setStatus('thinking');
2896
+ getMarketplaceListing(id).then((detail) => {
2897
+ return installMarketplaceListing(detail, { confirmStdio }).then((result) => {
2898
+ if (result.success) {
2899
+ add('system', result.message);
2900
+ const envNote = detail.payload?.kind === 'mcp' && detail.payload.requiredEnv?.length
2901
+ ? `\nSet ${detail.payload.requiredEnv.join(', ')} in your environment, then /mcp to connect.`
2902
+ : '';
2903
+ add('system', envNote.trim());
2904
+ }
2905
+ else {
2906
+ add('error', `Install failed: ${result.error}`);
2907
+ }
2908
+ });
2909
+ }).catch((err) => {
2910
+ add('error', `Install failed: ${err.message}`);
2911
+ }).finally(() => { setIsLoading(false); setStatus('ready'); });
2912
+ return true;
2913
+ }
2914
+ if (p[1] === 'info') {
2915
+ const id = p[2];
2916
+ if (!id) {
2917
+ add('system', 'Usage: /marketplace info <id>');
2918
+ return true;
2919
+ }
2920
+ setIsLoading(true);
2921
+ setStatus('thinking');
2922
+ getMarketplaceListing(id).then((detail) => {
2923
+ const payload = detail.payload;
2924
+ const lines = [
2925
+ `${detail.name} — ${detail.trust} · v${detail.version} by ${detail.author || 'unknown'}`,
2926
+ detail.summary,
2927
+ ];
2928
+ if (payload?.kind === 'mcp') {
2929
+ lines.push(payload.transport === 'http'
2930
+ ? `Remote MCP over https → ${payload.serverUrl}`
2931
+ : `Local MCP (stdio) → ${payload.command} ${(payload.args ?? []).join(' ')}`.trim());
2932
+ if (payload.requiredEnv?.length)
2933
+ lines.push(`Requires env: ${payload.requiredEnv.join(', ')}`);
2934
+ }
2935
+ else if (payload?.kind === 'skill') {
2936
+ lines.push(`Writes .scalix/skills/${payload.name}/ (${payload.files.length} file(s))`);
2937
+ }
2938
+ else if (payload) {
2939
+ lines.push(`Writes .scalix/${payload.kind === 'rule' ? 'rules' : 'agents'}/${payload.fileName}`);
2940
+ }
2941
+ else {
2942
+ lines.push('No installable payload on this listing.');
2943
+ }
2944
+ add('system', lines.join('\n'));
2945
+ }).catch((err) => {
2946
+ add('error', `Marketplace unavailable: ${err.message}`);
2947
+ }).finally(() => { setIsLoading(false); setStatus('ready'); });
2948
+ return true;
2949
+ }
2950
+ const query = p.slice(1).join(' ');
2951
+ setIsLoading(true);
2952
+ setStatus('thinking');
2953
+ listMarketplaceListings({ q: query, limit: 12 }).then((list) => {
2954
+ add('system', `Scalix Marketplace — "${query}":\n` + renderList(list) +
2955
+ '\n\n/marketplace info <id> · /marketplace install <id>');
2956
+ }).catch((err) => {
2957
+ add('error', `Marketplace unavailable: ${err.message}`);
2958
+ }).finally(() => { setIsLoading(false); setStatus('ready'); });
2959
+ return true;
2960
+ }
2834
2961
  if (cmd === '/index') {
2835
2962
  setIsLoading(true);
2836
2963
  setStatus('thinking');
@@ -0,0 +1,146 @@
1
+ /**
2
+ * OS-level command sandbox — the outer wall behind the logical checks in
3
+ * sandbox.ts. When enabled, run_command executes inside a kernel-enforced
4
+ * sandbox instead of a bare shell:
5
+ *
6
+ * filesystem — writes confined to the workspace + system temp; network allowed
7
+ * strict — filesystem confinement PLUS no network (Antigravity's
8
+ * strict-mode equivalent; also the mode to run untrusted builds in)
9
+ *
10
+ * Backends:
11
+ * macOS — sandbox-exec (seatbelt). Deprecated by Apple but still present and
12
+ * used widely; the profile denies file-writes outside the allowlist.
13
+ * Linux — bubblewrap (bwrap). Root filesystem read-only, workspace/tmp bound
14
+ * writable; strict mode unshares the network namespace.
15
+ * Windows — not supported: the tool fails closed with the reason rather than
16
+ * silently running unsandboxed.
17
+ *
18
+ * Mode is opt-in: `commandSandbox` in ~/.scalix-coder/config.json, overridden by
19
+ * SCALIX_COMMAND_SANDBOX (off|filesystem|strict). Default off — this must never
20
+ * change behaviour for an existing install without the user asking for it.
21
+ *
22
+ * Trades: package managers that cache into $HOME (pip, cargo) will fail to
23
+ * write their caches in filesystem mode. That is the point of the wall; the
24
+ * agent sees the EACCES and can tell the user.
25
+ */
26
+ import * as fs from 'node:fs';
27
+ import * as os from 'node:os';
28
+ import * as path from 'node:path';
29
+ export const COMMAND_SANDBOX_MODES = ['off', 'filesystem', 'strict'];
30
+ const SANDBOX_EXEC = '/usr/bin/sandbox-exec';
31
+ /**
32
+ * Resolve the effective mode. Env wins over config so an operator can force a
33
+ * policy without touching the user's config file; an invalid value anywhere
34
+ * resolves to 'off' (never guesses upward).
35
+ */
36
+ export function resolveCommandSandboxMode(configValue, envValue) {
37
+ const fromEnv = (envValue ?? '').trim().toLowerCase();
38
+ if (fromEnv) {
39
+ return COMMAND_SANDBOX_MODES.includes(fromEnv) ? fromEnv : 'off';
40
+ }
41
+ const fromConfig = typeof configValue === 'string' ? configValue.trim().toLowerCase() : '';
42
+ if (fromConfig) {
43
+ return COMMAND_SANDBOX_MODES.includes(fromConfig) ? fromConfig : 'off';
44
+ }
45
+ return 'off';
46
+ }
47
+ /** Seatbelt profile: baseline allow, deny all writes, re-allow workspace + temp. */
48
+ export function buildSeatbeltProfile(mode, workspace, tmp) {
49
+ const q = (p) => p.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
50
+ const lines = [
51
+ '(version 1)',
52
+ '(allow default)',
53
+ '(deny file-write*)',
54
+ `(allow file-write*`,
55
+ ` (subpath "${q(workspace)}")`,
56
+ ` (subpath "${q(tmp)}")`,
57
+ // macOS temp roots resolve through /private symlinks; seatbelt matches the
58
+ // literal path, so both spellings are allowlisted.
59
+ ` (regex #"^/(private/)?var/folders/")`,
60
+ ` (literal "/dev/null")`,
61
+ ` (literal "/dev/zero")`,
62
+ ` (literal "/dev/dtrace-helper")`,
63
+ `)`,
64
+ ];
65
+ if (mode === 'strict') {
66
+ lines.push('(deny network*)');
67
+ }
68
+ return lines.join('\n') + '\n';
69
+ }
70
+ /** Bubblewrap argv prefix: root read-only, workspace + /tmp writable. */
71
+ export function buildBubblewrapArgs(mode, workspace) {
72
+ const args = [
73
+ '--ro-bind', '/', '/',
74
+ '--bind', workspace, workspace,
75
+ '--bind', '/tmp', '/tmp',
76
+ '--dev', '/dev',
77
+ '--proc', '/proc',
78
+ '--tmpfs', '/run',
79
+ ];
80
+ if (mode === 'strict') {
81
+ args.push('--unshare-net');
82
+ }
83
+ return args;
84
+ }
85
+ function findOnPath(name) {
86
+ const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
87
+ for (const dir of dirs) {
88
+ const candidate = path.join(dir, name);
89
+ try {
90
+ if (fs.statSync(candidate).isFile())
91
+ return candidate;
92
+ }
93
+ catch {
94
+ /* next */
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+ /**
100
+ * Build the sandbox wrap for one command execution, or an error explaining why
101
+ * it cannot run (missing backend binary, unsupported platform). Pure aside from
102
+ * filesystem availability probes — safe to call per command.
103
+ */
104
+ export function buildSandboxWrap(mode, workspace, shell) {
105
+ if (mode === 'off')
106
+ return { ok: false, error: 'sandbox disabled' };
107
+ if (process.platform === 'win32') {
108
+ return { ok: false, error: 'the command sandbox is not supported on Windows — set commandSandbox to "off" in ~/.scalix-coder/config.json or unset SCALIX_COMMAND_SANDBOX' };
109
+ }
110
+ const cwd = path.resolve(workspace);
111
+ if (process.platform === 'darwin') {
112
+ if (!fs.existsSync(SANDBOX_EXEC)) {
113
+ return { ok: false, error: 'sandbox-exec was not found at /usr/bin/sandbox-exec — set commandSandbox to "off" to run without the sandbox' };
114
+ }
115
+ return {
116
+ ok: true,
117
+ wrap: {
118
+ execPath: SANDBOX_EXEC,
119
+ // -f (profile file) not -p: the profile embeds absolute paths, and a
120
+ // workspace path containing quotes must never be interpolated into an
121
+ // inline profile string. The caller writes the profile to disk.
122
+ args: ['-f', '@PROFILE@', shell.path, ...shell.args, '@COMMAND@'],
123
+ profile: buildSeatbeltProfile(mode, cwd, os.tmpdir()),
124
+ backend: 'seatbelt',
125
+ },
126
+ };
127
+ }
128
+ // linux and other unix
129
+ const bwrap = findOnPath('bwrap');
130
+ if (!bwrap) {
131
+ return { ok: false, error: 'bubblewrap (bwrap) is not on PATH — install it (e.g. apt install bubblewrap) or set commandSandbox to "off"' };
132
+ }
133
+ return {
134
+ ok: true,
135
+ wrap: {
136
+ execPath: bwrap,
137
+ args: [...buildBubblewrapArgs(mode, cwd), '--', shell.path, ...shell.args, '@COMMAND@'],
138
+ backend: 'bubblewrap',
139
+ },
140
+ };
141
+ }
142
+ /** Splice the real command (and, on darwin, the profile file) into a wrap. */
143
+ export function materializeWrap(wrap, command, profilePath) {
144
+ const args = wrap.args.map((a) => (a === '@COMMAND@' ? command : a === '@PROFILE@' ? (profilePath ?? '') : a));
145
+ return { execPath: wrap.execPath, args };
146
+ }
package/dist/commands.js CHANGED
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { execSync } from 'node:child_process';
5
5
  import { logger } from './logger.js';
6
6
  import { PROJECT_LOOKUP_DIRS, PROJECT_CONFIG_DIR } from './constants.js';
7
+ import { sanitizeEnvForCommand, sandboxPath } from './sandbox.js';
7
8
  const COMMAND_DIRS = PROJECT_LOOKUP_DIRS.map(d => `${d}/commands`);
8
9
  function parseFrontmatter(content) {
9
10
  const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
@@ -62,12 +63,16 @@ export async function loadCustomCommands(cwd = process.cwd()) {
62
63
  }
63
64
  function expandFileReferences(text, cwd) {
64
65
  return text.replace(/@([\w./-]+\.\w+)/g, (_match, filePath) => {
65
- // Reject path traversal so a command file can't pull in files outside the
66
- // project via `@../../etc/passwd`.
67
- if (filePath.split(/[\\/]/).includes('..')) {
68
- return `@${filePath} (path traversal blocked)`;
69
- }
66
+ // @file reads go into the prompt, so they must respect the same sandbox as
67
+ // read_file: `..` segments, absolute paths outside the project, and
68
+ // symlinks pointing out are all blocked by sandboxPath().
70
69
  const resolved = path.resolve(cwd, filePath);
70
+ try {
71
+ sandboxPath(resolved);
72
+ }
73
+ catch {
74
+ return `@${filePath} (outside project — blocked)`;
75
+ }
71
76
  if (!existsSync(resolved))
72
77
  return `@${filePath} (file not found)`;
73
78
  try {
@@ -82,7 +87,14 @@ function expandFileReferences(text, cwd) {
82
87
  function expandInlineBash(text) {
83
88
  return text.replace(/!`([^`]+)`/g, (_match, command) => {
84
89
  try {
85
- return execSync(command, { encoding: 'utf-8', timeout: 10_000 }).trim();
90
+ // Command templates come from project files. Strip credential-bearing
91
+ // variables from the child environment (same policy as hooks and
92
+ // run_command) so a repo-supplied command cannot read API keys.
93
+ return execSync(command, {
94
+ encoding: 'utf-8',
95
+ timeout: 10_000,
96
+ env: sanitizeEnvForCommand(),
97
+ }).trim();
86
98
  }
87
99
  catch (err) {
88
100
  return `(command failed: ${err.message})`;
package/dist/constants.js CHANGED
@@ -76,6 +76,9 @@ export const CORE_INSTRUCTIONS = `You are an expert coding assistant. You write
76
76
  - Write no comments unless the WHY is non-obvious. Well-named code documents itself.
77
77
  - Prefer simple, readable solutions over clever ones.
78
78
 
79
+ ## Artifacts
80
+ - For multi-step work, write an implementation plan with write_artifact (type "plan") before editing, and a walkthrough (type "walkthrough") when the task is done. Use "tasklist" for checklists the user should review, "summary" for research findings. Keep them concise — they are the review surface, not a diary.
81
+
79
82
  ## Safety
80
83
  - Never modify files outside the working directory.
81
84
  - Never expose API keys, secrets, or credentials in code.
@@ -452,6 +455,9 @@ export const PRIVATE_NETWORK_HOSTNAMES = new Set([
452
455
  '::', '[::]', '0:0:0:0:0:0:0:1', '[0:0:0:0:0:0:0:1]',
453
456
  // common cloud metadata endpoints (also covered by 169.254/CIDR, listed for clarity)
454
457
  'metadata.google.internal', 'metadata',
458
+ // Alibaba Cloud ECS instance metadata sits outside every RFC-1918 range and
459
+ // outside 169.254/16 — without this entry it is reachable as a "public" host.
460
+ '100.100.100.200',
455
461
  ]);
456
462
  export const PRIVATE_NETWORK_SUFFIXES = ['.local', '.internal', '.localhost'];
457
463
  export const PRIVATE_NETWORK_CIDRS = [
@@ -463,6 +469,8 @@ export const PRIVATE_NETWORK_CIDRS = [
463
469
  /^172\.(1[6-9]|2\d|3[01])\./,
464
470
  /^192\.168\./,
465
471
  /^169\.254\./,
472
+ // Alibaba Cloud instance metadata (see hostname note above).
473
+ /^100\.100\.100\.200$/,
466
474
  /^\[?fe80:/i,
467
475
  /^\[?fc00:/i,
468
476
  /^\[?fd[0-9a-f]{2}:/i,
@@ -77,7 +77,7 @@ export class CostTracker {
77
77
  // only attaches x-scalix-cost-cents to non-streaming responses (the
78
78
  // headers are already flushed by the time a stream's usage frame
79
79
  // arrives), so a streamed session simply never learns the figure.
80
- lines.push(provider === 'openai'
80
+ lines.push(provider && provider !== 'scalix'
81
81
  ? 'Est. cost: not reported by your endpoint — check your provider\'s billing dashboard.'
82
82
  : 'Est. cost: not reported on streamed turns — see console.scalix.world → Billing.');
83
83
  }
package/dist/headless.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import readline from 'node:readline';
2
2
  import fs from 'node:fs/promises';
3
3
  import { execSync } from 'node:child_process';
4
- import { createAgent } from './agent.js';
4
+ import { createAgent, REPORTED_ERROR } from './agent.js';
5
5
  import { defaultTools } from './tools.js';
6
6
  import { extraTools } from './tools-extra.js';
7
7
  import { memoryTools } from './tools-memory.js';
@@ -241,7 +241,9 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeE
241
241
  fullResponse = await agent.send(prompt);
242
242
  }
243
243
  catch (err) {
244
- console.error(`Error: ${err.message}`);
244
+ // The agent's 'error' event already printed this — don't print twice.
245
+ if (!err?.[REPORTED_ERROR])
246
+ console.error(`Error: ${err.message}`);
245
247
  if (beforeExit)
246
248
  await beforeExit();
247
249
  process.exit(1);
@@ -359,7 +361,11 @@ async function runInteractive() {
359
361
  return Promise.resolve('/exit');
360
362
  return new Promise((resolve) => rl.question(question, resolve));
361
363
  }
362
- const customCmds = await loadCustomCommands(process.cwd());
364
+ // Command templates can carry !`cmd` inline-bash — trust-gate like plugins.
365
+ const customCmds = isWorkspaceTrusted() ? await loadCustomCommands(process.cwd()) : [];
366
+ if (!isWorkspaceTrusted()) {
367
+ process.stderr.write('Project custom commands skipped (untrusted workspace).\n');
368
+ }
363
369
  const agent = createAgent({
364
370
  apiKey,
365
371
  baseUrl,
@@ -643,7 +649,18 @@ async function runInteractive() {
643
649
  const cheapModel = pickCheapestModel(agent.getModels());
644
650
  console.log(`\nDelegating to sub-agent (${shortModel(cheapModel)})...`);
645
651
  try {
646
- const result = await spawnSubAgent({ apiKey: apiKey, baseUrl, models: agent.getModels() }, prompt, { model: cheapModel, tools: 'read-only', maxSteps: 10 });
652
+ const result = await spawnSubAgent({
653
+ apiKey: apiKey,
654
+ baseUrl,
655
+ // Inherit the parent's provider + cloud credentials so a BYOK
656
+ // session delegates through the same endpoint dialect it runs on;
657
+ // without `provider` the sub-agent defaults to the Scalix client
658
+ // and 404s on every request against a third-party base URL.
659
+ provider: active.provider,
660
+ cloudApiKey: active.cloudApiKey,
661
+ cloudBaseUrl: active.cloudBaseUrl,
662
+ models: agent.getModels(),
663
+ }, prompt, { model: cheapModel, tools: 'read-only', maxSteps: 10 });
647
664
  console.log(`\n${result.response}\n`);
648
665
  console.log(`\x1b[36mSub-agent: ${result.steps} steps, ~${result.tokenEstimate} tokens\x1b[0m\n`);
649
666
  }
@@ -28,13 +28,15 @@ import { voiceTools } from './tools-voice.js';
28
28
  import { browserTools, closeBrowser, setBrowserFrameSink } from './tools-browser.js';
29
29
  import { setLocalOriginApprover, listApprovedLocalOrigins, forgetLocalOrigin } from './browser-access.js';
30
30
  import { connectMcpServers } from './mcp.js';
31
- import { loadConfig, resolveActiveProvider, DEFAULT_LIMITS } from './settings.js';
31
+ import { loadConfig, resolveActiveProvider, resolveProviderByKey, summarizeProviders, DEFAULT_LIMITS } from './settings.js';
32
32
  import { PERMISSION_MODES } from './sandbox.js';
33
+ import { isProviderId } from './providers/index.js';
33
34
  import { discoverPlugins, getPluginTools } from './plugins.js';
34
35
  import { isWorkspaceTrusted } from './workspace-trust.js';
35
36
  import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS, ENV_MODEL, fetchModelSpecs, registerUserModels, normalizeModelId, VERSION } from './constants.js';
36
37
  import { loadMemory, autoDetectConventions, saveMemory, addNote, detectCorrection } from './project-memory.js';
37
38
  import { buildProjectContext, contextToInstructions } from './context-builder.js';
39
+ import { AgentRegistry } from './agent-registry.js';
38
40
  // Server→client requests awaiting an answer. Keyed by request id; the value is
39
41
  // resolved with whatever `result` object the client sends back. confirmTool was
40
42
  // the only user of this for a long time — the browser agent's local-origin
@@ -169,6 +171,7 @@ export async function runIpcServer() {
169
171
  agent.on('turn:summary', (i) => ev('turn:summary', i));
170
172
  agent.on('thinking:start', () => ev('thinking:start'));
171
173
  agent.on('thinking:end', () => ev('thinking:end'));
174
+ agent.on('reasoning:update', (t) => ev('reasoning:update', { text: t }));
172
175
  agent.on('usage', (d) => ev('usage', d));
173
176
  agent.on('error', (e) => ev('error', { message: e.message }));
174
177
  agent.on('context:compress', (i) => ev('context:compress', i));
@@ -181,13 +184,18 @@ export async function runIpcServer() {
181
184
  if (sessions.size >= MAX_SESSIONS) {
182
185
  throw new Error(`Session limit reached (${MAX_SESSIONS}) — close a chat tab first.`);
183
186
  }
184
- // Per-session provider/creds: overrides let each tab use a different AI provider
185
- // (Scalix, OpenAI, etc.) with its own API key, base URL, and model selection.
187
+ // Per-session provider/creds: overrides let each tab use a different AI
188
+ // provider (Scalix, any configured BYOK registry entry) with its own API
189
+ // key, base URL, and model selection. An explicit provider key resolves its
190
+ // credentials from the registry, so the front end only has to name it.
191
+ const overrideActive = override?.provider
192
+ ? resolveProviderByKey(config, override.provider)
193
+ : null;
186
194
  const sessionProvider = override?.provider ?? active.provider;
187
- const sessionApiKey = override?.apiKey ?? apiKey;
188
- const sessionBaseUrl = override?.baseUrl ?? baseUrl;
189
- const sessionCloudApiKey = override?.cloudApiKey ?? active.cloudApiKey;
190
- const sessionCloudBaseUrl = override?.cloudBaseUrl ?? active.cloudBaseUrl;
195
+ const sessionApiKey = override?.apiKey ?? overrideActive?.apiKey ?? apiKey;
196
+ const sessionBaseUrl = override?.baseUrl ?? overrideActive?.baseUrl ?? baseUrl;
197
+ const sessionCloudApiKey = override?.cloudApiKey ?? overrideActive?.cloudApiKey ?? active.cloudApiKey;
198
+ const sessionCloudBaseUrl = override?.cloudBaseUrl ?? overrideActive?.cloudBaseUrl ?? active.cloudBaseUrl;
191
199
  const sessionModels = override?.model ?? selectedModels;
192
200
  const sessionPermissionMode = override?.permissionMode ?? permissionMode;
193
201
  const agent = createAgent({
@@ -236,7 +244,7 @@ export async function runIpcServer() {
236
244
  // Signal ready with capability info. `version` lets the extension detect
237
245
  // engine/extension drift (a globally-installed CLI can lag the bundled one).
238
246
  // `multiSession` tells the extension it may open parallel chat sessions.
239
- write({ type: 'event', event: 'ready', data: { version: VERSION, tools: allTools.length, models: selectedModels, permissionMode, multiSession: true } });
247
+ write({ type: 'event', event: 'ready', data: { version: VERSION, tools: allTools.length, models: selectedModels, permissionMode, multiSession: true, provider: active.provider, providers: summarizeProviders(config) } });
240
248
  if (defaultSession.agent.getSkippedHookCount() > 0) {
241
249
  process.stderr.write(`IPC: project hooks skipped (untrusted workspace): ${defaultSession.agent.getSkippedHookCount()}\n`);
242
250
  }
@@ -257,6 +265,13 @@ export async function runIpcServer() {
257
265
  }
258
266
  })
259
267
  .catch(err => { process.stderr.write(`IPC: MCP startup failed: ${err?.message || err}\n`); });
268
+ // Sub-agent lifecycle → the IDE's Agents tab. The registry is a process-wide
269
+ // singleton (the session agents and every spawned sub-agent register in it),
270
+ // so hook it once here and broadcast each spawn/completion as an event.
271
+ const registry = AgentRegistry.getInstance();
272
+ const subagentEvent = (entry) => write({ type: 'event', event: 'subagent', data: entry });
273
+ registry.on('agent:spawned', subagentEvent);
274
+ registry.on('agent:completed', subagentEvent);
260
275
  // Process JSONL from stdin — session-routed requests
261
276
  const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
262
277
  rl.on('line', async (line) => {
@@ -295,7 +310,7 @@ export async function runIpcServer() {
295
310
  break;
296
311
  }
297
312
  const override = {};
298
- if (params?.provider && (params.provider === 'scalix' || params.provider === 'openai'))
313
+ if (isProviderId(params?.provider))
299
314
  override.provider = params.provider;
300
315
  if (params?.apiKey && typeof params.apiKey === 'string')
301
316
  override.apiKey = params.apiKey;
@@ -434,7 +449,11 @@ export async function runIpcServer() {
434
449
  }
435
450
  case 'getModels': {
436
451
  const session = resolveSession(params);
437
- write({ type: 'response', id, result: { models: session.agent.getModels() } });
452
+ write({ type: 'response', id, result: { models: session.agent.getModels(), provider: session.agent.getProvider() } });
453
+ break;
454
+ }
455
+ case 'getProviders': {
456
+ write({ type: 'response', id, result: { providers: summarizeProviders(config), active: active.provider } });
438
457
  break;
439
458
  }
440
459
  case 'registerModels': {
@@ -495,11 +514,23 @@ export async function runIpcServer() {
495
514
  }
496
515
  case 'setPermissionMode': {
497
516
  const session = resolveSession(params);
498
- if (params?.mode)
517
+ // Same whitelist createSession applies — an unvalidated mode string
518
+ // would degrade every tool to 'ask' (fail-safe) but leave the
519
+ // session reporting a mode that does not exist.
520
+ if (params?.mode && PERMISSION_MODES.includes(params.mode)) {
499
521
  session.agent.setPermissionMode(params.mode);
522
+ }
500
523
  write({ type: 'response', id, result: { mode: session.agent.getPermissionMode() } });
501
524
  break;
502
525
  }
526
+ case 'listAgents': {
527
+ // The IDE's Agents tab polls this: every registered agent (session
528
+ // roots plus sub-agents spawned through the `agent` tool) with its
529
+ // live status, model, token and step counts.
530
+ const all = AgentRegistry.getInstance().listAll();
531
+ write({ type: 'response', id, result: { agents: all, usage: AgentRegistry.getInstance().getUsageSummary() } });
532
+ break;
533
+ }
503
534
  default:
504
535
  write({ type: 'response', id, error: { message: `Unknown method: ${method}` } });
505
536
  }