@borgee/agents-host 0.2.33 → 0.2.35

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.
Files changed (40) hide show
  1. package/README.md +28 -7
  2. package/dist/agents-host.d.ts +17 -0
  3. package/dist/agents-host.js +52 -0
  4. package/dist/chat/chat-control-plane.d.ts +9 -0
  5. package/dist/chat/sdk-chat-control-plane.d.ts +10 -1
  6. package/dist/chat/sdk-chat-control-plane.js +6 -0
  7. package/dist/cli-args.d.ts +1 -1
  8. package/dist/cli-args.js +5 -0
  9. package/dist/compatibility-gates.d.ts +1 -0
  10. package/dist/compatibility-gates.js +2 -0
  11. package/dist/config.d.ts +4 -1
  12. package/dist/config.js +21 -3
  13. package/dist/context/main-session-delegation.d.ts +1 -0
  14. package/dist/context/main-session-delegation.js +6 -0
  15. package/dist/context/prompt.js +4 -2
  16. package/dist/gateway/localhost-gateway.js +66 -0
  17. package/dist/local-config.js +10 -1
  18. package/dist/managed-daemon.js +5 -0
  19. package/dist/plugin-sdk.js +58 -1
  20. package/dist/plugin-sdk.js.map +2 -2
  21. package/dist/policy/gateway-authorization.d.ts +18 -3
  22. package/dist/policy/gateway-authorization.js +33 -1
  23. package/dist/providers/claude/adapter.js +3 -1
  24. package/dist/providers/claude/cli-client.d.ts +25 -1
  25. package/dist/providers/claude/cli-client.js +127 -10
  26. package/dist/providers/codex/adapter.js +3 -1
  27. package/dist/providers/codex/cli-client.d.ts +25 -1
  28. package/dist/providers/codex/cli-client.js +127 -10
  29. package/dist/providers/codex/project-doc.js +3 -0
  30. package/dist/providers/copilot/adapter.js +3 -1
  31. package/dist/providers/copilot/cli-client.d.ts +25 -1
  32. package/dist/providers/copilot/cli-client.js +125 -10
  33. package/dist/providers/create-provider.js +14 -9
  34. package/dist/providers/idle-backend-shutdown.d.ts +16 -0
  35. package/dist/providers/idle-backend-shutdown.js +53 -0
  36. package/dist/types.d.ts +7 -0
  37. package/package.json +2 -2
  38. package/skills/borgee-agent/SKILL.md +19 -2
  39. package/skills/borgee-agent/borgee-agent.mjs +56 -1
  40. package/skills/borgee-agent/borgee-agent.py +33 -2
@@ -6,8 +6,18 @@ import spawn from 'cross-spawn';
6
6
  import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from '@agentclientprotocol/sdk';
7
7
  import { HostLogger, summarizeChildStderr, summarizeError } from '../../debug.js';
8
8
  import { assertCodexProjectDocumentSize, buildCodexProjectDocument } from './project-doc.js';
9
+ import { IDLE_BACKEND_SHUTDOWN_DISABLED_MS, IdleBackendShutdownScheduler, } from '../idle-backend-shutdown.js';
9
10
  const SESSION_TAINTED_ERRORS = new WeakSet();
10
11
  const DEFAULT_IDLE_SESSION_TTL_MS = 2 * 24 * 60 * 60 * 1000;
12
+ const IDLE_BACKEND_STOPPED_MESSAGE = 'Codex ACP backend stopped after idle timeout';
13
+ /**
14
+ * A fatal teardown has callers waiting on it, so it force-kills quickly. An
15
+ * idle recycle has nobody waiting, and the CLIs unlink their on-disk session
16
+ * locks during SIGTERM cleanup — a lock left behind by SIGKILL makes the next
17
+ * restore believe the session is still in use (github/copilot-cli#3255). So the
18
+ * idle path trades latency nobody observes for a clean exit.
19
+ */
20
+ const DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS = 5_000;
11
21
  const DEFAULT_SHUTDOWN_GRACE_PERIOD_MS = 250;
12
22
  const DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS = 250;
