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/ipc-server.js
CHANGED
|
@@ -5,30 +5,78 @@
|
|
|
5
5
|
* Protocol:
|
|
6
6
|
* stdin (client→server): {"type":"request","id":"...","method":"...","params":{}}
|
|
7
7
|
* {"type":"response","id":"...","result":{}} (for confirmTool)
|
|
8
|
-
* stdout (server→client): {"type":"event","event":"...","data":{}}
|
|
8
|
+
* stdout (server→client): {"type":"event","event":"...","data":{},"sessionId":"..."}
|
|
9
9
|
* {"type":"response","id":"...","result":{}}
|
|
10
10
|
* {"type":"request","id":"...","method":"confirmTool","params":{}}
|
|
11
|
+
*
|
|
12
|
+
* Multi-session: the server hosts one Agent per session so several chat tabs can
|
|
13
|
+
* run concurrent, isolated conversations. Session-scoped requests carry
|
|
14
|
+
* `params.sessionId` (missing → the `default` session, which always exists), and
|
|
15
|
+
* session-scoped events carry a top-level `sessionId`. Global events (ready,
|
|
16
|
+
* mcp:status, browser:frame) and global methods (getMemory, saveMemory,
|
|
17
|
+
* registerModels, setClientCapabilities, forgetLocalOrigin) are untagged.
|
|
11
18
|
*/
|
|
12
19
|
import readline from 'node:readline';
|
|
13
20
|
import { createAgent } from './agent.js';
|
|
14
21
|
import { defaultTools } from './tools.js';
|
|
15
22
|
import { extraTools } from './tools-extra.js';
|
|
23
|
+
import { memoryTools } from './tools-memory.js';
|
|
16
24
|
import { webTools } from './tools-web.js';
|
|
17
25
|
import { advancedTools } from './tools-advanced.js';
|
|
18
26
|
import { visionTools } from './tools-vision.js';
|
|
19
27
|
import { voiceTools } from './tools-voice.js';
|
|
20
|
-
import { browserTools, closeBrowser } from './tools-browser.js';
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
28
|
+
import { browserTools, closeBrowser, setBrowserFrameSink } from './tools-browser.js';
|
|
29
|
+
import { setLocalOriginApprover, listApprovedLocalOrigins, forgetLocalOrigin } from './browser-access.js';
|
|
30
|
+
import { connectMcpServers } from './mcp.js';
|
|
31
|
+
import { loadConfig, resolveActiveProvider, resolveProviderByKey, summarizeProviders, DEFAULT_LIMITS } from './settings.js';
|
|
32
|
+
import { PERMISSION_MODES } from './sandbox.js';
|
|
33
|
+
import { isProviderId } from './providers/index.js';
|
|
23
34
|
import { discoverPlugins, getPluginTools } from './plugins.js';
|
|
24
35
|
import { isWorkspaceTrusted } from './workspace-trust.js';
|
|
25
36
|
import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS, ENV_MODEL, fetchModelSpecs, registerUserModels, normalizeModelId, VERSION } from './constants.js';
|
|
26
|
-
import { loadMemory, autoDetectConventions, saveMemory } from './project-memory.js';
|
|
37
|
+
import { loadMemory, autoDetectConventions, saveMemory, addNote, detectCorrection } from './project-memory.js';
|
|
27
38
|
import { buildProjectContext, contextToInstructions } from './context-builder.js';
|
|
28
|
-
|
|
39
|
+
import { AgentRegistry } from './agent-registry.js';
|
|
40
|
+
// Server→client requests awaiting an answer. Keyed by request id; the value is
|
|
41
|
+
// resolved with whatever `result` object the client sends back. confirmTool was
|
|
42
|
+
// the only user of this for a long time — the browser agent's local-origin
|
|
43
|
+
// prompt is the second, so the map holds raw results and each caller reads the
|
|
44
|
+
// field it cares about.
|
|
45
|
+
const MAX_PENDING_REQUESTS = 100;
|
|
46
|
+
const MAX_IPC_MESSAGE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
47
|
+
const pendingClientRequests = new Map();
|
|
29
48
|
let confirmCounter = 0;
|
|
30
49
|
function write(msg) {
|
|
31
|
-
|
|
50
|
+
const payload = JSON.stringify(msg);
|
|
51
|
+
if (Buffer.byteLength(payload, 'utf8') > MAX_IPC_MESSAGE_BYTES) {
|
|
52
|
+
process.stderr.write(`IPC: outbound message exceeds ${MAX_IPC_MESSAGE_BYTES} bytes — dropped\n`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
process.stdout.write(payload + '\n');
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Ask the client something and wait for its answer. Falls back to `onTimeout`
|
|
59
|
+
* if the client stays silent — a front end that has gone away must not hang
|
|
60
|
+
* the agent, and silence is never taken as consent.
|
|
61
|
+
*/
|
|
62
|
+
function askClient(method, params, timeoutMs, onTimeout, read) {
|
|
63
|
+
if (pendingClientRequests.size >= MAX_PENDING_REQUESTS) {
|
|
64
|
+
return Promise.resolve(onTimeout);
|
|
65
|
+
}
|
|
66
|
+
const id = `confirm_${++confirmCounter}`;
|
|
67
|
+
write({ type: 'request', id, method, params });
|
|
68
|
+
return new Promise(resolve => {
|
|
69
|
+
let settled = false;
|
|
70
|
+
const finish = (v) => { if (!settled) {
|
|
71
|
+
settled = true;
|
|
72
|
+
resolve(v);
|
|
73
|
+
} };
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
pendingClientRequests.delete(id);
|
|
76
|
+
finish(onTimeout);
|
|
77
|
+
}, timeoutMs);
|
|
78
|
+
pendingClientRequests.set(id, (result) => { clearTimeout(timer); finish(read(result)); });
|
|
79
|
+
});
|
|
32
80
|
}
|
|
33
81
|
async function buildInstructions() {
|
|
34
82
|
let base = DEFAULT_INSTRUCTIONS;
|
|
@@ -89,7 +137,6 @@ export async function runIpcServer() {
|
|
|
89
137
|
const baseUrl = active.baseUrl;
|
|
90
138
|
if (active.provider === 'scalix')
|
|
91
139
|
fetchModelSpecs(baseUrl, apiKey).catch(() => { });
|
|
92
|
-
const mcpTools = loadMcpServices();
|
|
93
140
|
// Only discover/load project-local plugins in a trusted workspace — plugins
|
|
94
141
|
// dynamically import and execute code from the project directory.
|
|
95
142
|
const plugins = isWorkspaceTrusted() ? await discoverPlugins(process.cwd()) : [];
|
|
@@ -97,77 +144,141 @@ export async function runIpcServer() {
|
|
|
97
144
|
process.stderr.write('IPC: project plugins skipped (untrusted workspace)\n');
|
|
98
145
|
}
|
|
99
146
|
const pluginTools = getPluginTools(plugins);
|
|
100
|
-
const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...
|
|
147
|
+
const allTools = [...defaultTools, ...extraTools, ...memoryTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...pluginTools];
|
|
101
148
|
const instructions = await buildInstructions();
|
|
102
|
-
// Bidirectional confirm: send request to
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
149
|
+
// Bidirectional confirm: send request to the client, wait for response.
|
|
150
|
+
// Each session tags its own prompt so the GUI can show it in the tab whose
|
|
151
|
+
// tool is actually trying to run.
|
|
152
|
+
const confirmFor = (sessionId) => (toolName, args) => askClient('confirmTool', { sessionId, name: toolName, args }, 60_000, false, r => r?.allowed ?? false);
|
|
153
|
+
const permissionMode = (process.env.SCALIX_PERMISSION_MODE || config.permissionMode || 'default');
|
|
154
|
+
// ─── Multi-session registry ───
|
|
155
|
+
// One Agent per chat tab so parallel conversations never share context or
|
|
156
|
+
// serialize on each other. The `default` session always exists, so older
|
|
157
|
+
// clients that never send a sessionId keep exactly the old behaviour.
|
|
158
|
+
const MAX_SESSIONS = 16;
|
|
159
|
+
const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
160
|
+
const sessions = new Map();
|
|
161
|
+
let mcpHub = null;
|
|
162
|
+
const forwardSessionEvents = (sessionId, agent) => {
|
|
163
|
+
const ev = (event, data) => write({ type: 'event', event, data, sessionId });
|
|
164
|
+
agent.on('stream:delta', (d, a) => ev('stream:delta', { delta: d, accumulated: a }));
|
|
165
|
+
agent.on('stream:end', (t) => ev('stream:end', { text: t }));
|
|
166
|
+
agent.on('tool:call', (n, a) => ev('tool:call', { name: n, args: a }));
|
|
167
|
+
agent.on('tool:result', (n, r) => ev('tool:result', { name: n, result: r }));
|
|
168
|
+
agent.on('tool:blocked', (n, r) => ev('tool:blocked', { name: n, reason: r }));
|
|
169
|
+
agent.on('step:start', (i) => ev('step:start', i));
|
|
170
|
+
agent.on('step:done', (i) => ev('step:done', i));
|
|
171
|
+
agent.on('turn:summary', (i) => ev('turn:summary', i));
|
|
172
|
+
agent.on('thinking:start', () => ev('thinking:start'));
|
|
173
|
+
agent.on('thinking:end', () => ev('thinking:end'));
|
|
174
|
+
agent.on('reasoning:update', (t) => ev('reasoning:update', { text: t }));
|
|
175
|
+
agent.on('usage', (d) => ev('usage', d));
|
|
176
|
+
agent.on('error', (e) => ev('error', { message: e.message }));
|
|
177
|
+
agent.on('context:compress', (i) => ev('context:compress', i));
|
|
178
|
+
agent.on('context:usage', (i) => ev('context:usage', i));
|
|
179
|
+
};
|
|
180
|
+
const addSession = (id, override) => {
|
|
181
|
+
const existing = sessions.get(id);
|
|
182
|
+
if (existing)
|
|
183
|
+
return existing;
|
|
184
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
185
|
+
throw new Error(`Session limit reached (${MAX_SESSIONS}) — close a chat tab first.`);
|
|
186
|
+
}
|
|
187
|
+
// Per-session provider/creds: overrides let each tab use a different AI
|
|
188
|
+
// provider (Scalix, any configured BYOK registry entry) with its own API
|
|
189
|
+
// key, base URL, and model selection. An explicit provider key resolves its
|
|
190
|
+
// credentials from the registry, so the front end only has to name it.
|
|
191
|
+
const overrideActive = override?.provider
|
|
192
|
+
? resolveProviderByKey(config, override.provider)
|
|
193
|
+
: null;
|
|
194
|
+
const sessionProvider = override?.provider ?? active.provider;
|
|
195
|
+
const sessionApiKey = override?.apiKey ?? overrideActive?.apiKey ?? apiKey;
|
|
196
|
+
const sessionBaseUrl = override?.baseUrl ?? overrideActive?.baseUrl ?? baseUrl;
|
|
197
|
+
const sessionCloudApiKey = override?.cloudApiKey ?? overrideActive?.cloudApiKey ?? active.cloudApiKey;
|
|
198
|
+
const sessionCloudBaseUrl = override?.cloudBaseUrl ?? overrideActive?.cloudBaseUrl ?? active.cloudBaseUrl;
|
|
199
|
+
const sessionModels = override?.model ?? selectedModels;
|
|
200
|
+
const sessionPermissionMode = override?.permissionMode ?? permissionMode;
|
|
201
|
+
const agent = createAgent({
|
|
202
|
+
apiKey: sessionApiKey,
|
|
203
|
+
baseUrl: sessionBaseUrl,
|
|
204
|
+
provider: sessionProvider,
|
|
205
|
+
cloudApiKey: sessionCloudApiKey,
|
|
206
|
+
cloudBaseUrl: sessionCloudBaseUrl,
|
|
207
|
+
model: sessionModels,
|
|
208
|
+
instructions,
|
|
209
|
+
// A COPY per agent: refreshCloudTools/attachMcpHub mutate config.tools,
|
|
210
|
+
// and a shared array would double-register tools across sessions.
|
|
211
|
+
tools: [...allTools],
|
|
212
|
+
permissionMode: sessionPermissionMode,
|
|
213
|
+
maxSteps: limits.maxSteps,
|
|
214
|
+
maxMessages: limits.maxMessages,
|
|
215
|
+
contextManager: {
|
|
216
|
+
maxContextTokens: limits.maxContextTokens,
|
|
217
|
+
compressionThreshold: limits.compressionThreshold,
|
|
218
|
+
maxToolResultTokens: limits.maxToolResultTokens,
|
|
219
|
+
},
|
|
220
|
+
instructionLoader: buildInstructions,
|
|
221
|
+
confirmToolCall: confirmFor(id),
|
|
222
|
+
autoVerify: true,
|
|
120
223
|
});
|
|
224
|
+
forwardSessionEvents(id, agent);
|
|
225
|
+
if (mcpHub)
|
|
226
|
+
agent.attachMcpHub(mcpHub);
|
|
227
|
+
const session = { id, agent, busy: false };
|
|
228
|
+
sessions.set(id, session);
|
|
229
|
+
// Platform tools register per agent; each session reports its own status,
|
|
230
|
+
// tagged with its id, exactly like the single-agent path did for default.
|
|
231
|
+
void agent.refreshCloudTools()
|
|
232
|
+
.then(status => { write({ type: 'event', event: 'cloud:status', data: status, sessionId: id }); })
|
|
233
|
+
.catch(err => { process.stderr.write(`IPC: cloud tools registration failed (${id}): ${err?.message || err}\n`); });
|
|
234
|
+
return session;
|
|
121
235
|
};
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
instructions,
|
|
131
|
-
tools: allTools,
|
|
132
|
-
permissionMode,
|
|
133
|
-
maxSteps: limits.maxSteps,
|
|
134
|
-
maxMessages: limits.maxMessages,
|
|
135
|
-
contextManager: {
|
|
136
|
-
maxContextTokens: limits.maxContextTokens,
|
|
137
|
-
compressionThreshold: limits.compressionThreshold,
|
|
138
|
-
maxToolResultTokens: limits.maxToolResultTokens,
|
|
139
|
-
},
|
|
140
|
-
instructionLoader: buildInstructions,
|
|
141
|
-
confirmToolCall,
|
|
142
|
-
autoVerify: true,
|
|
143
|
-
});
|
|
144
|
-
// Forward all agent events as JSONL
|
|
145
|
-
agent.on('stream:delta', (d, a) => write({ type: 'event', event: 'stream:delta', data: { delta: d, accumulated: a } }));
|
|
146
|
-
agent.on('stream:end', t => write({ type: 'event', event: 'stream:end', data: { text: t } }));
|
|
147
|
-
agent.on('tool:call', (n, a) => write({ type: 'event', event: 'tool:call', data: { name: n, args: a } }));
|
|
148
|
-
agent.on('tool:result', (n, r) => write({ type: 'event', event: 'tool:result', data: { name: n, result: r } }));
|
|
149
|
-
agent.on('tool:blocked', (n, r) => write({ type: 'event', event: 'tool:blocked', data: { name: n, reason: r } }));
|
|
150
|
-
agent.on('thinking:start', () => write({ type: 'event', event: 'thinking:start' }));
|
|
151
|
-
agent.on('thinking:end', () => write({ type: 'event', event: 'thinking:end' }));
|
|
152
|
-
agent.on('usage', d => write({ type: 'event', event: 'usage', data: d }));
|
|
153
|
-
agent.on('error', e => write({ type: 'event', event: 'error', data: { message: e.message } }));
|
|
154
|
-
agent.on('context:compress', i => write({ type: 'event', event: 'context:compress', data: i }));
|
|
155
|
-
agent.on('context:usage', i => write({ type: 'event', event: 'context:usage', data: i }));
|
|
236
|
+
const resolveSession = (params) => {
|
|
237
|
+
const id = typeof params?.sessionId === 'string' && params.sessionId ? params.sessionId : 'default';
|
|
238
|
+
const session = sessions.get(id);
|
|
239
|
+
if (!session)
|
|
240
|
+
throw new Error(`Unknown session: ${id}`);
|
|
241
|
+
return session;
|
|
242
|
+
};
|
|
243
|
+
const defaultSession = addSession('default');
|
|
156
244
|
// Signal ready with capability info. `version` lets the extension detect
|
|
157
245
|
// engine/extension drift (a globally-installed CLI can lag the bundled one).
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
|
|
246
|
+
// `multiSession` tells the extension it may open parallel chat sessions.
|
|
247
|
+
write({ type: 'event', event: 'ready', data: { version: VERSION, tools: allTools.length, models: selectedModels, permissionMode, multiSession: true, provider: active.provider, providers: summarizeProviders(config) } });
|
|
248
|
+
if (defaultSession.agent.getSkippedHookCount() > 0) {
|
|
249
|
+
process.stderr.write(`IPC: project hooks skipped (untrusted workspace): ${defaultSession.agent.getSkippedHookCount()}\n`);
|
|
250
|
+
}
|
|
251
|
+
// Connect configured MCP servers. Not awaited, for the same reason as the
|
|
252
|
+
// cloud catalog below: `npx -y <server>` can spend half a minute fetching a
|
|
253
|
+
// package on first run and the IDE's connect handshake must not sit behind
|
|
254
|
+
// it. The hub connects ONCE and is attached to every session agent — current
|
|
255
|
+
// and future — so all tabs share the same external tool processes.
|
|
256
|
+
void connectMcpServers()
|
|
257
|
+
.then(hub => {
|
|
258
|
+
mcpHub = hub;
|
|
259
|
+
for (const s of sessions.values())
|
|
260
|
+
s.agent.attachMcpHub(hub);
|
|
261
|
+
write({ type: 'event', event: 'mcp:status', data: hub.status() });
|
|
262
|
+
for (const st of hub.status()) {
|
|
263
|
+
if (st.state !== 'connected')
|
|
264
|
+
process.stderr.write(`IPC: MCP "${st.label}" ${st.state}: ${st.error ?? 'unknown error'}\n`);
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
.catch(err => { process.stderr.write(`IPC: MCP startup failed: ${err?.message || err}\n`); });
|
|
268
|
+
// Sub-agent lifecycle → the IDE's Agents tab. The registry is a process-wide
|
|
269
|
+
// singleton (the session agents and every spawned sub-agent register in it),
|
|
270
|
+
// so hook it once here and broadcast each spawn/completion as an event.
|
|
271
|
+
const registry = AgentRegistry.getInstance();
|
|
272
|
+
const subagentEvent = (entry) => write({ type: 'event', event: 'subagent', data: entry });
|
|
273
|
+
registry.on('agent:spawned', subagentEvent);
|
|
274
|
+
registry.on('agent:completed', subagentEvent);
|
|
275
|
+
// Process JSONL from stdin — session-routed requests
|
|
169
276
|
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
170
277
|
rl.on('line', async (line) => {
|
|
278
|
+
if (Buffer.byteLength(line, 'utf8') > MAX_IPC_MESSAGE_BYTES) {
|
|
279
|
+
process.stderr.write(`IPC: inbound message exceeds ${MAX_IPC_MESSAGE_BYTES} bytes — dropped\n`);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
171
282
|
let msg;
|
|
172
283
|
try {
|
|
173
284
|
msg = JSON.parse(line);
|
|
@@ -176,11 +287,11 @@ export async function runIpcServer() {
|
|
|
176
287
|
process.stderr.write(`IPC: invalid JSON: ${line.slice(0, 100)}\n`);
|
|
177
288
|
return;
|
|
178
289
|
}
|
|
179
|
-
// Response to server-initiated confirmTool
|
|
180
|
-
if (msg.type === 'response' &&
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
resolve(msg.result
|
|
290
|
+
// Response to a server-initiated request (confirmTool, confirmLocalBrowser)
|
|
291
|
+
if (msg.type === 'response' && pendingClientRequests.has(msg.id)) {
|
|
292
|
+
const resolve = pendingClientRequests.get(msg.id);
|
|
293
|
+
pendingClientRequests.delete(msg.id);
|
|
294
|
+
resolve(msg.result);
|
|
184
295
|
return;
|
|
185
296
|
}
|
|
186
297
|
if (msg.type !== 'request')
|
|
@@ -188,63 +299,164 @@ export async function runIpcServer() {
|
|
|
188
299
|
const { id, method, params } = msg;
|
|
189
300
|
try {
|
|
190
301
|
switch (method) {
|
|
302
|
+
case 'createSession': {
|
|
303
|
+
const sid = typeof params?.sessionId === 'string' ? params.sessionId.trim() : '';
|
|
304
|
+
if (!sid) {
|
|
305
|
+
write({ type: 'response', id, error: { message: 'Missing sessionId' } });
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
if (!SESSION_ID_RE.test(sid)) {
|
|
309
|
+
write({ type: 'response', id, error: { message: `Invalid sessionId: must match /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/` } });
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
const override = {};
|
|
313
|
+
if (isProviderId(params?.provider))
|
|
314
|
+
override.provider = params.provider;
|
|
315
|
+
if (params?.apiKey && typeof params.apiKey === 'string')
|
|
316
|
+
override.apiKey = params.apiKey;
|
|
317
|
+
if (params?.baseUrl && typeof params.baseUrl === 'string')
|
|
318
|
+
override.baseUrl = params.baseUrl;
|
|
319
|
+
if (params?.cloudApiKey && typeof params.cloudApiKey === 'string')
|
|
320
|
+
override.cloudApiKey = params.cloudApiKey;
|
|
321
|
+
if (params?.cloudBaseUrl && typeof params.cloudBaseUrl === 'string')
|
|
322
|
+
override.cloudBaseUrl = params.cloudBaseUrl;
|
|
323
|
+
if (Array.isArray(params?.model))
|
|
324
|
+
override.model = params.model;
|
|
325
|
+
if (params?.permissionMode && PERMISSION_MODES.includes(params.permissionMode))
|
|
326
|
+
override.permissionMode = params.permissionMode;
|
|
327
|
+
try {
|
|
328
|
+
const session = addSession(sid, override);
|
|
329
|
+
write({ type: 'response', id, result: { sessionId: session.id, models: session.agent.getModels(), permissionMode: session.agent.getPermissionMode() } });
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
write({ type: 'response', id, error: { message: err.message } });
|
|
333
|
+
}
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
case 'closeSession': {
|
|
337
|
+
const sid = typeof params?.sessionId === 'string' ? params.sessionId.trim() : '';
|
|
338
|
+
if (!sid) {
|
|
339
|
+
write({ type: 'response', id, error: { message: 'Missing sessionId' } });
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
if (sid === 'default') {
|
|
343
|
+
write({ type: 'response', id, error: { message: 'The default session cannot be closed' } });
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
const session = sessions.get(sid);
|
|
347
|
+
if (session) {
|
|
348
|
+
session.agent.abort();
|
|
349
|
+
sessions.delete(sid);
|
|
350
|
+
}
|
|
351
|
+
write({ type: 'response', id, result: { success: true } });
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
case 'listSessions':
|
|
355
|
+
write({ type: 'response', id, result: { sessions: [...sessions.keys()] } });
|
|
356
|
+
break;
|
|
191
357
|
case 'send': {
|
|
192
358
|
if (!params?.content) {
|
|
193
359
|
write({ type: 'response', id, error: { message: 'Missing content' } });
|
|
194
360
|
break;
|
|
195
361
|
}
|
|
196
|
-
|
|
362
|
+
let session;
|
|
363
|
+
try {
|
|
364
|
+
session = resolveSession(params);
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
write({ type: 'response', id, error: { message: err.message } });
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
if (session.busy) {
|
|
197
371
|
write({ type: 'response', id, error: { message: 'Agent is busy' } });
|
|
198
372
|
break;
|
|
199
373
|
}
|
|
200
|
-
busy = true;
|
|
201
|
-
write({ type: 'event', event: 'status', data: { status: 'thinking' } });
|
|
374
|
+
session.busy = true;
|
|
375
|
+
write({ type: 'event', event: 'status', data: { status: 'thinking' }, sessionId: session.id });
|
|
202
376
|
try {
|
|
377
|
+
const content = params.content;
|
|
378
|
+
const correction = detectCorrection(content);
|
|
379
|
+
if (correction)
|
|
380
|
+
addNote(process.cwd(), correction).catch(() => { });
|
|
203
381
|
const images = params.images;
|
|
204
|
-
const text = await agent.send(
|
|
382
|
+
const text = await session.agent.send(content, images);
|
|
205
383
|
write({ type: 'response', id, result: { text } });
|
|
206
384
|
}
|
|
207
385
|
catch (err) {
|
|
208
386
|
write({ type: 'response', id, error: { message: err.message } });
|
|
209
387
|
}
|
|
210
388
|
finally {
|
|
211
|
-
busy = false;
|
|
212
|
-
write({ type: 'event', event: 'status', data: { status: 'idle' } });
|
|
389
|
+
session.busy = false;
|
|
390
|
+
write({ type: 'event', event: 'status', data: { status: 'idle' }, sessionId: session.id });
|
|
391
|
+
}
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
case 'getMemory': {
|
|
395
|
+
const mem = await loadMemory(process.cwd());
|
|
396
|
+
write({ type: 'response', id, result: { memory: mem } });
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
case 'saveMemory': {
|
|
400
|
+
const note = params?.note;
|
|
401
|
+
if (note && note.trim()) {
|
|
402
|
+
await addNote(process.cwd(), note.trim());
|
|
403
|
+
write({ type: 'response', id, result: { success: true } });
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
write({ type: 'response', id, error: { message: 'Note is required' } });
|
|
213
407
|
}
|
|
214
408
|
break;
|
|
215
409
|
}
|
|
216
|
-
case 'abort':
|
|
217
|
-
|
|
410
|
+
case 'abort': {
|
|
411
|
+
const session = resolveSession(params);
|
|
412
|
+
session.agent.abort();
|
|
218
413
|
write({ type: 'response', id, result: { success: true } });
|
|
219
414
|
break;
|
|
220
|
-
|
|
221
|
-
|
|
415
|
+
}
|
|
416
|
+
case 'clear': {
|
|
417
|
+
const session = resolveSession(params);
|
|
418
|
+
session.agent.clearHistory();
|
|
222
419
|
write({ type: 'response', id, result: { success: true } });
|
|
223
420
|
break;
|
|
421
|
+
}
|
|
224
422
|
case 'setModel': {
|
|
225
|
-
|
|
226
|
-
// any truthy string (typos included) and answered an EMPTY model
|
|
227
|
-
// with success, both of which read back as "switched" in the UI.
|
|
423
|
+
const session = resolveSession(params);
|
|
228
424
|
const requested = typeof params?.model === 'string' ? params.model.trim() : '';
|
|
229
425
|
if (!requested || !/^[a-zA-Z0-9][\w./:@-]*$/.test(requested)) {
|
|
230
426
|
write({ type: 'response', id, error: { message: `Invalid model id: "${requested || '(empty)'}"` } });
|
|
231
427
|
break;
|
|
232
428
|
}
|
|
233
|
-
agent.setModel(normalizeModelId(requested));
|
|
234
|
-
write({ type: 'response', id, result: { model: agent.getModels()[0] } });
|
|
429
|
+
session.agent.setModel(normalizeModelId(requested));
|
|
430
|
+
write({ type: 'response', id, result: { model: session.agent.getModels()[0] } });
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
case 'setPreset': {
|
|
434
|
+
const session = resolveSession(params);
|
|
435
|
+
try {
|
|
436
|
+
session.agent.setPreset(params?.presetId);
|
|
437
|
+
write({ type: 'response', id, result: { success: true } });
|
|
438
|
+
}
|
|
439
|
+
catch (err) {
|
|
440
|
+
write({ type: 'response', id, error: { message: err.message } });
|
|
441
|
+
}
|
|
235
442
|
break;
|
|
236
443
|
}
|
|
237
444
|
case 'compact': {
|
|
238
|
-
const
|
|
445
|
+
const session = resolveSession(params);
|
|
446
|
+
const r = await session.agent.compact();
|
|
239
447
|
write({ type: 'response', id, result: { message: r } });
|
|
240
448
|
break;
|
|
241
449
|
}
|
|
242
|
-
case 'getModels':
|
|
243
|
-
|
|
450
|
+
case 'getModels': {
|
|
451
|
+
const session = resolveSession(params);
|
|
452
|
+
write({ type: 'response', id, result: { models: session.agent.getModels(), provider: session.agent.getProvider() } });
|
|
244
453
|
break;
|
|
454
|
+
}
|
|
455
|
+
case 'getProviders': {
|
|
456
|
+
write({ type: 'response', id, result: { providers: summarizeProviders(config), active: active.provider } });
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
245
459
|
case 'registerModels': {
|
|
246
|
-
// A model spec added in the client mid-session must reach the agent
|
|
247
|
-
// now; the config file is only read at engine start.
|
|
248
460
|
const specs = params?.models;
|
|
249
461
|
if (specs)
|
|
250
462
|
registerUserModels(specs);
|
|
@@ -252,27 +464,73 @@ export async function runIpcServer() {
|
|
|
252
464
|
break;
|
|
253
465
|
}
|
|
254
466
|
case 'getContextInfo': {
|
|
255
|
-
const
|
|
467
|
+
const session = resolveSession(params);
|
|
468
|
+
const u = session.agent.getContextUsage();
|
|
256
469
|
write({ type: 'response', id, result: {
|
|
257
470
|
estimatedTokens: u.tokens, maxTokens: u.maxTokens,
|
|
258
|
-
messageCount: agent.getMessages().length, utilization: u.percentage / 100,
|
|
259
|
-
breakdown: agent.getContextBreakdown(),
|
|
471
|
+
messageCount: session.agent.getMessages().length, utilization: u.percentage / 100,
|
|
472
|
+
breakdown: session.agent.getContextBreakdown(),
|
|
260
473
|
} });
|
|
261
474
|
break;
|
|
262
475
|
}
|
|
263
|
-
case 'getMessages':
|
|
264
|
-
|
|
476
|
+
case 'getMessages': {
|
|
477
|
+
const session = resolveSession(params);
|
|
478
|
+
write({ type: 'response', id, result: { messages: session.agent.getMessages() } });
|
|
265
479
|
break;
|
|
266
|
-
|
|
480
|
+
}
|
|
481
|
+
case 'loadMessages': {
|
|
482
|
+
const session = resolveSession(params);
|
|
267
483
|
if (params?.messages)
|
|
268
|
-
agent.loadMessages(params.messages);
|
|
484
|
+
session.agent.loadMessages(params.messages);
|
|
269
485
|
write({ type: 'response', id, result: { success: true } });
|
|
270
486
|
break;
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
487
|
+
}
|
|
488
|
+
case 'setClientCapabilities': {
|
|
489
|
+
if (params?.localBrowserPrompt) {
|
|
490
|
+
setLocalOriginApprover(({ origin, url }) => askClient('confirmLocalBrowser', { origin, url }, 120_000, 'deny', r => (r?.verdict === 'always' || r?.verdict === 'once') ? r.verdict : 'deny'));
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
setLocalOriginApprover(null);
|
|
494
|
+
}
|
|
495
|
+
if (params?.browserFrames) {
|
|
496
|
+
setBrowserFrameSink(frame => write({ type: 'event', event: 'browser:frame', data: frame }));
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
setBrowserFrameSink(null);
|
|
500
|
+
}
|
|
501
|
+
write({ type: 'response', id, result: {
|
|
502
|
+
localBrowserPrompt: !!params?.localBrowserPrompt,
|
|
503
|
+
browserFrames: !!params?.browserFrames,
|
|
504
|
+
approvedLocalOrigins: listApprovedLocalOrigins(process.cwd()),
|
|
505
|
+
} });
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
case 'forgetLocalOrigin': {
|
|
509
|
+
const origin = typeof params?.origin === 'string' ? params.origin : '';
|
|
510
|
+
if (origin)
|
|
511
|
+
forgetLocalOrigin(origin, process.cwd());
|
|
512
|
+
write({ type: 'response', id, result: { approvedLocalOrigins: listApprovedLocalOrigins(process.cwd()) } });
|
|
275
513
|
break;
|
|
514
|
+
}
|
|
515
|
+
case 'setPermissionMode': {
|
|
516
|
+
const session = resolveSession(params);
|
|
517
|
+
// Same whitelist createSession applies — an unvalidated mode string
|
|
518
|
+
// would degrade every tool to 'ask' (fail-safe) but leave the
|
|
519
|
+
// session reporting a mode that does not exist.
|
|
520
|
+
if (params?.mode && PERMISSION_MODES.includes(params.mode)) {
|
|
521
|
+
session.agent.setPermissionMode(params.mode);
|
|
522
|
+
}
|
|
523
|
+
write({ type: 'response', id, result: { mode: session.agent.getPermissionMode() } });
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
case 'listAgents': {
|
|
527
|
+
// The IDE's Agents tab polls this: every registered agent (session
|
|
528
|
+
// roots plus sub-agents spawned through the `agent` tool) with its
|
|
529
|
+
// live status, model, token and step counts.
|
|
530
|
+
const all = AgentRegistry.getInstance().listAll();
|
|
531
|
+
write({ type: 'response', id, result: { agents: all, usage: AgentRegistry.getInstance().getUsageSummary() } });
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
276
534
|
default:
|
|
277
535
|
write({ type: 'response', id, error: { message: `Unknown method: ${method}` } });
|
|
278
536
|
}
|
|
@@ -281,9 +539,12 @@ export async function runIpcServer() {
|
|
|
281
539
|
write({ type: 'response', id, error: { message: err.message } });
|
|
282
540
|
}
|
|
283
541
|
});
|
|
542
|
+
// stdio MCP servers are child processes of this one — closing the hub on the
|
|
543
|
+
// way out is what stops them outliving the IDE session.
|
|
544
|
+
const shutdown = () => Promise.all([closeBrowser(), mcpHub?.close() ?? Promise.resolve()]);
|
|
284
545
|
rl.on('error', (err) => {
|
|
285
546
|
process.stderr.write(`IPC readline error: ${err.message}\n`);
|
|
286
|
-
|
|
547
|
+
shutdown().finally(() => process.exit(1));
|
|
287
548
|
});
|
|
288
|
-
rl.on('close', () =>
|
|
549
|
+
rl.on('close', () => shutdown().finally(() => process.exit(0)));
|
|
289
550
|
}
|