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.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Crew board context (scalix-org roadmap/SCALIX-CREW.md, T2.2/T2.3).
3
+ *
4
+ * BOARD.md is a READ-ONLY projection of a crew's notice board, written into
5
+ * the workspace by whoever runs the session (the orchestrator's CrewHydrator
6
+ * for cloud sessions; the cockpit for local ones) and refreshed before each
7
+ * turn. The engine never writes it — posting goes through the
8
+ * scalix_crew_board_post platform tool, the board's only write path.
9
+ *
10
+ * Durable lessons do not live here either: a member that learns something
11
+ * permanent from a board note promotes it into project memory (/remember),
12
+ * deliberately. Board = news; memory = knowledge.
13
+ */
14
+ import fs from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ export const BOARD_FILE = 'BOARD.md';
17
+ /** A board is news, not a document dump — cap what lands in context so a
18
+ * runaway board cannot crowd the task out of the window. */
19
+ export const BOARD_CONTEXT_MAX_CHARS = 16 * 1024;
20
+ /** Read the workspace board projection. Null when absent or empty — those
21
+ * mean "this session has no crew board", which is different from a board
22
+ * with no notes (that renders its own explicit empty state). */
23
+ export async function readBoardFile(cwd) {
24
+ try {
25
+ const raw = await fs.readFile(path.join(cwd, BOARD_FILE), 'utf-8');
26
+ const trimmed = raw.trim();
27
+ if (!trimmed)
28
+ return null;
29
+ return trimmed.length > BOARD_CONTEXT_MAX_CHARS
30
+ ? trimmed.slice(0, BOARD_CONTEXT_MAX_CHARS) + '\n…(board truncated for context)'
31
+ : trimmed;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ export function boardToContext(board) {
38
+ if (!board)
39
+ return '';
40
+ return [
41
+ '## Crew Board (read-only projection)',
42
+ 'Shared coordination state from your crew, current as of session start.',
43
+ 'Post updates with the scalix_crew_board_post tool. Never edit BOARD.md —',
44
+ 'it is overwritten on every refresh and your edit would be silently lost.',
45
+ '',
46
+ board,
47
+ ].join('\n');
48
+ }
49
+ /**
50
+ * Turn-start freshness for a session whose system prompt is assembled once:
51
+ * tracks the last projection seen and, when the file moves, hands back a
52
+ * system note carrying the new copy for the conversation history. One file
53
+ * read per turn; a quiet board costs nothing else.
54
+ */
55
+ export class BoardTurnRefresher {
56
+ cwd;
57
+ lastSeen = null;
58
+ constructor(cwd) {
59
+ this.cwd = cwd;
60
+ }
61
+ /** Baseline — call once near session start, after the system prompt has
62
+ * embedded the board, so the first turnNote() only fires on real change. */
63
+ async prime() {
64
+ this.lastSeen = await readBoardFile(this.cwd);
65
+ }
66
+ /** Null when nothing changed (or the file vanished — a removed projection
67
+ * is a session-plumbing event, not news to tell the model about). */
68
+ async turnNote() {
69
+ const current = await readBoardFile(this.cwd);
70
+ if (current === this.lastSeen)
71
+ return null;
72
+ this.lastSeen = current;
73
+ if (current === null)
74
+ return null;
75
+ return [
76
+ '## Crew Board (updated since your last turn — supersedes earlier copies)',
77
+ '',
78
+ current,
79
+ ].join('\n');
80
+ }
81
+ }
Binary file
package/dist/cli.js CHANGED
@@ -15,17 +15,20 @@ 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';
@@ -47,6 +50,7 @@ import { createWorktree, listWorktrees, removeWorktree, cleanupStaleWorktrees }
47
50
  import { sendNotification, getTerminalType } from './terminal-notify.js';
48
51
  import { stringWidth, truncateText } from './text-utils.js';
49
52
  import { isWorkspaceTrusted, trustWorkspace, hasProjectExecSurface } from './workspace-trust.js';
53
+ import { setLocalOriginApprover } from './browser-access.js';
50
54
  import { fetchMe, fetchCloudTools, groupCloudTools, filterCatalogByScopes, cloudKeyRemedy, CONSOLE_KEYS_URL } from './cloud.js';
51
55
  logger.setLevel('silent');
52
56
  // Promisified async child_process helpers (keep the TUI responsive vs execSync).
@@ -86,7 +90,7 @@ setSandboxRoot(process.cwd());
86
90
  // Suppress noisy React 19 development-mode reconciler warnings that leak to stderr
87
91
  const _origConsoleError = console.error;
88
92
  console.error = (...args) => {
89
- if (typeof args[0] === 'string' && args[0].startsWith('Warning:') && args[0].includes('same key'))
93
+ if (args.length === 1 && typeof args[0] === 'string' && args[0].includes('same key'))
90
94
  return;
91
95
  _origConsoleError(...args);
92
96
  };
@@ -95,14 +99,12 @@ console.error = (...args) => {
95
99
  // sanitizeInput) and buffer the pasted content into the input value instead of
96
100
  // treating each embedded newline as a separate Enter (which caused many submits).
97
101
  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();
102
+ // User-registered external MCP servers (mcp-services.json) are not in this
103
+ // list: they are connected once the agent exists and registered from the live
104
+ // connection by startMcp(), the same way Scalix Cloud platform tools arrive via
105
+ // agent.refreshCloudTools() (see connectCloud below).
104
106
  let pluginTools = [];
105
- const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools];
107
+ const allTools = [...defaultTools, ...extraTools, ...memoryTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools];
106
108
  // ─── Confirmation bridge (event-driven) ───
107
109
  import { EventEmitter } from 'node:events';
108
110
  const confirmEmitter = new EventEmitter();
@@ -134,6 +136,11 @@ const confirmToolCall = async (toolName, args) => {
134
136
  confirmEmitter.emit('pending');
135
137
  });
136
138
  };
