@borgee/agents-host 0.1.5 → 0.1.7

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.
@@ -5,4 +5,5 @@ export declare class CopilotProviderAdapter implements ProviderAdapter {
5
5
  private readonly cli;
6
6
  constructor(cli: CopilotCliClient);
7
7
  generateReply(input: ProviderInput): Promise<ProviderReply>;
8
+ dispose(): Promise<void>;
8
9
  }
@@ -15,4 +15,7 @@ export class CopilotProviderAdapter {
15
15
  const text = await this.cli.generateReply(input.channelId, prompt);
16
16
  return { text };
17
17
  }
18
+ async dispose() {
19
+ await this.cli.dispose();
20
+ }
18
21
  }
@@ -1,24 +1,62 @@
1
+ import spawn from 'cross-spawn';
2
+ import { PROTOCOL_VERSION, client, methods, ndJsonStream } from '@agentclientprotocol/sdk';
3
+ interface CopilotAcpRuntime {
4
+ spawn: typeof spawn;
5
+ client: typeof client;
6
+ ndJsonStream: typeof ndJsonStream;
7
+ methods: typeof methods;
8
+ protocolVersion: typeof PROTOCOL_VERSION;
9
+ cwd: string;
10
+ idleSessionTtlMs: number;
11
+ shutdownGracePeriodMs: number;
12
+ shutdownForceKillWaitMs: number;
13
+ }
1
14
  /**
2
- * CLI client for the GitHub Copilot CLI (`copilot`) non-interactive mode,
3
- * with native per-channel session continuity.
4
- *
5
- * Unlike the Claude CLI, Copilot's non-interactive mode takes the prompt as
6
- * a `-p/--prompt` argument rather than reading it from stdin, so the prompt
7
- * is appended to the configured base args on every invocation instead of
8
- * being written to the child process's stdin.
15
+ * Persistent ACP-backed client for the GitHub Copilot CLI (`copilot --acp`).
9
16
  *
10
- * Verified CLI behavior (`copilot --help` + empirical check): `--session-id
11
- * <uuid>` both *creates* a new session pinned to that UUID (first call) and
12
- * *resumes* it (subsequent calls with the same UUID) — one flag covers both
13
- * cases, unlike Claude's `--session-id`/`--resume` split. No message history
14
- * is kept on our side — the CLI's own session storage is the single source
15
- * of truth for conversation memory.
17
+ * The legacy one-shot `COPILOT_ARGS` / constructor args are intentionally
18
+ * ignored here: the prototype always launches the child as `copilot --acp`,
19
+ * performs the ACP initialize handshake once, then reuses one ACP session per
20
+ * Borgee channel for the process lifetime.
16
21
  */
17
22
  export declare class CopilotCliClient {
18
23
  private readonly command;
19
- private readonly args;
20
- private readonly sessionIdsByChannel;
21
- constructor(command: string, args: string[]);
24
+ private readonly runtime;
25
+ private readonly channels;
26
+ private readonly closingSessions;
27
+ private readonly pendingSessionStarts;
28
+ private readonly pendingSessionCloses;
29
+ private readonly fatalPromise;
30
+ private rejectFatalPromise;
31
+ private child?;
32
+ private connection?;
33
+ private startPromise?;
34
+ private shutdownPromise?;
35
+ private childExitPromise?;
36
+ private resolveChildExit?;
37
+ private fatalError;
38
+ private disposing;
39
+ private backendClosed;
40
+ constructor(command: string, _ignoredArgs?: string[], runtimeOverrides?: Partial<CopilotAcpRuntime>);
22
41
  generateReply(channelId: string, prompt: string): Promise<string>;
23
- private run;
42
+ dispose(): Promise<void>;
43
+ private ensureStarted;
44
+ private startBackend;
45
+ private getOrCreateChannelState;
46
+ private processChannelQueue;
47
+ private getOrCreateSession;
48
+ private runTurn;
49
+ private raceWithFatal;
50
+ private failAll;
51
+ private invalidateSession;
52
+ private closeSession;
53
+ private rejectQueuedTurnsAfterSessionTaint;
54
+ private clearIdleTimer;
55
+ private reconcileIdleChannelState;
56
+ private evictIdleChannel;
57
+ private shutdownBackend;
58
+ private waitForPendingSessionStarts;
59
+ private waitForPendingSessionCloses;
60
+ private waitForChildExit;
24
61
  }
