@borgee/agents-host 0.2.1 → 0.2.26

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 (78) hide show
  1. package/README.md +184 -21
  2. package/dist/agents-host-supervisor.d.ts +7 -5
  3. package/dist/agents-host-supervisor.js +24 -4
  4. package/dist/agents-host.d.ts +89 -15
  5. package/dist/agents-host.js +2099 -142
  6. package/dist/chat/chat-control-plane.d.ts +14 -3
  7. package/dist/chat/sdk-chat-control-plane.d.ts +16 -4
  8. package/dist/chat/sdk-chat-control-plane.js +69 -9
  9. package/dist/cli-args.d.ts +46 -5
  10. package/dist/cli-args.js +313 -32
  11. package/dist/cli.d.ts +9 -0
  12. package/dist/cli.js +112 -5
  13. package/dist/compatibility-gates.d.ts +35 -0
  14. package/dist/compatibility-gates.js +127 -0
  15. package/dist/config.d.ts +1 -0
  16. package/dist/config.js +23 -5
  17. package/dist/connections-state-store.d.ts +81 -0
  18. package/dist/connections-state-store.js +228 -0
  19. package/dist/context/injection.d.ts +109 -0
  20. package/dist/context/injection.js +350 -0
  21. package/dist/context/prompt.d.ts +4 -1
  22. package/dist/context/prompt.js +170 -1
  23. package/dist/context/turn-preparation.d.ts +9 -0
  24. package/dist/context/turn-preparation.js +106 -0
  25. package/dist/debug.d.ts +44 -0
  26. package/dist/debug.js +135 -0
  27. package/dist/gateway/localhost-gateway.d.ts +52 -0
  28. package/dist/gateway/localhost-gateway.js +857 -0
  29. package/dist/index.js +7 -5
  30. package/dist/local-config.d.ts +4 -1
  31. package/dist/local-config.js +24 -7
  32. package/dist/managed-daemon-log.d.ts +34 -0
  33. package/dist/managed-daemon-log.js +261 -0
  34. package/dist/managed-daemon.d.ts +220 -0
  35. package/dist/managed-daemon.js +1601 -0
  36. package/dist/policy/authorization-audit.d.ts +63 -0
  37. package/dist/policy/authorization-audit.js +94 -0
  38. package/dist/policy/copilot-permission.d.ts +15 -0
  39. package/dist/policy/copilot-permission.js +193 -0
  40. package/dist/policy/gateway-authorization.d.ts +42 -0
  41. package/dist/policy/gateway-authorization.js +162 -0
  42. package/dist/providers/awaiting-user.d.ts +12 -0
  43. package/dist/providers/awaiting-user.js +151 -0
  44. package/dist/providers/claude/adapter.d.ts +3 -1
  45. package/dist/providers/claude/adapter.js +8 -12
  46. package/dist/providers/claude/cli-client.d.ts +12 -5
  47. package/dist/providers/claude/cli-client.js +184 -37
  48. package/dist/providers/claude/session-store.d.ts +24 -0
  49. package/dist/providers/claude/session-store.js +65 -12
  50. package/dist/providers/codex/adapter.d.ts +11 -0
  51. package/dist/providers/codex/adapter.js +19 -0
  52. package/dist/providers/codex/cli-client.d.ts +103 -0
  53. package/dist/providers/codex/cli-client.js +1133 -0
  54. package/dist/providers/codex/project-doc.d.ts +3 -0
  55. package/dist/providers/codex/project-doc.js +66 -0
  56. package/dist/providers/codex/session-store.d.ts +38 -0
  57. package/dist/providers/codex/session-store.js +150 -0
  58. package/dist/providers/copilot/adapter.d.ts +3 -1
  59. package/dist/providers/copilot/adapter.js +8 -12
  60. package/dist/providers/copilot/cli-client.d.ts +20 -2
  61. package/dist/providers/copilot/cli-client.js +251 -71
  62. package/dist/providers/copilot/session-store.d.ts +24 -0
  63. package/dist/providers/copilot/session-store.js +65 -12
  64. package/dist/providers/create-provider.d.ts +11 -2
  65. package/dist/providers/create-provider.js +131 -12
  66. package/dist/run.d.ts +1 -0
  67. package/dist/run.js +5 -2
  68. package/dist/state-paths.d.ts +13 -1
  69. package/dist/state-paths.js +84 -3
  70. package/dist/task-thread-resolution.d.ts +10 -0
  71. package/dist/task-thread-resolution.js +48 -0
  72. package/dist/types.d.ts +174 -1
  73. package/dist/visible-mentions.d.ts +3 -0
  74. package/dist/visible-mentions.js +15 -0
  75. package/package.json +19 -17
  76. package/skills/borgee-agent/SKILL.md +33 -0
  77. package/skills/borgee-agent/borgee-agent.mjs +473 -0
  78. package/skills/borgee-agent/borgee-agent.py +409 -0