139
+ // The browser tools refuse localhost and the LAN until the user says otherwise.
140
+ // In the TUI that question goes through the same confirmation prompt as any
141
+ // other tool call, so "Always allow" covers the rest of the session; a lasting,
142
+ // written-down approval is the IDE's job (it can show and revoke the list).
143
+ setLocalOriginApprover(async ({ origin, url }) => (await confirmToolCall('browser_open_local_address', { origin, url })) ? 'once' : 'deny');
137
144
  // ─── Agent (deferred until config is ready) ───
138
145
  let baseInstructions = DEFAULT_INSTRUCTIONS;
139
146
  let agent = null;
@@ -560,6 +567,21 @@ function formatContextSize(tokens) {
560
567
  return `${(tokens / 1_000).toFixed(tokens % 1_000 === 0 ? 0 : 1)}K`;
561
568
  return String(tokens);
562
569
  }
570
+ /**
571
+ * Paste box for the OpenRouter manual fallback: OpenRouter shows a single-use,
572
+ * 10-minute authorization code when the browser can't reach our loopback
573
+ * server (SSH/headless) or the user prefers another device.
574
+ */
575
+ function ManualCodeInput({ onSubmit }) {
576
+ const [code, setCode] = useState('');
577
+ 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) => {
578
+ const trimmed = v.trim();
579
+ if (trimmed) {
580
+ onSubmit(trimmed);
581
+ setCode('');
582
+ }
583
+ }, placeholder: "paste code..." })] })] }));
584
+ }
563
585
  /**
564
586
  * What to pre-fill the context-window box with.
565
587
  *
@@ -577,11 +599,17 @@ function defaultContextTokens(p, existing) {
577
599
  }
578
600
  function SetupScreen({ onComplete, existingConfig }) {
579
601
  const [provider, setProvider] = useState(existingConfig?.provider === 'openai' ? 'openai' : 'scalix');
602
+ // BYOK sub-flavor: a plain OpenAI-compatible endpoint (manual key + URL) or
603
+ // the OpenRouter OAuth connect. Both persist identically (providers.openai);
604
+ // the flavor only decides which steps run between provider and models.
605
+ const [byokFlavor, setByokFlavor] = useState('endpoint');
580
606
  // Scalix = zero-config default; BYOK (OpenAI-compatible) skips the
581
607
  // 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'];
608
+ const STEPS = provider !== 'openai'
609
+ ? ['provider', 'api-key', 'base-url', 'cloud', 'models', 'context-window']
610
+ : byokFlavor === 'openrouter'
611
+ ? ['provider', 'openrouter-auth', 'models', 'context-window']
612
+ : ['provider', 'api-key', 'base-url', 'models', 'context-window'];
585
613
  const [step, setStep] = useState('provider');
586
614
  const [apiKey, setApiKey] = useState(existingConfig?.apiKey || process.env[ENV_API_KEY] || '');
587
615
  const [baseUrl, setBaseUrl] = useState(existingConfig?.baseUrl || DEFAULT_BASE_URL);
@@ -605,6 +633,11 @@ function SetupScreen({ onComplete, existingConfig }) {
605
633
  * borrowed default. */