62
+ export {};
@@ -1,65 +1,508 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { Readable, Writable } from 'node:stream';
2
2
  import spawn from 'cross-spawn';
3
+ import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from '@agentclientprotocol/sdk';
4
+ const SESSION_TAINTED_ERRORS = new WeakSet();
5
+ const DEFAULT_IDLE_SESSION_TTL_MS = 2 * 24 * 60 * 60 * 1000;
6
+ const DEFAULT_SHUTDOWN_GRACE_PERIOD_MS = 250;
7
+ const DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS = 250;
8
+ 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';
9
+ const DEFAULT_RUNTIME = {
10
+ spawn,
11
+ client,
12
+ ndJsonStream,
13
+ methods,
14
+ protocolVersion: PROTOCOL_VERSION,
15
+ cwd: process.cwd(),
16
+ idleSessionTtlMs: DEFAULT_IDLE_SESSION_TTL_MS,
17
+ shutdownGracePeriodMs: DEFAULT_SHUTDOWN_GRACE_PERIOD_MS,
18
+ shutdownForceKillWaitMs: DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS,
19
+ };
20
+ function createDeferredTurn(prompt) {
21
+ let settled = false;
22
+ let resolvePromise;
23
+ let rejectPromise;
24
+ const promise = new Promise((resolve, reject) => {
25
+ resolvePromise = resolve;
26
+ rejectPromise = reject;
27
+ });
28
+ return {
29
+ prompt,
30
+ promise,
31
+ resolve(value) {
32
+ if (settled)
33
+ return;
34
+ settled = true;
35
+ resolvePromise(value);
36
+ },
37
+ reject(error) {
38
+ if (settled)
39
+ return;
40
+ settled = true;
41
+ rejectPromise(normalizeError(error));
42
+ },
43
+ };
44
+ }
45
+ function normalizeError(error) {
46
+ return error instanceof Error ? error : new Error(String(error));
47
+ }
48
+ function markSessionTainted(error) {
49
+ const normalized = normalizeError(error);
50
+ SESSION_TAINTED_ERRORS.add(normalized);
51
+ return normalized;
52
+ }
53
+ function isSessionTainted(error) {
54
+ return error instanceof Error && SESSION_TAINTED_ERRORS.has(error);
55
+ }
56
+ function selectPermissionOption(options) {
57
+ const option = options.find((candidate) => candidate.kind === 'allow_always') ??
58
+ options.find((candidate) => candidate.kind === 'allow_once');
59
+ if (!option) {
60
+ throw new Error('Copilot ACP requested permission but did not offer an allow option');
61
+ }
62
+ return option.optionId;
63
+ }
3
64
  /**
4
- * CLI client for the GitHub Copilot CLI (`copilot`) non-interactive mode,
5
- * with native per-channel session continuity.
65
+ * Persistent ACP-backed client for the GitHub Copilot CLI (`copilot --acp`).
6
66
  *
7
- * Unlike the Claude CLI, Copilot's non-interactive mode takes the prompt as
8
- * a `-p/--prompt` argument rather than reading it from stdin, so the prompt
9
- * is appended to the configured base args on every invocation instead of
10
- * being written to the child process's stdin.
11
- *
12
- * Verified CLI behavior (`copilot --help` + empirical check): `--session-id
13
- * <uuid>` both *creates* a new session pinned to that UUID (first call) and
14
- * *resumes* it (subsequent calls with the same UUID) — one flag covers both
15
- * cases, unlike Claude's `--session-id`/`--resume` split. No message history
16
- * is kept on our side — the CLI's own session storage is the single source
17
- * of truth for conversation memory.
67
+ * The legacy one-shot `COPILOT_ARGS` / constructor args are intentionally
68
+ * ignored here: the prototype always launches the child as `copilot --acp`,
69
+ * performs the ACP initialize handshake once, then reuses one ACP session per
70
+ * Borgee channel for the process lifetime.
18
71
  */
