scalix 1.5.1 → 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
@@ -15,21 +15,26 @@ import SelectInput from 'ink-select-input';
15
15
  import { createAgent, messageContentToString, REPORTED_ERROR } from './agent.js';
16
16
  import { defaultTools } from './tools.js';
17
17
  import { extraTools } from './tools-extra.js';
18
+ import { memoryTools } from './tools-memory.js';
18
19
  import { webTools } from './tools-web.js';
19
20
  import { advancedTools } from './tools-advanced.js';
20
21
  import { visionTools } from './tools-vision.js';
21
22
  import { voiceTools } from './tools-voice.js';
22
23
  import { browserTools, closeBrowser } from './tools-browser.js';
23
- import { loadMcpServices } from './mcp.js';
24
+ import { startMcp, validateMcpUrl } from './mcp.js';
24
25
  import { renderMarkdown } from './markdown.js';
25
26
  import { costTracker } from './cost-tracker.js';
26
27
  import { saveSession, listSessions, loadSession, generateSessionId, saveCrashState, loadCrashState, clearCrashState, forkSession } from './sessions.js';
28
+ import { saveFeedback } from './feedback.js';
27
29
  import { logger } from './logger.js';
28
- import { VERSION, PRODUCT_NAME, DEFAULT_MODELS, DEFAULT_BASE_URL, DEFAULT_INSTRUCTIONS, ENV_API_KEY, ENV_MODEL, TOOL_DEFAULTS, HEADLESS_DEFAULTS, GIT_BOT_IDENTITY, BYOK_DEFAULT_CONTEXT_TOKENS, BYOK_DEFAULT_MAX_OUTPUT_TOKENS, USER_CONFIG_DIR, getModelContextWindow, fetchModelSpecs, normalizeModelId, registerUserModels, hasExplicitModelSpec } from './constants.js';
30
+ import { VERSION, PRODUCT_NAME, DEFAULT_MODELS, DEFAULT_BASE_URL, DEFAULT_INSTRUCTIONS, ENV_API_KEY, ENV_MODEL, TOOL_DEFAULTS, HEADLESS_DEFAULTS, GIT_BOT_IDENTITY, BYOK_DEFAULT_CONTEXT_TOKENS, BYOK_DEFAULT_MAX_OUTPUT_TOKENS, USER_CONFIG_DIR, OPENROUTER_BASE_URL, getModelContextWindow, fetchModelSpecs, normalizeModelId, registerUserModels, hasExplicitModelSpec } from './constants.js';
31
+ import { buildAuthUrl, exchangeCodeForKey, generatePkcePair, openInBrowser, startLoopbackCallback } from './openrouter-oauth.js';
29
32
  import { checkForUpdate, getUpdateBanner, performUpdate } from './update-checker.js';
30
33
  import { loadConfig, loadGlobalConfig, saveConfig, isConfigured, resolveActiveProvider, DEFAULT_LIMITS, THINKING_MODES } from './settings.js';
31
34
  import { fetchByokModels } from './providers/openai.js';
32
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';
33
38
  import { buildProjectIndex, indexToContext } from './project-index.js';
34
39
  import { collectGitInfo, gitInfoToContext } from './git-context.js';
35
40
  import { loadMemory, autoDetectConventions, saveMemory, memoryToContext, addNote, detectCorrection } from './project-memory.js';
@@ -47,7 +52,9 @@ import { createWorktree, listWorktrees, removeWorktree, cleanupStaleWorktrees }
47
52
  import { sendNotification, getTerminalType } from './terminal-notify.js';
48
53
  import { stringWidth, truncateText } from './text-utils.js';
49
54
  import { isWorkspaceTrusted, trustWorkspace, hasProjectExecSurface } from './workspace-trust.js';
55
+ import { setLocalOriginApprover } from './browser-access.js';
50
56
  import { fetchMe, fetchCloudTools, groupCloudTools, filterCatalogByScopes, cloudKeyRemedy, CONSOLE_KEYS_URL } from './cloud.js';
57
+ import { listMarketplaceListings, getMarketplaceListing, installMarketplaceListing } from './marketplace.js';
51
58
  logger.setLevel('silent');
52
59
  // Promisified async child_process helpers (keep the TUI responsive vs execSync).
53
60
  const execFileAsync = promisify(execFile);
@@ -86,7 +93,7 @@ setSandboxRoot(process.cwd());
86
93
  // Suppress noisy React 19 development-mode reconciler warnings that leak to stderr
87
94
  const _origConsoleError = console.error;
88
95
  console.error = (...args) => {
89
- if (typeof args[0] === 'string' && args[0].startsWith('Warning:') && args[0].includes('same key'))
96
+ if (args.length === 1 && typeof args[0] === 'string' && args[0].includes('same key'))
90
97
  return;
91
98
  _origConsoleError(...args);
92
99
  };
@@ -95,14 +102,12 @@ console.error = (...args) => {
95
102
  // sanitizeInput) and buffer the pasted content into the input value instead of
96
103
  // treating each embedded newline as a separate Enter (which caused many submits).
97
104
  process.stdout.write('\x1b[?2004h');
98
- // User-registered external MCP servers (mcp-services.json). These are
99
- // decorative config entries only — they are filtered out of API requests by
100
- // transformToolsForApi. Scalix Cloud platform tools do NOT use this path: they
101
- // are fetched from the account's catalog and registered as first-class
102
- // function tools via agent.refreshCloudTools() (see connectCloud below).
103
- const mcpTools = loadMcpServices();
105
+ // User-registered external MCP servers (mcp-services.json) are not in this
106
+ // list: they are connected once the agent exists and registered from the live
107
+ // connection by startMcp(), the same way Scalix Cloud platform tools arrive via
108
+ // agent.refreshCloudTools() (see connectCloud below).
104
109
  let pluginTools = [];
105
- const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools];
110
+ const allTools = [...defaultTools, ...extraTools, ...memoryTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools];
106
111
  // ─── Confirmation bridge (event-driven) ───
