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.
@@ -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 { loadMcpServices } from './mcp.js';
22
- import { loadConfig, resolveActiveProvider, DEFAULT_LIMITS } from './settings.js';
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
- const pendingConfirms = new Map();
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
- process.stdout.write(JSON.stringify(msg) + '\n');
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, ...mcpTools, ...pluginTools];
147
+ const allTools = [...defaultTools, ...extraTools, ...memoryTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...pluginTools];
101
148
  const instructions = await buildInstructions();
102
- // Bidirectional confirm: send request to VS Code, wait for response
103
- const confirmToolCall = async (toolName, args) => {
104
- const id = `confirm_${++confirmCounter}`;
105
- write({ type: 'request', id, method: 'confirmTool', params: { name: toolName, args } });
106
- return new Promise(resolve => {
107
- let resolved = false;
108
- const safeResolve = (v) => { if (!resolved) {
109
- resolved = true;
110
- resolve(v);
111
- } };
112
- pendingConfirms.set(id, { resolve: safeResolve });
113
- const timer = setTimeout(() => {
114
- if (pendingConfirms.has(id)) {
115
- pendingConfirms.delete(id);
116
- safeResolve(false);
117
- }
118
- }, 60_000);
119
- pendingConfirms.set(id, { resolve: (v) => { clearTimeout(timer); safeResolve(v); } });
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 permissionMode = (process.env.SCALIX_PERMISSION_MODE || config.permissionMode || 'default');
123
- const agent = createAgent({
124
- apiKey,
125
- baseUrl,
126
- provider: active.provider,
127
- cloudApiKey: active.cloudApiKey,
128
- cloudBaseUrl: active.cloudBaseUrl,
129
- model: selectedModels,
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
- write({ type: 'event', event: 'ready', data: { version: VERSION, tools: allTools.length, models: selectedModels, permissionMode } });
159
- // Register the account's Scalix Cloud platform tools (deploy, database, …).
160
- // Not awaited: the catalog fetch crosses the network and the extension's
161
- // connect handshake must not wait on it — the tools join the set when the
162
- // fetch lands. Without this call the GUI engine had NO scalix_* tools at
163
- // all, and models asked to deploy invented SDKs and CLI flags instead.
164
- void agent.refreshCloudTools()
165
- .then(status => { write({ type: 'event', event: 'cloud:status', data: status }); })
166
- .catch(err => { process.stderr.write(`IPC: cloud tools registration failed: ${err?.message || err}\n`); });
167
- // Process JSONL from stdin
168
- let busy = false;
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 request
180
- if (msg.type === 'response' && pendingConfirms.has(msg.id)) {
181
- const { resolve } = pendingConfirms.get(msg.id);
182
- pendingConfirms.delete(msg.id);
183
- resolve(msg.result?.allowed ?? false);
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
- if (busy) {
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(params.content, images);
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
- agent.abort();
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
- case 'clear':
221
- agent.clearHistory();
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
- // Same format rule as the headless REPL — the old handler accepted
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 r = await agent.compact();
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
- write({ type: 'response', id, result: { models: agent.getModels() } });
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 u = agent.getContextUsage();
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
- write({ type: 'response', id, result: { messages: agent.getMessages() } });
476
+ case 'getMessages': {
477
+ const session = resolveSession(params);
478
+ write({ type: 'response', id, result: { messages: session.agent.getMessages() } });
265
479
  break;
266
- case 'loadMessages':
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
- case 'setPermissionMode':
272
- if (params?.mode)
273
- agent.setPermissionMode(params.mode);
274
- write({ type: 'response', id, result: { mode: agent.getPermissionMode() } });
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
- closeBrowser().finally(() => process.exit(1));
547
+ shutdown().finally(() => process.exit(1));
287
548
  });
288
- rl.on('close', () => closeBrowser().finally(() => process.exit(0)));
549
+ rl.on('close', () => shutdown().finally(() => process.exit(0)));
289
550
  }