606
634
  const [byokContextWindows, setByokContextWindows] = useState({});
607
635
  const [cloudCheck, setCloudCheck] = useState({ phase: 'checking' });
636
+ // ── OpenRouter connect (OAuth PKCE) ─────────────────────────────────────
637
+ // The PKCE verifier must outlive the effect that created it when the user
638
+ // falls back to manual code paste, so it lives in a ref.
639
+ const orPairRef = useRef(null);
640
+ const [orAuth, setOrAuth] = useState({ phase: 'starting' });
608
641
  const { exit } = useApp();
609
642
  // Validate the key against Scalix Cloud when the cloud step is entered:
610
643
  // /v1/me for identity/plan, then the tool catalog for the unlocked count.
@@ -634,6 +667,80 @@ function SetupScreen({ onComplete, existingConfig }) {
634
667
  // apiKey/baseUrl are intentionally not deps: they are frozen once the user
635
668
  // advances past base-url, and the step transition is the trigger.
636
669
  }, [step]);
670
+ // Shared tail of BOTH OpenRouter paths (browser callback and manual paste):
671
+ // persist the exchanged key, point the endpoint at OpenRouter, and rejoin
672
+ // the ordinary BYOK model-discovery flow exactly as the base-url step does.
673
+ const finishOpenRouterAuth = useCallback((key) => {
674
+ setApiKey(key);
675
+ setApiKeyError(false);
676
+ setBaseUrl(OPENROUTER_BASE_URL);
677
+ setBaseUrlError(false);
678
+ setFetchingModels(true);
679
+ fetchByokModels(OPENROUTER_BASE_URL, key).then((found) => {
680
+ setFetchedModels(found.map((m) => ({ id: m.id })));
681
+ setByokContextWindows(Object.fromEntries(found.filter((m) => m.contextWindow).map((m) => [m.id, m.contextWindow])));
682
+ }).catch((err) => { logger.warn('Failed to list endpoint models', { error: err.message }); })
683
+ .finally(() => { setFetchingModels(false); });
684
+ setStep('models');
685
+ }, []);
686
+ // The whole connect lives in one effect keyed on the step: pair → loopback
687
+ // bind → browser open → await code → exchange. Cleanup closes the server so
688
+ // a wizard abandoned mid-flow never leaks a socket or a pending promise.
689
+ useEffect(() => {
690
+ if (step !== 'openrouter-auth')
691
+ return;
692
+ let cancelled = false;
693
+ let handle = null;
694
+ setOrAuth({ phase: 'starting' });
695
+ (async () => {
696
+ const pair = generatePkcePair();
697
+ orPairRef.current = pair;
698
+ handle = await startLoopbackCallback();
699
+ if (cancelled) {
700
+ handle.close();
701
+ return;
702
+ }
703
+ const authUrl = buildAuthUrl(handle.url, pair);
704
+ const autoOpened = await openInBrowser(authUrl);
705
+ if (!cancelled)
706
+ setOrAuth({ phase: 'waiting', url: authUrl, autoOpened });
707
+ try {
708
+ const code = await handle.code;
709
+ if (cancelled)
710
+ return;
711
+ setOrAuth({ phase: 'exchanging' });
712
+ const key = await exchangeCodeForKey(code, pair.verifier);
713
+ if (!cancelled)
714
+ finishOpenRouterAuth(key);
715
+ }
716
+ catch (err) {
717
+ if (!cancelled)
718
+ setOrAuth({ phase: 'fail', error: err?.message || String(err), url: authUrl });
719
+ }
720
+ })().catch((err) => {
721
+ // Bind/spawn failure lands here — manual paste remains available.
722
+ if (!cancelled)
723
+ setOrAuth({ phase: 'fail', error: err?.message || String(err) });
724
+ });
725
+ return () => {
726
+ cancelled = true;
727
+ handle?.close();
728
+ };
729
+ }, [step, finishOpenRouterAuth]);
730
+ const submitManualOpenRouterCode = useCallback(async (raw) => {
731
+ const code = raw.trim();
732
+ const pair = orPairRef.current;
733
+ if (!pair || !code)
734
+ return;
735
+ setOrAuth({ phase: 'exchanging' });
736
+ try {
737
+ const key = await exchangeCodeForKey(code, pair.verifier);
738
+ finishOpenRouterAuth(key);
739
+ }
740
+ catch (err) {
741
+ setOrAuth({ phase: 'fail', error: err?.message || String(err) });
742
+ }
743
+ }, [finishOpenRouterAuth]);
637
744
  // The smallest window across the picked models, because compression has to