13
23
  const DEFAULT_SESSION_CAPABILITIES = {
@@ -30,6 +40,8 @@ const DEFAULT_RUNTIME = {
30
40
  protocolVersion: PROTOCOL_VERSION,
31
41
  cwd: process.cwd(),
32
42
  idleSessionTtlMs: DEFAULT_IDLE_SESSION_TTL_MS,
43
+ idleBackendShutdownMs: IDLE_BACKEND_SHUTDOWN_DISABLED_MS,
44
+ idleShutdownGracePeriodMs: DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS,
33
45
  shutdownGracePeriodMs: DEFAULT_SHUTDOWN_GRACE_PERIOD_MS,
34
46
  shutdownForceKillWaitMs: DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS,
35
47
  readFile: async (path, options) => {
@@ -286,6 +298,14 @@ export class CodexCliClient {
286
298
  logger;
287
299
  runtime;
288
300
  channels = new Map();
301
+ /**
302
+ * The provider session currently bound to channelId, or undefined before a
303
+ * turn has opened one. Surfaced so agents-host can record which session
304
+ * worked a task without the agent being asked to report its own id.
305
+ */
306
+ sessionIdForChannel(channelId) {
307
+ return this.channels.get(channelId)?.session?.sessionId;
308
+ }
289
309
  persistedSessions = new Map();
290
310
  closingSessions = new WeakSet();
291
311
  pendingSessionStarts = new Set();
@@ -298,7 +318,13 @@ export class CodexCliClient {
298
318
  startPromise;
299
319
  shutdownPromise;
300
320
  childExitPromise;
301
- resolveChildExit;
321
+ /**
322
+ * Bumped on every spawn so the `exit` / `connection.closed` handlers of a
323
+ * backend we deliberately recycled stay silent instead of latching a fatal
324
+ * error onto the client that already owns its successor.
325
+ */
326
+ backendGeneration = 0;
327
+ idleShutdownPromise;
302
328
  fatalError = null;
303
329
  disposing = false;
304
330
  backendClosed = false;
@@ -306,6 +332,7 @@ export class CodexCliClient {
306
332
  sessionStoreLoadPromise = null;
307
333
  sessionStoreWriteQueue = Promise.resolve();
308
334
  sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
335
+ idleBackendShutdown;
309
336
  constructor(command, args = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger()) {
310
337
  this.command = command;
311
338
  this.args = args;
@@ -313,6 +340,16 @@ export class CodexCliClient {
313
340
  this.resolveSessionStoreAgentId = resolveSessionStoreAgentId;
314
341
  this.logger = logger;
315
342
  this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
343
+ this.idleBackendShutdown = new IdleBackendShutdownScheduler({
344
+ idleShutdownMs: this.runtime.idleBackendShutdownMs,
345
+ isIdle: () => this.isBackendIdle(),
346
+ shutdown: () => this.shutdownIdleBackend(),
347
+ onShutdownFailed: (error) => {
348
+ this.logger.error('failed to stop the idle Codex ACP backend', {
349
+ error: summarizeError(error),
350
+ });
351
+ },
352
+ });
316
353
  this.fatalPromise = new Promise((_, reject) => {
317
354
  this.rejectFatalPromise = reject;
318
355
  });
@@ -334,6 +371,7 @@ export class CodexCliClient {
334
371
  const sessionRouting = resolveSessionRouting(preparedTurn);
335
372
  const state = this.getOrCreateChannelState(sessionRouting.key, preparedTurn.channelId, sessionRouting.persistence);
336
373
  this.clearIdleTimer(state);
374
+ this.idleBackendShutdown.cancel();
337
375
  const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn.prompt, preparedTurn.promptContext, sessionRouting.persistence, options);
338
376
  state.queue.push(turn);
339
377
  this.processChannelQueue(sessionRouting.key, state);
@@ -342,12 +380,17 @@ export class CodexCliClient {
342
380
  async dispose() {
343
381
  const error = new Error('Codex ACP backend stopped');
344
382
  this.disposing = true;
383
+ this.idleBackendShutdown.cancel();
345
384
  this.logger?.debug('stopping Codex ACP backend');
346
385
  const closed = this.connection?.closed ?? Promise.resolve();
347
386
  this.failAll(error);
348
387
  let thrown;
349
388
  try {
350
- await Promise.all([closed, this.shutdownPromise ?? Promise.resolve()]);
389
+ await Promise.all([
390
+ closed,
391
+ this.idleShutdownPromise ?? Promise.resolve(),
392
+ this.shutdownPromise ?? Promise.resolve(),
393
+ ]);
351
394
  }
352
395
  catch (disposeError) {
353
396
  thrown = disposeError;
@@ -368,6 +411,12 @@ export class CodexCliClient {
368
411
  if (this.fatalError) {
369
412
  throw this.fatalError;
370
413
  }
414
+ if (this.idleShutdownPromise) {
415
+ await this.idleShutdownPromise;
416
+ if (this.fatalError) {
417
+ throw this.fatalError;
418
+ }
419
+ }
371
420
  if (!this.startPromise) {
372
421
  this.startPromise = this.startBackend();
373
422
  }
@@ -380,6 +429,8 @@ export class CodexCliClient {
380
429
  }
381
430
  }
382
431
  async startBackend() {
432
+ const generation = ++this.backendGeneration;
433
+ const ownsCurrentBackend = () => (this.backendGeneration === generation && !this.disposing);
383
434
  const launch = resolveAdapterLaunch(this.command, this.args);
384
435
  this.logger?.debug('starting Codex ACP process', {
385
436
  command: launch.command,
@@ -396,13 +447,20 @@ export class CodexCliClient {
396
447
  });
397
448
  child.stderr.resume();
398
449
  this.logger?.debug('spawned Codex ACP process', { pid: child.pid });
450
+ let resolveChildExit;
451
+ this.childExitPromise = new Promise((resolve) => {
452
+ resolveChildExit = resolve;
453
+ });
399
454
  child.once('error', (error) => {
400
455
  this.logger?.debugError('Codex ACP process error', error);
456
+ if (!ownsCurrentBackend()) {
457
+ return;
458
+ }
401
459
  this.failAll(new Error(`Codex ACP process error: ${normalizeError(error).message}`));
402
460
  });
403
461
  child.once('exit', (code, signal) => {
404
- this.resolveChildExit?.();
405
- if (this.disposing) {
462
+ resolveChildExit();
463
+ if (!ownsCurrentBackend()) {
406
464
  this.logger?.debug('Codex ACP process exited during shutdown', { code, signal });
407
465
  return;
408
466
  }
@@ -410,9 +468,6 @@ export class CodexCliClient {
410
468
  const suffix = signal ? `signal ${signal}` : `code ${String(code ?? 'unknown')}`;
411
469
  this.failAll(new Error(`Codex ACP process exited unexpectedly (${suffix})`));
412
470
  });
413
- this.childExitPromise = new Promise((resolveChildExit) => {
414
- this.resolveChildExit = resolveChildExit;
415
- });
416
471
  const output = Writable.toWeb(child.stdin);
417
472
  const input = Readable.toWeb(child.stdout);
418
473
  const stream = this.runtime.ndJsonStream(output, input);
@@ -422,7 +477,7 @@ export class CodexCliClient {
422
477
  const connection = app.connect(stream);
423
478
  this.connection = connection;
424
479
  void connection.closed.then(() => {
425
- if (!this.disposing) {
480
+ if (ownsCurrentBackend()) {
426
481
  this.logger?.debugError('Codex ACP connection closed unexpectedly');
427
482
  this.failAll(new Error('Codex ACP connection closed unexpectedly'));
428
483
  }
@@ -512,6 +567,7 @@ export class CodexCliClient {
512
567
  finally {
513
568
  state.processing = false;
514
569
  this.reconcileIdleChannelState(channelId, state);
570
+ this.idleBackendShutdown.reconcile();
515
571
  }
516
572
  })();
517
573
  }
@@ -856,6 +912,7 @@ export class CodexCliClient {
856
912
  }
857
913
  this.fatalError = error;
858
914
  this.rejectFatalPromise(error);
915
+ this.idleBackendShutdown.cancel();
859
916
  for (const [channelId, state] of this.channels.entries()) {
860
917
  this.clearIdleTimer(state);
861
918
  state.activeTurn?.reject(error);
@@ -1073,6 +1130,66 @@ export class CodexCliClient {
1073
1130
  return;
1074
1131
  }
1075
1132
  this.backendClosed = true;
1133
+ await this.closeBackendProcess(error, this.runtime.shutdownGracePeriodMs);
1134
+ }
1135
+ /**
1136
+ * Backend teardown without the fatal latch: the client stays usable and
1137
+ * `ensureStarted()` spawns a replacement process on the next turn. Only safe
1138
+ * while `isBackendIdle()` holds, because every channel's live ACP session is
1139
+ * dropped here and no turn is ever replayed onto its successor.
1140
+ */
1141
+ async shutdownIdleBackend() {
1142
+ // Detach the running backend before any signal reaches it, so its own exit
1143
+ // and connection-closed handlers read this teardown as deliberate instead
1144
+ // of latching a fatal error on a client that stays usable.
1145
+ this.backendGeneration += 1;
1146
+ const error = new Error(IDLE_BACKEND_STOPPED_MESSAGE);
1147
+ const sessions = [];
1148
+ for (const [channelId, state] of [...this.channels.entries()]) {
1149
+ this.clearIdleTimer(state);
1150
+ if (state.session) {
1151
+ sessions.push(state.session);
1152
+ }
1153
+ state.session = undefined;
1154
+ state.sessionCwd = undefined;
1155
+ state.sessionAdditionalDirectories = undefined;
1156
+ state.sessionVisibilityKey = undefined;
1157
+ this.channels.delete(channelId);
1158
+ }
1159
+ this.logger?.debug('stopping idle Codex ACP backend', {
1160
+ channels: sessions.length,
1161
+ idleShutdownMs: this.runtime.idleBackendShutdownMs,
1162
+ });
1163
+ for (const session of sessions) {
1164
+ this.closeSession(session);
1165
+ }
1166
+ const idleShutdownPromise = this.closeBackendProcess(error, this.runtime.idleShutdownGracePeriodMs).finally(() => {
1167
+ this.startPromise = undefined;
1168
+ this.childExitPromise = undefined;
1169
+ this.sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
1170
+ this.idleShutdownPromise = undefined;
1171
+ });
1172
+ this.idleShutdownPromise = idleShutdownPromise;
1173
+ await idleShutdownPromise;
1174
+ }
1175
+ isBackendIdle() {
1176
+ if (this.disposing || this.fatalError || this.backendClosed || this.idleShutdownPromise) {
1177
+ return false;
1178
+ }
1179
+ if (!this.child) {
1180
+ return false;
1181
+ }
1182
+ if (this.pendingSessionStarts.size > 0) {
1183
+ return false;
1184
+ }
1185
+ for (const state of this.channels.values()) {
1186
+ if (state.activeTurn || state.queue.length > 0 || state.sessionPromise || state.processing) {
1187
+ return false;
1188
+ }
1189
+ }
1190
+ return true;
1191
+ }
1192
+ async closeBackendProcess(error, gracePeriodMs) {
1076
1193
  const connection = this.connection;
1077
1194
  const child = this.child;
1078
1195
  await this.waitForPendingSessionStarts();
@@ -1084,9 +1201,9 @@ export class CodexCliClient {
1084
1201
  return;
1085
1202
  }
1086
1203
  const childExitPromise = this.childExitPromise ?? Promise.resolve();
1087
- this.logger?.debug('sending SIGTERM to Codex ACP process');
1204
+ this.logger?.debug('sending SIGTERM to Codex ACP process', { gracePeriodMs });
1088
1205
  child.kill('SIGTERM');
1089
- const exitedAfterTerm = await this.waitForChildExit(childExitPromise, this.runtime.shutdownGracePeriodMs);
1206
+ const exitedAfterTerm = await this.waitForChildExit(childExitPromise, gracePeriodMs);
1090
1207
  if (exitedAfterTerm) {
1091
1208
  this.logger?.debug('Codex ACP process exited after SIGTERM');
1092
1209
  this.child = undefined;
@@ -2,6 +2,7 @@ import { basename } from 'node:path';
2
2
  import { buildAttentionSummaryLines } from '../../context/attention.js';
3
3
  import { buildCollaborationCapabilityDeclarationSummaryLines, buildMissedCollaborationDiagnosticSummaryLines, } from '../../context/collaboration-capabilities-diagnostics.js';
4
4
  import { buildCollaborationOutcomeSummaryLines } from '../../context/collaboration-outcome.js';
5
+ import { MAIN_SESSION_DELEGATION_LINES } from '../../context/main-session-delegation.js';
5
6
  import { buildTaskThreadCollaborationSummaryLines } from '../../context/task-thread-collaboration.js';
6
7
  const PROJECT_DOC_MAX_BYTES = 32 * 1024;
7
8
  function buildSkillRuntimeLines(context) {
@@ -37,6 +38,8 @@ export function buildCodexProjectDocument(context) {
37
38
  '',
38
39
  'This workspace belongs to one Borgee channel session hosted by agents-host.',
39
40
  'Keep visible replies concise, do not claim actions you did not perform, and prefer the per-turn prompt when it provides newer turn-local details.',
41
+ '',
42
+ ...MAIN_SESSION_DELEGATION_LINES,
40
43
  ...(context?.channelContextPayloadPath
41
44
  ? [
42
45
  '',
@@ -11,7 +11,9 @@ export class CopilotProviderAdapter {
11
11
  const text = await this.cli.generateReply(preparedTurn, {
12
12
  onProgress: createAwaitingUserProgressHandler(options?.onProgress),
13
13
  });
14
- return parseProviderReply(text);
14
+ // Read the session AFTER the turn: a first turn on a channel opens the
15
+ // session as it runs, so before this point there is nothing to report.
16
+ return { ...parseProviderReply(text), sessionId: this.cli.sessionIdForChannel(input.channelId) };
15
17
  }
16
18
  async dispose() {
17
19
  await this.cli.dispose();
@@ -13,6 +13,8 @@ interface CopilotAcpRuntime {
13
13
  protocolVersion: typeof PROTOCOL_VERSION;
14
14
  cwd: string;
15
15
  idleSessionTtlMs: number;
16
+ idleBackendShutdownMs: number;
17
+ idleShutdownGracePeriodMs: number;
16
18
  shutdownGracePeriodMs: number;
17
19
  shutdownForceKillWaitMs: number;
18
20
  }
@@ -37,6 +39,12 @@ export declare class CopilotCliClient {
37
39
  private readonly permissionPolicy;
38
40
  private readonly runtime;
39
41
  private readonly channels;
42
+ /**
43
+ * The provider session currently bound to channelId, or undefined before a
44
+ * turn has opened one. Surfaced so agents-host can record which session
45
+ * worked a task without the agent being asked to report its own id.
46
+ */
47
+ sessionIdForChannel(channelId: string): string | undefined;
40
48
  private readonly persistedSessions;
41
49
  private readonly closingSessions;
42
50
  private readonly pendingSessionStarts;
@@ -49,7 +57,13 @@ export declare class CopilotCliClient {
49
57
  private startPromise?;
50
58
  private shutdownPromise?;
51
59
  private childExitPromise?;
52
- private resolveChildExit?;
60
+ /**
61
+ * Bumped on every spawn so the `exit` / `connection.closed` handlers of a
62
+ * backend we deliberately recycled stay silent instead of latching a fatal
63
+ * error onto the client that already owns its successor.
64
+ */
65
+ private backendGeneration;
66
+ private idleShutdownPromise?;
53
67
  private fatalError;
54
68
  private disposing;
55
69
  private backendClosed;
@@ -57,6 +71,7 @@ export declare class CopilotCliClient {
57
71
  private sessionStoreLoadPromise;
58
72
  private sessionStoreWriteQueue;
59
73
  private sessionCapabilities;
74
+ private readonly idleBackendShutdown;
60
75
  constructor(command: string, _ignoredArgs?: string[], runtimeOverrides?: Partial<CopilotAcpRuntime>, sessionStore?: CopilotChannelSessionStore | undefined, resolveSessionStoreAgentId?: () => string | undefined, logger?: DebugLogger, permissionPolicy?: CopilotPermissionPolicyOptions);
61
76
  generateReply(turn: PreparedProviderTurnInput, options?: ProviderGenerateOptions): Promise<string>;
62
77
  generateReply(channelId: string, prompt: string, options?: ProviderGenerateOptions): Promise<string>;
@@ -92,6 +107,15 @@ export declare class CopilotCliClient {
92
107
  private trackSessionStoreOperation;
93
108
  private evictIdleChannel;
94
109
  private shutdownBackend;
110
+ /**
111
+ * Backend teardown without the fatal latch: the client stays usable and
112
+ * `ensureStarted()` spawns a replacement process on the next turn. Only safe
113
+ * while `isBackendIdle()` holds, because every channel's live ACP session is
114
+ * dropped here and no turn is ever replayed onto its successor.
115
+ */
116
+ private shutdownIdleBackend;
117
+ private isBackendIdle;
118
+ private closeBackendProcess;
95
119
  private waitForPendingSessionStarts;
96
120
  private waitForPendingSessionCloses;
97
121
  private waitForChildExit;
@@ -3,8 +3,18 @@ import spawn from 'cross-spawn';
3
3
  import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from '@agentclientprotocol/sdk';
4
4
  import { HostLogger, summarizeChildStderr, summarizeError } from '../../debug.js';
5
5
  import { resolveCopilotPermissionResponse } from '../../policy/copilot-permission.js';
6
+ import { IDLE_BACKEND_SHUTDOWN_DISABLED_MS, IdleBackendShutdownScheduler, } from '../idle-backend-shutdown.js';
6
7
  const SESSION_TAINTED_ERRORS = new WeakSet();
7
8
  const DEFAULT_IDLE_SESSION_TTL_MS = 2 * 24 * 60 * 60 * 1000;
9
+ const IDLE_BACKEND_STOPPED_MESSAGE = 'Copilot ACP backend stopped after idle timeout';
10
+ /**
11
+ * A fatal teardown has callers waiting on it, so it force-kills quickly. An
12
+ * idle recycle has nobody waiting, and the CLIs unlink their on-disk session
13
+ * locks during SIGTERM cleanup — a lock left behind by SIGKILL makes the next
14
+ * restore believe the session is still in use (github/copilot-cli#3255). So the
15
+ * idle path trades latency nobody observes for a clean exit.
16
+ */
17
+ const DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS = 5_000;
8
18
  const DEFAULT_SHUTDOWN_GRACE_PERIOD_MS = 250;
9
19
  const DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS = 250;
10
20
  const QUEUED_TURN_DROPPED_MESSAGE = 'Copilot ACP session was reset after a failed turn; queued turns were dropped instead of replaying them on a fresh session';
@@ -16,6 +26,8 @@ const DEFAULT_RUNTIME = {
16
26
  protocolVersion: PROTOCOL_VERSION,
17
27
  cwd: process.cwd(),
18
28
  idleSessionTtlMs: DEFAULT_IDLE_SESSION_TTL_MS,
29
+ idleBackendShutdownMs: IDLE_BACKEND_SHUTDOWN_DISABLED_MS,
30
+ idleShutdownGracePeriodMs: DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS,
19
31
  shutdownGracePeriodMs: DEFAULT_SHUTDOWN_GRACE_PERIOD_MS,
20
32
  shutdownForceKillWaitMs: DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS,
21
33
  };
@@ -206,6 +218,14 @@ export class CopilotCliClient {
206
218
  permissionPolicy;
207
219
  runtime;
208
220
  channels = new Map();
221
+ /**
222
+ * The provider session currently bound to channelId, or undefined before a
223
+ * turn has opened one. Surfaced so agents-host can record which session
224
+ * worked a task without the agent being asked to report its own id.
225
+ */
226
+ sessionIdForChannel(channelId) {
227
+ return this.channels.get(channelId)?.session?.sessionId;
228
+ }
209
229
  persistedSessions = new Map();
210
230
  closingSessions = new WeakSet();
211
231
  pendingSessionStarts = new Set();
@@ -218,7 +238,13 @@ export class CopilotCliClient {
218
238
  startPromise;
219
239
  shutdownPromise;
220
240
  childExitPromise;
221
- resolveChildExit;
241
+ /**
242
+ * Bumped on every spawn so the `exit` / `connection.closed` handlers of a
243
+ * backend we deliberately recycled stay silent instead of latching a fatal
244
+ * error onto the client that already owns its successor.
245
+ */
246
+ backendGeneration = 0;
247
+ idleShutdownPromise;
222
248
  fatalError = null;
223
249
  disposing = false;
224
250
  backendClosed = false;
@@ -226,6 +252,7 @@ export class CopilotCliClient {
226
252
  sessionStoreLoadPromise = null;
227
253
  sessionStoreWriteQueue = Promise.resolve();
228
254
  sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
255
+ idleBackendShutdown;
229
256
  constructor(command, _ignoredArgs = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger(), permissionPolicy = {}) {
230
257
  this.command = command;
231
258
  this.sessionStore = sessionStore;
@@ -233,6 +260,16 @@ export class CopilotCliClient {
233
260
  this.logger = logger;
234
261
  this.permissionPolicy = permissionPolicy;
235
262
  this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
263
+ this.idleBackendShutdown = new IdleBackendShutdownScheduler({
264
+ idleShutdownMs: this.runtime.idleBackendShutdownMs,
265
+ isIdle: () => this.isBackendIdle(),
266
+ shutdown: () => this.shutdownIdleBackend(),
267
+ onShutdownFailed: (error) => {
268
+ this.logger.error('failed to stop the idle Copilot ACP backend', {
269
+ error: summarizeError(error),
270
+ });
271
+ },
272
+ });
236
273
  this.fatalPromise = new Promise((_, reject) => {
237
274
  this.rejectFatalPromise = reject;
238
275
  });
@@ -254,6 +291,7 @@ export class CopilotCliClient {
254
291
  const sessionRouting = resolveSessionRouting(preparedTurn);
255
292
  const state = this.getOrCreateChannelState(sessionRouting.key, preparedTurn.channelId, sessionRouting.persistence);
256
293
  this.clearIdleTimer(state);
294
+ this.idleBackendShutdown.cancel();
257
295
  const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn.prompt, sessionRouting.persistence, preparedTurn.promptContext, options);
258
296
  state.queue.push(turn);
259
297
  this.processChannelQueue(sessionRouting.key, state);
@@ -262,12 +300,17 @@ export class CopilotCliClient {
262
300
  async dispose() {
263
301
  const error = new Error('Copilot ACP backend stopped');
264
302
  this.disposing = true;
303
+ this.idleBackendShutdown.cancel();
265
304
  this.logger?.debug('stopping Copilot ACP backend');
266
305
  const closed = this.connection?.closed ?? Promise.resolve();
267
306
  this.failAll(error);
268
307
  let thrown;
269
308
  try {
270
- await Promise.all([closed, this.shutdownPromise ?? Promise.resolve()]);
309
+ await Promise.all([
310
+ closed,
311
+ this.idleShutdownPromise ?? Promise.resolve(),
312
+ this.shutdownPromise ?? Promise.resolve(),
313
+ ]);
271
314
  }
272
315
  catch (disposeError) {
273
316
  thrown = disposeError;
@@ -288,6 +331,12 @@ export class CopilotCliClient {
288
331
  if (this.fatalError) {
289
332
  throw this.fatalError;
290
333
  }
334
+ if (this.idleShutdownPromise) {
335
+ await this.idleShutdownPromise;
336
+ if (this.fatalError) {
337
+ throw this.fatalError;
338
+ }
339
+ }
291
340
  if (!this.startPromise) {
292
341
  this.startPromise = this.startBackend();
293
342
  }
@@ -300,6 +349,8 @@ export class CopilotCliClient {
300
349
  }
301
350
  }
302
351
  async startBackend() {
352
+ const generation = ++this.backendGeneration;
353
+ const ownsCurrentBackend = () => (this.backendGeneration === generation && !this.disposing);
303
354
  this.logger?.debug('starting Copilot ACP process', {
304
355
  command: this.command,
305
356
  args: ['--acp'],
@@ -315,13 +366,20 @@ export class CopilotCliClient {
315
366
  });
316
367
  child.stderr.resume();
317
368
  this.logger?.debug('spawned Copilot ACP process', { pid: child.pid });
369
+ let resolveChildExit;
370
+ this.childExitPromise = new Promise((resolve) => {
371
+ resolveChildExit = resolve;
372
+ });
318
373
  child.once('error', (error) => {
319
374
  this.logger?.debugError('Copilot ACP process error', error);
375
+ if (!ownsCurrentBackend()) {
376
+ return;
377
+ }
320
378
  this.failAll(new Error(`Copilot ACP process error: ${normalizeError(error).message}`));
321
379
  });
322
380
  child.once('exit', (code, signal) => {
323
- this.resolveChildExit?.();
324
- if (this.disposing) {
381
+ resolveChildExit();
382
+ if (!ownsCurrentBackend()) {
325
383
  this.logger?.debug('Copilot ACP process exited during shutdown', { code, signal });
326
384
  return;
327
385
  }
@@ -329,9 +387,6 @@ export class CopilotCliClient {
329
387
  const suffix = signal ? `signal ${signal}` : `code ${String(code ?? 'unknown')}`;
330
388
  this.failAll(new Error(`Copilot ACP process exited unexpectedly (${suffix})`));
331
389
  });
332
- this.childExitPromise = new Promise((resolve) => {
333
- this.resolveChildExit = resolve;
334
- });
335
390
  const output = Writable.toWeb(child.stdin);
336
391
  const input = Readable.toWeb(child.stdout);
337
392
  const stream = this.runtime.ndJsonStream(output, input);
@@ -341,7 +396,7 @@ export class CopilotCliClient {
341
396
  const connection = app.connect(stream);
342
397
  this.connection = connection;
343
398
  void connection.closed.then(() => {
344
- if (!this.disposing) {
399
+ if (ownsCurrentBackend()) {
345
400
  this.logger?.debugError('Copilot ACP connection closed unexpectedly');
346
401
  this.failAll(new Error('Copilot ACP connection closed unexpectedly'));
347
402
  }
@@ -428,6 +483,7 @@ export class CopilotCliClient {
428
483
  finally {
429
484
  state.processing = false;
430
485
  this.reconcileIdleChannelState(channelId, state);
486
+ this.idleBackendShutdown.reconcile();
431
487
  }
432
488
  })();
433
489
  }
@@ -643,6 +699,7 @@ export class CopilotCliClient {
643
699
  }
644
700
  this.fatalError = error;
645
701
  this.rejectFatalPromise(error);
702
+ this.idleBackendShutdown.cancel();
646
703
  for (const [channelId, state] of this.channels.entries()) {
647
704
  this.clearIdleTimer(state);
648
705
  state.activeTurn?.reject(error);
@@ -884,6 +941,64 @@ export class CopilotCliClient {
884
941
  return;
885
942
  }
886
943
  this.backendClosed = true;
944
+ await this.closeBackendProcess(error, this.runtime.shutdownGracePeriodMs);
945
+ }
946
+ /**
947
+ * Backend teardown without the fatal latch: the client stays usable and
948
+ * `ensureStarted()` spawns a replacement process on the next turn. Only safe
949
+ * while `isBackendIdle()` holds, because every channel's live ACP session is
950
+ * dropped here and no turn is ever replayed onto its successor.
951
+ */
952
+ async shutdownIdleBackend() {
953
+ // Detach the running backend before any signal reaches it, so its own exit
954
+ // and connection-closed handlers read this teardown as deliberate instead
955
+ // of latching a fatal error on a client that stays usable.
956
+ this.backendGeneration += 1;
957
+ const error = new Error(IDLE_BACKEND_STOPPED_MESSAGE);
958
+ const sessions = [];
959
+ for (const [channelId, state] of [...this.channels.entries()]) {
960
+ this.clearIdleTimer(state);
961
+ if (state.session) {
962
+ sessions.push(state.session);
963
+ }
964
+ state.session = undefined;
965
+ state.sessionCwd = undefined;
966
+ this.channels.delete(channelId);
967
+ }
968
+ this.logger?.debug('stopping idle Copilot ACP backend', {
969
+ channels: sessions.length,
970
+ idleShutdownMs: this.runtime.idleBackendShutdownMs,
971
+ });
972
+ for (const session of sessions) {
973
+ this.closeSession(session);
974
+ }
975
+ const idleShutdownPromise = this.closeBackendProcess(error, this.runtime.idleShutdownGracePeriodMs).finally(() => {
976
+ this.startPromise = undefined;
977
+ this.childExitPromise = undefined;
978
+ this.sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
979
+ this.idleShutdownPromise = undefined;
980
+ });
981
+ this.idleShutdownPromise = idleShutdownPromise;
982
+ await idleShutdownPromise;
983
+ }
984
+ isBackendIdle() {
985
+ if (this.disposing || this.fatalError || this.backendClosed || this.idleShutdownPromise) {
986
+ return false;
987
+ }
988
+ if (!this.child) {
989
+ return false;
990
+ }
991
+ if (this.pendingSessionStarts.size > 0) {
992
+ return false;
993
+ }
994
+ for (const state of this.channels.values()) {
995
+ if (state.activeTurn || state.queue.length > 0 || state.sessionPromise || state.processing) {
996
+ return false;
997
+ }
998
+ }
999
+ return true;
1000
+ }
1001
+ async closeBackendProcess(error, gracePeriodMs) {
887
1002
  const connection = this.connection;
888
1003
  const child = this.child;
889
1004
  await this.waitForPendingSessionStarts();
@@ -895,9 +1010,9 @@ export class CopilotCliClient {
895
1010
  return;
896
1011
  }
897
1012
  const childExitPromise = this.childExitPromise ?? Promise.resolve();
898
- this.logger?.debug('sending SIGTERM to Copilot ACP process');
1013
+ this.logger?.debug('sending SIGTERM to Copilot ACP process', { gracePeriodMs });
899
1014
  child.kill('SIGTERM');
900
- const exitedAfterTerm = await this.waitForChildExit(childExitPromise, this.runtime.shutdownGracePeriodMs);
1015
+ const exitedAfterTerm = await this.waitForChildExit(childExitPromise, gracePeriodMs);
901
1016
  if (exitedAfterTerm) {
902
1017
  this.logger?.debug('Copilot ACP process exited after SIGTERM');
903
1018
  this.child = undefined;