107
112
  import { EventEmitter } from 'node:events';
108
113
  const confirmEmitter = new EventEmitter();
@@ -134,6 +139,11 @@ const confirmToolCall = async (toolName, args) => {
134
139
  confirmEmitter.emit('pending');
135
140
  });
136
141
  };
142
+ // The browser tools refuse localhost and the LAN until the user says otherwise.
143
+ // In the TUI that question goes through the same confirmation prompt as any
144
+ // other tool call, so "Always allow" covers the rest of the session; a lasting,
145
+ // written-down approval is the IDE's job (it can show and revoke the list).
146
+ setLocalOriginApprover(async ({ origin, url }) => (await confirmToolCall('browser_open_local_address', { origin, url })) ? 'once' : 'deny');
137
147
  // ─── Agent (deferred until config is ready) ───
138
148
  let baseInstructions = DEFAULT_INSTRUCTIONS;
139
149
  let agent = null;
@@ -335,9 +345,13 @@ function initAgent(config) {
335
345
  }).catch((err) => {
336
346
  logger.warn('Failed to build enhanced instructions', { error: err.message });
337
347
  });
338
- loadCustomCommands(process.cwd()).then((cmds) => {
339
- customCommands = cmds;
340
- }).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
+ }
341
355
  loadSkills(process.cwd()).then((skills) => {
342
356
  loadedSkills = skills;
343
357
  }).catch((err) => { logger.warn('Failed to load skills', { error: err.message }); });
@@ -560,6 +574,21 @@ function formatContextSize(tokens) {
560
574
  return `${(tokens / 1_000).toFixed(tokens % 1_000 === 0 ? 0 : 1)}K`;
561
575
  return String(tokens);
562
576
  }
577
+ /**
578
+ * Paste box for the OpenRouter manual fallback: OpenRouter shows a single-use,
579
+ * 10-minute authorization code when the browser can't reach our loopback
580
+ * server (SSH/headless) or the user prefers another device.
581
+ */
582
+ function ManualCodeInput({ onSubmit }) {
583
+ const [code, setCode] = useState('');
584
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "Or paste the single-use code OpenRouter shows after you approve:" }), _jsxs(Box, { children: [_jsxs(Text, { color: "#06B6D4", bold: true, children: ['\u276F', " "] }), _jsx(LongTextInput, { value: code, onChange: setCode, onSubmit: (v) => {
585
+ const trimmed = v.trim();
586
+ if (trimmed) {
587
+ onSubmit(trimmed);
588
+ setCode('');
589
+ }
590
+ }, placeholder: "paste code..." })] })] }));
591
+ }
563
592
  /**
564
593
  * What to pre-fill the context-window box with.
565
594
  *
@@ -577,11 +606,17 @@ function defaultContextTokens(p, existing) {
577
606
  }
578
607
  function SetupScreen({ onComplete, existingConfig }) {
579
608
  const [provider, setProvider] = useState(existingConfig?.provider === 'openai' ? 'openai' : 'scalix');
609
+ // BYOK sub-flavor: a plain OpenAI-compatible endpoint (manual key + URL) or
610
+ // the OpenRouter OAuth connect. Both persist identically (providers.openai);
611
+ // the flavor only decides which steps run between provider and models.
612
+ const [byokFlavor, setByokFlavor] = useState('endpoint');
580
613
  // Scalix = zero-config default; BYOK (OpenAI-compatible) skips the
581
614
  // Scalix-only cloud step and enters models manually.
582
- const STEPS = provider === 'openai'
583
- ? ['provider', 'api-key', 'base-url', 'models', 'context-window']
584
- : ['provider', 'api-key', 'base-url', 'cloud', 'models', 'context-window'];
615
+ const STEPS = provider !== 'openai'
616
+ ? ['provider', 'api-key', 'base-url', 'cloud', 'models', 'context-window']
617
+ : byokFlavor === 'openrouter'
618
+ ? ['provider', 'openrouter-auth', 'models', 'context-window']
619
+ : ['provider', 'api-key', 'base-url', 'models', 'context-window'];
585
620
  const [step, setStep] = useState('provider');
586
621
  const [apiKey, setApiKey] = useState(existingConfig?.apiKey || process.env[ENV_API_KEY] || '');
587
622
  const [baseUrl, setBaseUrl] = useState(existingConfig?.baseUrl || DEFAULT_BASE_URL);
@@ -605,6 +640,11 @@ function SetupScreen({ onComplete, existingConfig }) {
605
640
  * borrowed default. */
606
641
  const [byokContextWindows, setByokContextWindows] = useState({});
607
642
  const [cloudCheck, setCloudCheck] = useState({ phase: 'checking' });
643
+ // ── OpenRouter connect (OAuth PKCE) ─────────────────────────────────────
644
+ // The PKCE verifier must outlive the effect that created it when the user
645
+ // falls back to manual code paste, so it lives in a ref.
646
+ const orPairRef = useRef(null);
647
+ const [orAuth, setOrAuth] = useState({ phase: 'starting' });
608
648
  const { exit } = useApp();
609
649
  // Validate the key against Scalix Cloud when the cloud step is entered:
610
650
  // /v1/me for identity/plan, then the tool catalog for the unlocked count.
@@ -634,6 +674,80 @@ function SetupScreen({ onComplete, existingConfig }) {
634
674
  // apiKey/baseUrl are intentionally not deps: they are frozen once the user
635
675
  // advances past base-url, and the step transition is the trigger.
636
676
  }, [step]);
