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