@borgee/agents-host 0.2.33 → 0.2.44

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 (60) hide show
  1. package/README.md +28 -7
  2. package/dist/agents-host.d.ts +19 -0
  3. package/dist/agents-host.js +163 -44
  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 +9 -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/injection.d.ts +5 -3
  14. package/dist/context/injection.js +45 -29
  15. package/dist/context/main-session-delegation.d.ts +1 -0
  16. package/dist/context/main-session-delegation.js +6 -0
  17. package/dist/context/prompt.js +25 -35
  18. package/dist/context/skill-manual.d.ts +13 -0
  19. package/dist/context/skill-manual.js +18 -0
  20. package/dist/context/turn-preparation.js +13 -4
  21. package/dist/gateway/localhost-gateway.js +75 -1
  22. package/dist/hosted-turn-content.d.ts +15 -0
  23. package/dist/hosted-turn-content.js +50 -0
  24. package/dist/local-config.js +10 -1
  25. package/dist/managed-daemon.d.ts +3 -2
  26. package/dist/managed-daemon.js +82 -29
  27. package/dist/plugin-sdk.js +58 -1
  28. package/dist/plugin-sdk.js.map +2 -2
  29. package/dist/policy/gateway-authorization.d.ts +18 -3
  30. package/dist/policy/gateway-authorization.js +33 -1
  31. package/dist/providers/claude/adapter.d.ts +3 -1
  32. package/dist/providers/claude/adapter.js +13 -1
  33. package/dist/providers/claude/cli-client.d.ts +36 -3
  34. package/dist/providers/claude/cli-client.js +225 -37
  35. package/dist/providers/codex/adapter.d.ts +3 -1
  36. package/dist/providers/codex/adapter.js +13 -1
  37. package/dist/providers/codex/cli-client.d.ts +35 -3
  38. package/dist/providers/codex/cli-client.js +212 -31
  39. package/dist/providers/codex/project-doc.js +16 -29
  40. package/dist/providers/copilot/adapter.d.ts +3 -1
  41. package/dist/providers/copilot/adapter.js +13 -1
  42. package/dist/providers/copilot/cli-client.d.ts +34 -2
  43. package/dist/providers/copilot/cli-client.js +196 -18
  44. package/dist/providers/create-provider.d.ts +1 -1
  45. package/dist/providers/create-provider.js +30 -14
  46. package/dist/providers/idle-backend-shutdown.d.ts +16 -0
  47. package/dist/providers/idle-backend-shutdown.js +53 -0
  48. package/dist/providers/provider-adapter.d.ts +35 -0
  49. package/dist/providers/provider-adapter.js +44 -1
  50. package/dist/state-paths.d.ts +9 -1
  51. package/dist/state-paths.js +22 -3
  52. package/dist/types.d.ts +40 -2
  53. package/package.json +2 -2
  54. package/skills/borgee-agent/SKILL.md +133 -35
  55. package/skills/borgee-agent/references/errors.md +38 -0
  56. package/skills/borgee-agent/references/task-properties.md +30 -0
  57. package/skills/borgee-agent/scripts/borgee-agent.mjs +553 -0
  58. package/skills/borgee-agent/scripts/borgee-agent.py +547 -0
  59. package/skills/borgee-agent/borgee-agent.mjs +0 -507
  60. package/skills/borgee-agent/borgee-agent.py +0 -438
@@ -13,14 +13,21 @@ 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;
20
+ fetch(input: string | URL, init?: RequestInit): Promise<Response>;
18
21
  }
19
22
  interface CopilotPermissionPolicyOptions {
20
23
  gateEnabled?: boolean;
21
24
  policyMode?: InternalPolicyMode;
22
25
  auditSink?: AuthorizationAuditSinkLike;
23
26
  }
27
+ interface CopilotHostedImageInputConfig {
28
+ borgeeBaseUrl?: string;
29
+ agentApiKey?: string;
30
+ }
24
31
  /**
25
32
  * Persistent ACP-backed client for the GitHub Copilot CLI (`copilot --acp`).
26
33
  *
@@ -35,8 +42,15 @@ export declare class CopilotCliClient {
35
42
  private readonly resolveSessionStoreAgentId;
36
43
  private readonly logger;
37
44
  private readonly permissionPolicy;
45
+ private readonly imageInputConfig;
38
46
  private readonly runtime;
39
47
  private readonly channels;
48
+ /**
49
+ * The provider session currently bound to channelId, or undefined before a
50
+ * turn has opened one. Surfaced so agents-host can record which session
51
+ * worked a task without the agent being asked to report its own id.
52
+ */
53
+ sessionIdForChannel(channelId: string): string | undefined;
40
54
  private readonly persistedSessions;