677
+ // Shared tail of BOTH OpenRouter paths (browser callback and manual paste):
678
+ // persist the exchanged key, point the endpoint at OpenRouter, and rejoin
679
+ // the ordinary BYOK model-discovery flow exactly as the base-url step does.
680
+ const finishOpenRouterAuth = useCallback((key) => {
681
+ setApiKey(key);
682
+ setApiKeyError(false);
683
+ setBaseUrl(OPENROUTER_BASE_URL);
684
+ setBaseUrlError(false);
685
+ setFetchingModels(true);
686
+ fetchByokModels(OPENROUTER_BASE_URL, key).then((found) => {
687
+ setFetchedModels(found.map((m) => ({ id: m.id })));
688
+ setByokContextWindows(Object.fromEntries(found.filter((m) => m.contextWindow).map((m) => [m.id, m.contextWindow])));
689
+ }).catch((err) => { logger.warn('Failed to list endpoint models', { error: err.message }); })
690
+ .finally(() => { setFetchingModels(false); });
691
+ setStep('models');
692
+ }, []);
693
+ // The whole connect lives in one effect keyed on the step: pair → loopback
694
+ // bind → browser open → await code → exchange. Cleanup closes the server so
695
+ // a wizard abandoned mid-flow never leaks a socket or a pending promise.
696
+ useEffect(() => {
697
+ if (step !== 'openrouter-auth')
698
+ return;
699
+ let cancelled = false;
700
+ let handle = null;
701
+ setOrAuth({ phase: 'starting' });
702
+ (async () => {
703
+ const pair = generatePkcePair();
704
+ orPairRef.current = pair;
705
+ handle = await startLoopbackCallback();
706
+ if (cancelled) {
707
+ handle.close();
708
+ return;
709
+ }
710
+ const authUrl = buildAuthUrl(handle.url, pair);
711
+ const autoOpened = await openInBrowser(authUrl);
712
+ if (!cancelled)
713
+ setOrAuth({ phase: 'waiting', url: authUrl, autoOpened });
714
+ try {
715
+ const code = await handle.code;
716
+ if (cancelled)
717
+ return;
718
+ setOrAuth({ phase: 'exchanging' });
719
+ const key = await exchangeCodeForKey(code, pair.verifier);
720
+ if (!cancelled)
721
+ finishOpenRouterAuth(key);
722
+ }
723
+ catch (err) {
724
+ if (!cancelled)
725
+ setOrAuth({ phase: 'fail', error: err?.message || String(err), url: authUrl });
726
+ }
727
+ })().catch((err) => {
728
+ // Bind/spawn failure lands here — manual paste remains available.
729
+ if (!cancelled)
730
+ setOrAuth({ phase: 'fail', error: err?.message || String(err) });
731
+ });
732
+ return () => {
733
+ cancelled = true;
734
+ handle?.close();
735
+ };
736
+ }, [step, finishOpenRouterAuth]);
737
+ const submitManualOpenRouterCode = useCallback(async (raw) => {
738
+ const code = raw.trim();
739
+ const pair = orPairRef.current;
740
+ if (!pair || !code)
741
+ return;
742
+ setOrAuth({ phase: 'exchanging' });
743
+ try {
744
+ const key = await exchangeCodeForKey(code, pair.verifier);
745
+ finishOpenRouterAuth(key);
746
+ }
747
+ catch (err) {
748
+ setOrAuth({ phase: 'fail', error: err?.message || String(err) });
749
+ }
750
+ }, [finishOpenRouterAuth]);
637
751
  // The smallest window across the picked models, because compression has to
638
752
  // fit the tightest one — a session that fits the 128K model and not the 8K
639
753
  // fallback fails the moment it falls back. null when the endpoint reported
@@ -685,32 +799,45 @@ function SetupScreen({ onComplete, existingConfig }) {
685
799
  }
686
800
  }
687
801
  });
688
- return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsx(Banner, {}), _jsxs(Box, { flexDirection: "column", paddingX: 3, paddingY: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u2501'.repeat(4), " First-time Setup ", '\u2501'.repeat(4)] }) }), _jsxs(Box, { marginBottom: 1, gap: 1, children: [_jsx(Text, { color: "#20a0a0", children: STEPS.map((_, i) => i < stepNumber ? '\u25CF' : '\u25CB').join('') }), _jsxs(Text, { dimColor: true, children: ["Step ", stepNumber, " of ", totalSteps] })] }), step === 'provider' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Provider" }), _jsx(Text, { dimColor: true, children: "Where should the agent send inference?" }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
802
+ return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsx(Banner, {}), _jsxs(Box, { flexDirection: "column", paddingX: 3, paddingY: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "#06B6D4", bold: true, children: ['\u2501'.repeat(4), " First-time Setup ", '\u2501'.repeat(4)] }) }), _jsxs(Box, { marginBottom: 1, gap: 1, children: [_jsx(Text, { color: "#06B6D4", children: STEPS.map((_, i) => i < stepNumber ? '\u25CF' : '\u25CB').join('') }), _jsxs(Text, { dimColor: true, children: ["Step ", stepNumber, " of ", totalSteps] })] }), step === 'provider' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Provider" }), _jsx(Text, { dimColor: true, children: "Where should the agent send inference?" }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
689
803
  { label: 'Scalix Cloud (recommended) \u2014 api.scalix.world, platform tools included', value: 'scalix' },
