scalix 1.5.1 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -3
- package/LICENSE +0 -1
- package/README.md +47 -2
- package/dist/agent-presets.js +33 -0
- package/dist/agent.js +233 -18
- package/dist/api-client.js +4 -4
- package/dist/board-context.js +81 -0
- package/dist/browser-access.js +0 -0
- package/dist/cli.js +270 -45
- package/dist/cloud.js +1 -1
- package/dist/config.js +44 -5
- package/dist/constants.js +82 -11
- package/dist/context-builder.js +9 -1
- package/dist/feedback.js +51 -0
- package/dist/headless.js +22 -5
- package/dist/ipc-server.js +338 -108
- package/dist/keychain.js +34 -9
- package/dist/mcp.js +470 -28
- package/dist/openrouter-oauth.js +149 -0
- package/dist/project-index.js +46 -23
- package/dist/sandbox.js +115 -27
- package/dist/sessions.js +12 -0
- package/dist/slash-commands.js +6 -0
- package/dist/sub-agent.js +3 -2
- package/dist/tools-browser.js +87 -16
- package/dist/tools-extra.js +15 -3
- package/dist/tools-memory.js +74 -0
- package/dist/tools-web.js +67 -6
- package/dist/tools.js +15 -14
- package/dist/workspace-trust.js +7 -1
- package/package.json +3 -1
package/dist/ipc-server.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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, ...
|
|
145
|
+
const allTools = [...defaultTools, ...extraTools, ...memoryTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...pluginTools];
|
|
101
146
|
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
|
-
|
|
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
|
|
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 }));
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
|
|
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
|
|
180
|
-
if (msg.type === 'response' &&
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
resolve(msg.result
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
457
|
+
case 'getMessages': {
|
|
458
|
+
const session = resolveSession(params);
|
|
459
|
+
write({ type: 'response', id, result: { messages: session.agent.getMessages() } });
|
|
265
460
|
break;
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
516
|
+
shutdown().finally(() => process.exit(1));
|
|
287
517
|
});
|
|
288
|
-
rl.on('close', () =>
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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', [
|