@yemi33/minions 0.1.1910 → 0.1.1911
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/engine/cc-worker-pool.js +512 -0
- package/package.json +1 -1
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/cc-worker-pool.js — Persistent per-tab Copilot worker pool (W-mp3ioyh300056f36).
|
|
3
|
+
*
|
|
4
|
+
* Sub-task B of W-mp2w003600196c51 ("CC perf: persistent per-tab Copilot
|
|
5
|
+
* worker"). Wraps `copilot --acp` (Agent Client Protocol — long-lived JSON-RPC
|
|
6
|
+
* server over stdin/stdout) so that a Command Center / doc-chat tab keeps a
|
|
7
|
+
* single warm Copilot process across N turns instead of paying the
|
|
8
|
+
* spawn + initialize + session/new (~14 s) cost on every turn.
|
|
9
|
+
*
|
|
10
|
+
* Public API:
|
|
11
|
+
*
|
|
12
|
+
* pool.getSession({ tabId, model, effort, mcpServers, systemPromptHash, cwd })
|
|
13
|
+
* → SessionHandle: {
|
|
14
|
+
* sessionId,
|
|
15
|
+
* stream(promptText, { onChunk, onDone, onError, signal, systemPromptText }) → Promise,
|
|
16
|
+
* cancel(), // session/cancel notification
|
|
17
|
+
* close(), // tear down this tab
|
|
18
|
+
* }
|
|
19
|
+
* pool.closeTab(tabId) // cancel inflight, kill proc, drop entry
|
|
20
|
+
* pool.shutdown() // close every tab and stop the idle reaper
|
|
21
|
+
*
|
|
22
|
+
* Internal model (one process per tab):
|
|
23
|
+
*
|
|
24
|
+
* tabs: Map<tabId, Worker> where Worker = {
|
|
25
|
+
* proc, sessionId, model, effort, mcpServers, mcpServersHash,
|
|
26
|
+
* systemPromptHash, cwd, lastUsedAt,
|
|
27
|
+
* pending: Map<id, { resolve, reject }>, inflight, readBuf, nextReqId
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* TODO (sub-task C/D follow-up): multiplex N tabs onto one ACP process
|
|
31
|
+
* (`session/new` per tab, single `proc`). ACP supports it; left as a
|
|
32
|
+
* follow-up perf win once one-process-per-tab is proven in the dashboard.
|
|
33
|
+
*
|
|
34
|
+
* Eviction rules:
|
|
35
|
+
* - mcpServers changes → kill proc entirely + respawn
|
|
36
|
+
* - systemPromptHash change → keep proc, send a fresh `session/new`
|
|
37
|
+
* - model / effort change → no eviction (state updated in place)
|
|
38
|
+
* - closeTab → `session/cancel` if inflight, then kill proc
|
|
39
|
+
* - Idle > 10 min → kill proc (reaper sweep every 60 s)
|
|
40
|
+
*
|
|
41
|
+
* Auth precheck: when `copilot --acp` exits before the initialize handshake
|
|
42
|
+
* completes, surface
|
|
43
|
+
* "copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login
|
|
44
|
+
* is complete (...)"
|
|
45
|
+
* so the dashboard can surface a precise reason instead of a generic timeout.
|
|
46
|
+
*
|
|
47
|
+
* No new npm deps — Node built-ins only (child_process, events, timers, crypto).
|
|
48
|
+
*
|
|
49
|
+
* See `notes/archive/2026-05-13-ripley-W-mp3h2wq2000e64f7-2026-05-13-0301.md`
|
|
50
|
+
* for the ACP protocol probe (line framing, request/response correlation,
|
|
51
|
+
* cold-vs-warm timings, cancel semantics, configOptions surface).
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
const { spawn } = require('child_process');
|
|
55
|
+
const crypto = require('crypto');
|
|
56
|
+
|
|
57
|
+
// 10 minutes — matches the work-item spec.
|
|
58
|
+
const IDLE_REAPER_MS = 10 * 60 * 1000;
|
|
59
|
+
// Reaper sweep cadence. Not exposed as ENGINE_DEFAULTS to keep the pool
|
|
60
|
+
// dependency-free; sub-task C/D can plumb a config knob if needed.
|
|
61
|
+
const REAPER_INTERVAL_MS = 60 * 1000;
|
|
62
|
+
|
|
63
|
+
// Test seam — every external side effect goes through `_internals` so
|
|
64
|
+
// test/unit/cc-worker-pool.test.js can stub spawn/now/killImmediate.
|
|
65
|
+
const _internals = {
|
|
66
|
+
spawnAcp({ cwd } = {}) {
|
|
67
|
+
return spawn(
|
|
68
|
+
'copilot',
|
|
69
|
+
['--acp', '--allow-all', '--max-autopilot-continues', '1'],
|
|
70
|
+
{
|
|
71
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
72
|
+
cwd: cwd || process.cwd(),
|
|
73
|
+
windowsHide: true,
|
|
74
|
+
// Don't pass shell:true — Copilot is a binary on PATH and a shell
|
|
75
|
+
// wrapper would swallow stdin/stdout framing.
|
|
76
|
+
}
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
killImmediate(proc) {
|
|
80
|
+
// Lazy require so unit tests that don't load engine/shared still work.
|
|
81
|
+
try {
|
|
82
|
+
const shared = require('./shared');
|
|
83
|
+
shared.killImmediate(proc);
|
|
84
|
+
} catch {
|
|
85
|
+
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
now() { return Date.now(); },
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const _tabs = new Map();
|
|
92
|
+
let _reaperTimer = null;
|
|
93
|
+
|
|
94
|
+
function _hashMcpServers(mcpServers) {
|
|
95
|
+
// Stable hash via JSON.stringify; mcpServers is an array of plain objects
|
|
96
|
+
// in practice (name/command/env) so the natural key order is fine.
|
|
97
|
+
const json = mcpServers == null ? '[]' : JSON.stringify(mcpServers);
|
|
98
|
+
return crypto.createHash('sha256').update(json).digest('hex');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
class Worker {
|
|
102
|
+
constructor({ tabId, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd }) {
|
|
103
|
+
this.tabId = tabId;
|
|
104
|
+
this.model = model;
|
|
105
|
+
this.effort = effort;
|
|
106
|
+
this.mcpServers = mcpServers || [];
|
|
107
|
+
this.mcpServersHash = mcpServersHash;
|
|
108
|
+
this.systemPromptHash = systemPromptHash;
|
|
109
|
+
this.cwd = cwd || process.cwd();
|
|
110
|
+
|
|
111
|
+
this.proc = null;
|
|
112
|
+
this.sessionId = null;
|
|
113
|
+
this.pending = new Map(); // id → { resolve, reject }
|
|
114
|
+
this.inflight = null; // current { id, sessionId, onChunk, onDone, onError, signal, signalHandler, settled }
|
|
115
|
+
this.readBuf = '';
|
|
116
|
+
this.nextReqId = 1;
|
|
117
|
+
this.lastUsedAt = _internals.now();
|
|
118
|
+
this.killed = false;
|
|
119
|
+
this.spawnError = null;
|
|
120
|
+
this.firstSystemPromptSent = false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Spawn + initialize handshake ────────────────────────────────────────
|
|
124
|
+
async _spawnAndInit() {
|
|
125
|
+
let proc;
|
|
126
|
+
try {
|
|
127
|
+
proc = _internals.spawnAcp({ cwd: this.cwd });
|
|
128
|
+
} catch (err) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (${err.message})`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
this.proc = proc;
|
|
134
|
+
|
|
135
|
+
proc.stdout.on('data', (chunk) => this._onStdout(chunk));
|
|
136
|
+
// stderr is captured for diagnostics but we don't act on it.
|
|
137
|
+
if (proc.stderr) proc.stderr.on('data', () => { /* ignore */ });
|
|
138
|
+
|
|
139
|
+
// Race the init handshake against an early process exit so an auth
|
|
140
|
+
// failure (which kills the daemon before initialize completes) surfaces
|
|
141
|
+
// a clear error instead of hanging forever.
|
|
142
|
+
let earlyExitReject;
|
|
143
|
+
const earlyExitPromise = new Promise((_, reject) => {
|
|
144
|
+
earlyExitReject = (code) => {
|
|
145
|
+
this.killed = true;
|
|
146
|
+
const err = new Error(
|
|
147
|
+
`copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (exit ${code})`
|
|
148
|
+
);
|
|
149
|
+
this.spawnError = err;
|
|
150
|
+
this._failAllPending(err);
|
|
151
|
+
reject(err);
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
const earlyExitHandler = (code) => earlyExitReject(code);
|
|
155
|
+
proc.once('exit', earlyExitHandler);
|
|
156
|
+
|
|
157
|
+
const errorHandler = (err) => {
|
|
158
|
+
const wrapped = new Error(
|
|
159
|
+
`copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (${err.message})`
|
|
160
|
+
);
|
|
161
|
+
this.spawnError = wrapped;
|
|
162
|
+
this.killed = true;
|
|
163
|
+
this._failAllPending(wrapped);
|
|
164
|
+
};
|
|
165
|
+
proc.on('error', errorHandler);
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
await Promise.race([
|
|
169
|
+
this._call('initialize', { protocolVersion: 1, clientCapabilities: {} }),
|
|
170
|
+
earlyExitPromise,
|
|
171
|
+
]);
|
|
172
|
+
const result = await Promise.race([
|
|
173
|
+
this._call('session/new', { cwd: this.cwd, mcpServers: this.mcpServers }),
|
|
174
|
+
earlyExitPromise,
|
|
175
|
+
]);
|
|
176
|
+
this.sessionId = result && result.sessionId;
|
|
177
|
+
if (!this.sessionId) {
|
|
178
|
+
throw new Error('copilot --acp failed -- session/new returned no sessionId');
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
// Either the handshake finished (swap to a persistent exit handler that
|
|
182
|
+
// just marks killed) or it failed (proc is dying anyway).
|
|
183
|
+
proc.removeListener('exit', earlyExitHandler);
|
|
184
|
+
}
|
|
185
|
+
proc.on('exit', () => {
|
|
186
|
+
this.killed = true;
|
|
187
|
+
const err = new Error('copilot --acp process exited');
|
|
188
|
+
this._failAllPending(err);
|
|
189
|
+
// Settle inflight too if it's still hanging
|
|
190
|
+
if (this.inflight && !this.inflight.settled) {
|
|
191
|
+
const cb = this.inflight.onError;
|
|
192
|
+
this.inflight.settled = true;
|
|
193
|
+
this.inflight = null;
|
|
194
|
+
if (cb) try { cb(err); } catch { /* swallow */ }
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
_failAllPending(err) {
|
|
200
|
+
for (const { reject } of this.pending.values()) {
|
|
201
|
+
try { reject(err); } catch { /* swallow */ }
|
|
202
|
+
}
|
|
203
|
+
this.pending.clear();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Newline-delimited JSON-RPC line framing ────────────────────────────
|
|
207
|
+
_onStdout(chunk) {
|
|
208
|
+
this.readBuf += chunk.toString('utf8');
|
|
209
|
+
let i;
|
|
210
|
+
while ((i = this.readBuf.indexOf('\n')) >= 0) {
|
|
211
|
+
const line = this.readBuf.slice(0, i).trim();
|
|
212
|
+
this.readBuf = this.readBuf.slice(i + 1);
|
|
213
|
+
if (!line) continue;
|
|
214
|
+
let obj;
|
|
215
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
216
|
+
this._handleMessage(obj);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
_handleMessage(obj) {
|
|
221
|
+
// Response (matches an outbound id)
|
|
222
|
+
if (obj.id != null && this.pending.has(obj.id)) {
|
|
223
|
+
const { resolve, reject } = this.pending.get(obj.id);
|
|
224
|
+
this.pending.delete(obj.id);
|
|
225
|
+
if (obj.error) reject(new Error(obj.error.message || 'ACP error'));
|
|
226
|
+
else resolve(obj.result);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// Notification (no id) — only `session/update` matters for streaming.
|
|
230
|
+
if (obj.method === 'session/update' && obj.params && this.inflight) {
|
|
231
|
+
if (obj.params.sessionId !== this.inflight.sessionId) return;
|
|
232
|
+
const update = obj.params.update;
|
|
233
|
+
if (!update) return;
|
|
234
|
+
if (update.sessionUpdate === 'agent_message_chunk') {
|
|
235
|
+
const text = _extractChunkText(update.content);
|
|
236
|
+
if (text && this.inflight.onChunk) {
|
|
237
|
+
try { this.inflight.onChunk(text); } catch { /* swallow */ }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Other update kinds (available_commands_update, tool_call, ...) are
|
|
241
|
+
// ignored in sub-task B. Sub-task C/D will surface tool_call to the
|
|
242
|
+
// dashboard's onToolUse callback.
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
_writeFrame(obj) {
|
|
247
|
+
if (this.killed) return;
|
|
248
|
+
if (!this.proc || !this.proc.stdin || this.proc.stdin.destroyed) return;
|
|
249
|
+
try { this.proc.stdin.write(JSON.stringify(obj) + '\n'); }
|
|
250
|
+
catch { /* pipe may be broken; exit handler will fire */ }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
_call(method, params) {
|
|
254
|
+
return new Promise((resolve, reject) => {
|
|
255
|
+
const id = this.nextReqId++;
|
|
256
|
+
this.pending.set(id, { resolve, reject });
|
|
257
|
+
this._writeFrame({ jsonrpc: '2.0', id, method, params });
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
_notify(method, params) {
|
|
262
|
+
this._writeFrame({ jsonrpc: '2.0', method, params });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Stream a single turn ───────────────────────────────────────────────
|
|
266
|
+
stream(promptText, opts = {}) {
|
|
267
|
+
const { onChunk, onDone, onError, signal, systemPromptText } = opts;
|
|
268
|
+
if (this.killed) {
|
|
269
|
+
const err = new Error('cc-worker-pool: tab is closed');
|
|
270
|
+
if (onError) try { onError(err); } catch { /* swallow */ }
|
|
271
|
+
return Promise.resolve();
|
|
272
|
+
}
|
|
273
|
+
if (this.inflight) {
|
|
274
|
+
const err = new Error('cc-worker-pool: a prompt is already in flight on this tab');
|
|
275
|
+
if (onError) try { onError(err); } catch { /* swallow */ }
|
|
276
|
+
return Promise.resolve();
|
|
277
|
+
}
|
|
278
|
+
this.lastUsedAt = _internals.now();
|
|
279
|
+
|
|
280
|
+
// Inject the <system> block on the first prompt of a session — Copilot
|
|
281
|
+
// ACP has no in-protocol "set system prompt" method (per Ripley's note),
|
|
282
|
+
// so the per-session adapter pattern of <system>...</system> prepended
|
|
283
|
+
// to the first user message remains correct.
|
|
284
|
+
let prompt = promptText;
|
|
285
|
+
if (!this.firstSystemPromptSent && systemPromptText) {
|
|
286
|
+
prompt = `<system>\n${systemPromptText}\n</system>\n\n${promptText}`;
|
|
287
|
+
}
|
|
288
|
+
this.firstSystemPromptSent = true;
|
|
289
|
+
|
|
290
|
+
const id = this.nextReqId++;
|
|
291
|
+
const inflight = {
|
|
292
|
+
id,
|
|
293
|
+
sessionId: this.sessionId,
|
|
294
|
+
onChunk,
|
|
295
|
+
onDone,
|
|
296
|
+
onError,
|
|
297
|
+
signal,
|
|
298
|
+
signalHandler: null,
|
|
299
|
+
settled: false,
|
|
300
|
+
};
|
|
301
|
+
this.inflight = inflight;
|
|
302
|
+
|
|
303
|
+
if (signal && typeof signal.addEventListener === 'function') {
|
|
304
|
+
inflight.signalHandler = () => this.cancel();
|
|
305
|
+
try { signal.addEventListener('abort', inflight.signalHandler); } catch { /* swallow */ }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return new Promise((resolve) => {
|
|
309
|
+
const finalize = (err, result) => {
|
|
310
|
+
if (inflight.settled) return;
|
|
311
|
+
inflight.settled = true;
|
|
312
|
+
if (this.inflight === inflight) this.inflight = null;
|
|
313
|
+
this.lastUsedAt = _internals.now();
|
|
314
|
+
if (signal && inflight.signalHandler) {
|
|
315
|
+
try { signal.removeEventListener('abort', inflight.signalHandler); }
|
|
316
|
+
catch { /* swallow */ }
|
|
317
|
+
}
|
|
318
|
+
if (err) {
|
|
319
|
+
if (onError) try { onError(err); } catch { /* swallow */ }
|
|
320
|
+
} else {
|
|
321
|
+
if (onDone) try { onDone(result); } catch { /* swallow */ }
|
|
322
|
+
}
|
|
323
|
+
resolve();
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
this.pending.set(id, {
|
|
327
|
+
resolve: (result) => finalize(null, result),
|
|
328
|
+
reject: (err) => finalize(err, null),
|
|
329
|
+
});
|
|
330
|
+
this._writeFrame({
|
|
331
|
+
jsonrpc: '2.0',
|
|
332
|
+
id,
|
|
333
|
+
method: 'session/prompt',
|
|
334
|
+
params: { sessionId: this.sessionId, prompt: [{ type: 'text', text: prompt }] },
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
cancel() {
|
|
340
|
+
if (!this.inflight || this.killed) return;
|
|
341
|
+
// session/cancel is a JSON-RPC notification (no id). The in-flight
|
|
342
|
+
// session/prompt response will arrive with stopReason=cancelled per the
|
|
343
|
+
// ACP spec — we don't synthesize a fake response client-side.
|
|
344
|
+
this._notify('session/cancel', { sessionId: this.inflight.sessionId });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async newSession({ mcpServers, systemPromptHash }) {
|
|
348
|
+
// Cancel any inflight before swapping the underlying session.
|
|
349
|
+
if (this.inflight) {
|
|
350
|
+
this.cancel();
|
|
351
|
+
// Wait briefly for the cancelled response to settle so we don't leak
|
|
352
|
+
// a stale inflight reference into the new session.
|
|
353
|
+
const inflight = this.inflight;
|
|
354
|
+
await new Promise((resolve) => {
|
|
355
|
+
const start = _internals.now();
|
|
356
|
+
const wait = () => {
|
|
357
|
+
if (!inflight || inflight.settled || _internals.now() - start > 1000) resolve();
|
|
358
|
+
else setTimeout(wait, 5);
|
|
359
|
+
};
|
|
360
|
+
wait();
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
const result = await this._call('session/new', {
|
|
364
|
+
cwd: this.cwd,
|
|
365
|
+
mcpServers: mcpServers || [],
|
|
366
|
+
});
|
|
367
|
+
this.sessionId = result && result.sessionId;
|
|
368
|
+
this.systemPromptHash = systemPromptHash;
|
|
369
|
+
this.firstSystemPromptSent = false;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
close() {
|
|
373
|
+
if (this.killed) return;
|
|
374
|
+
this.killed = true;
|
|
375
|
+
// Best-effort cancel of an inflight prompt so the daemon doesn't keep
|
|
376
|
+
// generating into a torn-down session before we kill the proc.
|
|
377
|
+
if (this.inflight) {
|
|
378
|
+
try { this._notify('session/cancel', { sessionId: this.inflight.sessionId }); }
|
|
379
|
+
catch { /* swallow */ }
|
|
380
|
+
}
|
|
381
|
+
if (this.proc) {
|
|
382
|
+
try { _internals.killImmediate(this.proc); } catch { /* already dead */ }
|
|
383
|
+
}
|
|
384
|
+
this._failAllPending(new Error('cc-worker-pool: worker closed'));
|
|
385
|
+
if (this.inflight && !this.inflight.settled) {
|
|
386
|
+
const cb = this.inflight.onError;
|
|
387
|
+
this.inflight.settled = true;
|
|
388
|
+
this.inflight = null;
|
|
389
|
+
if (cb) try { cb(new Error('cc-worker-pool: worker closed')); } catch { /* swallow */ }
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function _extractChunkText(content) {
|
|
395
|
+
if (content == null) return '';
|
|
396
|
+
if (typeof content === 'string') return content;
|
|
397
|
+
if (Array.isArray(content)) {
|
|
398
|
+
let out = '';
|
|
399
|
+
for (const block of content) {
|
|
400
|
+
if (block && typeof block.text === 'string') out += block.text;
|
|
401
|
+
}
|
|
402
|
+
return out;
|
|
403
|
+
}
|
|
404
|
+
if (typeof content === 'object' && typeof content.text === 'string') return content.text;
|
|
405
|
+
return '';
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ── Public API ────────────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
async function getSession({ tabId, model, effort, mcpServers, systemPromptHash, cwd } = {}) {
|
|
411
|
+
if (!tabId) throw new Error('cc-worker-pool.getSession: tabId is required');
|
|
412
|
+
const mcpServersHash = _hashMcpServers(mcpServers);
|
|
413
|
+
let worker = _tabs.get(tabId);
|
|
414
|
+
|
|
415
|
+
if (worker) {
|
|
416
|
+
if (worker.killed) {
|
|
417
|
+
_tabs.delete(tabId);
|
|
418
|
+
worker = null;
|
|
419
|
+
} else if (worker.mcpServersHash !== mcpServersHash) {
|
|
420
|
+
// mcpServers shape changed → must respawn the proc; the daemon
|
|
421
|
+
// resolves MCP server config at process boot, not per session.
|
|
422
|
+
_tabs.delete(tabId);
|
|
423
|
+
worker.close();
|
|
424
|
+
worker = null;
|
|
425
|
+
} else if (worker.systemPromptHash !== systemPromptHash) {
|
|
426
|
+
// System prompt changed → keep the warm process, drop the session
|
|
427
|
+
// and create a fresh one. Saves the ~2.1 s initialize handshake.
|
|
428
|
+
await worker.newSession({ mcpServers, systemPromptHash });
|
|
429
|
+
worker.model = model;
|
|
430
|
+
worker.effort = effort;
|
|
431
|
+
worker.lastUsedAt = _internals.now();
|
|
432
|
+
} else {
|
|
433
|
+
// Warm reuse — only update bookkeeping. model/effort changes on a
|
|
434
|
+
// warm session are tracked here but not pushed via configOptions in
|
|
435
|
+
// sub-task B; sub-task C/D will wire that to ACP `session/configure`
|
|
436
|
+
// (or equivalent) once the dashboard exposes the toggle live.
|
|
437
|
+
worker.model = model;
|
|
438
|
+
worker.effort = effort;
|
|
439
|
+
worker.lastUsedAt = _internals.now();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (!worker) {
|
|
444
|
+
worker = new Worker({
|
|
445
|
+
tabId, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd,
|
|
446
|
+
});
|
|
447
|
+
_tabs.set(tabId, worker);
|
|
448
|
+
try {
|
|
449
|
+
await worker._spawnAndInit();
|
|
450
|
+
} catch (err) {
|
|
451
|
+
_tabs.delete(tabId);
|
|
452
|
+
try { worker.close(); } catch { /* already torn down */ }
|
|
453
|
+
throw err;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
_ensureReaper();
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
sessionId: worker.sessionId,
|
|
461
|
+
stream: (promptText, opts) => worker.stream(promptText, opts),
|
|
462
|
+
cancel: () => worker.cancel(),
|
|
463
|
+
close: () => closeTab(tabId),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function closeTab(tabId) {
|
|
468
|
+
const worker = _tabs.get(tabId);
|
|
469
|
+
if (!worker) return;
|
|
470
|
+
_tabs.delete(tabId);
|
|
471
|
+
worker.close();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function shutdown() {
|
|
475
|
+
for (const worker of _tabs.values()) {
|
|
476
|
+
try { worker.close(); } catch { /* swallow */ }
|
|
477
|
+
}
|
|
478
|
+
_tabs.clear();
|
|
479
|
+
if (_reaperTimer) {
|
|
480
|
+
clearInterval(_reaperTimer);
|
|
481
|
+
_reaperTimer = null;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function _ensureReaper() {
|
|
486
|
+
if (_reaperTimer) return;
|
|
487
|
+
_reaperTimer = setInterval(_reapIdleTabs, REAPER_INTERVAL_MS);
|
|
488
|
+
if (typeof _reaperTimer.unref === 'function') _reaperTimer.unref();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function _reapIdleTabs() {
|
|
492
|
+
const now = _internals.now();
|
|
493
|
+
for (const [tabId, worker] of [..._tabs]) {
|
|
494
|
+
if (worker.inflight) continue;
|
|
495
|
+
if (now - worker.lastUsedAt > IDLE_REAPER_MS) {
|
|
496
|
+
_tabs.delete(tabId);
|
|
497
|
+
try { worker.close(); } catch { /* already torn down */ }
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
module.exports = {
|
|
503
|
+
getSession,
|
|
504
|
+
closeTab,
|
|
505
|
+
shutdown,
|
|
506
|
+
// Exposed for unit tests; engine code MUST go through the public API.
|
|
507
|
+
_internals,
|
|
508
|
+
_tabs,
|
|
509
|
+
_reapIdleTabs,
|
|
510
|
+
IDLE_REAPER_MS,
|
|
511
|
+
REAPER_INTERVAL_MS,
|
|
512
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1911",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|