41
55
  private readonly closingSessions;
42
56
  private readonly pendingSessionStarts;
@@ -49,7 +63,13 @@ export declare class CopilotCliClient {
49
63
  private startPromise?;
50
64
  private shutdownPromise?;
51
65
  private childExitPromise?;
52
- private resolveChildExit?;
66
+ /**
67
+ * Bumped on every spawn so the `exit` / `connection.closed` handlers of a
68
+ * backend we deliberately recycled stay silent instead of latching a fatal
69
+ * error onto the client that already owns its successor.
70
+ */
71
+ private backendGeneration;
72
+ private idleShutdownPromise?;
53
73
  private fatalError;
54
74
  private disposing;
55
75
  private backendClosed;
@@ -57,7 +77,8 @@ export declare class CopilotCliClient {
57
77
  private sessionStoreLoadPromise;
58
78
  private sessionStoreWriteQueue;
59
79
  private sessionCapabilities;
60
- constructor(command: string, _ignoredArgs?: string[], runtimeOverrides?: Partial<CopilotAcpRuntime>, sessionStore?: CopilotChannelSessionStore | undefined, resolveSessionStoreAgentId?: () => string | undefined, logger?: DebugLogger, permissionPolicy?: CopilotPermissionPolicyOptions);
80
+ private readonly idleBackendShutdown;
81
+ constructor(command: string, _ignoredArgs?: string[], runtimeOverrides?: Partial<CopilotAcpRuntime>, sessionStore?: CopilotChannelSessionStore | undefined, resolveSessionStoreAgentId?: () => string | undefined, logger?: DebugLogger, permissionPolicy?: CopilotPermissionPolicyOptions, imageInputConfig?: CopilotHostedImageInputConfig);
61
82
  generateReply(turn: PreparedProviderTurnInput, options?: ProviderGenerateOptions): Promise<string>;
62
83
  generateReply(channelId: string, prompt: string, options?: ProviderGenerateOptions): Promise<string>;
63
84
  dispose(): Promise<void>;
@@ -71,6 +92,8 @@ export declare class CopilotCliClient {
71
92
  private restoreSession;
72
93
  private clearBufferedSessionReplay;
73
94
  private resolveSessionCwd;
95
+ private buildPromptInput;
96
+ private fetchImageBlock;
74
97
  private recycleSessionIfCwdChanged;
75
98
  private runTurn;
76
99
  private raceWithFatal;
@@ -92,6 +115,15 @@ export declare class CopilotCliClient {
92
115
  private trackSessionStoreOperation;
93
116
  private evictIdleChannel;
94
117
  private shutdownBackend;
118
+ /**
119
+ * Backend teardown without the fatal latch: the client stays usable and
120
+ * `ensureStarted()` spawns a replacement process on the next turn. Only safe
121
+ * while `isBackendIdle()` holds, because every channel's live ACP session is
122
+ * dropped here and no turn is ever replayed onto its successor.
123
+ */
124
+ private shutdownIdleBackend;
125
+ private isBackendIdle;
126
+ private closeBackendProcess;
95
127
  private waitForPendingSessionStarts;
96
128
  private waitForPendingSessionCloses;
97
129
  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,8 +26,11 @@ 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,
33
+ fetch: async (input, init) => fetch(input, init),
21
34
  };
22
35
  const DEFAULT_SESSION_CAPABILITIES = {
23
36
  loadSession: false,
@@ -26,6 +39,28 @@ const DEFAULT_SESSION_CAPABILITIES = {
26
39
  function hasVisibleText(value) {
27
40
  return typeof value === 'string' && value.trim().length > 0;
28
41
  }
42
+ function isAbsoluteHttpUrl(value) {
43
+ return value.startsWith('http://') || value.startsWith('https://');
44
+ }
45
+ function resolveHostedAttachmentUrl(rawUrl, borgeeBaseUrl) {
46
+ if (isAbsoluteHttpUrl(rawUrl)) {
47
+ return rawUrl;
48
+ }
49
+ const base = borgeeBaseUrl.replace(/\/$/, '');
50
+ const path = rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`;
51
+ return `${base}${path}`;
52
+ }
53
+ function isSameOrigin(left, right) {
54
+ try {
55
+ return new URL(left).origin === new URL(right).origin;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ function asImagePart(part) {
62
+ return part.type === 'image' ? part : null;
63
+ }
29
64
  function formatToolProgress(title, status) {
30
65
  const normalizedTitle = hasVisibleText(title) ? title.trim() : null;
31
66
  switch (status) {
@@ -104,7 +139,7 @@ class CopilotProgressCollector {
104
139
  this.onProgress({ text });
105
140
  }
106
141
  }
107
- function createDeferredTurn(channelId, prompt, sessionPersistence, promptContext, options) {
142
+ function createDeferredTurn(channelId, preparedTurn, sessionPersistence, options) {
108
143
  let settled = false;
109
144
  let resolvePromise;
110
145
  let rejectPromise;
@@ -114,9 +149,8 @@ function createDeferredTurn(channelId, prompt, sessionPersistence, promptContext
114
149
  });
115
150
  return {
116
151
  channelId,
117
- prompt,
152
+ preparedTurn,
118
153
  sessionPersistence,
119
- promptContext,
120
154
  options,
121
155
  promise,
122
156
  resolve(value) {
@@ -204,8 +238,17 @@ export class CopilotCliClient {
204
238
  resolveSessionStoreAgentId;
205
239
  logger;
206
240
  permissionPolicy;
241
+ imageInputConfig;
207
242
  runtime;
208
243
  channels = new Map();
244
+ /**
245
+ * The provider session currently bound to channelId, or undefined before a
246
+ * turn has opened one. Surfaced so agents-host can record which session
247
+ * worked a task without the agent being asked to report its own id.
248
+ */
249
+ sessionIdForChannel(channelId) {
250
+ return this.channels.get(channelId)?.session?.sessionId;
251
+ }
209
252
  persistedSessions = new Map();
210
253
  closingSessions = new WeakSet();
211
254
  pendingSessionStarts = new Set();
@@ -218,7 +261,13 @@ export class CopilotCliClient {
218
261
  startPromise;
219
262
  shutdownPromise;
220
263
  childExitPromise;
221
- resolveChildExit;
264
+ /**
265
+ * Bumped on every spawn so the `exit` / `connection.closed` handlers of a
266
+ * backend we deliberately recycled stay silent instead of latching a fatal
267
+ * error onto the client that already owns its successor.
268
+ */
269
+ backendGeneration = 0;
270
+ idleShutdownPromise;
222
271
  fatalError = null;
223
272
  disposing = false;
224
273
  backendClosed = false;
@@ -226,13 +275,25 @@ export class CopilotCliClient {
226
275
  sessionStoreLoadPromise = null;
227
276
  sessionStoreWriteQueue = Promise.resolve();
228
277
  sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
229
- constructor(command, _ignoredArgs = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger(), permissionPolicy = {}) {
278
+ idleBackendShutdown;
279
+ constructor(command, _ignoredArgs = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger(), permissionPolicy = {}, imageInputConfig = {}) {
230
280
  this.command = command;
231
281
  this.sessionStore = sessionStore;
232
282
  this.resolveSessionStoreAgentId = resolveSessionStoreAgentId;
233
283
  this.logger = logger;
234
284
  this.permissionPolicy = permissionPolicy;
285
+ this.imageInputConfig = imageInputConfig;
235
286
  this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
287
+ this.idleBackendShutdown = new IdleBackendShutdownScheduler({
288
+ idleShutdownMs: this.runtime.idleBackendShutdownMs,
289
+ isIdle: () => this.isBackendIdle(),
290
+ shutdown: () => this.shutdownIdleBackend(),
291
+ onShutdownFailed: (error) => {
292
+ this.logger.error('failed to stop the idle Copilot ACP backend', {
293
+ error: summarizeError(error),
294
+ });
295
+ },
296
+ });
236
297
  this.fatalPromise = new Promise((_, reject) => {
237
298
  this.rejectFatalPromise = reject;
238
299
  });
@@ -242,19 +303,27 @@ export class CopilotCliClient {
242
303
  if (this.fatalError) {
243
304
  throw this.fatalError;
244
305
  }
245
- const preparedTurn = typeof channelIdOrTurn === 'string'
306
+ const preparedTurnBase = typeof channelIdOrTurn === 'string'
246
307
  ? {
247
308
  channelId: channelIdOrTurn,
309
+ incomingContent: '',
310
+ incomingParts: [],
248
311
  prompt: typeof promptOrOptions === 'string' ? promptOrOptions : '',
249
312
  }
250
313
  : channelIdOrTurn;
314
+ const preparedTurn = {
315
+ ...preparedTurnBase,
316
+ incomingContent: preparedTurnBase.incomingContent ?? '',
317
+ incomingParts: preparedTurnBase.incomingParts ?? [],
318
+ };
251
319
  const options = typeof channelIdOrTurn === 'string'
252
320
  ? maybeOptions
253
321
  : promptOrOptions;
254
322
  const sessionRouting = resolveSessionRouting(preparedTurn);
255
323
  const state = this.getOrCreateChannelState(sessionRouting.key, preparedTurn.channelId, sessionRouting.persistence);
256
324
  this.clearIdleTimer(state);
257
- const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn.prompt, sessionRouting.persistence, preparedTurn.promptContext, options);
325
+ this.idleBackendShutdown.cancel();
326
+ const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn, sessionRouting.persistence, options);
258
327
  state.queue.push(turn);
259
328
  this.processChannelQueue(sessionRouting.key, state);
260
329
  return turn.promise;
@@ -262,12 +331,17 @@ export class CopilotCliClient {
262
331
  async dispose() {
263
332
  const error = new Error('Copilot ACP backend stopped');
264
333
  this.disposing = true;
334
+ this.idleBackendShutdown.cancel();
265
335
  this.logger?.debug('stopping Copilot ACP backend');
266
336
  const closed = this.connection?.closed ?? Promise.resolve();
267
337
  this.failAll(error);
268
338
  let thrown;
269
339
  try {
270
- await Promise.all([closed, this.shutdownPromise ?? Promise.resolve()]);
340
+ await Promise.all([
341
+ closed,
342
+ this.idleShutdownPromise ?? Promise.resolve(),
343
+ this.shutdownPromise ?? Promise.resolve(),
344
+ ]);
271
345
  }
272
346
  catch (disposeError) {
273
347
  thrown = disposeError;
@@ -288,6 +362,12 @@ export class CopilotCliClient {
288
362
  if (this.fatalError) {
289
363
  throw this.fatalError;
290
364
  }
365
+ if (this.idleShutdownPromise) {
366
+ await this.idleShutdownPromise;
367
+ if (this.fatalError) {
368
+ throw this.fatalError;
369
+ }
370
+ }
291
371
  if (!this.startPromise) {
292
372
  this.startPromise = this.startBackend();
293
373
  }
@@ -300,6 +380,8 @@ export class CopilotCliClient {
300
380
  }
301
381
  }
302
382
  async startBackend() {
383
+ const generation = ++this.backendGeneration;
384
+ const ownsCurrentBackend = () => (this.backendGeneration === generation && !this.disposing);
303
385
  this.logger?.debug('starting Copilot ACP process', {
304
386
  command: this.command,
305
387
  args: ['--acp'],
@@ -315,13 +397,20 @@ export class CopilotCliClient {
315
397
  });
316
398
  child.stderr.resume();
317
399
  this.logger?.debug('spawned Copilot ACP process', { pid: child.pid });
400
+ let resolveChildExit;
401
+ this.childExitPromise = new Promise((resolve) => {
402
+ resolveChildExit = resolve;
403
+ });
318
404
  child.once('error', (error) => {
319
405
  this.logger?.debugError('Copilot ACP process error', error);
406
+ if (!ownsCurrentBackend()) {
407
+ return;
408
+ }
320
409
  this.failAll(new Error(`Copilot ACP process error: ${normalizeError(error).message}`));
321
410
  });
322
411
  child.once('exit', (code, signal) => {
323
- this.resolveChildExit?.();
324
- if (this.disposing) {
412
+ resolveChildExit();
413
+ if (!ownsCurrentBackend()) {
325
414
  this.logger?.debug('Copilot ACP process exited during shutdown', { code, signal });
326
415
  return;
327
416
  }
@@ -329,9 +418,6 @@ export class CopilotCliClient {
329
418
  const suffix = signal ? `signal ${signal}` : `code ${String(code ?? 'unknown')}`;
330
419
  this.failAll(new Error(`Copilot ACP process exited unexpectedly (${suffix})`));
331
420
  });
332
- this.childExitPromise = new Promise((resolve) => {
333
- this.resolveChildExit = resolve;
334
- });
335
421
  const output = Writable.toWeb(child.stdin);
336
422
  const input = Readable.toWeb(child.stdout);
337
423
  const stream = this.runtime.ndJsonStream(output, input);
@@ -341,7 +427,7 @@ export class CopilotCliClient {
341
427
  const connection = app.connect(stream);
342
428
  this.connection = connection;
343
429
  void connection.closed.then(() => {
344
- if (!this.disposing) {
430
+ if (ownsCurrentBackend()) {
345
431
  this.logger?.debugError('Copilot ACP connection closed unexpectedly');
346
432
  this.failAll(new Error('Copilot ACP connection closed unexpectedly'));
347
433
  }
@@ -400,13 +486,14 @@ export class CopilotCliClient {
400
486
  }
401
487
  state.activeTurn = turn;
402
488
  try {
403
- state.cwd = await this.resolveSessionCwd(turn.promptContext);
489
+ state.cwd = await this.resolveSessionCwd(turn.preparedTurn.promptContext);
404
490
  await this.ensureStarted();
405
491
  await this.recycleSessionIfCwdChanged(channelId, state);
406
492
  const session = await this.getOrCreateSession(channelId, state);
407
493
  let reply;
408
494
  try {
409
- reply = await this.runTurn(session, turn.prompt, turn.options);
495
+ const promptInput = await this.buildPromptInput(turn.preparedTurn);
496
+ reply = await this.runTurn(session, promptInput, turn.options);
410
497
  }
411
498
  catch (error) {
412
499
  if (isSessionTainted(error)) {
@@ -428,6 +515,7 @@ export class CopilotCliClient {
428
515
  finally {
429
516
  state.processing = false;
430
517
  this.reconcileIdleChannelState(channelId, state);
518
+ this.idleBackendShutdown.reconcile();
431
519
  }
432
520
  })();
433
521
  }
@@ -561,6 +649,37 @@ export class CopilotCliClient {
561
649
  async resolveSessionCwd(promptContext) {
562
650
  return promptContext?.taskWorkspace?.rootPath ?? this.runtime.cwd;
563
651
  }
652
+ async buildPromptInput(turn) {
653
+ const imageParts = turn.incomingParts.map(asImagePart).filter((part) => part !== null);
654
+ if (imageParts.length === 0) {
655
+ return turn.prompt;
656
+ }
657
+ const imageBlocks = await Promise.all(imageParts.map(async (part) => this.fetchImageBlock(part.attachment.url, part.attachment.contentType)));
658
+ return [{ type: 'text', text: turn.prompt }, ...imageBlocks];
659
+ }
660
+ async fetchImageBlock(rawUrl, contentType) {
661
+ const baseUrl = this.imageInputConfig.borgeeBaseUrl?.trim();
662
+ const agentApiKey = this.imageInputConfig.agentApiKey?.trim();
663
+ if (!baseUrl || !agentApiKey) {
664
+ throw new Error('Copilot image input requires Borgee base URL and agent API key');
665
+ }
666
+ const resolvedUrl = resolveHostedAttachmentUrl(rawUrl, baseUrl);
667
+ if (!isSameOrigin(resolvedUrl, baseUrl)) {
668
+ throw new Error(`Copilot image input only supports Borgee-origin attachments: ${resolvedUrl}`);
669
+ }
670
+ const response = await this.runtime.fetch(resolvedUrl, {
671
+ headers: { Authorization: `Bearer ${agentApiKey}` },
672
+ });
673
+ if (!response.ok) {
674
+ throw new Error(`Copilot image fetch failed with ${response.status} ${response.statusText}`.trim());
675
+ }
676
+ return {
677
+ type: 'image',
678
+ mimeType: contentType,
679
+ data: Buffer.from(await response.arrayBuffer()).toString('base64'),
680
+ uri: response.url || resolvedUrl,
681
+ };
682
+ }
564
683
  async recycleSessionIfCwdChanged(channelId, state) {
565
684
  const session = state.session;
566
685
  if (!session) {
@@ -643,6 +762,7 @@ export class CopilotCliClient {
643
762
  }
644
763
  this.fatalError = error;
645
764
  this.rejectFatalPromise(error);
765
+ this.idleBackendShutdown.cancel();
646
766
  for (const [channelId, state] of this.channels.entries()) {
647
767
  this.clearIdleTimer(state);
648
768
  state.activeTurn?.reject(error);
@@ -884,6 +1004,64 @@ export class CopilotCliClient {
884
1004
  return;
885
1005
  }
886
1006
  this.backendClosed = true;
1007
+ await this.closeBackendProcess(error, this.runtime.shutdownGracePeriodMs);
1008
+ }
1009
+ /**
1010
+ * Backend teardown without the fatal latch: the client stays usable and
1011
+ * `ensureStarted()` spawns a replacement process on the next turn. Only safe
1012
+ * while `isBackendIdle()` holds, because every channel's live ACP session is
1013
+ * dropped here and no turn is ever replayed onto its successor.
1014
+ */
1015
+ async shutdownIdleBackend() {
1016
+ // Detach the running backend before any signal reaches it, so its own exit
1017
+ // and connection-closed handlers read this teardown as deliberate instead
1018
+ // of latching a fatal error on a client that stays usable.
1019
+ this.backendGeneration += 1;
1020
+ const error = new Error(IDLE_BACKEND_STOPPED_MESSAGE);
1021
+ const sessions = [];
1022
+ for (const [channelId, state] of [...this.channels.entries()]) {
1023
+ this.clearIdleTimer(state);
1024
+ if (state.session) {
1025
+ sessions.push(state.session);
1026
+ }
1027
+ state.session = undefined;
1028
+ state.sessionCwd = undefined;
1029
+ this.channels.delete(channelId);
1030
+ }
1031
+ this.logger?.debug('stopping idle Copilot ACP backend', {
1032
+ channels: sessions.length,
1033
+ idleShutdownMs: this.runtime.idleBackendShutdownMs,
1034
+ });
1035
+ for (const session of sessions) {
1036
+ this.closeSession(session);
1037
+ }
1038
+ const idleShutdownPromise = this.closeBackendProcess(error, this.runtime.idleShutdownGracePeriodMs).finally(() => {
1039
+ this.startPromise = undefined;
1040
+ this.childExitPromise = undefined;
1041
+ this.sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
1042
+ this.idleShutdownPromise = undefined;
1043
+ });
1044
+ this.idleShutdownPromise = idleShutdownPromise;
1045
+ await idleShutdownPromise;
1046
+ }
1047
+ isBackendIdle() {
1048
+ if (this.disposing || this.fatalError || this.backendClosed || this.idleShutdownPromise) {
1049
+ return false;
1050
+ }
1051
+ if (!this.child) {
1052
+ return false;
1053
+ }
1054
+ if (this.pendingSessionStarts.size > 0) {
1055
+ return false;
1056
+ }
1057
+ for (const state of this.channels.values()) {
1058
+ if (state.activeTurn || state.queue.length > 0 || state.sessionPromise || state.processing) {
1059
+ return false;
1060
+ }
1061
+ }
1062
+ return true;
1063
+ }
1064
+ async closeBackendProcess(error, gracePeriodMs) {
887
1065
  const connection = this.connection;
888
1066
  const child = this.child;
889
1067
  await this.waitForPendingSessionStarts();
@@ -895,9 +1073,9 @@ export class CopilotCliClient {
895
1073
  return;
896
1074
  }
897
1075
  const childExitPromise = this.childExitPromise ?? Promise.resolve();
898
- this.logger?.debug('sending SIGTERM to Copilot ACP process');
1076
+ this.logger?.debug('sending SIGTERM to Copilot ACP process', { gracePeriodMs });
899
1077
  child.kill('SIGTERM');
900
- const exitedAfterTerm = await this.waitForChildExit(childExitPromise, this.runtime.shutdownGracePeriodMs);
1078
+ const exitedAfterTerm = await this.waitForChildExit(childExitPromise, gracePeriodMs);
901
1079
  if (exitedAfterTerm) {
902
1080
  this.logger?.debug('Copilot ACP process exited after SIGTERM');
903
1081
  this.child = undefined;
@@ -1,6 +1,6 @@
1
1
  import type { DebugLogger } from '../debug.js';
2
2
  import type { ProviderRuntimeConfig } from '../types.js';
3
- import type { ProviderAdapter } from './provider-adapter.js';
3
+ import { type ProviderAdapter } from './provider-adapter.js';
4
4
  import type { LocalhostGatewayContextPublisher } from '../context/injection.js';
5
5
  import type { AuthorizationAuditSinkLike } from '../policy/authorization-audit.js';
6
6
  interface CreateProviderOptions {
@@ -2,6 +2,7 @@ import { FileChannelContextStore } from '../context/injection.js';
2
2
  import { createProviderConnectionsSessionStore } from '../connections-state-store.js';
3
3
  import { ProviderTurnPreparer } from '../context/turn-preparation.js';
4
4
  import { CONNECTIONS_STATE_LAYER_COMPATIBILITY_GATE, CONTEXT_INJECTION_COMPATIBILITY_GATE, CODEX_PROVIDER_COMPATIBILITY_GATE, COPILOT_PROVIDER_V2_COMPATIBILITY_GATE, INTERNAL_PROVIDER_IMPLEMENTATIONS_ENV, LOCALHOST_GATEWAY_COMPATIBILITY_GATE, parseInternalProviderImplementationOverrides, POLICY_AUDIT_ENFORCEMENT_COMPATIBILITY_GATE, resolveInternalPolicyMode, resolveInternalCompatibilityGates, SKILL_RUNTIME_COMPATIBILITY_GATE, } from '../compatibility-gates.js';
5
+ import { resolveIdleBackendShutdownMs } from './idle-backend-shutdown.js';
5
6
  import { ClaudeCliClient } from './claude/cli-client.js';
6
7
  import { ClaudeProviderAdapter } from './claude/adapter.js';
7
8
  import { FileClaudeChannelSessionStore } from './claude/session-store.js';
@@ -9,16 +10,18 @@ import { CodexCliClient } from './codex/cli-client.js';
9
10
  import { CodexProviderAdapter } from './codex/adapter.js';
10
11
  import { FileCodexChannelSessionStore } from './codex/session-store.js';
11
12
  import { CopilotCliClient } from './copilot/cli-client.js';
12
- import { CopilotProviderAdapter } from './copilot/adapter.js';
13
+ import { COPILOT_HOSTED_PROVIDER_CAPABILITIES, CopilotProviderAdapter } from './copilot/adapter.js';
13
14
  import { FileCopilotChannelSessionStore } from './copilot/session-store.js';
14
15
  import { resolveClaudeSessionMapPath, resolveCodexSessionMapPath, resolveCopilotSessionMapPath, } from '../state-paths.js';
15
16
  import { createAwaitingUserProgressHandler, parseProviderReply } from './awaiting-user.js';
16
17
  class ProviderV2CompatibilityAdapter {
17
18
  provider;
18
19
  turnPreparer;
19
- constructor(provider, turnPreparer) {
20
+ capabilities;
21
+ constructor(provider, turnPreparer, capabilities) {
20
22
  this.provider = provider;
21
23
  this.turnPreparer = turnPreparer;
24
+ this.capabilities = capabilities;
22
25
  }
23
26
  async generateReply(input, options) {
24
27
  const preparedTurn = await this.turnPreparer.prepare(input);
@@ -39,7 +42,9 @@ class CliBackedProviderV2 {
39
42
  const text = await this.cli.generateReply(input.turn, {
40
43
  onProgress: createAwaitingUserProgressHandler(options?.onProgress),
41
44
  });
42
- return parseProviderReply(text);
45
+ // Read the session AFTER the turn: a first turn on a channel opens the
46
+ // session as it runs, so before this point there is nothing to report.
47
+ return { ...parseProviderReply(text), sessionId: this.cli.sessionIdForChannel(input.turn.channelId) };
43
48
  }
44
49
  async shutdown() {
45
50
  await this.cli.dispose();
@@ -57,8 +62,8 @@ function resolveCopilotImplementation(routingConfig) {
57
62
  }
58
63
  return routingConfig.implementationOverrides.copilot ?? 'v2';
59
64
  }
60
- function createClaudeProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer) {
61
- const cli = new ClaudeCliClient(config.claudeCommand, config.claudeArgs, {}, createProviderConnectionsSessionStore({
65
+ function createClaudeProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer, idleBackendShutdownMs) {
66
+ const cli = new ClaudeCliClient(config.claudeCommand, config.claudeArgs, { idleBackendShutdownMs }, createProviderConnectionsSessionStore({
62
67
  provider: 'claude',
63
68
  legacyStore: new FileClaudeChannelSessionStore({
64
69
  resolvePath: (agentId) => resolveClaudeSessionMapPath(config.stateRootDir, agentId),
@@ -66,13 +71,17 @@ function createClaudeProvider(config, debugLogger, connectionsStateGateEnabled,
66
71
  stateRootDir: config.stateRootDir,
67
72
  gateEnabled: connectionsStateGateEnabled,
68
73
  logger: debugLogger,
69
- }), config.resolveStableAgentId, debugLogger);
74
+ }), config.resolveStableAgentId, debugLogger, {
75
+ borgeeBaseUrl: config.borgeeBaseUrl,
76
+ agentApiKey: config.agentApiKey,
77
+ });
70
78
  return new ClaudeProviderAdapter(cli, turnPreparer);
71
79
  }
72
- function createCopilotProvider(config, debugLogger, connectionsStateGateEnabled, implementation, turnPreparer, policyAuditGateEnabled, authorizationAuditSink) {
80
+ function createCopilotProvider(config, debugLogger, connectionsStateGateEnabled, implementation, turnPreparer, policyAuditGateEnabled, authorizationAuditSink, idleBackendShutdownMs) {
73
81
  const policyMode = resolveInternalPolicyMode(policyAuditGateEnabled);
74
82
  const cli = new CopilotCliClient(config.copilotCommand, config.copilotArgs, {
75
83
  idleSessionTtlMs: config.copilotSessionTtlMinutes * 60 * 1000,
84
+ idleBackendShutdownMs,
76
85
  }, createProviderConnectionsSessionStore({
77
86
  provider: 'copilot',
78
87
  legacyStore: new FileCopilotChannelSessionStore({
@@ -85,13 +94,16 @@ function createCopilotProvider(config, debugLogger, connectionsStateGateEnabled,
85
94
  gateEnabled: policyAuditGateEnabled,
86
95
  policyMode,
87
96
  auditSink: authorizationAuditSink,
97
+ }, {
98
+ borgeeBaseUrl: config.borgeeBaseUrl,
99
+ agentApiKey: config.agentApiKey,
88
100
  });
89
101
  return implementation === 'v2'
90
- ? new ProviderV2CompatibilityAdapter(new CopilotProviderV2(cli), turnPreparer)
102
+ ? new ProviderV2CompatibilityAdapter(new CopilotProviderV2(cli), turnPreparer, COPILOT_HOSTED_PROVIDER_CAPABILITIES)
91
103
  : new CopilotProviderAdapter(cli, turnPreparer);
92
104
  }
93
- function createCodexProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer) {
94
- const cli = new CodexCliClient(config.codexCommand, config.codexArgs, {}, createProviderConnectionsSessionStore({
105
+ function createCodexProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer, idleBackendShutdownMs) {
106
+ const cli = new CodexCliClient(config.codexCommand, config.codexArgs, { idleBackendShutdownMs }, createProviderConnectionsSessionStore({
95
107
  provider: 'codex',
96
108
  legacyStore: new FileCodexChannelSessionStore({
97
109
  resolvePath: (agentId) => resolveCodexSessionMapPath(config.stateRootDir, agentId),
@@ -99,7 +111,10 @@ function createCodexProvider(config, debugLogger, connectionsStateGateEnabled, t
99
111
  stateRootDir: config.stateRootDir,
100
112
  gateEnabled: connectionsStateGateEnabled,
101
113
  logger: debugLogger,
102
- }), config.resolveStableAgentId, debugLogger);
114
+ }), config.resolveStableAgentId, debugLogger, {
115
+ borgeeBaseUrl: config.borgeeBaseUrl,
116
+ agentApiKey: config.agentApiKey,
117
+ });
103
118
  return new CodexProviderAdapter(cli, turnPreparer);
104
119
  }
105
120
  export function createProvider(config, debugLogger, options = {}) {
@@ -112,6 +127,7 @@ export function createProvider(config, debugLogger, options = {}) {
112
127
  const skillRuntimeGateEnabled = routingConfig.compatibilityGates.has(SKILL_RUNTIME_COMPATIBILITY_GATE);
113
128
  const localhostGatewayGateEnabled = routingConfig.compatibilityGates.has(LOCALHOST_GATEWAY_COMPATIBILITY_GATE);
114
129
  const policyAuditGateEnabled = routingConfig.compatibilityGates.has(POLICY_AUDIT_ENFORCEMENT_COMPATIBILITY_GATE);
130
+ const idleBackendShutdownMs = resolveIdleBackendShutdownMs(config.providerIdleShutdownMinutes, routingConfig.compatibilityGates);
115
131
  const channelContextStore = contextInjectionGateEnabled
116
132
  ? new FileChannelContextStore(config.stateRootDir, {
117
133
  skillRuntimeEnabled: skillRuntimeGateEnabled,
@@ -124,16 +140,16 @@ export function createProvider(config, debugLogger, options = {}) {
124
140
  const turnPreparer = new ProviderTurnPreparer(channelContextStore, debugLogger);
125
141
  switch (config.provider) {
126
142
  case 'claude': {
127
- return createClaudeProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer);
143
+ return createClaudeProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer, idleBackendShutdownMs);
128
144
  }
129
145
  case 'codex': {
130
146
  if (!routingConfig.compatibilityGates.has(CODEX_PROVIDER_COMPATIBILITY_GATE)) {
131
147
  throw new Error(`Unsupported provider: ${String(config.provider)} (enable ${CODEX_PROVIDER_COMPATIBILITY_GATE})`);
132
148
  }
133
- return createCodexProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer);
149
+ return createCodexProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer, idleBackendShutdownMs);
134
150
  }
135
151
  case 'copilot': {
136
- return createCopilotProvider(config, debugLogger, connectionsStateGateEnabled, resolveCopilotImplementation(routingConfig), turnPreparer, policyAuditGateEnabled, options.authorizationAuditSink);
152
+ return createCopilotProvider(config, debugLogger, connectionsStateGateEnabled, resolveCopilotImplementation(routingConfig), turnPreparer, policyAuditGateEnabled, options.authorizationAuditSink, idleBackendShutdownMs);
137
153
  }
138
154
  default:
139
155
  throw new Error(`Unsupported provider: ${String(config.provider)}`);
@@ -0,0 +1,16 @@
1
+ export declare const IDLE_BACKEND_SHUTDOWN_DISABLED_MS = 0;
2
+ export declare function resolveIdleBackendShutdownMs(idleShutdownMinutes: number, compatibilityGates: ReadonlySet<string>): number;
3
+ export interface IdleBackendShutdownSchedulerOptions {
4
+ idleShutdownMs: number;
5
+ isIdle: () => boolean;
6
+ shutdown: () => Promise<void>;
7
+ onShutdownFailed: (error: unknown) => void;
8
+ }
9
+ export declare class IdleBackendShutdownScheduler {
10
+ private readonly options;
11
+ private timer?;
12
+ constructor(options: IdleBackendShutdownSchedulerOptions);
13
+ get enabled(): boolean;
14
+ cancel(): void;
15
+ reconcile(): void;
16
+ }