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.
- package/.env.example +5 -3
- package/CHANGELOG.md +24 -0
- package/LICENSE +0 -1
- package/README.md +47 -2
- package/dist/agent-presets.js +33 -0
- package/dist/agent.js +358 -42
- package/dist/api-client.js +12 -4
- package/dist/board-context.js +81 -0
- package/dist/browser-access.js +0 -0
- package/dist/cli.js +435 -94
- package/dist/cloud.js +95 -6
- package/dist/config.js +44 -5
- package/dist/constants.js +121 -19
- package/dist/context-builder.js +9 -1
- package/dist/feedback.js +51 -0
- package/dist/headless.js +44 -13
- package/dist/ipc-server.js +362 -99
- 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/settings.js +33 -2
- 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
|
-
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
|
-
|
|
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;
|
|
@@ -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, ...
|
|
145
|
+
const allTools = [...defaultTools, ...extraTools, ...memoryTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...pluginTools];
|
|
86
146
|
const instructions = await buildInstructions();
|
|
87
|
-
// Bidirectional confirm: send request to
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
155
|
-
if (msg.type === 'response' &&
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
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);
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
457
|
+
case 'getMessages': {
|
|
458
|
+
const session = resolveSession(params);
|
|
459
|
+
write({ type: 'response', id, result: { messages: session.agent.getMessages() } });
|
|
232
460
|
break;
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
516
|
+
shutdown().finally(() => process.exit(1));
|
|
254
517
|
});
|
|
255
|
-
rl.on('close', () =>
|
|
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
|
-
|
|
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', [
|