638
745
  // fit the tightest one — a session that fits the 128K model and not the 8K
639
746
  // fallback fails the moment it falls back. null when the endpoint reported
@@ -685,32 +792,45 @@ function SetupScreen({ onComplete, existingConfig }) {
685
792
  }
686
793
  }
687
794
  });
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: [
795
+ 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
796
  { 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' },
797
+ { label: 'OpenRouter \u2014 connect your account in the browser (400+ models, billed to you)', value: 'openrouter' },
798
+ { label: 'OpenAI-compatible endpoint \u2014 bring your own key (OpenAI, Ollama, vLLM, ...)', value: 'openai' },
691
799
  ], onSelect: (item) => {
692
- const p = item.value === 'openai' ? 'openai' : 'scalix';
693
- setProvider(p);
694
- if (p === 'openai') {
800
+ if (item.value === 'openrouter') {
801
+ // OpenRouter IS an OpenAI-compatible endpoint under the
802
+ // hood — config plumbing is identical; only the wizard
803
+ // steps differ (key arrives via OAuth, URL is fixed).
804
+ setProvider('openai');
805
+ setByokFlavor('openrouter');
695
806
  setApiKey(existingConfig?.providers?.openai?.apiKey || '');
696
- setBaseUrl(existingConfig?.providers?.openai?.baseUrl || '');
807
+ setBaseUrl(existingConfig?.providers?.openai?.baseUrl || OPENROUTER_BASE_URL);
697
808
  }
698
809
  else {
699
- setApiKey(existingConfig?.apiKey || process.env[ENV_API_KEY] || '');
700
- setBaseUrl(existingConfig?.baseUrl || DEFAULT_BASE_URL);
810
+ const p = item.value === 'openai' ? 'openai' : 'scalix';
811
+ setProvider(p);
812
+ setByokFlavor('endpoint');
813
+ if (p === 'openai') {
814
+ setApiKey(existingConfig?.providers?.openai?.apiKey || '');
815
+ setBaseUrl(existingConfig?.providers?.openai?.baseUrl || '');
816
+ }
817
+ else {
818
+ setApiKey(existingConfig?.apiKey || process.env[ENV_API_KEY] || '');
819
+ setBaseUrl(existingConfig?.baseUrl || DEFAULT_BASE_URL);
820
+ }
701
821
  }
702
822
  // Model ids do not transfer between endpoints: keeping the
703
823
  // configured list after a switch pre-fills the next step with
704
824
  // ids the new endpoint has never heard of, and the wizard
705
825
  // finishes "successfully" onto a 404 on the first turn.
706
- const configured = (existingConfig?.provider === 'openai' ? 'openai' : 'scalix') === p;
826
+ const configured = (existingConfig?.provider === 'openai' ? 'openai' : 'scalix') === (item.value === 'scalix' ? 'scalix' : 'openai');
707
827
  setModels(configured && existingConfig?.models?.length ? [...existingConfig.models] : []);
708
828
  setModelsMode('custom');
709
829
  setFetchedModels([]);
710
830
  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) => {
831
+ setContextInput(String(defaultContextTokens(item.value === 'scalix' ? 'scalix' : 'openai', existingConfig?.limits?.maxContextTokens)));
832
+ setStep(item.value === 'openrouter' ? 'openrouter-auth' : item.value === 'openai' ? 'api-key' : 'api-key');
833
+ } }) })] })), 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
834
  const cleaned = v.trim();
