@yemi33/minions 0.1.2372 → 0.1.2374
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/dashboard/js/settings.js +1 -0
- package/dashboard.js +127 -112
- package/docs/design-inbox-entries-schema.md +106 -0
- package/docs/live-checkout-mode.md +27 -0
- package/docs/runtime-adapters.md +103 -0
- package/engine/acp-transport.js +594 -0
- package/engine/agent-worker-pool.js +367 -0
- package/engine/cc-worker-pool.js +38 -563
- package/engine/cleanup.js +34 -19
- package/engine/cli.js +19 -0
- package/engine/db/migrations/019-inbox-entries.js +37 -0
- package/engine/inbox-store.js +276 -0
- package/engine/live-checkout.js +168 -0
- package/engine/pooled-agent-process.js +158 -0
- package/engine/runtimes/copilot.js +5 -0
- package/engine/shared.js +75 -1
- package/engine/timeout.js +51 -7
- package/engine.js +81 -7
- package/package.json +1 -1
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/acp-transport.js — Shared ACP (Agent Client Protocol) transport layer
|
|
3
|
+
* (P-4c6e8a72, extracted from engine/cc-worker-pool.js).
|
|
4
|
+
*
|
|
5
|
+
* `copilot --acp` is a long-lived JSON-RPC server over stdin/stdout. This
|
|
6
|
+
* module owns the protocol-level plumbing that is identical regardless of
|
|
7
|
+
* WHO is pooling the worker (a CC/doc-chat tab vs. a fleet agent dispatch):
|
|
8
|
+
*
|
|
9
|
+
* - process spawn (`spawnAcp`) — resolves the `copilot` binary via the
|
|
10
|
+
* runtime adapter and launches it with `--acp`.
|
|
11
|
+
* - newline-delimited JSON-RPC framing, request/response correlation, and
|
|
12
|
+
* `session/update` notification dispatch (the `Worker` class).
|
|
13
|
+
* - typed error envelope (`ERROR_CODES` / `typedError`).
|
|
14
|
+
* - ACP `session/update` content helpers (`extractChunkText`,
|
|
15
|
+
* `mapAcpToolCallToToolUse`) and the `session/new` params builder
|
|
16
|
+
* (`buildSessionNewParams`).
|
|
17
|
+
*
|
|
18
|
+
* Consumers (engine/cc-worker-pool.js — one persistent worker per CC/doc-chat
|
|
19
|
+
* tab, permanent ownership; engine/agent-worker-pool.js — a fixed-size pool of
|
|
20
|
+
* workers leased/released per fleet agent dispatch) own their OWN pooling
|
|
21
|
+
* policy (eviction rules, idle reaping, FIFO acquire queues, mcpServersHash
|
|
22
|
+
* matching strategy) on top of this shared transport. Neither consumer
|
|
23
|
+
* duplicates the JSON-RPC framing code — both `require('./acp-transport')`.
|
|
24
|
+
*
|
|
25
|
+
* `Worker` takes its OS-process side effects (`spawnAcp`, `killImmediate`,
|
|
26
|
+
* `now`) via constructor-injected `internals` rather than a module-level
|
|
27
|
+
* singleton, so two independent pools (CC's tab pool and the fleet's agent
|
|
28
|
+
* pool) can each stub their own `_internals` for tests without stepping on
|
|
29
|
+
* each other's module state.
|
|
30
|
+
*
|
|
31
|
+
* See `notes/archive/2026-05-13-ripley-W-mp3h2wq2000e64f7-2026-05-13-0301.md`
|
|
32
|
+
* for the original ACP protocol probe (line framing, request/response
|
|
33
|
+
* correlation, cold-vs-warm timings, cancel semantics, configOptions surface)
|
|
34
|
+
* and `notes/archive/2026-07-10-ripley-P-7a1c2e4f-2026-07-10-0141.md` for the
|
|
35
|
+
* follow-up spike confirming: cancel is safe to reuse (no eviction needed
|
|
36
|
+
* purely for a cancel), `stopReason` is `end_turn` even after a cancel on
|
|
37
|
+
* copilot CLI 1.0.70 (NOT `cancelled`), and `session/new`'s `mcpServers`
|
|
38
|
+
* param only accepts remote http/sse servers — local/stdio MCP servers are
|
|
39
|
+
* resolved from `$COPILOT_HOME/mcp-config.json` at process boot, so a
|
|
40
|
+
* changed local-server set requires a full respawn (not just a fresh
|
|
41
|
+
* `session/new`).
|
|
42
|
+
*
|
|
43
|
+
* No new npm deps — Node built-ins only (child_process, crypto).
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const { spawn } = require('child_process');
|
|
47
|
+
const crypto = require('crypto');
|
|
48
|
+
|
|
49
|
+
// W-mpmwxni2000c25c7-c — typed error codes the transport emits through every
|
|
50
|
+
// failure exit so a consumer (CC streaming handler / doc-chat pool wrapper /
|
|
51
|
+
// fleet agent-worker-pool / SSE writer) can render a structured error
|
|
52
|
+
// envelope instead of parsing the stderr string.
|
|
53
|
+
const ERROR_CODES = Object.freeze({
|
|
54
|
+
// spawn() threw synchronously OR the child process emitted an 'error'
|
|
55
|
+
// event (binary missing on PATH, exec failed, EPERM, etc.). Retriable
|
|
56
|
+
// because a transient PATH / fs glitch may recover.
|
|
57
|
+
WORKER_SPAWN_FAILED: 'worker-spawn-failed',
|
|
58
|
+
// The worker process exited DURING the ACP handshake (initialize or
|
|
59
|
+
// session/new) — usually `copilot login` is incomplete or the CLI
|
|
60
|
+
// version is too old. Also fires when session/new returns no
|
|
61
|
+
// sessionId. Retriable: the caller swaps to a fallback model / a re-auth
|
|
62
|
+
// may unblock the next attempt.
|
|
63
|
+
ACP_HANDSHAKE_FAILED: 'acp-handshake-failed',
|
|
64
|
+
// The worker process exited AFTER a successful handshake (the daemon
|
|
65
|
+
// died mid-turn). Retriable — the next call cold-spawns a fresh worker.
|
|
66
|
+
WORKER_DIED: 'worker-died',
|
|
67
|
+
// The consumer's per-turn timeout fired before the ACP session/prompt
|
|
68
|
+
// resolved. Owned by the consumer (the transport itself has no turn
|
|
69
|
+
// timeout) but exported here so all callers stringify the same constant.
|
|
70
|
+
// Retriable — most timeouts are transient.
|
|
71
|
+
CC_TURN_TIMEOUT: 'cc-turn-timeout',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Build a typed Error carrying the `{ message, code, retriable }` envelope
|
|
75
|
+
// fields the consumer expects. Plain Errors flow through unchanged; the
|
|
76
|
+
// helper only stamps the extra metadata. Keep retriable defaulting to
|
|
77
|
+
// `true` so a caller that forgets to set it still gets the safe default.
|
|
78
|
+
function typedError(message, code, retriable = true) {
|
|
79
|
+
const err = new Error(message);
|
|
80
|
+
err.code = code;
|
|
81
|
+
err.retriable = retriable;
|
|
82
|
+
return err;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Cap concurrent warm spawns triggered by a burst of acquisitions. Without a
|
|
86
|
+
// cap, a burst of callers would fan out N parallel cold spawns; with one,
|
|
87
|
+
// the excess waits in FIFO order while the first few warm. Exported so
|
|
88
|
+
// consumers that pre-warm (like cc-worker-pool.js's warmTab) can share the
|
|
89
|
+
// same knob instead of hardcoding their own copy.
|
|
90
|
+
const WARM_MAX_CONCURRENT = 3;
|
|
91
|
+
|
|
92
|
+
// Real spawn implementation — resolves `copilot` through the runtime adapter
|
|
93
|
+
// so every ACP transport consumer gets the SAME native/leadingArgs contract
|
|
94
|
+
// as the only other spawn site (engine/spawn-agent.js ~line 595). A bare
|
|
95
|
+
// `spawn('copilot', {shell:false})` cannot exec a `.cmd`/`.ps1`/extensionless
|
|
96
|
+
// npm shim on Windows — it ENOENTs instantly (copilot issue #2370 /
|
|
97
|
+
// W-mqid8uev). resolveBinary() resolves the real JS entry
|
|
98
|
+
// (node_modules/@github/copilot/index.js) and reports whether to spawn it
|
|
99
|
+
// directly (native) or under the current Node process (shim).
|
|
100
|
+
function spawnAcp({ cwd } = {}) {
|
|
101
|
+
const copilot = require('./runtimes/copilot');
|
|
102
|
+
const resolved = copilot.resolveBinary({ env: process.env });
|
|
103
|
+
if (!resolved || !resolved.bin) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`copilot binary not resolvable -- ${copilot.installHint || 'install the GitHub Copilot CLI'}`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const { bin, native, leadingArgs = [] } = resolved;
|
|
109
|
+
const acpArgs = [...leadingArgs, '--acp', '--allow-all', '--max-autopilot-continues', '1'];
|
|
110
|
+
// Mirror engine/spawn-agent.js: native binaries run directly; a non-native
|
|
111
|
+
// (JS entry / npm shim) runs under process.execPath as `node <bin> ...`.
|
|
112
|
+
const execBin = native ? bin : process.execPath;
|
|
113
|
+
const execArgs = native ? acpArgs : [bin, ...acpArgs];
|
|
114
|
+
return spawn(execBin, execArgs, {
|
|
115
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
116
|
+
cwd: cwd || process.cwd(),
|
|
117
|
+
windowsHide: true,
|
|
118
|
+
// Don't pass shell:true — the native/leadingArgs handling above already
|
|
119
|
+
// produces a directly-spawnable argv, and a shell wrapper would swallow
|
|
120
|
+
// the stdin/stdout JSON-RPC framing.
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Real kill implementation — lazy require so a caller that stubs its own
|
|
125
|
+
// _internals for tests doesn't need engine/shared loaded.
|
|
126
|
+
function killImmediate(proc) {
|
|
127
|
+
try {
|
|
128
|
+
const shared = require('./shared');
|
|
129
|
+
shared.killImmediate(proc);
|
|
130
|
+
} catch {
|
|
131
|
+
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function hashMcpServers(mcpServers) {
|
|
136
|
+
// Stable hash via JSON.stringify; mcpServers is an array of plain objects
|
|
137
|
+
// in practice (name/command/env) so the natural key order is fine.
|
|
138
|
+
const json = mcpServers == null ? '[]' : JSON.stringify(mcpServers);
|
|
139
|
+
return crypto.createHash('sha256').update(json).digest('hex');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Bug B (issue #2479): `model` and `effort` were stored on the Worker but
|
|
143
|
+
// never forwarded to ACP `session/new`, so any model override was silently
|
|
144
|
+
// dropped. Build the params object here and only include fields that are
|
|
145
|
+
// defined — sending `model: undefined` would force the daemon onto its
|
|
146
|
+
// built-in default instead of letting it pick whatever the user has
|
|
147
|
+
// configured globally.
|
|
148
|
+
function buildSessionNewParams({ cwd, mcpServers, model, effort }) {
|
|
149
|
+
const params = { cwd, mcpServers: mcpServers || [] };
|
|
150
|
+
if (model !== undefined && model !== null && model !== '') params.model = model;
|
|
151
|
+
if (effort !== undefined && effort !== null && effort !== '') params.effort = effort;
|
|
152
|
+
return params;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function extractChunkText(content) {
|
|
156
|
+
if (content == null) return '';
|
|
157
|
+
if (typeof content === 'string') return content;
|
|
158
|
+
if (Array.isArray(content)) {
|
|
159
|
+
let out = '';
|
|
160
|
+
for (const block of content) {
|
|
161
|
+
if (block && typeof block.text === 'string') out += block.text;
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
if (typeof content === 'object' && typeof content.text === 'string') return content.text;
|
|
166
|
+
return '';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Map an ACP `tool_call` session/update notification to the {name, input}
|
|
170
|
+
// shape the dashboard's formatToolSummary already understands. ACP's `kind`
|
|
171
|
+
// is a coarse category (execute|read|edit|search|fetch|think|other); we
|
|
172
|
+
// translate to the closest Claude tool name so the existing chip formatters
|
|
173
|
+
// keep working (Bash → "$ <cmd>", Read → "Reading <path>", etc.). Unknown
|
|
174
|
+
// kinds fall back to ACP's human-readable `title` with the raw input
|
|
175
|
+
// attached, which renders through the default `<title>(<key>: <val>)`
|
|
176
|
+
// formatter.
|
|
177
|
+
//
|
|
178
|
+
// Kind-based routing is intentionally NOT used — ACP overloads `kind:read`
|
|
179
|
+
// for both file-view and grep, and `kind:execute` sometimes arrives without
|
|
180
|
+
// a `command`. Field-detection on rawInput is more reliable.
|
|
181
|
+
function mapAcpToolCallToToolUse(update) {
|
|
182
|
+
if (!update || update.sessionUpdate !== 'tool_call') return null;
|
|
183
|
+
const rawInput = (update.rawInput && typeof update.rawInput === 'object') ? update.rawInput : {};
|
|
184
|
+
const title = update.title || update.kind || 'Tool';
|
|
185
|
+
|
|
186
|
+
if (typeof rawInput.command === 'string' && rawInput.command) {
|
|
187
|
+
return { name: 'Bash', input: { command: rawInput.command } };
|
|
188
|
+
}
|
|
189
|
+
if (typeof rawInput.pattern === 'string' && rawInput.pattern) {
|
|
190
|
+
return { name: 'Grep', input: { pattern: rawInput.pattern, path: rawInput.paths || rawInput.path || '.' } };
|
|
191
|
+
}
|
|
192
|
+
if (typeof rawInput.path === 'string' && rawInput.path) {
|
|
193
|
+
const isEdit = String(update.kind || '').toLowerCase() === 'edit';
|
|
194
|
+
return { name: isEdit ? 'Edit' : 'Read', input: { file_path: rawInput.path } };
|
|
195
|
+
}
|
|
196
|
+
if (typeof rawInput.url === 'string' && rawInput.url) {
|
|
197
|
+
return { name: 'WebFetch', input: { url: rawInput.url } };
|
|
198
|
+
}
|
|
199
|
+
return { name: title, input: {} };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Worker — one `copilot --acp` process, one ACP session, newline-delimited
|
|
204
|
+
* JSON-RPC framing over stdin/stdout.
|
|
205
|
+
*
|
|
206
|
+
* Construction does NOT spawn the process — call `_spawnAndInit()` (spawn +
|
|
207
|
+
* `initialize` + `session/new` handshake) to bring the worker up. Consumers
|
|
208
|
+
* own the higher-level lifecycle policy (permanent per-tab ownership for
|
|
209
|
+
* cc-worker-pool.js; lease/release for agent-worker-pool.js).
|
|
210
|
+
*
|
|
211
|
+
* `internals` is `{ spawnAcp({cwd}), killImmediate(proc), now() }` —
|
|
212
|
+
* constructor-injected so each consumer module can stub its OWN copy for
|
|
213
|
+
* tests (see cc-worker-pool.js's and agent-worker-pool.js's respective
|
|
214
|
+
* `_internals` objects) without the two pools' test stubs colliding on a
|
|
215
|
+
* shared module-level singleton. `trace(...parts)` is an optional structured
|
|
216
|
+
* logger (default no-op); consumers wire their own env-gated trace prefix.
|
|
217
|
+
*/
|
|
218
|
+
class Worker {
|
|
219
|
+
constructor({ id, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd, internals, trace }) {
|
|
220
|
+
this.id = id;
|
|
221
|
+
this.model = model;
|
|
222
|
+
this.effort = effort;
|
|
223
|
+
this.mcpServers = mcpServers || [];
|
|
224
|
+
this.mcpServersHash = mcpServersHash;
|
|
225
|
+
this.systemPromptHash = systemPromptHash;
|
|
226
|
+
this.cwd = cwd || process.cwd();
|
|
227
|
+
this._internals = internals;
|
|
228
|
+
this._trace = typeof trace === 'function' ? trace : () => {};
|
|
229
|
+
|
|
230
|
+
this.proc = null;
|
|
231
|
+
this.sessionId = null;
|
|
232
|
+
this.pending = new Map(); // id → { resolve, reject }
|
|
233
|
+
this.inflight = null; // current { id, sessionId, onChunk, onDone, onError, signal, signalHandler, settled }
|
|
234
|
+
this.readBuf = '';
|
|
235
|
+
this.nextReqId = 1;
|
|
236
|
+
this.lastUsedAt = this._internals.now();
|
|
237
|
+
this.killed = false;
|
|
238
|
+
this.spawnError = null;
|
|
239
|
+
this.firstSystemPromptSent = false;
|
|
240
|
+
// In-flight spawn+initialize+session/new promise. Set by the consumer
|
|
241
|
+
// before the worker is registered in its own pool bookkeeping, cleared
|
|
242
|
+
// after the handshake settles. Racing callers await this to avoid the
|
|
243
|
+
// "warm-reuse path returns sessionId=null while init is still pending"
|
|
244
|
+
// hang on first message of a freshly-warmed worker (W-mpd45blx00072f04).
|
|
245
|
+
this.initPromise = null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── Spawn + initialize handshake ────────────────────────────────────────
|
|
249
|
+
async _spawnAndInit() {
|
|
250
|
+
let proc;
|
|
251
|
+
try {
|
|
252
|
+
proc = this._internals.spawnAcp({ cwd: this.cwd });
|
|
253
|
+
} catch (err) {
|
|
254
|
+
// spawn() threw synchronously — typically ENOENT (copilot binary not
|
|
255
|
+
// on PATH) or EACCES. Surface as worker-spawn-failed so the consumer
|
|
256
|
+
// can show "install the CLI / fix PATH" guidance.
|
|
257
|
+
throw typedError(
|
|
258
|
+
`copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (${err.message})`,
|
|
259
|
+
ERROR_CODES.WORKER_SPAWN_FAILED,
|
|
260
|
+
true
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
this.proc = proc;
|
|
264
|
+
|
|
265
|
+
proc.stdout.on('data', (chunk) => this._onStdout(chunk));
|
|
266
|
+
// stderr is captured for diagnostics but we don't act on it.
|
|
267
|
+
if (proc.stderr) proc.stderr.on('data', () => { /* ignore */ });
|
|
268
|
+
|
|
269
|
+
// Race the init handshake against an early process exit so an auth
|
|
270
|
+
// failure (which kills the daemon before initialize completes) surfaces
|
|
271
|
+
// a clear error instead of hanging forever.
|
|
272
|
+
let earlyExitReject;
|
|
273
|
+
const earlyExitPromise = new Promise((_, reject) => {
|
|
274
|
+
earlyExitReject = (code) => {
|
|
275
|
+
this.killed = true;
|
|
276
|
+
// Early exit DURING the handshake = acp-handshake-failed (almost
|
|
277
|
+
// always missing `copilot login`, stale CLI, or daemon crash on
|
|
278
|
+
// boot). Retriable so re-auth or a CLI upgrade can recover.
|
|
279
|
+
const err = typedError(
|
|
280
|
+
`copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (exit ${code})`,
|
|
281
|
+
ERROR_CODES.ACP_HANDSHAKE_FAILED,
|
|
282
|
+
true
|
|
283
|
+
);
|
|
284
|
+
this.spawnError = err;
|
|
285
|
+
this._failAllPending(err);
|
|
286
|
+
reject(err);
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
const earlyExitHandler = (code) => earlyExitReject(code);
|
|
290
|
+
proc.once('exit', earlyExitHandler);
|
|
291
|
+
|
|
292
|
+
const errorHandler = (err) => {
|
|
293
|
+
// proc 'error' event fires when the OS can't actually start the child
|
|
294
|
+
// (ENOENT after a successful spawn() call, etc.). Treat as a spawn
|
|
295
|
+
// failure even though we made it past the synchronous spawn() above.
|
|
296
|
+
const wrapped = typedError(
|
|
297
|
+
`copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (${err.message})`,
|
|
298
|
+
ERROR_CODES.WORKER_SPAWN_FAILED,
|
|
299
|
+
true
|
|
300
|
+
);
|
|
301
|
+
this.spawnError = wrapped;
|
|
302
|
+
this.killed = true;
|
|
303
|
+
this._failAllPending(wrapped);
|
|
304
|
+
};
|
|
305
|
+
proc.on('error', errorHandler);
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
await Promise.race([
|
|
309
|
+
this._call('initialize', { protocolVersion: 1, clientCapabilities: {} }),
|
|
310
|
+
earlyExitPromise,
|
|
311
|
+
]);
|
|
312
|
+
const result = await Promise.race([
|
|
313
|
+
this._call('session/new', buildSessionNewParams({
|
|
314
|
+
cwd: this.cwd, mcpServers: this.mcpServers, model: this.model, effort: this.effort,
|
|
315
|
+
})),
|
|
316
|
+
earlyExitPromise,
|
|
317
|
+
]);
|
|
318
|
+
this.sessionId = result && result.sessionId;
|
|
319
|
+
if (!this.sessionId) {
|
|
320
|
+
// Handshake completed without an error but the daemon didn't hand
|
|
321
|
+
// back a sessionId — protocol violation or partial init failure.
|
|
322
|
+
throw typedError(
|
|
323
|
+
'copilot --acp failed -- session/new returned no sessionId',
|
|
324
|
+
ERROR_CODES.ACP_HANDSHAKE_FAILED,
|
|
325
|
+
true
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
} finally {
|
|
329
|
+
// Either the handshake finished (swap to a persistent exit handler that
|
|
330
|
+
// just marks killed) or it failed (proc is dying anyway).
|
|
331
|
+
proc.removeListener('exit', earlyExitHandler);
|
|
332
|
+
}
|
|
333
|
+
proc.on('exit', () => {
|
|
334
|
+
this.killed = true;
|
|
335
|
+
// Post-handshake exit = the daemon died mid-conversation. Retriable
|
|
336
|
+
// because the next call will cold-spawn a fresh worker.
|
|
337
|
+
const err = typedError(
|
|
338
|
+
'copilot --acp process exited',
|
|
339
|
+
ERROR_CODES.WORKER_DIED,
|
|
340
|
+
true
|
|
341
|
+
);
|
|
342
|
+
this._failAllPending(err);
|
|
343
|
+
// Settle inflight too if it's still hanging
|
|
344
|
+
if (this.inflight && !this.inflight.settled) {
|
|
345
|
+
const cb = this.inflight.onError;
|
|
346
|
+
this.inflight.settled = true;
|
|
347
|
+
this.inflight = null;
|
|
348
|
+
if (cb) try { cb(err); } catch { /* swallow */ }
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
_failAllPending(err) {
|
|
354
|
+
for (const { reject } of this.pending.values()) {
|
|
355
|
+
try { reject(err); } catch { /* swallow */ }
|
|
356
|
+
}
|
|
357
|
+
this.pending.clear();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── Newline-delimited JSON-RPC line framing ────────────────────────────
|
|
361
|
+
_onStdout(chunk) {
|
|
362
|
+
this.readBuf += chunk.toString('utf8');
|
|
363
|
+
let i;
|
|
364
|
+
while ((i = this.readBuf.indexOf('\n')) >= 0) {
|
|
365
|
+
const line = this.readBuf.slice(0, i).trim();
|
|
366
|
+
this.readBuf = this.readBuf.slice(i + 1);
|
|
367
|
+
if (!line) continue;
|
|
368
|
+
let obj;
|
|
369
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
370
|
+
this._handleMessage(obj);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
_handleMessage(obj) {
|
|
375
|
+
// Response (matches an outbound id)
|
|
376
|
+
if (obj.id != null && this.pending.has(obj.id)) {
|
|
377
|
+
const { resolve, reject } = this.pending.get(obj.id);
|
|
378
|
+
this.pending.delete(obj.id);
|
|
379
|
+
if (obj.error) reject(new Error(obj.error.message || 'ACP error'));
|
|
380
|
+
else resolve(obj.result);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// Notification (no id) — only `session/update` matters for streaming.
|
|
384
|
+
if (obj.method === 'session/update' && obj.params) {
|
|
385
|
+
const notifSid = obj.params.sessionId;
|
|
386
|
+
const inflightSid = this.inflight ? this.inflight.sessionId : null;
|
|
387
|
+
const updKind = obj.params.update && obj.params.update.sessionUpdate;
|
|
388
|
+
if (!this.inflight) {
|
|
389
|
+
this._trace(`id=${this.id} session/update dropped: no inflight (notifSid=${notifSid} kind=${updKind})`);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (notifSid !== inflightSid) {
|
|
393
|
+
this._trace(`id=${this.id} session/update dropped: sid mismatch (notifSid=${notifSid} inflightSid=${inflightSid} kind=${updKind})`);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
this._trace(`id=${this.id} session/update delivered: sid=${notifSid} kind=${updKind}`);
|
|
397
|
+
const update = obj.params.update;
|
|
398
|
+
if (!update) return;
|
|
399
|
+
if (update.sessionUpdate === 'agent_message_chunk') {
|
|
400
|
+
const text = extractChunkText(update.content);
|
|
401
|
+
if (text && this.inflight.onChunk) {
|
|
402
|
+
try { this.inflight.onChunk(text); } catch { /* swallow */ }
|
|
403
|
+
}
|
|
404
|
+
} else if (update.sessionUpdate === 'tool_call' && this.inflight.onToolUse) {
|
|
405
|
+
// ACP `tool_call` (pending) → chip label via mapAcpToolCallToToolUse.
|
|
406
|
+
const mapped = mapAcpToolCallToToolUse(update);
|
|
407
|
+
if (mapped) {
|
|
408
|
+
try { this.inflight.onToolUse(mapped.name, mapped.input, update.toolCallId); }
|
|
409
|
+
catch { /* swallow */ }
|
|
410
|
+
}
|
|
411
|
+
} else if (update.sessionUpdate === 'tool_call_update' && this.inflight.onToolUpdate) {
|
|
412
|
+
// Terminal-state updates only (completed/failed). In-progress updates
|
|
413
|
+
// exist in the ACP spec but add chip churn without actionable info.
|
|
414
|
+
if (update.status === 'completed' || update.status === 'failed') {
|
|
415
|
+
try { this.inflight.onToolUpdate(update.toolCallId, update.status); }
|
|
416
|
+
catch { /* swallow */ }
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
_writeFrame(obj) {
|
|
423
|
+
if (this.killed) return;
|
|
424
|
+
if (!this.proc || !this.proc.stdin || this.proc.stdin.destroyed) return;
|
|
425
|
+
try { this.proc.stdin.write(JSON.stringify(obj) + '\n'); }
|
|
426
|
+
catch { /* pipe may be broken; exit handler will fire */ }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
_call(method, params) {
|
|
430
|
+
return new Promise((resolve, reject) => {
|
|
431
|
+
const id = this.nextReqId++;
|
|
432
|
+
this.pending.set(id, { resolve, reject });
|
|
433
|
+
this._writeFrame({ jsonrpc: '2.0', id, method, params });
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
_notify(method, params) {
|
|
438
|
+
this._writeFrame({ jsonrpc: '2.0', method, params });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ── Stream a single turn ───────────────────────────────────────────────
|
|
442
|
+
stream(promptText, opts = {}) {
|
|
443
|
+
const { onChunk, onToolUse, onToolUpdate, onDone, onError, signal, systemPromptText } = opts;
|
|
444
|
+
if (this.killed) {
|
|
445
|
+
const err = new Error('acp-transport: worker is closed');
|
|
446
|
+
if (onError) try { onError(err); } catch { /* swallow */ }
|
|
447
|
+
return Promise.resolve();
|
|
448
|
+
}
|
|
449
|
+
if (this.inflight) {
|
|
450
|
+
const err = new Error('acp-transport: a prompt is already in flight on this worker');
|
|
451
|
+
if (onError) try { onError(err); } catch { /* swallow */ }
|
|
452
|
+
return Promise.resolve();
|
|
453
|
+
}
|
|
454
|
+
this.lastUsedAt = this._internals.now();
|
|
455
|
+
|
|
456
|
+
// Inject the <system> block on the first prompt of a session — Copilot
|
|
457
|
+
// ACP has no in-protocol "set system prompt" method, so the per-session
|
|
458
|
+
// adapter pattern of <system>...</system> prepended to the first user
|
|
459
|
+
// message remains correct.
|
|
460
|
+
let prompt = promptText;
|
|
461
|
+
if (!this.firstSystemPromptSent && systemPromptText) {
|
|
462
|
+
prompt = `<system>\n${systemPromptText}\n</system>\n\n${promptText}`;
|
|
463
|
+
}
|
|
464
|
+
this.firstSystemPromptSent = true;
|
|
465
|
+
|
|
466
|
+
const id = this.nextReqId++;
|
|
467
|
+
const inflight = {
|
|
468
|
+
id,
|
|
469
|
+
sessionId: this.sessionId,
|
|
470
|
+
onChunk,
|
|
471
|
+
onToolUse,
|
|
472
|
+
onToolUpdate,
|
|
473
|
+
onDone,
|
|
474
|
+
onError,
|
|
475
|
+
signal,
|
|
476
|
+
signalHandler: null,
|
|
477
|
+
settled: false,
|
|
478
|
+
};
|
|
479
|
+
this.inflight = inflight;
|
|
480
|
+
this._trace(`id=${this.id} stream begin: worker.sessionId=${this.sessionId} inflight.sessionId=${inflight.sessionId} reqId=${id}`);
|
|
481
|
+
|
|
482
|
+
if (signal && typeof signal.addEventListener === 'function') {
|
|
483
|
+
inflight.signalHandler = () => this.cancel();
|
|
484
|
+
try { signal.addEventListener('abort', inflight.signalHandler); } catch { /* swallow */ }
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return new Promise((resolve) => {
|
|
488
|
+
const finalize = (err, result) => {
|
|
489
|
+
if (inflight.settled) return;
|
|
490
|
+
inflight.settled = true;
|
|
491
|
+
if (this.inflight === inflight) this.inflight = null;
|
|
492
|
+
this.lastUsedAt = this._internals.now();
|
|
493
|
+
if (signal && inflight.signalHandler) {
|
|
494
|
+
try { signal.removeEventListener('abort', inflight.signalHandler); }
|
|
495
|
+
catch { /* swallow */ }
|
|
496
|
+
}
|
|
497
|
+
if (err) {
|
|
498
|
+
if (onError) try { onError(err); } catch { /* swallow */ }
|
|
499
|
+
} else {
|
|
500
|
+
if (onDone) try { onDone(result); } catch { /* swallow */ }
|
|
501
|
+
}
|
|
502
|
+
resolve();
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
this.pending.set(id, {
|
|
506
|
+
resolve: (result) => finalize(null, result),
|
|
507
|
+
reject: (err) => finalize(err, null),
|
|
508
|
+
});
|
|
509
|
+
this._writeFrame({
|
|
510
|
+
jsonrpc: '2.0',
|
|
511
|
+
id,
|
|
512
|
+
method: 'session/prompt',
|
|
513
|
+
params: { sessionId: this.sessionId, prompt: [{ type: 'text', text: prompt }] },
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
cancel() {
|
|
519
|
+
if (!this.inflight || this.killed) return;
|
|
520
|
+
// session/cancel is a JSON-RPC notification (no id). Per the P-7a1c2e4f
|
|
521
|
+
// spike, the in-flight session/prompt response arrives with
|
|
522
|
+
// stopReason:"end_turn" (NOT "cancelled" on copilot CLI 1.0.70) — we
|
|
523
|
+
// don't branch on that field, only on the promise settling.
|
|
524
|
+
this._notify('session/cancel', { sessionId: this.inflight.sessionId });
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async newSession({ mcpServers, systemPromptHash, model, effort, cwd }) {
|
|
528
|
+
// Cancel any inflight before swapping the underlying session.
|
|
529
|
+
if (this.inflight) {
|
|
530
|
+
this.cancel();
|
|
531
|
+
// Wait briefly for the cancelled response to settle so we don't leak
|
|
532
|
+
// a stale inflight reference into the new session.
|
|
533
|
+
const inflight = this.inflight;
|
|
534
|
+
await new Promise((resolve) => {
|
|
535
|
+
const start = this._internals.now();
|
|
536
|
+
const wait = () => {
|
|
537
|
+
if (!inflight || inflight.settled || this._internals.now() - start > 1000) resolve();
|
|
538
|
+
else setTimeout(wait, 5);
|
|
539
|
+
};
|
|
540
|
+
wait();
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
// Bug B (issue #2479): if the caller is rotating the session because the
|
|
544
|
+
// system prompt changed, they may also be passing a fresh model/effort —
|
|
545
|
+
// update bookkeeping BEFORE session/new so the new fields land on the
|
|
546
|
+
// daemon. Falls through to whatever we already had when callers omit them.
|
|
547
|
+
if (model !== undefined) this.model = model;
|
|
548
|
+
if (effort !== undefined) this.effort = effort;
|
|
549
|
+
// agent-worker-pool.js reuses a warm worker across dispatches with
|
|
550
|
+
// different cwds (unlike cc-worker-pool.js, whose tabs never change
|
|
551
|
+
// cwd) — allow the caller to rotate it in on a session swap.
|
|
552
|
+
if (cwd !== undefined && cwd !== null && cwd !== '') this.cwd = cwd;
|
|
553
|
+
const result = await this._call('session/new', buildSessionNewParams({
|
|
554
|
+
cwd: this.cwd, mcpServers: mcpServers || [], model: this.model, effort: this.effort,
|
|
555
|
+
}));
|
|
556
|
+
this.sessionId = result && result.sessionId;
|
|
557
|
+
this.systemPromptHash = systemPromptHash;
|
|
558
|
+
this.firstSystemPromptSent = false;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
close() {
|
|
562
|
+
if (this.killed) return;
|
|
563
|
+
this.killed = true;
|
|
564
|
+
// Best-effort cancel of an inflight prompt so the daemon doesn't keep
|
|
565
|
+
// generating into a torn-down session before we kill the proc.
|
|
566
|
+
if (this.inflight) {
|
|
567
|
+
try { this._notify('session/cancel', { sessionId: this.inflight.sessionId }); }
|
|
568
|
+
catch { /* swallow */ }
|
|
569
|
+
}
|
|
570
|
+
if (this.proc) {
|
|
571
|
+
try { this._internals.killImmediate(this.proc); } catch { /* already dead */ }
|
|
572
|
+
}
|
|
573
|
+
this._failAllPending(new Error('acp-transport: worker closed'));
|
|
574
|
+
if (this.inflight && !this.inflight.settled) {
|
|
575
|
+
const cb = this.inflight.onError;
|
|
576
|
+
this.inflight.settled = true;
|
|
577
|
+
this.inflight = null;
|
|
578
|
+
if (cb) try { cb(new Error('acp-transport: worker closed')); } catch { /* swallow */ }
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
module.exports = {
|
|
584
|
+
ERROR_CODES,
|
|
585
|
+
typedError,
|
|
586
|
+
spawnAcp,
|
|
587
|
+
killImmediate,
|
|
588
|
+
hashMcpServers,
|
|
589
|
+
buildSessionNewParams,
|
|
590
|
+
extractChunkText,
|
|
591
|
+
mapAcpToolCallToToolUse,
|
|
592
|
+
Worker,
|
|
593
|
+
WARM_MAX_CONCURRENT,
|
|
594
|
+
};
|