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/.env.example +5 -3
- package/CHANGELOG.md +40 -0
- package/LICENSE +0 -1
- package/README.md +91 -3
- package/dist/agent-presets.js +33 -0
- package/dist/agent.js +372 -43
- package/dist/api-client.js +64 -5
- package/dist/board-context.js +81 -0
- package/dist/browser-access.js +0 -0
- package/dist/cli.js +397 -45
- package/dist/cloud.js +1 -1
- package/dist/command-sandbox.js +146 -0
- package/dist/commands.js +18 -6
- package/dist/config.js +44 -5
- package/dist/constants.js +90 -11
- package/dist/context-builder.js +9 -1
- package/dist/cost-tracker.js +1 -1
- package/dist/feedback.js +51 -0
- package/dist/headless.js +43 -9
- package/dist/ipc-server.js +371 -110
- package/dist/keychain.js +34 -9
- package/dist/marketplace.js +400 -0
- package/dist/mcp.js +498 -29
- package/dist/openrouter-oauth.js +149 -0
- package/dist/project-index.js +46 -23
- package/dist/providers/index.js +21 -4
- package/dist/sandbox.js +115 -27
- package/dist/secure-fetch.js +117 -0
- package/dist/sessions.js +12 -0
- package/dist/settings.js +193 -44
- package/dist/slash-commands.js +8 -0
- package/dist/sub-agent.js +6 -2
- package/dist/tools-browser.js +95 -18
- package/dist/tools-extra.js +101 -7
- package/dist/tools-memory.js +74 -0
- package/dist/tools-web.js +92 -28
- package/dist/tools.js +99 -23
- package/dist/workspace-trust.js +11 -1
- package/dist/worktree.js +24 -5
- package/package.json +5 -3
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 {
|
|
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 (
|
|
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)
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
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
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
583
|
-
? ['provider', 'api-key', 'base-url', 'models', 'context-window']
|
|
584
|
-
:
|
|
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: "#
|
|
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: '
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
700
|
-
|
|
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') ===
|
|
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(
|
|
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: "#
|
|
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 === '
|
|
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: "#
|
|
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: "#
|
|
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: "#
|
|
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: "#
|
|
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: "#
|
|
857
|
-
})(), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#
|
|
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: "#
|
|
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: "#
|
|
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
|
-
|
|
2607
|
-
|
|
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
|
-
|
|
2610
|
-
|
|
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
|
-
|
|
2614
|
-
|
|
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
|
}
|