690
- { label: 'OpenAI-compatible endpoint \u2014 bring your own key (OpenAI, OpenRouter, Ollama, vLLM, ...)', value: 'openai' },
804
+ { label: 'OpenRouter \u2014 connect your account in the browser (400+ models, billed to you)', value: 'openrouter' },
805
+ { label: 'OpenAI-compatible endpoint \u2014 bring your own key (OpenAI, Ollama, vLLM, ...)', value: 'openai' },
691
806
  ], onSelect: (item) => {
692
- const p = item.value === 'openai' ? 'openai' : 'scalix';
693
- setProvider(p);
694
- if (p === 'openai') {
807
+ if (item.value === 'openrouter') {
808
+ // OpenRouter IS an OpenAI-compatible endpoint under the
809
+ // hood — config plumbing is identical; only the wizard
810
+ // steps differ (key arrives via OAuth, URL is fixed).
811
+ setProvider('openai');
812
+ setByokFlavor('openrouter');
695
813
  setApiKey(existingConfig?.providers?.openai?.apiKey || '');
696
- setBaseUrl(existingConfig?.providers?.openai?.baseUrl || '');
814
+ setBaseUrl(existingConfig?.providers?.openai?.baseUrl || OPENROUTER_BASE_URL);
697
815
  }
698
816
  else {
699
- setApiKey(existingConfig?.apiKey || process.env[ENV_API_KEY] || '');
700
- setBaseUrl(existingConfig?.baseUrl || DEFAULT_BASE_URL);
817
+ const p = item.value === 'openai' ? 'openai' : 'scalix';
818
+ setProvider(p);
819
+ setByokFlavor('endpoint');
820
+ if (p === 'openai') {
821
+ setApiKey(existingConfig?.providers?.openai?.apiKey || '');
822
+ setBaseUrl(existingConfig?.providers?.openai?.baseUrl || '');
823
+ }
824
+ else {
825
+ setApiKey(existingConfig?.apiKey || process.env[ENV_API_KEY] || '');
826
+ setBaseUrl(existingConfig?.baseUrl || DEFAULT_BASE_URL);
827
+ }
701
828
  }
702
829
  // Model ids do not transfer between endpoints: keeping the
703
830
  // configured list after a switch pre-fills the next step with
704
831
  // ids the new endpoint has never heard of, and the wizard
705
832
  // finishes "successfully" onto a 404 on the first turn.
706
- const configured = (existingConfig?.provider === 'openai' ? 'openai' : 'scalix') === p;
833
+ const configured = (existingConfig?.provider === 'openai' ? 'openai' : 'scalix') === (item.value === 'scalix' ? 'scalix' : 'openai');
707
834
  setModels(configured && existingConfig?.models?.length ? [...existingConfig.models] : []);
708
835
  setModelsMode('custom');
709
836
  setFetchedModels([]);
710
837
  setByokContextWindows({});
711
- setContextInput(String(defaultContextTokens(p, existingConfig?.limits?.maxContextTokens)));
712
- setStep('api-key');
713
- } }) })] })), step === 'api-key' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "API Key" }), _jsx(Text, { dimColor: true, children: provider === 'openai' ? 'The API key for your endpoint. Press Enter to continue.' : 'Paste your Scalix API key (console.scalix.world/org/keys) and press Enter. Use a project-scoped key — the default all-scopes project key unlocks the platform tools.' }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(LongTextInput, { value: apiKey, onChange: (v) => { setApiKey(v); setApiKeyError(false); }, onSubmit: (v) => {
838
+ setContextInput(String(defaultContextTokens(item.value === 'scalix' ? 'scalix' : 'openai', existingConfig?.limits?.maxContextTokens)));
839
+ setStep(item.value === 'openrouter' ? 'openrouter-auth' : item.value === 'openai' ? 'api-key' : 'api-key');
840
+ } }) })] })), step === 'api-key' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "API Key" }), _jsx(Text, { dimColor: true, children: provider === 'openai' ? 'The API key for your endpoint. Press Enter to continue.' : 'Paste your Scalix API key (console.scalix.world/org/keys) and press Enter. Use a project-scoped key — the default all-scopes project key unlocks the platform tools.' }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#06B6D4", bold: true, children: ['\u276F', " "] }), _jsx(LongTextInput, { value: apiKey, onChange: (v) => { setApiKey(v); setApiKeyError(false); }, onSubmit: (v) => {
714
841
  const cleaned = v.trim();
715
842
  if (cleaned) {
716
843
  setApiKey(cleaned);
@@ -719,7 +846,22 @@ function SetupScreen({ onComplete, existingConfig }) {
719
846
  else {
720
847
  setApiKeyError(true);
721
848
  }
722
- }, placeholder: provider === 'openai' ? 'sk-...' : 'scalix_sk_...' })] }), apiKeyError && (_jsx(Box, { marginTop: 0, paddingLeft: 2, children: _jsx(Text, { color: "yellow", dimColor: true, children: "API key is required." }) }))] })), step === 'base-url' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: provider === 'openai' ? 'Endpoint URL' : 'Base URL' }), _jsx(Text, { dimColor: true, children: provider === 'openai' ? 'Your OpenAI-compatible endpoint root, with or without /v1 (required).' : 'Scalix API endpoint (or your self-hosted gateway)' }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: baseUrl, onChange: (v) => { setBaseUrl(sanitizeInput(v)); setBaseUrlError(false); }, onSubmit: (v) => {
849
+ }, placeholder: provider === 'openai' ? 'sk-...' : 'scalix_sk_...' })] }), apiKeyError && (_jsx(Box, { marginTop: 0, paddingLeft: 2, children: _jsx(Text, { color: "yellow", dimColor: true, children: "API key is required." }) }))] })), step === 'openrouter-auth' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Connect OpenRouter" }), _jsx(Text, { dimColor: true, children: "Sign in to YOUR OpenRouter account \u2014 models are billed to you, not to Scalix." }), orAuth.phase === 'starting' && (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#06B6D4", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Starting the connection..." })] })), orAuth.phase === 'waiting' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { children: orAuth.autoOpened
850
+ ? 'Your browser should have opened. Approve access there.'
851
+ : 'Could not open a browser here — open the URL below on any device:' }), _jsx(Box, { marginTop: 1, flexDirection: "column", borderStyle: "round", borderColor: "#06B6D4", paddingX: 2, alignSelf: "flex-start", children: _jsx(Text, { color: "#06B6D4", children: orAuth.url }) }), !orAuth.autoOpened && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "The page shows a code \u2014 paste it below when it does." }) })), _jsx(ManualCodeInput, { onSubmit: (code) => { void submitManualOpenRouterCode(code); } })] })), orAuth.phase === 'exchanging' && (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#06B6D4", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Exchanging the authorization for your API key..." })] })), orAuth.phase === 'fail' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: "red", children: ['✘', " ", orAuth.error] }), orAuth.url && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "You can also open this URL manually and paste the shown code:" }) }), _jsx(Box, { marginTop: 1, flexDirection: "column", borderStyle: "round", borderColor: "#06B6D4", paddingX: 2, alignSelf: "flex-start", children: _jsx(Text, { color: "#06B6D4", children: orAuth.url }) }), _jsx(ManualCodeInput, { onSubmit: (code) => { void submitManualOpenRouterCode(code); } })] })), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
852
+ { label: '↻ Retry the browser flow', value: '__retry__' },
853
+ ...(existingConfig ? [{ label: 'Back', value: '__back__' }] : []),
854
+ ], onSelect: (item) => {
855
+ if (item.value === '__retry__') {
856
+ // Remount the effect: fresh pair, fresh loopback port.
857
+ setOrAuth({ phase: 'starting' });
858
+ setStep('provider');
859
+ setTimeout(() => setStep('openrouter-auth'), 0);
860
+ }
861
+ else {
862
+ setStep('provider');
863
+ }
864
+ } }) })] }))] })), step === 'base-url' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: provider === 'openai' ? 'Endpoint URL' : 'Base URL' }), _jsx(Text, { dimColor: true, children: provider === 'openai' ? 'Your OpenAI-compatible endpoint root, with or without /v1 (required).' : 'Scalix API endpoint (or your self-hosted gateway)' }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#06B6D4", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: baseUrl, onChange: (v) => { setBaseUrl(sanitizeInput(v)); setBaseUrlError(false); }, onSubmit: (v) => {
723
865
  const typed = sanitizeInput(v).trim();
724
866
  if (provider === 'openai') {
725
867
  // BYOK has no default third-party URL \u2014 the endpoint is required.
@@ -760,7 +902,7 @@ function SetupScreen({ onComplete, existingConfig }) {
760
902
  setFetchingModels(false);
761
903
  });
762
904
  setStep('cloud');
763
- }, placeholder: provider === 'openai' ? 'e.g. http://localhost:11434/v1' : DEFAULT_BASE_URL })] }), baseUrlError && (_jsx(Box, { marginTop: 0, paddingLeft: 2, children: _jsx(Text, { color: "yellow", dimColor: true, children: "An endpoint URL is required for a BYOK provider." }) })), fetchingModels && (_jsx(Box, { paddingLeft: 2, marginTop: 1, children: _jsx(Text, { color: "#20a0a0", dimColor: true, children: "Fetching available models..." }) }))] })), step === 'cloud' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Scalix Cloud" }), cloudCheck.phase === 'checking' && (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#20a0a0", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Validating your API key with Scalix Cloud..." })] })), cloudCheck.phase === 'ok' && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "#30c090", paddingX: 2, marginTop: 1, alignSelf: "flex-start", children: [_jsxs(Text, { color: "#30c090", bold: true, children: ['✓', " Connected to Scalix Cloud"] }), _jsxs(Text, { children: ["Org: ", _jsx(Text, { color: "#20a0a0", children: cloudCheck.identity.identity.orgId })] }), _jsxs(Text, { children: ["Project: ", _jsx(Text, { color: "#20a0a0", children: cloudCheck.identity.identity.projectId || 'org-wide' })] }), _jsxs(Text, { children: ["Plan: ", _jsx(Text, { color: "#20a0a0", children: cloudCheck.identity.limits.plan })] }), _jsxs(Text, { children: ["Rate limit: ", _jsxs(Text, { color: "#20a0a0", children: [cloudCheck.identity.limits.rateLimitRps, " req/s"] })] }), _jsxs(Text, { color: "#30c090", bold: true, children: ['●', " ", cloudCheck.toolCount, " platform tools unlocked"] })] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [{ label: '→ Continue', value: '__continue__' }], onSelect: () => setStep('models') }) })] })), cloudCheck.phase === 'fail' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ['✘', " ", cloudCheck.error] }) }), cloudCheck.remedy && (_jsx(Text, { dimColor: true, children: cloudCheck.remedy })), !cloudCheck.remedy && !cloudCheck.error.includes(CONSOLE_KEYS_URL) && (_jsxs(Text, { dimColor: true, children: ["Create or check your key at ", CONSOLE_KEYS_URL] })), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
905
+ }, placeholder: provider === 'openai' ? 'e.g. http://localhost:11434/v1' : DEFAULT_BASE_URL })] }), baseUrlError && (_jsx(Box, { marginTop: 0, paddingLeft: 2, children: _jsx(Text, { color: "yellow", dimColor: true, children: "An endpoint URL is required for a BYOK provider." }) })), fetchingModels && (_jsx(Box, { paddingLeft: 2, marginTop: 1, children: _jsx(Text, { color: "#06B6D4", dimColor: true, children: "Fetching available models..." }) }))] })), step === 'cloud' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Scalix Cloud" }), cloudCheck.phase === 'checking' && (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#06B6D4", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Validating your API key with Scalix Cloud..." })] })), cloudCheck.phase === 'ok' && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "#22C55E", paddingX: 2, marginTop: 1, alignSelf: "flex-start", children: [_jsxs(Text, { color: "#22C55E", bold: true, children: ['✓', " Connected to Scalix Cloud"] }), _jsxs(Text, { children: ["Org: ", _jsx(Text, { color: "#06B6D4", children: cloudCheck.identity.identity.orgId })] }), _jsxs(Text, { children: ["Project: ", _jsx(Text, { color: "#06B6D4", children: cloudCheck.identity.identity.projectId || 'org-wide' })] }), _jsxs(Text, { children: ["Plan: ", _jsx(Text, { color: "#06B6D4", children: cloudCheck.identity.limits.plan })] }), _jsxs(Text, { children: ["Rate limit: ", _jsxs(Text, { color: "#06B6D4", children: [cloudCheck.identity.limits.rateLimitRps, " req/s"] })] }), _jsxs(Text, { color: "#22C55E", bold: true, children: ['●', " ", cloudCheck.toolCount, " platform tools unlocked"] })] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [{ label: '→ Continue', value: '__continue__' }], onSelect: () => setStep('models') }) })] })), cloudCheck.phase === 'fail' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ['✘', " ", cloudCheck.error] }) }), cloudCheck.remedy && (_jsx(Text, { dimColor: true, children: cloudCheck.remedy })), !cloudCheck.remedy && !cloudCheck.error.includes(CONSOLE_KEYS_URL) && (_jsxs(Text, { dimColor: true, children: ["Create or check your key at ", CONSOLE_KEYS_URL] })), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
764
906
  { label: 'Re-enter API key', value: '__reenter__' },