@@ -1,6 +1,8 @@
1
1
  import { Readable, Writable } from 'node:stream';
2
2
  import spawn from 'cross-spawn';
3
3
  import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from '@agentclientprotocol/sdk';
4
+ import { HostLogger, summarizeChildStderr, summarizeError } from '../../debug.js';
5
+ import { resolveCopilotPermissionResponse } from '../../policy/copilot-permission.js';
4
6
  const SESSION_TAINTED_ERRORS = new WeakSet();
5
7
  const DEFAULT_IDLE_SESSION_TTL_MS = 2 * 24 * 60 * 60 * 1000;
6
8
  const DEFAULT_SHUTDOWN_GRACE_PERIOD_MS = 250;
@@ -102,7 +104,7 @@ class CopilotProgressCollector {
102
104
  this.onProgress({ text });
103
105
  }
104
106
  }
105
- function createDeferredTurn(prompt, options) {
107
+ function createDeferredTurn(channelId, prompt, sessionPersistence, promptContext, options) {
106
108
  let settled = false;
107
109
  let resolvePromise;
108
110
  let rejectPromise;
@@ -111,7 +113,10 @@ function createDeferredTurn(prompt, options) {
111
113
  rejectPromise = reject;
112
114
  });
113
115
  return {
116
+ channelId,
114
117
  prompt,
118
+ sessionPersistence,
119
+ promptContext,
115
120
  options,
116
121
  promise,
117
122
  resolve(value) {
@@ -131,6 +136,34 @@ function createDeferredTurn(prompt, options) {
131
136
  function normalizeError(error) {
132
137
  return error instanceof Error ? error : new Error(String(error));
133
138
  }
139
+ function parsePersistedSessionRecord(rawValue) {
140
+ const trimmed = rawValue.trim();
141
+ if (trimmed.startsWith('{')) {
142
+ const parsed = JSON.parse(trimmed);
143
+ if (typeof parsed.sessionId === 'string' && parsed.sessionId.trim().length > 0) {
144
+ return {
145
+ sessionId: parsed.sessionId,
146
+ cwd: typeof parsed.cwd === 'string' && parsed.cwd.trim().length > 0
147
+ ? parsed.cwd
148
+ : undefined,
149
+ };
150
+ }
151
+ }
152
+ return { sessionId: rawValue };
153
+ }
154
+ function serializePersistedSessionRecord(record) {
155
+ return JSON.stringify({
156
+ sessionId: record.sessionId,
157
+ ...(record.cwd ? { cwd: record.cwd } : {}),
158
+ });
159
+ }
160
+ function resolveSessionRouting(turn) {
161
+ return {
162
+ channelId: turn.channelId,
163
+ key: turn.providerSessionRouting?.key ?? turn.channelId,
164
+ persistence: turn.providerSessionRouting?.persistence ?? 'persistent',
165
+ };
166
+ }
134
167
  function asObject(value) {
135
168
  return typeof value === 'object' && value !== null ? value : null;
136
169
  }
@@ -157,14 +190,6 @@ function markSessionTainted(error) {
157
190
  function isSessionTainted(error) {
158
191
  return error instanceof Error && SESSION_TAINTED_ERRORS.has(error);
159
192
  }
160
- function selectPermissionOption(options) {
161
- const option = options.find((candidate) => candidate.kind === 'allow_always') ??
162
- options.find((candidate) => candidate.kind === 'allow_once');
163
- if (!option) {
164
- throw new Error('Copilot ACP requested permission but did not offer an allow option');
165
- }
166
- return option.optionId;
167
- }
168
193
  /**
169
194
  * Persistent ACP-backed client for the GitHub Copilot CLI (`copilot --acp`).
170
195
  *
@@ -177,12 +202,15 @@ export class CopilotCliClient {
177
202
  command;
178
203
  sessionStore;
179
204
  resolveSessionStoreAgentId;
205
+ logger;
206
+ permissionPolicy;
180
207
  runtime;
181
208
  channels = new Map();
182
209
  persistedSessions = new Map();
183
210
  closingSessions = new WeakSet();
184
211
  pendingSessionStarts = new Set();
185
212
  pendingSessionCloses = new Set();
213
+ pendingSessionStoreOperations = new Set();
186
214
  fatalPromise;
187
215
  rejectFatalPromise;
188
216
  child;
@@ -198,33 +226,63 @@ export class CopilotCliClient {
198
226
  sessionStoreLoadPromise = null;
199
227
  sessionStoreWriteQueue = Promise.resolve();
200
228
  sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
201
- constructor(command, _ignoredArgs = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined) {
229
+ constructor(command, _ignoredArgs = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger(), permissionPolicy = {}) {
202
230
  this.command = command;
203
231
  this.sessionStore = sessionStore;
204
232
  this.resolveSessionStoreAgentId = resolveSessionStoreAgentId;
233
+ this.logger = logger;
234
+ this.permissionPolicy = permissionPolicy;
205
235
  this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
206
236
  this.fatalPromise = new Promise((_, reject) => {
207
237
  this.rejectFatalPromise = reject;
208
238
  });
209
239
  void this.fatalPromise.catch(() => { });
210
240
  }
211
- async generateReply(channelId, prompt, options) {
241
+ async generateReply(channelIdOrTurn, promptOrOptions, maybeOptions) {
212
242
  if (this.fatalError) {
213
243
  throw this.fatalError;
214
244
  }
215
- const state = this.getOrCreateChannelState(channelId);
245
+ const preparedTurn = typeof channelIdOrTurn === 'string'
246
+ ? {
247
+ channelId: channelIdOrTurn,
248
+ prompt: typeof promptOrOptions === 'string' ? promptOrOptions : '',
249
+ }
250
+ : channelIdOrTurn;
251
+ const options = typeof channelIdOrTurn === 'string'
252
+ ? maybeOptions
253
+ : promptOrOptions;
254
+ const sessionRouting = resolveSessionRouting(preparedTurn);
255
+ const state = this.getOrCreateChannelState(sessionRouting.key, preparedTurn.channelId, sessionRouting.persistence);
216
256
  this.clearIdleTimer(state);
217
- const turn = createDeferredTurn(prompt, options);
257
+ const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn.prompt, sessionRouting.persistence, preparedTurn.promptContext, options);
218
258
  state.queue.push(turn);
219
- this.processChannelQueue(channelId, state);
259
+ this.processChannelQueue(sessionRouting.key, state);
220
260
  return turn.promise;
221
261
  }
222
262
  async dispose() {
223
263
  const error = new Error('Copilot ACP backend stopped');
224
264
  this.disposing = true;
265
+ this.logger?.debug('stopping Copilot ACP backend');
225
266
  const closed = this.connection?.closed ?? Promise.resolve();
226
267
  this.failAll(error);
227
- await Promise.all([closed, this.shutdownPromise ?? Promise.resolve()]);
268
+ let thrown;
269
+ try {
270
+ await Promise.all([closed, this.shutdownPromise ?? Promise.resolve()]);
271
+ }
272
+ catch (disposeError) {
273
+ thrown = disposeError;
274
+ }
275
+ try {
276
+ await Promise.allSettled(this.pendingSessionStoreOperations);
277
+ await this.sessionStoreWriteQueue;
278
+ await this.sessionStore?.close?.();
279
+ }
280
+ catch (closeError) {
281
+ thrown ??= closeError;
282
+ }
283
+ if (thrown) {
284
+ throw thrown;
285
+ }
228
286
  }
229
287
  async ensureStarted() {
230
288
  if (this.fatalError) {
@@ -242,18 +300,32 @@ export class CopilotCliClient {
242
300
  }
243
301
  }
244
302
  async startBackend() {
303
+ this.logger?.debug('starting Copilot ACP process', {
304
+ command: this.command,
305
+ args: ['--acp'],
306
+ cwd: this.runtime.cwd,
307
+ });
245
308
  const child = this.runtime.spawn(this.command, ['--acp'], {
246
309
  stdio: ['pipe', 'pipe', 'pipe'],
247
310
  });
248
311
  this.child = child;
312
+ child.stderr.setEncoding('utf8');
313
+ child.stderr.on('data', (chunk) => {
314
+ this.logger?.childStderr('copilot stderr', summarizeChildStderr(chunk));
315
+ });
249
316
  child.stderr.resume();
317
+ this.logger?.debug('spawned Copilot ACP process', { pid: child.pid });
250
318
  child.once('error', (error) => {
319
+ this.logger?.debugError('Copilot ACP process error', error);
251
320
  this.failAll(new Error(`Copilot ACP process error: ${normalizeError(error).message}`));
252
321
  });
253
322
  child.once('exit', (code, signal) => {
254
323
  this.resolveChildExit?.();
255
- if (this.disposing)
324
+ if (this.disposing) {
325
+ this.logger?.debug('Copilot ACP process exited during shutdown', { code, signal });
256
326
  return;
327
+ }
328
+ this.logger?.debugError('Copilot ACP process exited unexpectedly', { code, signal });
257
329
  const suffix = signal ? `signal ${signal}` : `code ${String(code ?? 'unknown')}`;
258
330
  this.failAll(new Error(`Copilot ACP process exited unexpectedly (${suffix})`));
259
331
  });
@@ -265,16 +337,12 @@ export class CopilotCliClient {
265
337
  const stream = this.runtime.ndJsonStream(output, input);
266
338
  const app = this.runtime
267
339
  .client({ name: 'borgee-agents-host' })
268
- .onRequest(this.runtime.methods.client.session.requestPermission, ({ params }) => ({
269
- outcome: {
270
- outcome: 'selected',
271
- optionId: selectPermissionOption(params.options),
272
- },
273
- }));
340
+ .onRequest(this.runtime.methods.client.session.requestPermission, ({ params }) => (this.handlePermissionRequest(params)));
274
341
  const connection = app.connect(stream);
275
342
  this.connection = connection;
276
343
  void connection.closed.then(() => {
277
344
  if (!this.disposing) {
345
+ this.logger?.debugError('Copilot ACP connection closed unexpectedly');
278
346
  this.failAll(new Error('Copilot ACP connection closed unexpectedly'));
279
347
  }
280
348
  });
@@ -288,23 +356,34 @@ export class CopilotCliClient {
288
356
  },
289
357
  });
290
358
  this.sessionCapabilities = readSessionCapabilities(initializeResponse);
359
+ this.logger?.debug('initialized Copilot ACP connection', {
360
+ loadSession: this.sessionCapabilities.loadSession,
361
+ resumeSession: this.sessionCapabilities.resumeSession,
362
+ });
291
363
  }
292
364
  catch (error) {
293
365
  const normalized = new Error(`Copilot ACP initialize failed: ${normalizeError(error).message}`);
366
+ this.logger?.debugError('Copilot ACP initialize failed', normalized);
294
367
  this.failAll(normalized);
295
368
  throw normalized;
296
369
  }
297
370
  }
298
- getOrCreateChannelState(channelId) {
371
+ getOrCreateChannelState(channelId, resolvedChannelId, sessionPersistence) {
299
372
  let state = this.channels.get(channelId);
300
373
  if (!state) {
301
374
  state = {
375
+ channelId: resolvedChannelId,
376
+ sessionPersistence,
302
377
  idleTimerGeneration: 0,
303
378
  processing: false,
304
379
  queue: [],
305
380
  };
306
381
  this.channels.set(channelId, state);
307
382
  }
383
+ else if (state.channelId !== resolvedChannelId
384
+ || state.sessionPersistence !== sessionPersistence) {
385
+ throw new Error(`Copilot session routing changed for key "${channelId}"`);
386
+ }
308
387
  return state;
309
388
  }
310
389
  processChannelQueue(channelId, state) {
@@ -321,7 +400,9 @@ export class CopilotCliClient {
321
400
  }
322
401
  state.activeTurn = turn;
323
402
  try {
403
+ state.cwd = await this.resolveSessionCwd(turn.promptContext);
324
404
  await this.ensureStarted();
405
+ await this.recycleSessionIfCwdChanged(channelId, state);
325
406
  const session = await this.getOrCreateSession(channelId, state);
326
407
  let reply;
327
408
  try {
@@ -329,7 +410,7 @@ export class CopilotCliClient {
329
410
  }
330
411
  catch (error) {
331
412
  if (isSessionTainted(error)) {
332
- this.invalidateSession(state, session);
413
+ this.invalidateSession(channelId, state, session);
333
414
  this.rejectQueuedTurnsAfterSessionTaint(state, error);
334
415
  }
335
416
  throw error;
@@ -360,11 +441,13 @@ export class CopilotCliClient {
360
441
  if (!this.connection) {
361
442
  throw new Error('Copilot ACP connection is not available');
362
443
  }
363
- await this.ensureSessionStoreLoaded();
364
- const persistedSessionId = this.persistedSessions.get(channelId);
444
+ const cwd = state.cwd ?? this.runtime.cwd;
445
+ const persistedSessionId = state.sessionPersistence === 'persistent'
446
+ ? await this.readPersistedSessionId(channelId, cwd)
447
+ : undefined;
365
448
  const sessionPromise = persistedSessionId
366
- ? this.restoreOrCreateSession(channelId, persistedSessionId)
367
- : this.startFreshSession(channelId);
449
+ ? this.restoreOrCreateSession(channelId, persistedSessionId, cwd)
450
+ : this.startFreshSession(channelId, cwd);
368
451
  this.pendingSessionStarts.add(sessionPromise);
369
452
  state.sessionPromise = sessionPromise;
370
453
  let sessionAdopted = false;
@@ -387,10 +470,12 @@ export class CopilotCliClient {
387
470
  }
388
471
  sessionAdopted = true;
389
472
  state.session = session;
473
+ state.sessionCwd = cwd;
390
474
  return session;
391
475
  }
392
476
  catch (error) {
393
477
  state.session = undefined;
478
+ state.sessionCwd = undefined;
394
479
  throw error;
395
480
  }
396
481
  finally {
@@ -399,30 +484,41 @@ export class CopilotCliClient {
399
484
  }
400
485
  }
401
486
  }
402
- async startFreshSession(channelId) {
487
+ async startFreshSession(channelId, cwd) {
403
488
  if (!this.connection) {
404
489
  throw new Error('Copilot ACP connection is not available');
405
490
  }
406
- const session = await this.connection.agent.buildSession(this.runtime.cwd).start();
407
- await this.persistSessionBestEffort(channelId, session.sessionId);
491
+ const session = await this.connection.agent.buildSession(cwd).start();
492
+ this.logger?.debug('started fresh Copilot ACP session', { channelId });
493
+ const state = this.channels.get(channelId);
494
+ if (state?.sessionPersistence === 'persistent') {
495
+ await this.persistSessionBestEffort(channelId, session.sessionId, cwd);
496
+ }
408
497
  return session;
409
498
  }
410
- async restoreOrCreateSession(channelId, sessionId) {
499
+ async restoreOrCreateSession(channelId, sessionId, cwd) {
411
500
  if (!this.sessionCapabilities.resumeSession && !this.sessionCapabilities.loadSession) {
412
- return this.startFreshSession(channelId);
501
+ this.logger?.debug('Copilot ACP restore unsupported; starting fresh session', { channelId });
502
+ return this.startFreshSession(channelId, cwd);
413
503
  }
414
504
  try {
415
- return await this.restoreSession(sessionId);
505
+ const session = await this.restoreSession(sessionId, cwd);
506
+ this.logger?.debug('restored Copilot ACP session', {
507
+ channelId,
508
+ restoreMethod: this.sessionCapabilities.resumeSession ? 'session/resume' : 'session/load',
509
+ });
510
+ return session;
416
511
  }
417
512
  catch (error) {
418
513
  if (!isStaleRestoreFailure(error)) {
419
514
  throw normalizeError(error);
420
515
  }
516
+ this.logger?.debug('discarded stale Copilot ACP session and started fresh', { channelId });
421
517
  await this.clearPersistedSessionBestEffort(channelId);
422
- return this.startFreshSession(channelId);
518
+ return this.startFreshSession(channelId, cwd);
423
519
  }
424
520
  }
425
- async restoreSession(sessionId) {
521
+ async restoreSession(sessionId, cwd) {
426
522
  if (!this.connection) {
427
523
  throw new Error('Copilot ACP connection is not available');
428
524
  }
@@ -435,7 +531,7 @@ export class CopilotCliClient {
435
531
  if (this.sessionCapabilities.resumeSession) {
436
532
  await this.connection.agent.request(this.runtime.methods.agent.session.resume, {
437
533
  sessionId,
438
- cwd: this.runtime.cwd,
534
+ cwd,
439
535
  mcpServers: [],
440
536
  });
441
537
  return session;
@@ -443,7 +539,7 @@ export class CopilotCliClient {
443
539
  if (this.sessionCapabilities.loadSession) {
444
540
  await this.connection.agent.request(this.runtime.methods.agent.session.load, {
445
541
  sessionId,
446
- cwd: this.runtime.cwd,
542
+ cwd,
447
543
  mcpServers: [],
448
544
  });
449
545
  this.clearBufferedSessionReplay(session);
@@ -462,6 +558,31 @@ export class CopilotCliClient {
462
558
  updates.values = [];
463
559
  }
464
560
  }
561
+ async resolveSessionCwd(promptContext) {
562
+ return promptContext?.taskWorkspace?.rootPath ?? this.runtime.cwd;
563
+ }
564
+ async recycleSessionIfCwdChanged(channelId, state) {
565
+ const session = state.session;
566
+ if (!session) {
567
+ return;
568
+ }
569
+ const nextCwd = state.cwd ?? this.runtime.cwd;
570
+ if (state.sessionCwd === nextCwd) {
571
+ return;
572
+ }
573
+ this.logger?.debug('recycling Copilot ACP session after cwd changed', {
574
+ channelId,
575
+ previousCwd: state.sessionCwd,
576
+ nextCwd,
577
+ });
578
+ state.session = undefined;
579
+ state.sessionCwd = undefined;
580
+ this.clearIdleTimer(state);
581
+ if (!this.disposing && !this.backendClosed && state.sessionPersistence === 'persistent') {
582
+ await this.clearPersistedSessionBestEffort(channelId);
583
+ }
584
+ this.closeSession(session);
585
+ }
465
586
  async runTurn(session, prompt, options) {
466
587
  const promptPromise = this.raceWithFatal(session.prompt(prompt));
467
588
  const promptFailure = new Promise((_, reject) => {
@@ -505,6 +626,17 @@ export class CopilotCliClient {
505
626
  }
506
627
  return Promise.race([promise, this.fatalPromise]);
507
628
  }
629
+ handlePermissionRequest(params) {
630
+ return resolveCopilotPermissionResponse({
631
+ gateEnabled: this.permissionPolicy.gateEnabled ?? false,
632
+ policyMode: this.permissionPolicy.policyMode ?? 'audit-only',
633
+ params,
634
+ logger: this.logger,
635
+ auditSink: this.permissionPolicy.auditSink,
636
+ agentId: this.resolveSessionStoreAgentId()?.trim() || undefined,
637
+ channelId: this.findChannelIdBySessionId(params.sessionId),
638
+ });
639
+ }
508
640
  failAll(error) {
509
641
  if (this.fatalError) {
510
642
  return;
@@ -522,6 +654,7 @@ export class CopilotCliClient {
522
654
  this.closeSession(state.session);
523
655
  }
524
656
  state.session = undefined;
657
+ state.sessionCwd = undefined;
525
658
  state.sessionPromise = undefined;
526
659
  state.activeTurn = undefined;
527
660
  if (!state.processing) {
@@ -531,17 +664,16 @@ export class CopilotCliClient {
531
664
  this.channels.clear();
532
665
  this.shutdownPromise = this.shutdownBackend(error);
533
666
  }
534
- invalidateSession(state, session) {
667
+ invalidateSession(channelId, state, session) {
535
668
  this.clearIdleTimer(state);
536
669
  if (state.session === session) {
537
670
  state.session = undefined;
671
+ state.sessionCwd = undefined;
538
672
  }
673
+ this.logger?.debug('discarding tainted Copilot ACP session', { channelId });
539
674
  this.closeSession(session);
540
- for (const [channelId, candidate] of this.channels.entries()) {
541
- if (candidate === state) {
542
- void this.clearPersistedSessionBestEffort(channelId);
543
- break;
544
- }
675
+ if (!this.disposing && !this.backendClosed && state.sessionPersistence === 'persistent') {
676
+ void this.clearPersistedSessionBestEffort(channelId);
545
677
  }
546
678
  }
547
679
  closeSession(session) {
@@ -589,6 +721,14 @@ export class CopilotCliClient {
589
721
  clearTimeout(state.idleTimer);
590
722
  state.idleTimer = undefined;
591
723
  }
724
+ findChannelIdBySessionId(sessionId) {
725
+ for (const state of this.channels.values()) {
726
+ if (state.session?.sessionId === sessionId) {
727
+ return state.channelId;
728
+ }
729
+ }
730
+ return undefined;
731
+ }
592
732
  reconcileIdleChannelState(channelId, state) {
593
733
  if (this.channels.get(channelId) !== state) {
594
734
  return;
@@ -606,6 +746,15 @@ export class CopilotCliClient {
606
746
  this.channels.delete(channelId);
607
747
  return;
608
748
  }
749
+ if (state.sessionPersistence === 'ephemeral') {
750
+ this.clearIdleTimer(state);
751
+ const session = state.session;
752
+ state.session = undefined;
753
+ state.sessionCwd = undefined;
754
+ this.closeSession(session);
755
+ this.channels.delete(channelId);
756
+ return;
757
+ }
609
758
  this.clearIdleTimer(state);
610
759
  const generation = state.idleTimerGeneration;
611
760
  state.idleTimer = setTimeout(() => {
@@ -627,8 +776,8 @@ export class CopilotCliClient {
627
776
  this.sessionStoreLoadPromise = (async () => {
628
777
  const loaded = await this.sessionStore.load(agentId);
629
778
  this.persistedSessions.clear();
630
- for (const [channelId, sessionId] of Object.entries(loaded)) {
631
- this.persistedSessions.set(channelId, sessionId);
779
+ for (const [channelId, sessionValue] of Object.entries(loaded)) {
780
+ this.persistedSessions.set(channelId, parsePersistedSessionRecord(sessionValue));
632
781
  }
633
782
  this.loadedSessionStoreAgentId = agentId;
634
783
  })().finally(() => {
@@ -637,23 +786,37 @@ export class CopilotCliClient {
637
786
  }
638
787
  await this.sessionStoreLoadPromise;
639
788
  }
640
- async persistSession(channelId, sessionId) {
789
+ async readPersistedSessionId(channelId, cwd) {
641
790
  await this.ensureSessionStoreLoaded();
642
- this.persistedSessions.set(channelId, sessionId);
643
- await this.flushSessionStore();
644
- }
645
- async persistSessionBestEffort(channelId, sessionId) {
646
- try {
647
- await this.persistSession(channelId, sessionId);
791
+ const record = this.persistedSessions.get(channelId);
792
+ if (!record) {
793
+ return undefined;
648
794
  }
649
- catch (error) {
650
- console.error('[agents-host] failed to persist Copilot session map; keeping reply delivery', {
651
- agentId: this.loadedSessionStoreAgentId,
652
- channelId,
653
- sessionId,
654
- error,
655
- });
795
+ if (!record.cwd || record.cwd !== cwd) {
796
+ await this.clearPersistedSessionBestEffort(channelId);
797
+ return undefined;
656
798
  }
799
+ return record.sessionId;
800
+ }
801
+ async persistSession(channelId, sessionId, cwd) {
802
+ await this.ensureSessionStoreLoaded();
803
+ this.persistedSessions.set(channelId, { sessionId, cwd });
804
+ await this.flushSessionStore();
805
+ }
806
+ async persistSessionBestEffort(channelId, sessionId, cwd) {
807
+ await this.trackSessionStoreOperation((async () => {
808
+ try {
809
+ await this.persistSession(channelId, sessionId, cwd);
810
+ }
811
+ catch (error) {
812
+ this.logger.error('failed to persist Copilot session map; keeping reply delivery', {
813
+ agentId: this.loadedSessionStoreAgentId,
814
+ channelId,
815
+ sessionId,
816
+ error: summarizeError(error),
817
+ });
818
+ }
819
+ })());
657
820
  }
658
821
  async clearPersistedSession(channelId) {
659
822
  await this.ensureSessionStoreLoaded();
@@ -663,27 +826,39 @@ export class CopilotCliClient {
663
826
  await this.flushSessionStore();
664
827
  }
665
828
  async clearPersistedSessionBestEffort(channelId) {
666
- try {
667
- await this.clearPersistedSession(channelId);
668
- }
669
- catch (error) {
670
- console.error('[agents-host] failed to clear Copilot session map; retrying in-memory only', {
671
- agentId: this.loadedSessionStoreAgentId,
672
- channelId,
673
- error,
674
- });
675
- }
829
+ await this.trackSessionStoreOperation((async () => {
830
+ try {
831
+ await this.clearPersistedSession(channelId);
832
+ }
833
+ catch (error) {
834
+ this.logger.error('failed to clear Copilot session map; retrying in-memory only', {
835
+ agentId: this.loadedSessionStoreAgentId,
836
+ channelId,
837
+ error: summarizeError(error),
838
+ });
839
+ }
840
+ })());
676
841
  }
677
842
  async flushSessionStore() {
678
843
  if (!this.sessionStore || !this.loadedSessionStoreAgentId) {
679
844
  return;
680
845
  }
681
846
  const agentId = this.loadedSessionStoreAgentId;
682
- const snapshot = Object.fromEntries(this.persistedSessions.entries());
847
+ const snapshot = Object.fromEntries([...this.persistedSessions.entries()].map(([channelId, record]) => [
848
+ channelId,
849
+ serializePersistedSessionRecord(record),
850
+ ]));
683
851
  const write = this.sessionStoreWriteQueue.then(() => this.sessionStore.save(agentId, snapshot));
684
852
  this.sessionStoreWriteQueue = write.catch(() => { });
685
853
  await write;
686
854
  }
855
+ async trackSessionStoreOperation(operation) {
856
+ const tracked = operation.finally(() => {
857
+ this.pendingSessionStoreOperations.delete(tracked);
858
+ });
859
+ this.pendingSessionStoreOperations.add(tracked);
860
+ return tracked;
861
+ }
687
862
  evictIdleChannel(channelId, state, generation) {
688
863
  if (this.disposing || this.fatalError || this.backendClosed) {
689
864
  return;
@@ -697,7 +872,9 @@ export class CopilotCliClient {
697
872
  }
698
873
  const session = state.session;
699
874
  state.session = undefined;
875
+ state.sessionCwd = undefined;
700
876
  if (session) {
877
+ this.logger?.debug('evicting idle Copilot ACP session', { channelId });
701
878
  this.closeSession(session);
702
879
  }
703
880
  this.channels.delete(channelId);
@@ -718,12 +895,15 @@ export class CopilotCliClient {
718
895
  return;
719
896
  }
720
897
  const childExitPromise = this.childExitPromise ?? Promise.resolve();
898
+ this.logger?.debug('sending SIGTERM to Copilot ACP process');
721
899
  child.kill('SIGTERM');
722
900
  const exitedAfterTerm = await this.waitForChildExit(childExitPromise, this.runtime.shutdownGracePeriodMs);
723
901
  if (exitedAfterTerm) {
902
+ this.logger?.debug('Copilot ACP process exited after SIGTERM');
724
903
  this.child = undefined;
725
904
  return;
726
905
  }
906
+ this.logger?.debug('sending SIGKILL to Copilot ACP process after SIGTERM grace timeout');
727
907
  child.kill('SIGKILL');
728
908
  await this.waitForChildExit(childExitPromise, this.runtime.shutdownForceKillWaitMs);
729
909
  this.child = undefined;
@@ -1,9 +1,32 @@
1
1
  export interface CopilotChannelSessionStore {
2
2
  load(agentId: string): Promise<Record<string, string>>;
3
3
  save(agentId: string, sessions: Record<string, string>): Promise<void>;
4
+ close?(): Promise<void> | void;
4
5
  }
5
6
  export interface FileCopilotChannelSessionStoreOptions {
6
7
  resolvePath(agentId: string): string;
8
+ fileSystem?: CopilotSessionStoreFileSystem;
9
+ platform?: NodeJS.Platform;
10
+ }
11
+ interface CopilotSessionStoreDirectoryHandle {
12
+ sync(): Promise<void>;
13
+ close(): Promise<void>;
14
+ }
15
+ interface CopilotSessionStoreFileHandle extends CopilotSessionStoreDirectoryHandle {
16
+ writeFile(data: string, options: {
17
+ encoding: BufferEncoding;
18
+ }): Promise<void>;
19
+ }
20
+ interface CopilotSessionStoreFileSystem {
21
+ mkdir(path: string, options: {
22
+ recursive: true;
23
+ mode: number;
24
+ }): Promise<void>;
25
+ openDirectory(path: string): Promise<CopilotSessionStoreDirectoryHandle>;
26
+ openFile(path: string, flags: string, mode: number): Promise<CopilotSessionStoreFileHandle>;
27
+ readFile(path: string, encoding: BufferEncoding): Promise<string>;
28
+ rename(from: string, to: string): Promise<void>;
29
+ unlink(path: string): Promise<void>;
7
30
  }
8
31
  export declare class FileCopilotChannelSessionStore implements CopilotChannelSessionStore {
9
32
  private readonly options;
@@ -12,3 +35,4 @@ export declare class FileCopilotChannelSessionStore implements CopilotChannelSes
12
35
  load(agentId: string): Promise<Record<string, string>>;
13
36
  save(agentId: string, sessions: Record<string, string>): Promise<void>;
14
37
  }
38
+ export {};