19
72
  export class CopilotCliClient {
20
73
  command;
21
- args;
22
- sessionIdsByChannel = new Map();
23
- constructor(command, args) {
74
+ runtime;
75
+ channels = new Map();
76
+ closingSessions = new WeakSet();
77
+ pendingSessionStarts = new Set();
78
+ pendingSessionCloses = new Set();
79
+ fatalPromise;
80
+ rejectFatalPromise;
81
+ child;
82
+ connection;
83
+ startPromise;
84
+ shutdownPromise;
85
+ childExitPromise;
86
+ resolveChildExit;
87
+ fatalError = null;
88
+ disposing = false;
89
+ backendClosed = false;
90
+ constructor(command, _ignoredArgs = [], runtimeOverrides = {}) {
24
91
  this.command = command;
25
- this.args = args;
92
+ this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
93
+ this.fatalPromise = new Promise((_, reject) => {
94
+ this.rejectFatalPromise = reject;
95
+ });
96
+ void this.fatalPromise.catch(() => { });
26
97
  }
27
98
  async generateReply(channelId, prompt) {
28
- const sessionId = this.sessionIdsByChannel.get(channelId) ?? randomUUID();
29
- const text = await this.run([...this.args, `--session-id=${sessionId}`, '-p', prompt]);
30
- this.sessionIdsByChannel.set(channelId, sessionId);
31
- return text;
32
- }
33
- async run(args) {
34
- return new Promise((resolve, reject) => {
35
- const child = spawn(this.command, args, {
36
- stdio: ['ignore', 'pipe', 'pipe'],
37
- });
38
- let stdout = '';
39
- let stderr = '';
40
- child.stdout.setEncoding('utf8');
41
- child.stderr.setEncoding('utf8');
42
- child.stdout.on('data', (chunk) => {
43
- stdout += chunk;
44
- });
45
- child.stderr.on('data', (chunk) => {
46
- stderr += chunk;
47
- });
48
- child.on('error', (error) => {
49
- reject(error);
99
+ if (this.fatalError) {
100
+ throw this.fatalError;
101
+ }
102
+ const state = this.getOrCreateChannelState(channelId);
103
+ this.clearIdleTimer(state);
104
+ const turn = createDeferredTurn(prompt);
105
+ state.queue.push(turn);
106
+ this.processChannelQueue(channelId, state);
107
+ return turn.promise;
108
+ }
109
+ async dispose() {
110
+ const error = new Error('Copilot ACP backend stopped');
111
+ this.disposing = true;
112
+ const closed = this.connection?.closed ?? Promise.resolve();
113
+ this.failAll(error);
114
+ await Promise.all([closed, this.shutdownPromise ?? Promise.resolve()]);
115
+ }
116
+ async ensureStarted() {
117
+ if (this.fatalError) {
118
+ throw this.fatalError;
119
+ }
120
+ if (!this.startPromise) {
121
+ this.startPromise = this.startBackend();
122
+ }
123
+ await this.startPromise;
124
+ if (this.fatalError) {
125
+ throw this.fatalError;
126
+ }
127
+ if (!this.connection) {
128
+ throw new Error('Copilot ACP connection is not available');
129
+ }
130
+ }
131
+ async startBackend() {
132
+ const child = this.runtime.spawn(this.command, ['--acp'], {
133
+ stdio: ['pipe', 'pipe', 'pipe'],
134
+ });
135
+ this.child = child;
136
+ child.stderr.resume();
137
+ child.once('error', (error) => {
138
+ this.failAll(new Error(`Copilot ACP process error: ${normalizeError(error).message}`));
139
+ });
140
+ child.once('exit', (code, signal) => {
141
+ this.resolveChildExit?.();
142
+ if (this.disposing)
143
+ return;
144
+ const suffix = signal ? `signal ${signal}` : `code ${String(code ?? 'unknown')}`;
145
+ this.failAll(new Error(`Copilot ACP process exited unexpectedly (${suffix})`));
146
+ });
147
+ this.childExitPromise = new Promise((resolve) => {
148
+ this.resolveChildExit = resolve;
149
+ });
150
+ const output = Writable.toWeb(child.stdin);
151
+ const input = Readable.toWeb(child.stdout);
152
+ const stream = this.runtime.ndJsonStream(output, input);
153
+ const app = this.runtime
154
+ .client({ name: 'borgee-agents-host' })
155
+ .onRequest(this.runtime.methods.client.session.requestPermission, ({ params }) => ({
156
+ outcome: {
157
+ outcome: 'selected',
158
+ optionId: selectPermissionOption(params.options),
159
+ },
160
+ }));
161
+ const connection = app.connect(stream);
162
+ this.connection = connection;
163
+ void connection.closed.then(() => {
164
+ if (!this.disposing) {
165
+ this.failAll(new Error('Copilot ACP connection closed unexpectedly'));
166
+ }
167
+ });
168
+ try {
169
+ await connection.agent.request(this.runtime.methods.agent.initialize, {
170
+ protocolVersion: this.runtime.protocolVersion,
171
+ clientCapabilities: {},
172
+ clientInfo: {
173
+ name: '@borgee/agents-host',
174
+ version: '0.1.6',
175
+ },
50
176
  });
51
- child.on('close', (code) => {
52
- if (code !== 0) {
53
- reject(new Error(`Copilot CLI failed with code ${code}: ${stderr.trim()}`));
54
- return;
177
+ }
178
+ catch (error) {
179
+ const normalized = new Error(`Copilot ACP initialize failed: ${normalizeError(error).message}`);
180
+ this.failAll(normalized);
181
+ throw normalized;
182
+ }
183
+ }
184
+ getOrCreateChannelState(channelId) {
185
+ let state = this.channels.get(channelId);
186
+ if (!state) {
187
+ state = {
188
+ idleTimerGeneration: 0,
189
+ processing: false,
190
+ queue: [],
191
+ };
192
+ this.channels.set(channelId, state);
193
+ }
194
+ return state;
195
+ }
196
+ processChannelQueue(channelId, state) {
197
+ if (state.processing) {
198
+ return;
199
+ }
200
+ state.processing = true;
201
+ void (async () => {
202
+ try {
203
+ while (!this.fatalError) {
204
+ const turn = state.queue.shift();
205
+ if (!turn) {
206
+ return;
207
+ }
208
+ state.activeTurn = turn;
209
+ try {
210
+ await this.ensureStarted();
211
+ const session = await this.getOrCreateSession(channelId, state);
212
+ let reply;
213
+ try {
214
+ reply = await this.runTurn(session, turn.prompt);
215
+ }
216
+ catch (error) {
217
+ if (isSessionTainted(error)) {
218
+ this.invalidateSession(state, session);
219
+ this.rejectQueuedTurnsAfterSessionTaint(state, error);
220
+ }
221
+ throw error;
222
+ }
223
+ turn.resolve(reply);
224
+ }
225
+ catch (error) {
226
+ turn.reject(error);
227
+ }
228
+ finally {
229
+ state.activeTurn = undefined;
230
+ }
55
231
  }
56
- const text = stdout.trim();
57
- if (!text) {
58
- reject(new Error('Copilot CLI returned empty output'));
59
- return;
232
+ }
233
+ finally {
234
+ state.processing = false;
235
+ this.reconcileIdleChannelState(channelId, state);
236
+ }
237
+ })();
238
+ }
239
+ async getOrCreateSession(channelId, state) {
240
+ if (state.session) {
241
+ return state.session;
242
+ }
243
+ if (state.sessionPromise) {
244
+ return state.sessionPromise;
245
+ }
246
+ if (!this.connection) {
247
+ throw new Error('Copilot ACP connection is not available');
248
+ }
249
+ const sessionPromise = this.connection.agent.buildSession(this.runtime.cwd).start();
250
+ this.pendingSessionStarts.add(sessionPromise);
251
+ state.sessionPromise = sessionPromise;
252
+ let sessionAdopted = false;
253
+ void sessionPromise
254
+ .then((session) => {
255
+ if (!sessionAdopted &&
256
+ (this.fatalError !== null || this.disposing || state.sessionPromise !== sessionPromise)) {
257
+ this.closeSession(session);
258
+ }
259
+ })
260
+ .finally(() => {
261
+ this.pendingSessionStarts.delete(sessionPromise);
262
+ })
263
+ .catch(() => { });
264
+ try {
265
+ const session = await this.raceWithFatal(sessionPromise);
266
+ if (this.fatalError) {
267
+ this.closeSession(session);
268
+ throw this.fatalError;
269
+ }
270
+ sessionAdopted = true;
271
+ state.session = session;
272
+ return session;
273
+ }
274
+ catch (error) {
275
+ state.session = undefined;
276
+ throw error;
277
+ }
278
+ finally {
279
+ if (state.sessionPromise === sessionPromise) {
280
+ state.sessionPromise = undefined;
281
+ }
282
+ }
283
+ }
284
+ async runTurn(session, prompt) {
285
+ const promptPromise = this.raceWithFatal(session.prompt(prompt));
286
+ const promptFailure = new Promise((_, reject) => {
287
+ void promptPromise.catch((error) => reject(markSessionTainted(error)));
288
+ });
289
+ let text = '';
290
+ for (;;) {
291
+ let update;
292
+ try {
293
+ update = await this.raceWithFatal(Promise.race([session.nextUpdate(), promptFailure]));
294
+ }
295
+ catch (error) {
296
+ if (this.fatalError && error === this.fatalError) {
297
+ throw error;
60
298
  }
61
- resolve(text);
62
- });
299
+ throw markSessionTainted(error);
300
+ }
301
+ if (update.kind === 'stop') {
302
+ let response;
303
+ try {
304
+ response = await promptPromise;
305
+ }
306
+ catch (error) {
307
+ throw markSessionTainted(error);
308
+ }
309
+ if (response.stopReason !== 'end_turn') {
310
+ throw new Error(`Copilot ACP turn stopped with stopReason "${response.stopReason}"`);
311
+ }
312
+ const output = text.trim();
313
+ if (!output) {
314
+ throw new Error('Copilot ACP returned empty output');
315
+ }
316
+ return output;
317
+ }
318
+ if (update.update.sessionUpdate === 'agent_message_chunk' &&
319
+ update.update.content.type === 'text') {
320
+ text += update.update.content.text;
321
+ }
322
+ }
323
+ }
324
+ async raceWithFatal(promise) {
325
+ if (this.fatalError) {
326
+ throw this.fatalError;
327
+ }
328
+ return Promise.race([promise, this.fatalPromise]);
329
+ }
330
+ failAll(error) {
331
+ if (this.fatalError) {
332
+ return;
333
+ }
334
+ this.fatalError = error;
335
+ this.rejectFatalPromise(error);
336
+ for (const [channelId, state] of this.channels.entries()) {
337
+ this.clearIdleTimer(state);
338
+ state.activeTurn?.reject(error);
339
+ for (const turn of state.queue) {
340
+ turn.reject(error);
341
+ }
342
+ state.queue.length = 0;
343
+ if (state.session) {
344
+ this.closeSession(state.session);
345
+ }
346
+ state.session = undefined;
347
+ state.sessionPromise = undefined;
348
+ state.activeTurn = undefined;
349
+ if (!state.processing) {
350
+ this.channels.delete(channelId);
351
+ }
352
+ }
353
+ this.channels.clear();
354
+ this.shutdownPromise = this.shutdownBackend(error);
355
+ }
356
+ invalidateSession(state, session) {
357
+ this.clearIdleTimer(state);
358
+ if (state.session === session) {
359
+ state.session = undefined;
360
+ }
361
+ this.closeSession(session);
362
+ }
363
+ closeSession(session) {
364
+ if (this.closingSessions.has(session)) {
365
+ return;
366
+ }
367
+ this.closingSessions.add(session);
368
+ const agent = this.connection?.agent;
369
+ const closeMethod = this.runtime.methods.agent.session?.close;
370
+ const closePromise = typeof agent?.closeSession === 'function'
371
+ ? agent.closeSession({ sessionId: session.sessionId })
372
+ : closeMethod
373
+ ? this.connection?.agent.request(closeMethod, { sessionId: session.sessionId })
374
+ : undefined;
375
+ session.dispose();
376
+ if (!closePromise) {
377
+ return;
378
+ }
379
+ let trackedClosePromise;
380
+ trackedClosePromise = Promise.resolve(closePromise)
381
+ .then(() => undefined)
382
+ .catch(() => { })
383
+ .finally(() => {
384
+ this.pendingSessionCloses.delete(trackedClosePromise);
385
+ });
386
+ this.pendingSessionCloses.add(trackedClosePromise);
387
+ }
388
+ rejectQueuedTurnsAfterSessionTaint(state, error) {
389
+ if (state.queue.length === 0) {
390
+ return;
391
+ }
392
+ const rejection = new Error(QUEUED_TURN_DROPPED_MESSAGE, {
393
+ cause: normalizeError(error),
63
394
  });
395
+ for (const turn of state.queue) {
396
+ turn.reject(rejection);
397
+ }
398
+ state.queue.length = 0;
399
+ }
400
+ clearIdleTimer(state) {
401
+ state.idleTimerGeneration += 1;
402
+ if (!state.idleTimer) {
403
+ return;
404
+ }
405
+ clearTimeout(state.idleTimer);
406
+ state.idleTimer = undefined;
407
+ }
408
+ reconcileIdleChannelState(channelId, state) {
409
+ if (this.channels.get(channelId) !== state) {
410
+ return;
411
+ }
412
+ if (this.disposing || this.fatalError || this.backendClosed) {
413
+ this.clearIdleTimer(state);
414
+ return;
415
+ }
416
+ if (state.activeTurn || state.queue.length > 0 || state.sessionPromise || state.processing) {
417
+ this.clearIdleTimer(state);
418
+ return;
419
+ }
420
+ if (!state.session) {
421
+ this.clearIdleTimer(state);
422
+ this.channels.delete(channelId);
423
+ return;
424
+ }
425
+ this.clearIdleTimer(state);
426
+ const generation = state.idleTimerGeneration;
427
+ state.idleTimer = setTimeout(() => {
428
+ this.evictIdleChannel(channelId, state, generation);
429
+ }, this.runtime.idleSessionTtlMs);
430
+ }
431
+ evictIdleChannel(channelId, state, generation) {
432
+ if (this.disposing || this.fatalError || this.backendClosed) {
433
+ return;
434
+ }
435
+ if (this.channels.get(channelId) !== state || state.idleTimerGeneration !== generation) {
436
+ return;
437
+ }
438
+ state.idleTimer = undefined;
439
+ if (state.activeTurn || state.queue.length > 0 || state.sessionPromise || state.processing) {
440
+ return;
441
+ }
442
+ const session = state.session;
443
+ state.session = undefined;
444
+ if (session) {
445
+ this.closeSession(session);
446
+ }
447
+ this.channels.delete(channelId);
448
+ }
449
+ async shutdownBackend(error) {
450
+ if (this.backendClosed) {
451
+ return;
452
+ }
453
+ this.backendClosed = true;
454
+ const connection = this.connection;
455
+ const child = this.child;
456
+ await this.waitForPendingSessionStarts();
457
+ await this.waitForPendingSessionCloses();
458
+ connection?.close(error);
459
+ this.connection = undefined;
460
+ if (!child) {
461
+ this.child = undefined;
462
+ return;
463
+ }
464
+ const childExitPromise = this.childExitPromise ?? Promise.resolve();
465
+ child.kill('SIGTERM');
466
+ const exitedAfterTerm = await this.waitForChildExit(childExitPromise, this.runtime.shutdownGracePeriodMs);
467
+ if (exitedAfterTerm) {
468
+ this.child = undefined;
469
+ return;
470
+ }
471
+ child.kill('SIGKILL');
472
+ await this.waitForChildExit(childExitPromise, this.runtime.shutdownForceKillWaitMs);
473
+ this.child = undefined;
474
+ }
475
+ async waitForPendingSessionStarts() {
476
+ if (this.pendingSessionStarts.size === 0) {
477
+ return;
478
+ }
479
+ await Promise.race([
480
+ Promise.allSettled([...this.pendingSessionStarts]).then(() => undefined),
481
+ new Promise((resolve) => {
482
+ setTimeout(resolve, this.runtime.shutdownGracePeriodMs);
483
+ }),
484
+ ]);
485
+ }
486
+ async waitForPendingSessionCloses() {
487
+ if (this.pendingSessionCloses.size === 0) {
488
+ return;
489
+ }
490
+ await Promise.race([
491
+ Promise.allSettled([...this.pendingSessionCloses]).then(() => undefined),
492
+ new Promise((resolve) => {
493
+ setTimeout(resolve, this.runtime.shutdownGracePeriodMs);
494
+ }),
495
+ ]);
496
+ }
497
+ async waitForChildExit(childExitPromise, timeoutMs) {
498
+ if (timeoutMs <= 0) {
499
+ return false;
500
+ }
501
+ return Promise.race([
502
+ childExitPromise.then(() => true),
503
+ new Promise((resolve) => {
504
+ setTimeout(() => resolve(false), timeoutMs);
505
+ }),
506
+ ]);
64
507
  }
65
508
  }
@@ -9,7 +9,9 @@ export function createProvider(config) {
9
9
  return new ClaudeProviderAdapter(cli);
10
10
  }
11
11
  case 'copilot': {
12
- const cli = new CopilotCliClient(config.copilotCommand, config.copilotArgs);
12
+ const cli = new CopilotCliClient(config.copilotCommand, config.copilotArgs, {
13
+ idleSessionTtlMs: config.copilotSessionTtlMinutes * 60 * 1000,
14
+ });
13
15
  return new CopilotProviderAdapter(cli);
14
16
  }
15
17
  default:
@@ -1,4 +1,5 @@
1
1
  import type { ProviderInput, ProviderReply } from '../types.js';
2
2
  export interface ProviderAdapter {
3
3
  generateReply(input: ProviderInput): Promise<ProviderReply>;
4
+ dispose?(): Promise<void>;
4
5
  }