765
907
  { label: 'Skip — continue without Scalix Cloud (local tools only)', value: '__skip__' },
766
908
  ], onSelect: (item) => {
@@ -770,7 +912,7 @@ function SetupScreen({ onComplete, existingConfig }) {
770
912
  else {
771
913
  setStep('models');
772
914
  }
773
- } }) })] }))] })), step === 'models' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Models" }), fetchingModels && fetchedModels.length === 0 && models.length === 0 ? (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#20a0a0", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Asking your endpoint which models it serves..." })] })) : fetchedModels.length > 0 && modelsMode === 'custom' ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Select models from your API or type a custom model ID." }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(SelectInput, { items: [
915
+ } }) })] }))] })), step === 'models' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Models" }), fetchingModels && fetchedModels.length === 0 && models.length === 0 ? (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#06B6D4", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Asking your endpoint which models it serves..." })] })) : fetchedModels.length > 0 && modelsMode === 'custom' ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Select models from your API or type a custom model ID." }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(SelectInput, { items: [
774
916
  ...fetchedModels.filter((fm) => !models.includes(fm.id)).map((fm) => ({
775
917
  label: ` ${fm.name || fm.id}`,
776
918
  value: fm.id,
@@ -791,7 +933,7 @@ function SetupScreen({ onComplete, existingConfig }) {
791
933
  setModels((prev) => [...prev, item.value]);
792
934
  }
793
935
  }
794
- } }) }), models.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: "Selected:" }), models.map((m) => (_jsxs(Text, { color: "#30c090", children: ['\u25CF', " ", m] }, m)))] }))] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "Type a model ID and press Enter to add. Add at least one." }), models.length > 0 && modelsMode === 'list' ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["Added models (", '↑', "/", '↓', " to navigate, Enter on a model removes it):"] }), _jsx(SelectInput
936
+ } }) }), models.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: "Selected:" }), models.map((m) => (_jsxs(Text, { color: "#22C55E", children: ['\u25CF', " ", m] }, m)))] }))] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "Type a model ID and press Enter to add. Add at least one." }), models.length > 0 && modelsMode === 'list' ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["Added models (", '↑', "/", '↓', " to navigate, Enter on a model removes it):"] }), _jsx(SelectInput
795
937
  // Open on Continue. The list's first entries are the
796
938
  // models themselves and choosing one deletes it, so the
797
939
  // cursor used to start on a destructive action: the
@@ -830,7 +972,7 @@ function SetupScreen({ onComplete, existingConfig }) {
830
972
  return next;
831
973
  });
