scalix 1.5.0 → 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
- import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS, ENV_MODEL, fetchModelSpecs, registerUserModels, VERSION } from './constants.js';
26
- import { loadMemory, autoDetectConventions, saveMemory } from './project-memory.js';
35
+ import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS, ENV_MODEL, fetchModelSpecs, registerUserModels, normalizeModelId, VERSION } from './constants.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;
@@ -53,6 +99,14 @@ export async function runIpcServer() {
53
99
  console.log = (...a) => { process.stderr.write(a.map(String).join(' ') + '\n'); };
54
100
  console.error = (...a) => { process.stderr.write(a.map(String).join(' ') + '\n'); };
55
101
  console.warn = (...a) => { process.stderr.write(a.map(String).join(' ') + '\n'); };
102
+ // A stray rejection from a streaming/network path must not kill the engine:
103
+ // the extension auto-reconnects, but the fresh process has lost the whole
104
+ // session, which reads as the agent "forgetting" its task mid-way. Log to
105
+ // stderr (the extension surfaces it) and keep serving.
106
+ process.on('unhandledRejection', (reason) => {
107
+ const detail = reason instanceof Error ? (reason.stack || reason.message) : String(reason);
108
+ process.stderr.write(`IPC: unhandled rejection (kept alive): ${detail}\n`);
109
+ });
56
110
  const config = await loadConfig();
57
111
  const active = resolveActiveProvider(config);
58
112
  const apiKey = active.apiKey;
@@ -69,12 +123,18 @@ export async function runIpcServer() {
69
123
  ? process.env[ENV_MODEL].split(',').map(m => m.trim()).filter(Boolean)
70
124
  : [];
71
125
  const selectedModels = envModels.length > 0
72
- ? envModels
126
+ ? envModels.map(normalizeModelId)
73
127
  : config.models.length > 0 ? config.models : DEFAULT_MODELS;
128
+ if (selectedModels.length === 0) {
129
+ // Structured, like the missing-key path above — without this the Agent
130
+ // constructor throw escaped as plain text and the extension saw garbage
131
+ // instead of a protocol-level error.
132
+ write({ type: 'event', event: 'error', data: { message: 'No models configured. Set SCALIX_MODEL or pick a model in setup.' } });
133
+ process.exit(1);
134
+ }
74
135
  const baseUrl = active.baseUrl;
75
136
  if (active.provider === 'scalix')
76
137
  fetchModelSpecs(baseUrl, apiKey).catch(() => { });
77
- const mcpTools = loadMcpServices();
78
138
  // Only discover/load project-local plugins in a trusted workspace — plugins
79
139
  // dynamically import and execute code from the project directory.
80
140
  const plugins = isWorkspaceTrusted() ? await discoverPlugins(process.cwd()) : [];
@@ -82,67 +142,128 @@ export async function runIpcServer() {
82
142
  process.stderr.write('IPC: project plugins skipped (untrusted workspace)\n');
83
143
  }
84
144
  const pluginTools = getPluginTools(plugins);
85
- const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools, ...pluginTools];
145
+ const allTools = [...defaultTools, ...extraTools, ...memoryTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...pluginTools];
86
146
  const instructions = await buildInstructions();
87
- // Bidirectional confirm: send request to VS Code, wait for response
88
- const confirmToolCall = async (toolName, args) => {
89
- const id = `confirm_${++confirmCounter}`;
90
- write({ type: 'request', id, method: 'confirmTool', params: { name: toolName, args } });
91
- return new Promise(resolve => {
92
- let resolved = false;
93
- const safeResolve = (v) => { if (!resolved) {
94
- resolved = true;
95
- resolve(v);
96
- } };
97
- pendingConfirms.set(id, { resolve: safeResolve });
98
- const timer = setTimeout(() => {
99
- if (pendingConfirms.has(id)) {
100
- pendingConfirms.delete(id);
101
- safeResolve(false);
102
- }
103
- }, 60_000);
104
- 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,
105
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;
106
227
  };
107
- const permissionMode = (process.env.SCALIX_PERMISSION_MODE || config.permissionMode || 'default');
108
- const agent = createAgent({
109
- apiKey,
110
- baseUrl,
111
- provider: active.provider,
112
- model: selectedModels,
113
- instructions,
114
- tools: allTools,
115
- permissionMode,
116
- maxSteps: limits.maxSteps,
117
- maxMessages: limits.maxMessages,
118
- contextManager: {
119
- maxContextTokens: limits.maxContextTokens,
120
- compressionThreshold: limits.compressionThreshold,
121
- maxToolResultTokens: limits.maxToolResultTokens,
122
- },
123
- instructionLoader: buildInstructions,
124
- confirmToolCall,
125
- autoVerify: true,
126
- });
127
- // Forward all agent events as JSONL
128
- agent.on('stream:delta', (d, a) => write({ type: 'event', event: 'stream:delta', data: { delta: d, accumulated: a } }));
129
- agent.on('stream:end', t => write({ type: 'event', event: 'stream:end', data: { text: t } }));
130
- agent.on('tool:call', (n, a) => write({ type: 'event', event: 'tool:call', data: { name: n, args: a } }));
131
- agent.on('tool:result', (n, r) => write({ type: 'event', event: 'tool:result', data: { name: n, result: r } }));
132
- agent.on('tool:blocked', (n, r) => write({ type: 'event', event: 'tool:blocked', data: { name: n, reason: r } }));
133
- agent.on('thinking:start', () => write({ type: 'event', event: 'thinking:start' }));
134
- agent.on('thinking:end', () => write({ type: 'event', event: 'thinking:end' }));
135
- agent.on('usage', d => write({ type: 'event', event: 'usage', data: d }));
136
- agent.on('error', e => write({ type: 'event', event: 'error', data: { message: e.message } }));
137
- agent.on('context:compress', i => write({ type: 'event', event: 'context:compress', data: i }));
138
- 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');
139
236
  // Signal ready with capability info. `version` lets the extension detect
140
237
  // engine/extension drift (a globally-installed CLI can lag the bundled one).
141
- write({ type: 'event', event: 'ready', data: { version: VERSION, tools: allTools.length, models: selectedModels, permissionMode } });
142
- // Process JSONL from stdin
143
- 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
144
261
  const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
145
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
+ }
146
267
  let msg;
147
268
  try {
148
269
  msg = JSON.parse(line);
@@ -151,11 +272,11 @@ export async function runIpcServer() {
151
272
  process.stderr.write(`IPC: invalid JSON: ${line.slice(0, 100)}\n`);
152
273
  return;
153
274
  }
154
- // Response to server-initiated confirmTool request
155
- if (msg.type === 'response' && pendingConfirms.has(msg.id)) {
156
- const { resolve } = pendingConfirms.get(msg.id);
157
- pendingConfirms.delete(msg.id);
158
- 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);
159
280
  return;
160
281
  }
161
282
  if (msg.type !== 'request')
@@ -163,55 +284,160 @@ export async function runIpcServer() {
163
284
  const { id, method, params } = msg;
164
285
  try {
165
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;
166
342
  case 'send': {
167
343
  if (!params?.content) {
168
344
  write({ type: 'response', id, error: { message: 'Missing content' } });
169
345
  break;
170
346
  }
171
- 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) {
172
356
  write({ type: 'response', id, error: { message: 'Agent is busy' } });
173
357
  break;
174
358
  }
175
- busy = true;
176
- write({ type: 'event', event: 'status', data: { status: 'thinking' } });
359
+ session.busy = true;
360
+ write({ type: 'event', event: 'status', data: { status: 'thinking' }, sessionId: session.id });
177
361
  try {
362
+ const content = params.content;
363
+ const correction = detectCorrection(content);
364
+ if (correction)
365
+ addNote(process.cwd(), correction).catch(() => { });
178
366
  const images = params.images;
179
- const text = await agent.send(params.content, images);
367
+ const text = await session.agent.send(content, images);
180
368
  write({ type: 'response', id, result: { text } });
181
369
  }
182
370
  catch (err) {
183
371
  write({ type: 'response', id, error: { message: err.message } });
184
372
  }
185
373
  finally {
186
- busy = false;
187
- 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' } });
188
392
  }
189
393
  break;
190
394
  }
191
- case 'abort':
192
- agent.abort();
395
+ case 'abort': {
396
+ const session = resolveSession(params);
397
+ session.agent.abort();
193
398
  write({ type: 'response', id, result: { success: true } });
194
399
  break;
195
- case 'clear':
196
- agent.clearHistory();
400
+ }
401
+ case 'clear': {
402
+ const session = resolveSession(params);
403
+ session.agent.clearHistory();
197
404
  write({ type: 'response', id, result: { success: true } });
198
405
  break;
199
- case 'setModel':
200
- if (params?.model)
201
- agent.setModel(params.model);
202
- write({ type: 'response', id, result: { model: agent.getModels()[0] } });
406
+ }
407
+ case 'setModel': {
408
+ const session = resolveSession(params);
409
+ const requested = typeof params?.model === 'string' ? params.model.trim() : '';
410
+ if (!requested || !/^[a-zA-Z0-9][\w./:@-]*$/.test(requested)) {
411
+ write({ type: 'response', id, error: { message: `Invalid model id: "${requested || '(empty)'}"` } });
412
+ break;
413
+ }
414
+ session.agent.setModel(normalizeModelId(requested));
415
+ write({ type: 'response', id, result: { model: session.agent.getModels()[0] } });
203
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
+ }
427
+ break;
428
+ }
204
429
  case 'compact': {
205
- const r = await agent.compact();
430
+ const session = resolveSession(params);
431
+ const r = await session.agent.compact();
206
432
  write({ type: 'response', id, result: { message: r } });
207
433
  break;
208
434
  }
209
- case 'getModels':
210
- 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() } });
211
438
  break;