715
835
  if (cleaned) {
716
836
  setApiKey(cleaned);
@@ -719,7 +839,22 @@ function SetupScreen({ onComplete, existingConfig }) {
719
839
  else {
720
840
  setApiKeyError(true);
721
841
  }
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) => {
842
+ }, 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
843
+ ? 'Your browser should have opened. Approve access there.'
844
+ : '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: [
845
+ { label: '↻ Retry the browser flow', value: '__retry__' },
846
+ ...(existingConfig ? [{ label: 'Back', value: '__back__' }] : []),
847
+ ], onSelect: (item) => {
848
+ if (item.value === '__retry__') {
849
+ // Remount the effect: fresh pair, fresh loopback port.
850
+ setOrAuth({ phase: 'starting' });
851
+ setStep('provider');
852
+ setTimeout(() => setStep('openrouter-auth'), 0);
853
+ }
854
+ else {
855
+ setStep('provider');
856
+ }
857
+ } }) })] }))] })), 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
858
  const typed = sanitizeInput(v).trim();
724
859
  if (provider === 'openai') {
725
860
  // BYOK has no default third-party URL \u2014 the endpoint is required.
@@ -760,7 +895,7 @@ function SetupScreen({ onComplete, existingConfig }) {
760
895
  setFetchingModels(false);
761
896
  });
762
897
  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: [
898
+ }, 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
899
  { label: 'Re-enter API key', value: '__reenter__' },
765
900
  { label: 'Skip — continue without Scalix Cloud (local tools only)', value: '__skip__' },
766
901
  ], onSelect: (item) => {
@@ -770,7 +905,7 @@ function SetupScreen({ onComplete, existingConfig }) {
770
905
  else {
771
906
  setStep('models');
772
907
  }
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: [
908
+ } }) })] }))] })), 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
909
  ...fetchedModels.filter((fm) => !models.includes(fm.id)).map((fm) => ({
775
910
  label: ` ${fm.name || fm.id}`,
776
911
  value: fm.id,
@@ -791,7 +926,7 @@ function SetupScreen({ onComplete, existingConfig }) {
791
926
  setModels((prev) => [...prev, item.value]);
792
927
  }
793
928
  }
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
929
+ } }) }), 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
930
  // Open on Continue. The list's first entries are the
796
931
  // models themselves and choosing one deletes it, so the
797
932
  // cursor used to start on a destructive action: the
@@ -830,7 +965,7 @@ function SetupScreen({ onComplete, existingConfig }) {
830
965
  return next;
831
966
  });
832
967
  }
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) => {
968
+ } })] })) : (_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
969
  const trimmed = sanitizeInput(v).trim();
835
970
  if (trimmed) {
836
971
  if (!models.includes(trimmed)) {
@@ -853,8 +988,8 @@ function SetupScreen({ onComplete, existingConfig }) {
853
988
  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
989
  const parsed = parseInt(contextInput, 10);
855
990
  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) => {
991
+ return valid ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "#22C55E", children: ['\u25CF', " ", formatContextSize(parsed), " tokens (", parsed.toLocaleString(), ")"] }) })) : null;
992
+ })(), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#06B6D4", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: contextInput, onChange: setContextInput, onSubmit: (v) => {
858
993
  // saveConfig() writes the key to the OS keychain, and that
859
994
  // subprocess is not always quick — a locked keychain or a
860
995
  // pending "allow access?" dialog made one measured save take
@@ -913,7 +1048,7 @@ function SetupScreen({ onComplete, existingConfig }) {
913
1048
  }).catch(() => {
914
1049
  onComplete(newConfig);
915
1050
  });
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"] }) })] })] }));
1051
+ }, 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
1052
  }
918
1053
  const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart, tokens, mode, contextPercent, rateLimitWarn }) {
919
1054
  const { cols } = useTermSize();
@@ -1038,7 +1173,7 @@ function toolNamePreview(cols) {
1038
1173
  }
1039
1174
  const WelcomeScreen = memo(function WelcomeScreen({ cloud }) {
1040
1175
  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) })) })] }));