832
974
  }
833
- } })] })) : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [models.length > 0 && (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: models.map((m) => (_jsxs(Text, { color: "#30c090", children: ['\u25CF', " ", m] }, m))) })), _jsxs(Box, { children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: modelInput, onChange: (v) => setModelInput(sanitizeInput(v)), onSubmit: (v) => {
975
+ } })] })) : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [models.length > 0 && (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: models.map((m) => (_jsxs(Text, { color: "#22C55E", children: ['\u25CF', " ", m] }, m))) })), _jsxs(Box, { children: [_jsxs(Text, { color: "#06B6D4", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: modelInput, onChange: (v) => setModelInput(sanitizeInput(v)), onSubmit: (v) => {
834
976
  const trimmed = sanitizeInput(v).trim();
835
977
  if (trimmed) {
836
978
  if (!models.includes(trimmed)) {
@@ -853,8 +995,8 @@ function SetupScreen({ onComplete, existingConfig }) {
853
995
  reportedContextWindow !== null ? (_jsxs(Text, { dimColor: true, children: ["Pre-filled with ", formatContextSize(reportedContextWindow), " \u2014 reported by your endpoint", models.length > 1 ? ', the smallest across the models you picked' : '', "."] })) : (_jsxs(Text, { dimColor: true, children: ["Pre-filled with a safe ", formatContextSize(BYOK_DEFAULT_CONTEXT_TOKENS), " \u2014 we can't read your endpoint's real limit."] }))), (() => {
854
996
  const parsed = parseInt(contextInput, 10);
855
997
  const valid = parsed > 0;
856
- return valid ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "#30c090", children: ['\u25CF', " ", formatContextSize(parsed), " tokens (", parsed.toLocaleString(), ")"] }) })) : null;
857
- })(), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: contextInput, onChange: setContextInput, onSubmit: (v) => {
998
+ return valid ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "#22C55E", children: ['\u25CF', " ", formatContextSize(parsed), " tokens (", parsed.toLocaleString(), ")"] }) })) : null;
999
+ })(), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#06B6D4", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: contextInput, onChange: setContextInput, onSubmit: (v) => {
858
1000
  // saveConfig() writes the key to the OS keychain, and that
