scalix 1.5.1 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -3
- package/LICENSE +0 -1
- package/README.md +47 -2
- package/dist/agent-presets.js +33 -0
- package/dist/agent.js +233 -18
- package/dist/api-client.js +4 -4
- package/dist/board-context.js +81 -0
- package/dist/browser-access.js +0 -0
- package/dist/cli.js +270 -45
- package/dist/cloud.js +1 -1
- package/dist/config.js +44 -5
- package/dist/constants.js +82 -11
- package/dist/context-builder.js +9 -1
- package/dist/feedback.js +51 -0
- package/dist/headless.js +22 -5
- package/dist/ipc-server.js +338 -108
- package/dist/keychain.js +34 -9
- package/dist/mcp.js +470 -28
- package/dist/openrouter-oauth.js +149 -0
- package/dist/project-index.js +46 -23
- package/dist/sandbox.js +115 -27
- package/dist/sessions.js +12 -0
- package/dist/slash-commands.js +6 -0
- package/dist/sub-agent.js +3 -2
- package/dist/tools-browser.js +87 -16
- package/dist/tools-extra.js +15 -3
- package/dist/tools-memory.js +74 -0
- package/dist/tools-web.js +67 -6
- package/dist/tools.js +15 -14
- package/dist/workspace-trust.js +7 -1
- package/package.json +3 -1
|
@@ -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 {
|
|
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 (
|
|
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)
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
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
|
|
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
|
|
583
|
-
? ['provider', 'api-key', 'base-url', 'models', 'context-window']
|
|
584
|
-
:
|
|
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: "#
|
|
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: '
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
700
|
-
|
|
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') ===
|
|
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(
|
|
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: "#
|
|
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 === '
|
|
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: "#
|
|
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: "#
|
|
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: "#
|
|
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: "#
|
|
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: "#
|
|
857
|
-
})(), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#
|
|
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: "#
|
|
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: "#
|
|
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
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
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
|
}
|