439
+ }
212
440
  case 'registerModels': {
213
- // A model spec added in the client mid-session must reach the agent
214
- // now; the config file is only read at engine start.
215
441
  const specs = params?.models;
216
442
  if (specs)
217
443
  registerUserModels(specs);
@@ -219,27 +445,61 @@ export async function runIpcServer() {
219
445
  break;
220
446
  }
221
447
  case 'getContextInfo': {
222
- const u = agent.getContextUsage();
448
+ const session = resolveSession(params);
449
+ const u = session.agent.getContextUsage();
223
450
  write({ type: 'response', id, result: {
224
451
  estimatedTokens: u.tokens, maxTokens: u.maxTokens,
225
- messageCount: agent.getMessages().length, utilization: u.percentage / 100,
226
- breakdown: agent.getContextBreakdown(),
452
+ messageCount: session.agent.getMessages().length, utilization: u.percentage / 100,
453
+ breakdown: session.agent.getContextBreakdown(),
227
454
  } });
228
455
  break;
229
456
  }
230
- case 'getMessages':
231
- 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() } });
232
460
  break;
233
- case 'loadMessages':
461
+ }
462
+ case 'loadMessages': {
463
+ const session = resolveSession(params);
234
464
  if (params?.messages)
235
- agent.loadMessages(params.messages);
465
+ session.agent.loadMessages(params.messages);
236
466
  write({ type: 'response', id, result: { success: true } });
237
467
  break;
238
- 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);
239
498
  if (params?.mode)
240
- agent.setPermissionMode(params.mode);
241
- write({ type: 'response', id, result: { mode: agent.getPermissionMode() } });
499
+ session.agent.setPermissionMode(params.mode);
500
+ write({ type: 'response', id, result: { mode: session.agent.getPermissionMode() } });
242
501
  break;
502
+ }
243
503
  default:
244
504
  write({ type: 'response', id, error: { message: `Unknown method: ${method}` } });
245
505
  }
@@ -248,9 +508,12 @@ export async function runIpcServer() {
248
508
  write({ type: 'response', id, error: { message: err.message } });
249
509
  }
250
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()]);
251
514
  rl.on('error', (err) => {
252
515
  process.stderr.write(`IPC readline error: ${err.message}\n`);
253
- closeBrowser().finally(() => process.exit(1));
516
+ shutdown().finally(() => process.exit(1));
254
517
  });
255
- rl.on('close', () => closeBrowser().finally(() => process.exit(0)));
518
+ rl.on('close', () => shutdown().finally(() => process.exit(0)));
256
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', [