859
1001
  // subprocess is not always quick — a locked keychain or a
860
1002
  // pending "allow access?" dialog made one measured save take
@@ -913,7 +1055,7 @@ function SetupScreen({ onComplete, existingConfig }) {
913
1055
  }).catch(() => {
914
1056
  onComplete(newConfig);
915
1057
  });
916
- }, placeholder: String(defaultContextTokens(provider, existingConfig?.limits?.maxContextTokens)), focus: !saving })] }), saving && (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#20a0a0", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Saving configuration (storing your key in the OS keychain)..." })] }))] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, italic: true, children: [stepNumber > 1 ? 'Backspace (empty) to go back \u2022 ' : '', "ESC to quit"] }) })] })] }));
1058
+ }, placeholder: String(defaultContextTokens(provider, existingConfig?.limits?.maxContextTokens)), focus: !saving })] }), saving && (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#06B6D4", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Saving configuration (storing your key in the OS keychain)..." })] }))] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, italic: true, children: [stepNumber > 1 ? 'Backspace (empty) to go back \u2022 ' : '', "ESC to quit"] }) })] })] }));
917
1059
  }
918
1060
  const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart, tokens, mode, contextPercent, rateLimitWarn }) {
919
1061
  const { cols } = useTermSize();
@@ -1038,7 +1180,7 @@ function toolNamePreview(cols) {
1038
1180
  }
1039
1181
  const WelcomeScreen = memo(function WelcomeScreen({ cloud }) {
1040
1182
  const { cols } = useTermSize();
1041
- return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 0, children: [_jsx(Text, { dimColor: true, children: " Type a message or use /help for commands. Press Esc to exit." }), _jsxs(Box, { marginTop: 0, paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "Tools:" }), _jsx(Text, { dimColor: true, bold: true, wrap: "truncate-end", children: toolNamePreview(cols) })] }), _jsx(Box, { paddingLeft: 2, children: cloud.connected ? (_jsxs(Text, { color: "#20a0a0", children: ['☁', " Scalix Cloud ", '·', " ", cloud.identity ? `${cloud.identity.limits.plan} ${'·'} ` : '', cloud.toolCount, " platform tools"] })) : (_jsx(Text, { dimColor: true, children: cloudStatusLine(cloud, false) })) })] }));
1183
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 0, children: [_jsx(Text, { dimColor: true, children: " Type a message or use /help for commands. Press Esc to exit." }), _jsxs(Box, { marginTop: 0, paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "Tools:" }), _jsx(Text, { dimColor: true, bold: true, wrap: "truncate-end", children: toolNamePreview(cols) })] }), _jsx(Box, { paddingLeft: 2, children: cloud.connected ? (_jsxs(Text, { color: "#06B6D4", children: ['☁', " Scalix Cloud ", '·', " ", cloud.identity ? `${cloud.identity.limits.plan} ${'·'} ` : '', cloud.toolCount, " platform tools"] })) : (_jsx(Text, { dimColor: true, children: cloudStatusLine(cloud, false) })) })] }));
1042
1184
  });
1043
1185
  const UserMessage = memo(function UserMessage({ text }) {
1044
1186
  return (_jsx(Box, { paddingX: 2, marginTop: 1, children: _jsxs(Text, { wrap: "wrap", bold: true, children: [_jsx(Text, { color: "magentaBright", children: '\u276F ' }), text] }) }));
@@ -2169,6 +2311,22 @@ function App({ config }) {
2169
2311
  add('system', 'This workspace has project plugins/hooks/rules that were skipped because the workspace is not trusted. Run /trust to load them.');
2170
2312
  }
2171
2313
  }, [add]);
2314
+ // MCP: connect every configured server once the agent exists and register
2315
+ // the tools it reports. Only failures are announced — a working setup should
2316
+ // not narrate itself at every launch.
2317
+ useEffect(() => {
2318
+ if (!agent)
2319
+ return;
2320
+ let hub = null;
2321
+ let cancelled = false;
2322
+ startMcp(agent, (m) => { if (!cancelled)
2323
+ add('system', `⚠ ${m}`); })
2324
+ .then((h) => { hub = h; if (cancelled)
2325
+ void h.close(); })
2326
+ .catch(() => { });
2327
+ // stdio servers are child processes; a re-init must not leave them running.
2328
+ return () => { cancelled = true; void hub?.close(); };
2329
+ }, [agent]);
2172
2330
  // Scalix Cloud: on agent start (and after /setup or /login re-init), fetch
2173
2331
  // the account's tool catalog and register it. Failure or no key → local
2174
2332
  // tools only, with exactly one honest notice. No retry loop.
@@ -2377,6 +2535,43 @@ function App({ config }) {
2377
2535
  });
2378
2536
  return true;
2379
2537
  }
