@yemi33/minions 0.1.2383 → 0.1.2385

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.
@@ -93,6 +93,10 @@ function typedError(message, code, retriable = true) {
93
93
  // consumers that pre-warm (like cc-worker-pool.js's warmTab) can share the
94
94
  // same knob instead of hardcoding their own copy.
95
95
  const WARM_MAX_CONCURRENT = 3;
96
+ const ACP_HANDSHAKE_TIMEOUT_MS = 60000;
97
+ const SESSION_LOAD_RETRY_ATTEMPTS = 10;
98
+ const SESSION_LOAD_RETRY_DELAY_MS = 100;
99
+ const SESSION_ROTATION_CANCEL_WAIT_MS = 1000;
96
100
 
97
101
  function resolveWorkerRuntime(runtime) {
98
102
  const adapter = typeof runtime === 'string'
@@ -106,7 +110,7 @@ function resolveWorkerRuntime(runtime) {
106
110
 
107
111
  // Real spawn implementation. Binary resolution and worker argv are both
108
112
  // adapter-owned so harness CLI flag changes do not leak into the transport.
109
- function spawnAcp({ runtime, cwd, env } = {}) {
113
+ function spawnAcp({ runtime, cwd, env, workerOptions } = {}) {
110
114
  const adapter = resolveWorkerRuntime(runtime);
111
115
  const workerEnv = env || process.env;
112
116
  const resolved = adapter.resolveBinary({ env: workerEnv });
@@ -116,7 +120,10 @@ function spawnAcp({ runtime, cwd, env } = {}) {
116
120
  );
117
121
  }
118
122
  const { bin, native, leadingArgs = [] } = resolved;
119
- const acpArgs = [...leadingArgs, ...buildWorkerArgs(adapter, { cwd, env: workerEnv })];
123
+ const acpArgs = [
124
+ ...leadingArgs,
125
+ ...buildWorkerArgs(adapter, { ...(workerOptions || {}), cwd, env: workerEnv }),
126
+ ];
120
127
  // Mirror engine/spawn-agent.js: native binaries run directly; a non-native
121
128
  // (JS entry / npm shim) runs under process.execPath as `node <bin> ...`.
122
129
  const execBin = native ? bin : process.execPath;
@@ -143,6 +150,10 @@ function killImmediate(proc) {
143
150
  }
144
151
  }
145
152
 
153
+ function killRoot(proc) {
154
+ try { proc.kill('SIGKILL'); } catch { /* already dead */ }
155
+ }
156
+
146
157
  function hashMcpServers(mcpServers) {
147
158
  // Stable hash via JSON.stringify; mcpServers is an array of plain objects
148
159
  // in practice (name/command/env) so the natural key order is fine.
@@ -150,18 +161,31 @@ function hashMcpServers(mcpServers) {
150
161
  return crypto.createHash('sha256').update(json).digest('hex');
151
162
  }
152
163
 
153
- function _stableObject(value) {
154
- if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
164
+ function _stableValue(value) {
165
+ if (Array.isArray(value)) return value.map(_stableValue);
166
+ if (!value || typeof value !== 'object') return value;
155
167
  return Object.fromEntries(
156
168
  Object.entries(value)
157
169
  .filter(([, entry]) => entry !== undefined)
158
170
  .sort(([a], [b]) => a.localeCompare(b))
171
+ .map(([key, entry]) => [key, _stableValue(entry)])
159
172
  );
160
173
  }
161
174
 
162
- function hashProcessContext(processEnv) {
175
+ function _stableObject(value) {
176
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
177
+ return _stableValue(value);
178
+ }
179
+
180
+ function hashProcessContext(processEnv, processOptions) {
181
+ const context = processOptions === undefined
182
+ ? _stableObject(processEnv)
183
+ : {
184
+ env: _stableObject(processEnv),
185
+ options: _stableValue(processOptions),
186
+ };
163
187
  return crypto.createHash('sha256')
164
- .update(JSON.stringify(_stableObject(processEnv)))
188
+ .update(JSON.stringify(context))
165
189
  .digest('hex');
166
190
  }
167
191
 
@@ -203,6 +227,10 @@ function extractSessionModel(result) {
203
227
  return null;
204
228
  }
205
229
 
230
+ function buildSessionLoadParams({ sessionId, cwd, mcpServers }) {
231
+ return { sessionId, cwd, mcpServers: mcpServers || [] };
232
+ }
233
+
206
234
  function extractChunkText(content) {
207
235
  if (content == null) return '';
208
236
  if (typeof content === 'string') return content;
@@ -270,7 +298,7 @@ class Worker {
270
298
  constructor({
271
299
  runtime,
272
300
  id, model, effort, mcpServers, mcpServersHash, processEnv, processContextHash,
273
- systemPromptHash, cwd, internals, trace,
301
+ workerOptions, resumeSessionId, systemPromptHash, cwd, processCwd, internals, trace,
274
302
  }) {
275
303
  this.runtime = resolveWorkerRuntime(runtime);
276
304
  this.id = id;
@@ -280,12 +308,16 @@ class Worker {
280
308
  this.mcpServersHash = mcpServersHash;
281
309
  this.processEnv = processEnv;
282
310
  this.processContextHash = processContextHash;
311
+ this.workerOptions = workerOptions && typeof workerOptions === 'object' ? { ...workerOptions } : {};
312
+ this.resumeSessionId = resumeSessionId || null;
283
313
  this.systemPromptHash = systemPromptHash;
284
314
  this.cwd = cwd || process.cwd();
315
+ this.processCwd = processCwd || this.cwd;
285
316
  this._internals = internals;
286
317
  this._trace = typeof trace === 'function' ? trace : () => {};
287
318
 
288
319
  this.proc = null;
320
+ this.processStartedAt = 0;
289
321
  this.sessionId = null;
290
322
  this.pending = new Map(); // id → { resolve, reject }
291
323
  this.inflight = null; // current { id, sessionId, onChunk, onDone, onError, signal, signalHandler, settled }
@@ -295,6 +327,7 @@ class Worker {
295
327
  this.killed = false;
296
328
  this.spawnError = null;
297
329
  this.firstSystemPromptSent = false;
330
+ this.preservedDescendantPids = new Set();
298
331
  // In-flight spawn+initialize+session/new promise. Set by the consumer
299
332
  // before the worker is registered in its own pool bookkeeping, cleared
300
333
  // after the handshake settles. Racing callers await this to avoid the
@@ -307,7 +340,12 @@ class Worker {
307
340
  async _spawnAndInit() {
308
341
  let proc;
309
342
  try {
310
- proc = this._internals.spawnAcp({ runtime: this.runtime, cwd: this.cwd, env: this.processEnv });
343
+ proc = this._internals.spawnAcp({
344
+ runtime: this.runtime,
345
+ cwd: this.processCwd,
346
+ env: this.processEnv,
347
+ workerOptions: this.workerOptions,
348
+ });
311
349
  } catch (err) {
312
350
  const hint = this.runtime.workerErrorHint || `${this.runtime.name} persistent worker failed`;
313
351
  throw typedError(
@@ -359,30 +397,63 @@ class Worker {
359
397
  };
360
398
  proc.on('error', errorHandler);
361
399
 
400
+ const configuredHandshakeTimeout = Number(this._internals.handshakeTimeoutMs);
401
+ const handshakeTimeoutMs = Number.isFinite(configuredHandshakeTimeout) && configuredHandshakeTimeout > 0
402
+ ? configuredHandshakeTimeout
403
+ : ACP_HANDSHAKE_TIMEOUT_MS;
404
+ let handshakeTimer;
405
+ const handshakeTimeoutPromise = new Promise((_, reject) => {
406
+ handshakeTimer = setTimeout(() => {
407
+ reject(typedError(
408
+ `copilot --acp handshake timed out after ${handshakeTimeoutMs}ms`,
409
+ ERROR_CODES.ACP_HANDSHAKE_FAILED,
410
+ true
411
+ ));
412
+ }, handshakeTimeoutMs);
413
+ });
414
+
362
415
  try {
363
416
  await Promise.race([
364
417
  this._call('initialize', { protocolVersion: 1, clientCapabilities: {} }),
365
418
  earlyExitPromise,
419
+ handshakeTimeoutPromise,
366
420
  ]);
421
+ const sessionMethod = this.resumeSessionId ? 'session/load' : 'session/new';
422
+ const sessionParams = this.resumeSessionId
423
+ ? buildSessionLoadParams({
424
+ sessionId: this.resumeSessionId,
425
+ cwd: this.cwd,
426
+ mcpServers: this.mcpServers,
427
+ })
428
+ : buildSessionNewParams({
429
+ cwd: this.cwd,
430
+ mcpServers: this.mcpServers,
431
+ model: this.model,
432
+ effort: this.effort,
433
+ });
434
+ const sessionPromise = sessionMethod === 'session/load'
435
+ ? this._loadSession(sessionParams)
436
+ : this._call(sessionMethod, sessionParams);
367
437
  const result = await Promise.race([
368
- this._call('session/new', buildSessionNewParams({
369
- cwd: this.cwd, mcpServers: this.mcpServers, model: this.model, effort: this.effort,
370
- })),
438
+ sessionPromise,
371
439
  earlyExitPromise,
440
+ handshakeTimeoutPromise,
372
441
  ]);
373
- this.sessionId = result && result.sessionId;
442
+ this.sessionId = (result && result.sessionId) || this.resumeSessionId;
374
443
  this.currentModel = extractSessionModel(result) || normalizeExecutionModel(this.model);
375
444
  if (!this.sessionId) {
376
445
  // Handshake completed without an error but the daemon didn't hand
377
446
  // back a sessionId — protocol violation or partial init failure.
378
447
  const hint = this.runtime.workerErrorHint || `${this.runtime.name} persistent worker failed`;
379
448
  throw typedError(
380
- `${hint} (session/new returned no sessionId)`,
449
+ `${hint} (${sessionMethod} returned no sessionId)`,
381
450
  ERROR_CODES.ACP_HANDSHAKE_FAILED,
382
451
  true
383
452
  );
384
453
  }
454
+ this.firstSystemPromptSent = !!this.resumeSessionId;
385
455
  } finally {
456
+ clearTimeout(handshakeTimer);
386
457
  // Either the handshake finished (swap to a persistent exit handler that
387
458
  // just marks killed) or it failed (proc is dying anyway).
388
459
  proc.removeListener('exit', earlyExitHandler);
@@ -431,9 +502,13 @@ class Worker {
431
502
  _handleMessage(obj) {
432
503
  // Response (matches an outbound id)
433
504
  if (obj.id != null && this.pending.has(obj.id)) {
434
- const { resolve, reject } = this.pending.get(obj.id);
505
+ const { resolve, reject, method } = this.pending.get(obj.id);
435
506
  this.pending.delete(obj.id);
436
- if (obj.error) reject(new Error(obj.error.message || 'ACP error'));
507
+ if (obj.error) {
508
+ const err = new Error(obj.error.message || 'ACP error');
509
+ err.acpMethod = method || null;
510
+ reject(err);
511
+ }
437
512
  else resolve(obj.result);
438
513
  return;
439
514
  }
@@ -483,10 +558,35 @@ class Worker {
483
558
  catch { /* pipe may be broken; exit handler will fire */ }
484
559
  }
485
560
 
486
- _call(method, params) {
561
+ _call(method, params, timeoutMs) {
562
+ if (this.killed || !this.proc?.stdin || this.proc.stdin.destroyed) {
563
+ return Promise.reject(new Error('acp-transport: worker is closed'));
564
+ }
487
565
  return new Promise((resolve, reject) => {
488
566
  const id = this.nextReqId++;
489
- this.pending.set(id, { resolve, reject });
567
+ let timer;
568
+ const settle = (callback, value) => {
569
+ if (timer) clearTimeout(timer);
570
+ callback(value);
571
+ };
572
+ this.pending.set(id, {
573
+ resolve: (value) => settle(resolve, value),
574
+ reject: (err) => settle(reject, err),
575
+ method,
576
+ });
577
+ const parsedTimeout = Number(timeoutMs);
578
+ if (Number.isFinite(parsedTimeout) && parsedTimeout > 0) {
579
+ timer = setTimeout(() => {
580
+ if (!this.pending.delete(id)) return;
581
+ const err = typedError(
582
+ `acp-transport: ${method} timed out after ${parsedTimeout}ms`,
583
+ ERROR_CODES.ACP_HANDSHAKE_FAILED,
584
+ true
585
+ );
586
+ err.acpMethod = method;
587
+ reject(err);
588
+ }, parsedTimeout);
589
+ }
490
590
  this._writeFrame({ jsonrpc: '2.0', id, method, params });
491
591
  });
492
592
  }
@@ -495,6 +595,20 @@ class Worker {
495
595
  this._writeFrame({ jsonrpc: '2.0', method, params });
496
596
  }
497
597
 
598
+ async _loadSession(params) {
599
+ let lastError;
600
+ for (let attempt = 0; attempt < SESSION_LOAD_RETRY_ATTEMPTS; attempt++) {
601
+ try {
602
+ return await this._call('session/load', params, ACP_HANDSHAKE_TIMEOUT_MS);
603
+ } catch (err) {
604
+ lastError = err;
605
+ if (!/already loaded/i.test(String(err && err.message))) throw err;
606
+ await new Promise((resolve) => setTimeout(resolve, SESSION_LOAD_RETRY_DELAY_MS));
607
+ }
608
+ }
609
+ throw lastError;
610
+ }
611
+
498
612
  // ── Stream a single turn ───────────────────────────────────────────────
499
613
  stream(promptText, opts = {}) {
500
614
  const {
@@ -519,8 +633,11 @@ class Worker {
519
633
  // message remains correct.
520
634
  let prompt = promptText;
521
635
  const trustedContextText = buildTrustedSessionContext(trustedSessionContext);
522
- const trustedSystemText = [systemPromptText, trustedContextText].filter(Boolean).join('\n\n');
523
- if (!this.firstSystemPromptSent && trustedSystemText) {
636
+ const trustedSystemText = [
637
+ !this.firstSystemPromptSent ? systemPromptText : null,
638
+ trustedContextText,
639
+ ].filter(Boolean).join('\n\n');
640
+ if (trustedSystemText) {
524
641
  prompt = `<system>\n${trustedSystemText}\n</system>\n\n${promptText}`;
525
642
  }
526
643
  if (!deferSystemPrompt) this.firstSystemPromptSent = true;
@@ -586,8 +703,8 @@ class Worker {
586
703
  this._notify('session/cancel', { sessionId: this.inflight.sessionId });
587
704
  }
588
705
 
589
- async newSession(options = {}) {
590
- const { mcpServers, systemPromptHash, model, effort, cwd } = options;
706
+ async _prepareSession(options = {}) {
707
+ const { sessionId, mcpServers, systemPromptHash, model, effort, cwd } = options;
591
708
  // Cancel any inflight before swapping the underlying session.
592
709
  if (this.inflight) {
593
710
  this.cancel();
@@ -597,11 +714,15 @@ class Worker {
597
714
  await new Promise((resolve) => {
598
715
  const start = this._internals.now();
599
716
  const wait = () => {
600
- if (!inflight || inflight.settled || this._internals.now() - start > 1000) resolve();
717
+ if (!inflight || inflight.settled
718
+ || this._internals.now() - start > SESSION_ROTATION_CANCEL_WAIT_MS) resolve();
601
719
  else setTimeout(wait, 5);
602
720
  };
603
721
  wait();
604
722
  });
723
+ if (!inflight.settled) {
724
+ throw new Error('acp-transport: cancelled prompt did not settle before session rotation');
725
+ }
605
726
  }
606
727
  // Bug B (issue #2479): if the caller is rotating the session because the
607
728
  // system prompt changed, they may also be passing a fresh model/effort —
@@ -613,14 +734,71 @@ class Worker {
613
734
  // agent-worker-pool.js reuses a warm worker across dispatches with
614
735
  // different cwds (unlike cc-worker-pool.js, whose tabs never change
615
736
  // cwd) — allow the caller to rotate it in on a session swap.
616
- if (cwd !== undefined && cwd !== null && cwd !== '') this.cwd = cwd;
617
- const result = await this._call('session/new', buildSessionNewParams({
618
- cwd: this.cwd, mcpServers: mcpServers || [], model: this.model, effort: this.effort,
619
- }));
620
- this.sessionId = result && result.sessionId;
737
+ const nextCwd = cwd !== undefined && cwd !== null && cwd !== '' ? cwd : this.cwd;
738
+ if (sessionId && this.sessionId === sessionId) {
739
+ if (nextCwd !== this.cwd) {
740
+ const err = new Error('acp-transport: loaded session cannot change cwd without a worker respawn');
741
+ err.acpMethod = 'session/load';
742
+ throw err;
743
+ }
744
+ this.systemPromptHash = systemPromptHash;
745
+ this.firstSystemPromptSent = true;
746
+ return;
747
+ }
748
+ this.cwd = nextCwd;
749
+ const method = sessionId ? 'session/load' : 'session/new';
750
+ const params = sessionId
751
+ ? buildSessionLoadParams({ sessionId, cwd: this.cwd, mcpServers: mcpServers || [] })
752
+ : buildSessionNewParams({
753
+ cwd: this.cwd,
754
+ mcpServers: mcpServers || [],
755
+ model: this.model,
756
+ effort: this.effort,
757
+ });
758
+ const result = method === 'session/load'
759
+ ? await this._loadSession(params)
760
+ : await this._call(method, params, ACP_HANDSHAKE_TIMEOUT_MS);
761
+ this.sessionId = (result && result.sessionId) || sessionId;
762
+ if (!this.sessionId) {
763
+ const hint = this.runtime.workerErrorHint || `${this.runtime.name} persistent worker failed`;
764
+ throw typedError(
765
+ `${hint} (${method} returned no sessionId)`,
766
+ ERROR_CODES.ACP_HANDSHAKE_FAILED,
767
+ true
768
+ );
769
+ }
621
770
  this.currentModel = extractSessionModel(result) || normalizeExecutionModel(this.model);
622
771
  this.systemPromptHash = systemPromptHash;
623
- this.firstSystemPromptSent = false;
772
+ this.firstSystemPromptSent = !!sessionId;
773
+ }
774
+
775
+ async newSession(opts) {
776
+ return this._prepareSession({ ...opts, sessionId: null });
777
+ }
778
+
779
+ async loadSession(sessionId, opts) {
780
+ return this._prepareSession({ ...opts, sessionId });
781
+ }
782
+
783
+ preserveDescendants(pids) {
784
+ for (const pid of pids || []) {
785
+ const value = Number(pid);
786
+ if (Number.isInteger(value) && value > 0) this.preservedDescendantPids.add(value);
787
+ }
788
+ }
789
+
790
+ abandon() {
791
+ if (this.killed) return;
792
+ this.killed = true;
793
+ try { this.proc?.stdin?.end(); } catch { /* pipe already closed */ }
794
+ const err = new Error('acp-transport: unverified worker abandoned');
795
+ this._failAllPending(err);
796
+ if (this.inflight && !this.inflight.settled) {
797
+ const cb = this.inflight.onError;
798
+ this.inflight.settled = true;
799
+ this.inflight = null;
800
+ if (cb) try { cb(err); } catch { /* swallow */ }
801
+ }
624
802
  }
625
803
 
626
804
  close() {
@@ -633,7 +811,13 @@ class Worker {
633
811
  catch { /* swallow */ }
634
812
  }
635
813
  if (this.proc) {
636
- try { this._internals.killImmediate(this.proc); } catch { /* already dead */ }
814
+ try {
815
+ if (this.preservedDescendantPids.size > 0 && typeof this._internals.killRoot === 'function') {
816
+ this._internals.killRoot(this.proc);
817
+ } else {
818
+ this._internals.killImmediate(this.proc);
819
+ }
820
+ } catch { /* already dead */ }
637
821
  }
638
822
  this._failAllPending(new Error('acp-transport: worker closed'));
639
823
  if (this.inflight && !this.inflight.settled) {
@@ -651,13 +835,16 @@ module.exports = {
651
835
  spawnAcp,
652
836
  resolveWorkerRuntime,
653
837
  killImmediate,
838
+ killRoot,
654
839
  hashMcpServers,
655
840
  hashProcessContext,
656
841
  buildTrustedSessionContext,
657
842
  buildSessionNewParams,
658
843
  extractSessionModel,
844
+ buildSessionLoadParams,
659
845
  extractChunkText,
660
846
  mapAcpToolCallToToolUse,
661
847
  Worker,
662
848
  WARM_MAX_CONCURRENT,
849
+ ACP_HANDSHAKE_TIMEOUT_MS,
663
850
  };