1176
+ 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
1177
  });
1043
1178
  const UserMessage = memo(function UserMessage({ text }) {
1044
1179
  return (_jsx(Box, { paddingX: 2, marginTop: 1, children: _jsxs(Text, { wrap: "wrap", bold: true, children: [_jsx(Text, { color: "magentaBright", children: '\u276F ' }), text] }) }));
@@ -2169,6 +2304,22 @@ function App({ config }) {
2169
2304
  add('system', 'This workspace has project plugins/hooks/rules that were skipped because the workspace is not trusted. Run /trust to load them.');
2170
2305
  }
2171
2306
  }, [add]);
2307
+ // MCP: connect every configured server once the agent exists and register
2308
+ // the tools it reports. Only failures are announced — a working setup should
2309
+ // not narrate itself at every launch.
2310
+ useEffect(() => {
2311
+ if (!agent)
2312
+ return;
2313
+ let hub = null;
2314
+ let cancelled = false;
2315
+ startMcp(agent, (m) => { if (!cancelled)
2316
+ add('system', `⚠ ${m}`); })
2317
+ .then((h) => { hub = h; if (cancelled)
2318
+ void h.close(); })
2319
+ .catch(() => { });
2320
+ // stdio servers are child processes; a re-init must not leave them running.
2321
+ return () => { cancelled = true; void hub?.close(); };
2322
+ }, [agent]);
2172
2323
  // Scalix Cloud: on agent start (and after /setup or /login re-init), fetch
2173
2324
  // the account's tool catalog and register it. Failure or no key → local
2174
2325
  // tools only, with exactly one honest notice. No retry loop.
@@ -2377,6 +2528,43 @@ function App({ config }) {
2377
2528
  });
2378
2529
  return true;
2379
2530
  }