2538
+ if (cmd === '/rename') {
2539
+ const newName = text.slice(7).trim();
2540
+ if (!newName) {
2541
+ add('system', 'Usage: /rename <new session name>');
2542
+ return true;
2543
+ }
2544
+ const msgs = agent.getMessages();
2545
+ saveSession(sessionId, msgs, models[0] || 'unknown', newName, costTracker.getState()).then((meta) => {
2546
+ add('system', `Session renamed to: "${meta.name}"`);
2547
+ }).catch((err) => {
2548
+ add('error', `Failed to rename session: ${err.message}`);
2549
+ });
2550
+ return true;
2551
+ }
2552
+ if (cmd === '/feedback') {
2553
+ const feedbackMsg = text.slice(9).trim();
2554
+ if (!feedbackMsg) {
2555
+ add('system', 'Usage: /feedback <your feedback, issue report, or suggestion>');
2556
+ return true;
2557
+ }
2558
+ saveFeedback({
2559
+ category: 'general',
2560
+ message: feedbackMsg,
2561
+ systemInfo: {
2562
+ version: VERSION,
2563
+ nodeVersion: process.version,
2564
+ platform: os.platform(),
2565
+ arch: os.arch(),
2566
+ model: models[0] || 'unknown',
2567
+ },
2568
+ }).then(() => {
2569
+ add('system', 'Thank you! Your feedback has been recorded and submitted to Scalix.');
2570
+ }).catch((err) => {
2571
+ add('error', `Failed to record feedback: ${err.message}`);
2572
+ });
2573
+ return true;
2574
+ }
2380
2575
  if (cmd === '/load' || cmd === '/resume') {
2381
2576
  if (cmd === '/resume') {
2382
2577
  loadCrashState().then(async (state) => {
@@ -2581,6 +2776,48 @@ function App({ config }) {
2581
2776
  }
2582
2777
  return true;
2583
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
+ }
2803
+ if (cmd === '/preset') {
2804
+ const arg = text.split(' ')[1]?.toLowerCase();
2805
+ if (arg) {
2806
+ try {
2807
+ agent.setPreset(arg);
2808
+ // When a preset switches mode, sync the TUI state
2809
+ setCurrentMode(agent.getPermissionMode());
2810
+ add('system', `Preset active: ${arg} — mode is now ${agent.getPermissionMode()}`);
2811
+ }
2812
+ catch (e) {
2813
+ add('system', `Error: ${e.message}\nAvailable presets: cloud-architect, dba, devops, full-access`);
2814
+ }
2815
+ }
2816
+ else {
2817
+ add('system', `Usage: /preset <id>\nAvailable presets: cloud-architect, dba, devops, full-access`);
2818
+ }
2819
+ return true;
2820
+ }
2584
2821
  if (cmd === '/thinking') {
2585
2822
  const arg = text.split(' ')[1]?.toLowerCase();
2586
2823
  if (arg && THINKING_MODES.includes(arg)) {
@@ -2598,21 +2835,127 @@ function App({ config }) {
2598
2835
  }
2599
2836
  if (cmd === '/mcp') {
2600
2837
  const p = text.split(' ').filter(Boolean);
2838
+ // Bare /mcp reports what is connected \u2014 the question people actually
2839
+ // have, now that connecting is something that can fail.
2840
+ if (p.length === 1) {
2841
+ const st = agent.getMcpStatus();
2842
+ add('system', st.length
2843
+ ? st.map(s => `${s.state === 'connected' ? '\u25cf' : '\u25cb'} ${s.label} (${s.transport}) \u2014 ${s.state === 'connected' ? `${s.toolCount} tool${s.toolCount === 1 ? '' : 's'}` : s.error ?? s.state}`).join('\n')
2844
+ : 'No MCP servers configured. Add one to mcp-services.json, or: /mcp <label> <url>');
2845
+ return true;
2846
+ }
2601
2847
  if (p.length < 3) {
2602
- add('system', 'Usage: /mcp <label> <url>');
2848
+ add('system', 'Usage: /mcp \u2014 show server status\n /mcp <label> <url>');
2603
2849
  return true;
2604
2850
  }
2605
- {
2606
- try {
2607
- new URL(p[2]);
2851
+ // Same validator add_mcp_server and the config loader use \u2014 a typed URL
2852
+ // gets no more benefit of the doubt than a repo-supplied one.
2853
+ const badUrl = validateMcpUrl(p[2]);
2854
+ if (badUrl) {
2855
+ add('error', `Invalid MCP server URL: ${badUrl}`);
2856
+ return true;
2857
+ }
2858
+ agent.connectMcpServer({ serverLabel: p[1], serverUrl: p[2] }).then((s) => {
2859
+ add(s.state === 'connected' ? 'system' : 'error', s.state === 'connected'
2860
+ ? `MCP connected: ${p[1]} → ${p[2]} (${s.toolCount} tool${s.toolCount === 1 ? '' : 's'})`
2861
+ : `MCP ${p[1]} failed: ${s.error ?? 'unknown error'}`);
2862
+ });
2863
+ return true;
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;
2608
2892
  }
2609
- catch {
2610
- add('error', `Invalid URL: ${p[2]}`);
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>');
2611
2918
  return true;
2612
2919
  }
2613
- agent.addTool({ type: 'mcp', serverLabel: p[1], serverUrl: p[2] });
2614
- add('system', `MCP registered: ${p[1]} \u2192 ${p[2]}`);
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;
2615
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'); });
2616
2959
  return true;
2617
2960
  }
2618
2961
  if (cmd === '/index') {
@@ -2662,6 +3005,9 @@ function App({ config }) {
2662
3005
  discoverPluginsForAgent(agent).then(() => {
2663
3006
  add('system', `Loaded ${loadedPlugins.length} plugin(s).`);
2664
3007
  }).catch(() => { });
3008
+ const hookCount = agent.reloadHooks();
3009
+ if (hookCount > 0)
3010
+ add('system', `Loaded ${hookCount} project hook(s).`);
2665
3011
  buildEnhancedInstructions().then((enhanced) => {
2666
3012
  agent.setInstructions(enhanced);
2667
3013
  add('system', 'Reloaded project rules into context.');
@@ -2713,6 +3059,12 @@ function App({ config }) {
2713
3059
  }
2714
3060
  if (cmd === '/hooks') {
2715
3061
  const hooks = agent.getHooks();
3062
+ const skipped = agent.getSkippedHookCount();
3063
+ // Hooks that exist but were withheld are worth a line of their own —
3064
+ // "No hooks configured" would be a lie the user could act on wrongly.
3065
+ if (skipped > 0) {
3066
+ add('system', `${skipped} project hook(s) skipped — this workspace is untrusted. Run /trust to enable them.`);
3067
+ }
2716
3068
  hooksSummary(hooks).split('\n').forEach((l) => add('system', l));
2717
3069
  return true;
2718
3070
  }