2531
+ if (cmd === '/rename') {
2532
+ const newName = text.slice(7).trim();
2533
+ if (!newName) {
2534
+ add('system', 'Usage: /rename <new session name>');
2535
+ return true;
2536
+ }
2537
+ const msgs = agent.getMessages();
2538
+ saveSession(sessionId, msgs, models[0] || 'unknown', newName, costTracker.getState()).then((meta) => {
2539
+ add('system', `Session renamed to: "${meta.name}"`);
2540
+ }).catch((err) => {
2541
+ add('error', `Failed to rename session: ${err.message}`);
2542
+ });
2543
+ return true;
2544
+ }
2545
+ if (cmd === '/feedback') {
2546
+ const feedbackMsg = text.slice(9).trim();
2547
+ if (!feedbackMsg) {
2548
+ add('system', 'Usage: /feedback <your feedback, issue report, or suggestion>');
2549
+ return true;
2550
+ }
2551
+ saveFeedback({
2552
+ category: 'general',
2553
+ message: feedbackMsg,
2554
+ systemInfo: {
2555
+ version: VERSION,
2556
+ nodeVersion: process.version,
2557
+ platform: os.platform(),
2558
+ arch: os.arch(),
2559
+ model: models[0] || 'unknown',
2560
+ },
2561
+ }).then(() => {
2562
+ add('system', 'Thank you! Your feedback has been recorded and submitted to Scalix.');
2563
+ }).catch((err) => {
2564
+ add('error', `Failed to record feedback: ${err.message}`);
2565
+ });
2566
+ return true;
2567
+ }
2380
2568
  if (cmd === '/load' || cmd === '/resume') {
2381
2569
  if (cmd === '/resume') {
2382
2570
  loadCrashState().then(async (state) => {
@@ -2581,6 +2769,24 @@ function App({ config }) {
2581
2769
  }
2582
2770
  return true;
2583
2771
  }
2772
+ if (cmd === '/preset') {
2773
+ const arg = text.split(' ')[1]?.toLowerCase();
2774
+ if (arg) {
2775
+ try {
2776
+ agent.setPreset(arg);
2777
+ // When a preset switches mode, sync the TUI state
2778
+ setCurrentMode(agent.getPermissionMode());
2779
+ add('system', `Preset active: ${arg} — mode is now ${agent.getPermissionMode()}`);
2780
+ }
2781
+ catch (e) {
2782
+ add('system', `Error: ${e.message}\nAvailable presets: cloud-architect, dba, devops, full-access`);
2783
+ }
2784
+ }
2785
+ else {
2786
+ add('system', `Usage: /preset <id>\nAvailable presets: cloud-architect, dba, devops, full-access`);
2787
+ }
2788
+ return true;
2789
+ }
2584
2790
  if (cmd === '/thinking') {
2585
2791
  const arg = text.split(' ')[1]?.toLowerCase();
2586
2792
  if (arg && THINKING_MODES.includes(arg)) {
@@ -2598,21 +2804,31 @@ function App({ config }) {
2598
2804
  }
2599
2805
  if (cmd === '/mcp') {
2600
2806
  const p = text.split(' ').filter(Boolean);
2807
+ // Bare /mcp reports what is connected \u2014 the question people actually
2808
+ // have, now that connecting is something that can fail.
2809
+ if (p.length === 1) {
2810
+ const st = agent.getMcpStatus();
2811
+ add('system', st.length
2812
+ ? 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')
2813
+ : 'No MCP servers configured. Add one to mcp-services.json, or: /mcp <label> <url>');
2814
+ return true;
2815
+ }
2601
2816
  if (p.length < 3) {
2602
- add('system', 'Usage: /mcp <label> <url>');
2817
+ add('system', 'Usage: /mcp \u2014 show server status\n /mcp <label> <url>');
2603
2818
  return true;
2604
2819
  }
2605
- {
2606
- try {
2607
- new URL(p[2]);
2608
- }
2609
- catch {
2610
- add('error', `Invalid URL: ${p[2]}`);
2611
- return true;
2612
- }
2613
- agent.addTool({ type: 'mcp', serverLabel: p[1], serverUrl: p[2] });
2614
- add('system', `MCP registered: ${p[1]} \u2192 ${p[2]}`);
2820
+ // Same validator add_mcp_server and the config loader use \u2014 a typed URL
2821
+ // gets no more benefit of the doubt than a repo-supplied one.
2822
+ const badUrl = validateMcpUrl(p[2]);
2823
+ if (badUrl) {
2824
+ add('error', `Invalid MCP server URL: ${badUrl}`);
2825
+ return true;
2615
2826
  }
2827
+ agent.connectMcpServer({ serverLabel: p[1], serverUrl: p[2] }).then((s) => {
2828
+ add(s.state === 'connected' ? 'system' : 'error', s.state === 'connected'
2829
+ ? `MCP connected: ${p[1]} \u2192 ${p[2]} (${s.toolCount} tool${s.toolCount === 1 ? '' : 's'})`
2830
+ : `MCP ${p[1]} failed: ${s.error ?? 'unknown error'}`);
2831
+ });
2616
2832
  return true;
2617
2833
  }
2618
2834
  if (cmd === '/index') {
@@ -2662,6 +2878,9 @@ function App({ config }) {
2662
2878
  discoverPluginsForAgent(agent).then(() => {
2663
2879
  add('system', `Loaded ${loadedPlugins.length} plugin(s).`);
2664
2880
  }).catch(() => { });
2881
+ const hookCount = agent.reloadHooks();
2882
+ if (hookCount > 0)
2883
+ add('system', `Loaded ${hookCount} project hook(s).`);
2665
2884
  buildEnhancedInstructions().then((enhanced) => {
2666
2885
  agent.setInstructions(enhanced);
2667
2886
  add('system', 'Reloaded project rules into context.');
@@ -2713,6 +2932,12 @@ function App({ config }) {
2713
2932
  }
2714
2933
  if (cmd === '/hooks') {
2715
2934
  const hooks = agent.getHooks();
2935
+ const skipped = agent.getSkippedHookCount();
2936
+ // Hooks that exist but were withheld are worth a line of their own —
2937
+ // "No hooks configured" would be a lie the user could act on wrongly.
2938
+ if (skipped > 0) {
2939
+ add('system', `${skipped} project hook(s) skipped — this workspace is untrusted. Run /trust to enable them.`);
2940
+ }
2716
2941
  hooksSummary(hooks).split('\n').forEach((l) => add('system', l));
2717
2942
  return true;
2718
2943
  }