@borgee/agents-host 0.2.73 → 0.2.84

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 (50) hide show
  1. package/README.md +17 -30
  2. package/dist/agents-host-supervisor.d.ts +1 -0
  3. package/dist/agents-host-supervisor.js +3 -1
  4. package/dist/agents-host.d.ts +6 -4
  5. package/dist/agents-host.js +117 -16
  6. package/dist/background-runs.d.ts +23 -0
  7. package/dist/background-runs.js +153 -0
  8. package/dist/chat/chat-control-plane.d.ts +6 -1
  9. package/dist/chat/sdk-chat-control-plane.d.ts +4 -2
  10. package/dist/chat/sdk-chat-control-plane.js +7 -0
  11. package/dist/context/claude-file-brief.js +3 -3
  12. package/dist/context/injection.d.ts +18 -12
  13. package/dist/context/injection.js +53 -53
  14. package/dist/context/prompt.js +10 -10
  15. package/dist/context/resolved-working-folder.d.ts +3 -0
  16. package/dist/context/resolved-working-folder.js +106 -0
  17. package/dist/context/turn-preparation.js +1 -1
  18. package/dist/local-config.d.ts +7 -0
  19. package/dist/local-config.js +158 -12
  20. package/dist/managed-daemon.js +42 -35
  21. package/dist/plugin-sdk.js +179 -9
  22. package/dist/plugin-sdk.js.map +3 -3
  23. package/dist/policy/authorization-audit.d.ts +1 -1
  24. package/dist/policy/copilot-permission.d.ts +9 -0
  25. package/dist/policy/copilot-permission.js +120 -1
  26. package/dist/progress-to-activity.d.ts +1 -1
  27. package/dist/progress-to-activity.js +1 -0
  28. package/dist/providers/claude/adapter.d.ts +1 -1
  29. package/dist/providers/claude/adapter.js +7 -0
  30. package/dist/providers/claude/cli-client.js +4 -3
  31. package/dist/providers/codex/adapter.d.ts +1 -1
  32. package/dist/providers/codex/adapter.js +7 -0
  33. package/dist/providers/codex/cli-client.js +8 -7
  34. package/dist/providers/codex/project-doc.js +6 -6
  35. package/dist/providers/copilot/adapter.d.ts +6 -2
  36. package/dist/providers/copilot/adapter.js +27 -1
  37. package/dist/providers/copilot/cli-client.d.ts +37 -10
  38. package/dist/providers/copilot/cli-client.js +751 -122
  39. package/dist/providers/copilot/sdk-session.d.ts +149 -0
  40. package/dist/providers/copilot/sdk-session.js +981 -0
  41. package/dist/providers/create-provider.js +33 -0
  42. package/dist/providers/provider-adapter.d.ts +16 -1
  43. package/dist/providers/provider-adapter.js +13 -0
  44. package/dist/types.d.ts +32 -11
  45. package/package.json +3 -2
  46. package/skills/borgee-agent/references/task-properties.md +3 -3
  47. package/skills/borgee-agent/scripts/borgee-agent.mjs +1 -1
  48. package/skills/borgee-agent/scripts/borgee-agent.py +1 -1
  49. package/dist/context/resolved-workspace.d.ts +0 -3
  50. package/dist/context/resolved-workspace.js +0 -106
@@ -2,26 +2,33 @@ 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
4
  import { HostLogger, summarizeChildStderr, summarizeError } from '../../debug.js';
5
- import { AcpProgressCollector } from '../acp-progress-collector.js';
5
+ import { AcpProgressCollector, } from '../acp-progress-collector.js';
6
6
  import { ProviderTurnCancelledError } from '../provider-adapter.js';
7
- import { resolveCopilotPermissionResponse } from '../../policy/copilot-permission.js';
8
- import { isDiscussionOnlyResolvedWorkspace } from '../../context/resolved-workspace.js';
7
+ import { resolveCopilotPermissionResponse, resolveCopilotSdkPermissionResponse, } from '../../policy/copilot-permission.js';
8
+ import { isDiscussionOnlyResolvedWorkingFolder } from '../../context/resolved-working-folder.js';
9
9
  import { IDLE_BACKEND_SHUTDOWN_DISABLED_MS, IdleBackendShutdownScheduler, } from '../idle-backend-shutdown.js';
10
+ import { CopilotSdkSessionAdapter, createCopilotSdkClient, createCopilotSdkSessionConfig, } from './sdk-session.js';
10
11
  const SESSION_TAINTED_ERRORS = new WeakSet();
11
12
  const DEFAULT_IDLE_SESSION_TTL_MS = 2 * 24 * 60 * 60 * 1000;
12
- const IDLE_BACKEND_STOPPED_MESSAGE = 'Copilot ACP backend stopped after idle timeout';
13
+ const IDLE_BACKEND_STOPPED_MESSAGE = 'Copilot runtime backend stopped after idle timeout';
13
14
  /**
14
15
  * A fatal teardown has callers waiting on it, so it force-kills quickly. An
15
16
  * idle recycle has nobody waiting, and the CLIs unlink their on-disk session
16
17
  * 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.
18
+ * restore believe the session is still in use. The idle path therefore trades
19
+ * latency nobody observes for a clean exit.
19
20
  */
20
21
  const DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS = 5_000;
22
+ const DEFAULT_SESSION_CLOSE_TIMEOUT_MS = 5_000;
21
23
  const DEFAULT_SHUTDOWN_GRACE_PERIOD_MS = 250;
22
24
  const DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS = 250;
23
- 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';
25
+ const QUEUED_TURN_DROPPED_MESSAGE = 'Copilot session was reset after a failed turn; queued turns were dropped instead of replaying them on a fresh session';
26
+ function isCopilotSdkSession(session) {
27
+ return session instanceof CopilotSdkSessionAdapter;
28
+ }
24
29
  const DEFAULT_RUNTIME = {
30
+ transport: 'sdk',
31
+ createSdkClient: createCopilotSdkClient,
25
32
  spawn,
26
33
  client,
27
34
  ndJsonStream,
@@ -31,6 +38,7 @@ const DEFAULT_RUNTIME = {
31
38
  idleSessionTtlMs: DEFAULT_IDLE_SESSION_TTL_MS,
32
39
  idleBackendShutdownMs: IDLE_BACKEND_SHUTDOWN_DISABLED_MS,
33
40
  idleShutdownGracePeriodMs: DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS,
41
+ sessionCloseTimeoutMs: DEFAULT_SESSION_CLOSE_TIMEOUT_MS,
34
42
  shutdownGracePeriodMs: DEFAULT_SHUTDOWN_GRACE_PERIOD_MS,
35
43
  shutdownForceKillWaitMs: DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS,
36
44
  fetch: async (input, init) => fetch(input, init),
@@ -38,6 +46,7 @@ const DEFAULT_RUNTIME = {
38
46
  const DEFAULT_SESSION_CAPABILITIES = {
39
47
  loadSession: false,
40
48
  resumeSession: false,
49
+ closeSession: false,
41
50
  };
42
51
  function isAbsoluteHttpUrl(value) {
43
52
  return value.startsWith('http://') || value.startsWith('https://');
@@ -75,6 +84,7 @@ function createDeferredTurn(channelId, preparedTurn, sessionPersistence, options
75
84
  sessionPersistence,
76
85
  options,
77
86
  promise,
87
+ cancelled: false,
78
88
  resolve(value) {
79
89
  if (settled)
80
90
  return;
@@ -97,20 +107,61 @@ function parsePersistedSessionRecord(rawValue) {
97
107
  if (trimmed.startsWith('{')) {
98
108
  const parsed = JSON.parse(trimmed);
99
109
  if (typeof parsed.sessionId === 'string' && parsed.sessionId.trim().length > 0) {
110
+ const backgroundTaskExecutions = parseBackgroundTaskExecutions(parsed.backgroundTaskExecutions);
100
111
  return {
101
112
  sessionId: parsed.sessionId,
102
113
  cwd: typeof parsed.cwd === 'string' && parsed.cwd.trim().length > 0
103
114
  ? parsed.cwd
104
115
  : undefined,
116
+ ...(backgroundTaskExecutions ? { backgroundTaskExecutions } : {}),
105
117
  };
106
118
  }
107
119
  }
108
120
  return { sessionId: rawValue };
109
121
  }
122
+ function parseBackgroundTaskExecutions(value) {
123
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
124
+ return undefined;
125
+ }
126
+ const executions = {};
127
+ for (const [taskId, candidate] of Object.entries(value).slice(0, 128)) {
128
+ if (!taskId ||
129
+ !candidate ||
130
+ typeof candidate !== 'object' ||
131
+ Array.isArray(candidate)) {
132
+ continue;
133
+ }
134
+ const execution = candidate;
135
+ if (typeof execution.startedAt !== 'number' ||
136
+ !Number.isFinite(execution.startedAt) ||
137
+ typeof execution.terminal !== 'boolean' ||
138
+ typeof execution.waiting !== 'boolean') {
139
+ continue;
140
+ }
141
+ const activeStartedAt = typeof execution.activeStartedAt === 'number' &&
142
+ Number.isFinite(execution.activeStartedAt)
143
+ ? execution.activeStartedAt
144
+ : undefined;
145
+ const terminalAt = typeof execution.terminalAt === 'number' && Number.isFinite(execution.terminalAt)
146
+ ? execution.terminalAt
147
+ : undefined;
148
+ executions[taskId] = {
149
+ startedAt: execution.startedAt,
150
+ terminal: execution.terminal,
151
+ waiting: execution.waiting,
152
+ ...(activeStartedAt !== undefined ? { activeStartedAt } : {}),
153
+ ...(terminalAt !== undefined ? { terminalAt } : {}),
154
+ };
155
+ }
156
+ return Object.keys(executions).length > 0 ? executions : undefined;
157
+ }
110
158
  function serializePersistedSessionRecord(record) {
111
159
  return JSON.stringify({
112
160
  sessionId: record.sessionId,
113
161
  ...(record.cwd ? { cwd: record.cwd } : {}),
162
+ ...(record.backgroundTaskExecutions
163
+ ? { backgroundTaskExecutions: record.backgroundTaskExecutions }
164
+ : {}),
114
165
  });
115
166
  }
116
167
  function resolveSessionRouting(turn) {
@@ -136,6 +187,7 @@ function readSessionCapabilities(response) {
136
187
  return {
137
188
  loadSession: agentCapabilities?.loadSession === true,
138
189
  resumeSession: asObject(sessionCapabilities?.resume) !== null,
190
+ closeSession: asObject(sessionCapabilities?.close) !== null,
139
191
  };
140
192
  }
141
193
  function markSessionTainted(error) {
@@ -146,13 +198,64 @@ function markSessionTainted(error) {
146
198
  function isSessionTainted(error) {
147
199
  return error instanceof Error && SESSION_TAINTED_ERRORS.has(error);
148
200
  }
201
+ function isAgentTaskInput(value) {
202
+ const input = asObject(value);
203
+ return typeof input?.agent_type === 'string'
204
+ && typeof input.name === 'string'
205
+ && typeof input.description === 'string'
206
+ && typeof input.prompt === 'string'
207
+ && (input.mode === undefined
208
+ || input.mode === 'sync'
209
+ || input.mode === 'background');
210
+ }
211
+ const COPILOT_BACKGROUND_AGENT_ID = '[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}';
212
+ const COPILOT_SYNC_TASK_TIMEOUT_RESULT = new RegExp('^Agent is still running \\(agent_id: '
213
+ + COPILOT_BACKGROUND_AGENT_ID
214
+ + '\\)\\.'
215
+ + '(?: The agent is actively working \\([\\s\\S]*\\)\\.)?'
216
+ + ' Use read_agent to check for results'
217
+ + '(?:, or write_agent to send follow-up messages)?\\.$', 'i');
218
+ function isBackgroundAgentTaskResult(value) {
219
+ const result = asObject(value);
220
+ return typeof result?.content === 'string'
221
+ && COPILOT_SYNC_TASK_TIMEOUT_RESULT.test(result.content.trim());
222
+ }
223
+ class BackgroundAgentLaunchTracker {
224
+ taskCalls = new Map();
225
+ consume(notification) {
226
+ const update = notification.update;
227
+ if (update.sessionUpdate === 'tool_call') {
228
+ if (isAgentTaskInput(update.rawInput)) {
229
+ const input = asObject(update.rawInput);
230
+ this.taskCalls.set(update.toolCallId, {
231
+ requestedBackground: input?.mode === 'background',
232
+ });
233
+ }
234
+ return false;
235
+ }
236
+ if (update.sessionUpdate !== 'tool_call_update'
237
+ || !this.taskCalls.has(update.toolCallId)) {
238
+ return false;
239
+ }
240
+ if (update.status === 'failed') {
241
+ this.taskCalls.delete(update.toolCallId);
242
+ return false;
243
+ }
244
+ if (update.status !== 'completed') {
245
+ return false;
246
+ }
247
+ const taskCall = this.taskCalls.get(update.toolCallId);
248
+ this.taskCalls.delete(update.toolCallId);
249
+ return taskCall.requestedBackground || isBackgroundAgentTaskResult(update.rawOutput);
250
+ }
251
+ }
149
252
  /**
150
- * Persistent ACP-backed client for the GitHub Copilot CLI (`copilot --acp`).
253
+ * Persistent session client for the GitHub Copilot CLI.
151
254
  *
152
- * The legacy one-shot `COPILOT_ARGS` / constructor args are intentionally
153
- * ignored here: the prototype always launches the child as `copilot --acp`,
154
- * performs the ACP initialize handshake once, then reuses one ACP session per
155
- * Borgee channel for the process lifetime.
255
+ * Production uses the Copilot SDK headless JSON-RPC transport. The ACP runtime
256
+ * remains injectable for compatibility tests. Both paths reuse one provider
257
+ * session per Borgee channel because the background-agent registry belongs to
258
+ * that session and later read_agent/write_agent turns must keep that identity.
156
259
  */
157
260
  export class CopilotCliClient {
158
261
  command;
@@ -163,22 +266,75 @@ export class CopilotCliClient {
163
266
  imageInputConfig;
164
267
  runtime;
165
268
  channels = new Map();
269
+ channelWorkerRoutes = new Map();
270
+ sessionAuthorizationLeases = new Map();
166
271
  /**
167
272
  * The provider session currently bound to channelId, or undefined before a
168
273
  * turn has opened one. Surfaced so agents-host can record which session
169
274
  * worked a task without the agent being asked to report its own id.
170
275
  */
171
276
  sessionIdForChannel(channelId) {
172
- return this.channels.get(channelId)?.session?.sessionId;
277
+ return this.channelWorkerRoutes.get(channelId)?.state.session?.sessionId
278
+ ?? this.channels.get(channelId)?.session?.sessionId;
173
279
  }
174
- async cancelTurn(channelId) {
175
- const state = this.channels.get(channelId);
280
+ async interruptTurn(channelId) {
281
+ const workerRoute = this.channelWorkerRoutes.get(channelId);
282
+ const state = workerRoute?.phase === 'promoted' && workerRoute.state.activeTurn
283
+ ? workerRoute.state
284
+ : this.channels.get(channelId) ?? workerRoute?.state;
285
+ const activeTurn = state?.activeTurn;
176
286
  const session = state?.session;
177
- if (!state?.activeTurn || !session) {
287
+ if (!state || !activeTurn) {
178
288
  return false;
179
289
  }
290
+ activeTurn.cancelled = true;
291
+ if (!session) {
292
+ return true;
293
+ }
294
+ if (!isCopilotSdkSession(session)) {
295
+ return true;
296
+ }
180
297
  this.clearIdleTimer(state);
181
298
  this.idleBackendShutdown.cancel();
299
+ return session.interruptMainTurn();
300
+ }
301
+ async cancelSession(channelId) {
302
+ const workerRoute = this.channelWorkerRoutes.get(channelId);
303
+ const state = workerRoute?.phase === 'promoted'
304
+ ? workerRoute.state
305
+ : this.channels.get(channelId) ?? workerRoute?.state;
306
+ const session = state?.session;
307
+ if (!state) {
308
+ return false;
309
+ }
310
+ if (state.activeTurn) {
311
+ state.activeTurn.cancelled = true;
312
+ }
313
+ if (!session) {
314
+ if (!state.activeTurn) {
315
+ return false;
316
+ }
317
+ state.activeTurn.cancelled = true;
318
+ return true;
319
+ }
320
+ this.clearIdleTimer(state);
321
+ this.idleBackendShutdown.cancel();
322
+ if (isCopilotSdkSession(session)) {
323
+ const [foreground, background] = await Promise.allSettled([
324
+ state.activeTurn ? session.interruptMainTurn() : Promise.resolve(false),
325
+ session.cancelAllBackgroundWork(),
326
+ ]);
327
+ if (foreground.status === 'rejected' || background.status === 'rejected') {
328
+ const errors = [foreground, background]
329
+ .filter((result) => result.status === 'rejected')
330
+ .map((result) => normalizeError(result.reason));
331
+ throw new AggregateError(errors, 'Copilot SDK session cancellation failed');
332
+ }
333
+ return foreground.value || background.value;
334
+ }
335
+ if (!state.activeTurn) {
336
+ return false;
337
+ }
182
338
  const activeSession = session;
183
339
  if (typeof activeSession.cancel === 'function') {
184
340
  await activeSession.cancel({ sessionId: session.sessionId });
@@ -188,8 +344,23 @@ export class CopilotCliClient {
188
344
  await this.connection?.agent.notify(cancelMethod, { sessionId: session.sessionId });
189
345
  return true;
190
346
  }
347
+ async cancelTurn(channelId) {
348
+ return this.cancelSession(channelId);
349
+ }
350
+ async cancelBackgroundRun(channelId, providerRunId) {
351
+ const workerRoute = this.channelWorkerRoutes.get(channelId);
352
+ const state = workerRoute?.phase === 'promoted'
353
+ ? workerRoute.state
354
+ : this.channels.get(channelId) ?? workerRoute?.state;
355
+ const session = state?.session;
356
+ if (!session || !isCopilotSdkSession(session)) {
357
+ return false;
358
+ }
359
+ return session.cancelBackgroundRun(providerRunId);
360
+ }
191
361
  persistedSessions = new Map();
192
362
  closingSessions = new WeakSet();
363
+ orphanedSessions = new Set();
193
364
  pendingSessionStarts = new Set();
194
365
  pendingSessionCloses = new Set();
195
366
  pendingSessionStoreOperations = new Set();
@@ -197,6 +368,7 @@ export class CopilotCliClient {
197
368
  rejectFatalPromise;
198
369
  child;
199
370
  connection;
371
+ sdkClient;
200
372
  startPromise;
201
373
  shutdownPromise;
202
374
  childExitPromise;
@@ -207,6 +379,7 @@ export class CopilotCliClient {
207
379
  */
208
380
  backendGeneration = 0;
209
381
  idleShutdownPromise;
382
+ backendShutdownInProgress = false;
210
383
  fatalError = null;
211
384
  disposing = false;
212
385
  backendClosed = false;
@@ -214,6 +387,8 @@ export class CopilotCliClient {
214
387
  sessionStoreLoadPromise = null;
215
388
  sessionStoreWriteQueue = Promise.resolve();
216
389
  sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
390
+ backendRecycleRequired = false;
391
+ autonomousReplyHandler;
217
392
  idleBackendShutdown;
218
393
  constructor(command, _ignoredArgs = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger(), permissionPolicy = {}, imageInputConfig = {}) {
219
394
  this.command = command;
@@ -228,7 +403,7 @@ export class CopilotCliClient {
228
403
  isIdle: () => this.isBackendIdle(),
229
404
  shutdown: () => this.shutdownIdleBackend(),
230
405
  onShutdownFailed: (error) => {
231
- this.logger.error('failed to stop the idle Copilot ACP backend', {
406
+ this.logger.error('failed to stop the idle Copilot runtime backend', {
232
407
  error: summarizeError(error),
233
408
  });
234
409
  },
@@ -259,19 +434,26 @@ export class CopilotCliClient {
259
434
  ? maybeOptions
260
435
  : promptOrOptions;
261
436
  const sessionRouting = resolveSessionRouting(preparedTurn);
262
- const state = this.getOrCreateChannelState(sessionRouting.key, preparedTurn.channelId, sessionRouting.persistence);
437
+ const state = this.resolveChannelState(sessionRouting);
438
+ if (options?.onProgress) {
439
+ state.backgroundProgress = options.onProgress;
440
+ }
263
441
  this.clearIdleTimer(state);
264
442
  this.idleBackendShutdown.cancel();
265
443
  const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn, sessionRouting.persistence, options);
266
444
  state.queue.push(turn);
267
- this.processChannelQueue(sessionRouting.key, state);
445
+ this.processChannelQueue(state);
268
446
  return turn.promise;
269
447
  }
448
+ onAutonomousReply(handler) {
449
+ this.autonomousReplyHandler = handler;
450
+ }
270
451
  async dispose() {
271
- const error = new Error('Copilot ACP backend stopped');
452
+ const error = new Error('Copilot runtime backend stopped');
272
453
  this.disposing = true;
454
+ this.autonomousReplyHandler = undefined;
273
455
  this.idleBackendShutdown.cancel();
274
- this.logger?.debug('stopping Copilot ACP backend');
456
+ this.logger?.debug('stopping Copilot runtime backend');
275
457
  const closed = this.connection?.closed ?? Promise.resolve();
276
458
  this.failAll(error);
277
459
  let thrown;
@@ -314,11 +496,15 @@ export class CopilotCliClient {
314
496
  if (this.fatalError) {
315
497
  throw this.fatalError;
316
498
  }
317
- if (!this.connection) {
318
- throw new Error('Copilot ACP connection is not available');
499
+ if (this.runtime.transport === 'sdk' ? !this.sdkClient : !this.connection) {
500
+ throw new Error('Copilot runtime connection is not available');
319
501
  }
320
502
  }
321
503
  async startBackend() {
504
+ if (this.runtime.transport === 'sdk') {
505
+ await this.startSdkBackend();
506
+ return;
507
+ }
322
508
  const generation = ++this.backendGeneration;
323
509
  const ownsCurrentBackend = () => (this.backendGeneration === generation && !this.disposing);
324
510
  this.logger?.debug('starting Copilot ACP process', {
@@ -384,6 +570,7 @@ export class CopilotCliClient {
384
570
  this.logger?.debug('initialized Copilot ACP connection', {
385
571
  loadSession: this.sessionCapabilities.loadSession,
386
572
  resumeSession: this.sessionCapabilities.resumeSession,
573
+ closeSession: this.sessionCapabilities.closeSession,
387
574
  });
388
575
  }
389
576
  catch (error) {
@@ -393,25 +580,73 @@ export class CopilotCliClient {
393
580
  throw normalized;
394
581
  }
395
582
  }
396
- getOrCreateChannelState(channelId, resolvedChannelId, sessionPersistence) {
397
- let state = this.channels.get(channelId);
583
+ async startSdkBackend() {
584
+ this.logger?.debug('starting Copilot SDK runtime', {
585
+ command: this.command,
586
+ cwd: this.runtime.cwd,
587
+ });
588
+ const sdkClient = this.runtime.createSdkClient(this.command, this.runtime.cwd, this.logger.enabled);
589
+ this.sdkClient = sdkClient;
590
+ try {
591
+ await sdkClient.start();
592
+ this.sessionCapabilities = {
593
+ loadSession: false,
594
+ resumeSession: true,
595
+ closeSession: true,
596
+ };
597
+ this.logger?.debug('initialized Copilot SDK runtime');
598
+ }
599
+ catch (error) {
600
+ const normalized = new Error(`Copilot SDK startup failed: ${normalizeError(error).message}`);
601
+ this.logger?.debugError('Copilot SDK startup failed', normalized);
602
+ this.failAll(normalized);
603
+ throw normalized;
604
+ }
605
+ }
606
+ getOrCreateChannelState(routingKey, resolvedChannelId, sessionPersistence) {
607
+ let state = this.channels.get(routingKey);
398
608
  if (!state) {
399
609
  state = {
610
+ routingKey,
400
611
  channelId: resolvedChannelId,
401
612
  sessionPersistence,
613
+ sessionHasBackgroundAgents: false,
614
+ sessionHasPendingAutonomousTurn: false,
402
615
  idleTimerGeneration: 0,
403
616
  processing: false,
404
617
  queue: [],
405
618
  };
406
- this.channels.set(channelId, state);
619
+ this.channels.set(routingKey, state);
407
620
  }
408
621
  else if (state.channelId !== resolvedChannelId
409
622
  || state.sessionPersistence !== sessionPersistence) {
410
- throw new Error(`Copilot session routing changed for key "${channelId}"`);
623
+ throw new Error(`Copilot session routing changed for key "${routingKey}"`);
411
624
  }
412
625
  return state;
413
626
  }
414
- processChannelQueue(channelId, state) {
627
+ resolveChannelState(routing) {
628
+ const workerRoute = this.channelWorkerRoutes.get(routing.channelId);
629
+ if (workerRoute) {
630
+ // Candidate ownership reserves the whole channel so a foreground turn
631
+ // cannot open a competing worker registry before promotion.
632
+ return workerRoute.state;
633
+ }
634
+ if (routing.persistence === 'ephemeral') {
635
+ const foregroundState = this.channels.get(routing.channelId);
636
+ const state = foregroundState
637
+ && foregroundState.channelId === routing.channelId
638
+ && foregroundState.sessionPersistence === 'persistent'
639
+ ? foregroundState
640
+ : this.getOrCreateChannelState(routing.key, routing.channelId, routing.persistence);
641
+ this.channelWorkerRoutes.set(routing.channelId, {
642
+ phase: 'candidate',
643
+ state,
644
+ });
645
+ return state;
646
+ }
647
+ return this.getOrCreateChannelState(routing.key, routing.channelId, routing.persistence);
648
+ }
649
+ processChannelQueue(state) {
415
650
  if (state.processing) {
416
651
  return;
417
652
  }
@@ -426,20 +661,42 @@ export class CopilotCliClient {
426
661
  state.activeTurn = turn;
427
662
  try {
428
663
  state.cwd = await this.resolveSessionCwd(turn.preparedTurn.promptContext);
429
- state.toolPermissionMode = isDiscussionOnlyResolvedWorkspace(turn.preparedTurn.promptContext)
664
+ state.toolPermissionMode = isDiscussionOnlyResolvedWorkingFolder(turn.preparedTurn.promptContext)
430
665
  ? 'deny-all'
431
666
  : 'default';
667
+ const adoptingPersistentRoute = (turn.sessionPersistence === 'persistent'
668
+ && state.sessionPersistence === 'ephemeral');
669
+ if (adoptingPersistentRoute) {
670
+ // Delay the upgrade until this turn reaches the queue head so
671
+ // enqueueing it cannot change or abort the active kickoff.
672
+ state.sessionPersistence = 'persistent';
673
+ }
432
674
  await this.ensureStarted();
433
- await this.recycleSessionIfCwdChanged(channelId, state);
434
- const session = await this.getOrCreateSession(channelId, state);
675
+ await this.recycleSessionIfCwdChanged(state);
676
+ const existingSession = state.session;
677
+ const session = await this.getOrCreateSession(state);
678
+ if (turn.cancelled) {
679
+ throw new ProviderTurnCancelledError('Copilot turn cancelled');
680
+ }
681
+ if (adoptingPersistentRoute && existingSession === session) {
682
+ await this.persistSessionBestEffort(state, session.sessionId, state.sessionCwd ?? state.cwd ?? this.runtime.cwd);
683
+ }
684
+ this.bindSessionAuthorization(session, state);
435
685
  let reply;
436
686
  try {
437
687
  const promptInput = await this.buildPromptInput(turn.preparedTurn);
438
- reply = await this.runTurn(session, promptInput, turn.options);
688
+ if (turn.cancelled) {
689
+ throw new ProviderTurnCancelledError('Copilot turn cancelled');
690
+ }
691
+ reply = await this.runTurn(session, promptInput, turn.options, () => {
692
+ if (state.session === session) {
693
+ this.promoteWorkerRoute(state, session);
694
+ }
695
+ });
439
696
  }
440
697
  catch (error) {
441
698
  if (isSessionTainted(error)) {
442
- this.invalidateSession(channelId, state, session);
699
+ this.invalidateSession(state, session);
443
700
  this.rejectQueuedTurnsAfterSessionTaint(state, error);
444
701
  }
445
702
  throw error;
@@ -456,28 +713,28 @@ export class CopilotCliClient {
456
713
  }
457
714
  finally {
458
715
  state.processing = false;
459
- this.reconcileIdleChannelState(channelId, state);
460
- this.idleBackendShutdown.reconcile();
716
+ this.reconcileIdleChannelState(state);
717
+ this.reconcileBackendLifecycle();
461
718
  }
462
719
  })();
463
720
  }
464
- async getOrCreateSession(channelId, state) {
721
+ async getOrCreateSession(state) {
465
722
  if (state.session) {
466
723
  return state.session;
467
724
  }
468
725
  if (state.sessionPromise) {
469
726
  return state.sessionPromise;
470
727
  }
471
- if (!this.connection) {
472
- throw new Error('Copilot ACP connection is not available');
728
+ if (this.runtime.transport === 'sdk' ? !this.sdkClient : !this.connection) {
729
+ throw new Error('Copilot runtime connection is not available');
473
730
  }
474
731
  const cwd = state.cwd ?? this.runtime.cwd;
475
732
  const persistedSessionId = state.sessionPersistence === 'persistent'
476
- ? await this.readPersistedSessionId(channelId, cwd)
733
+ ? await this.readPersistedSessionId(state.channelId, cwd)
477
734
  : undefined;
478
735
  const sessionPromise = persistedSessionId
479
- ? this.restoreOrCreateSession(channelId, persistedSessionId, cwd)
480
- : this.startFreshSession(channelId, cwd);
736
+ ? this.restoreOrCreateSession(state, persistedSessionId, cwd)
737
+ : this.startFreshSession(state, cwd);
481
738
  this.pendingSessionStarts.add(sessionPromise);
482
739
  state.sessionPromise = sessionPromise;
483
740
  let sessionAdopted = false;
@@ -501,6 +758,19 @@ export class CopilotCliClient {
501
758
  sessionAdopted = true;
502
759
  state.session = session;
503
760
  state.sessionCwd = cwd;
761
+ state.sessionHasBackgroundAgents = false;
762
+ state.sessionHasPendingAutonomousTurn = false;
763
+ if (isCopilotSdkSession(session)) {
764
+ await session.reconcileBackgroundTasks();
765
+ const hasBackgroundAgents = await session.hasBackgroundAgents();
766
+ if (!state.sessionHasBackgroundAgents && hasBackgroundAgents) {
767
+ this.promoteWorkerRoute(state, session);
768
+ }
769
+ }
770
+ if (state.sessionPersistence === 'persistent'
771
+ && session.sessionId !== persistedSessionId) {
772
+ await this.persistSessionBestEffort(state, session.sessionId, cwd);
773
+ }
504
774
  return session;
505
775
  }
506
776
  catch (error) {
@@ -514,27 +784,33 @@ export class CopilotCliClient {
514
784
  }
515
785
  }
516
786
  }
517
- async startFreshSession(channelId, cwd) {
787
+ async startFreshSession(state, cwd) {
788
+ if (this.runtime.transport === 'sdk') {
789
+ if (!this.sdkClient) {
790
+ throw new Error('Copilot SDK runtime is not available');
791
+ }
792
+ const session = this.createSdkSessionAdapter(await this.sdkClient.createSession(createCopilotSdkSessionConfig(cwd, (request, invocation) => this.handleSdkPermissionRequest(request, invocation.sessionId))), state);
793
+ this.logger?.debug('started fresh Copilot SDK session', { channelId: state.channelId });
794
+ return session;
795
+ }
518
796
  if (!this.connection) {
519
797
  throw new Error('Copilot ACP connection is not available');
520
798
  }
521
799
  const session = await this.connection.agent.buildSession(cwd).start();
522
- this.logger?.debug('started fresh Copilot ACP session', { channelId });
523
- const state = this.channels.get(channelId);
524
- if (state?.sessionPersistence === 'persistent') {
525
- await this.persistSessionBestEffort(channelId, session.sessionId, cwd);
526
- }
800
+ this.logger?.debug('started fresh Copilot ACP session', { channelId: state.channelId });
527
801
  return session;
528
802
  }
529
- async restoreOrCreateSession(channelId, sessionId, cwd) {
803
+ async restoreOrCreateSession(state, sessionId, cwd) {
530
804
  if (!this.sessionCapabilities.resumeSession && !this.sessionCapabilities.loadSession) {
531
- this.logger?.debug('Copilot ACP restore unsupported; starting fresh session', { channelId });
532
- return this.startFreshSession(channelId, cwd);
805
+ this.logger?.debug('Copilot session restore unsupported; starting fresh session', {
806
+ channelId: state.channelId,
807
+ });
808
+ return this.startFreshSession(state, cwd);
533
809
  }
534
810
  try {
535
- const session = await this.restoreSession(sessionId, cwd);
536
- this.logger?.debug('restored Copilot ACP session', {
537
- channelId,
811
+ const session = await this.restoreSession(state, sessionId, cwd);
812
+ this.logger?.debug('restored Copilot session', {
813
+ channelId: state.channelId,
538
814
  restoreMethod: this.sessionCapabilities.resumeSession ? 'session/resume' : 'session/load',
539
815
  });
540
816
  return session;
@@ -543,12 +819,21 @@ export class CopilotCliClient {
543
819
  if (!isStaleRestoreFailure(error)) {
544
820
  throw normalizeError(error);
545
821
  }
546
- this.logger?.debug('discarded stale Copilot ACP session and started fresh', { channelId });
547
- await this.clearPersistedSessionBestEffort(channelId);
548
- return this.startFreshSession(channelId, cwd);
822
+ this.logger?.debug('discarded stale Copilot session and started fresh', {
823
+ channelId: state.channelId,
824
+ });
825
+ await this.clearPersistedSessionBestEffort(state.channelId, sessionId);
826
+ return this.startFreshSession(state, cwd);
549
827
  }
550
828
  }
551
- async restoreSession(sessionId, cwd) {
829
+ async restoreSession(state, sessionId, cwd) {
830
+ if (this.runtime.transport === 'sdk') {
831
+ if (!this.sdkClient) {
832
+ throw new Error('Copilot SDK runtime is not available');
833
+ }
834
+ const session = await this.sdkClient.resumeSession(sessionId, createCopilotSdkSessionConfig(cwd, (request, invocation) => this.handleSdkPermissionRequest(request, invocation.sessionId)));
835
+ return this.createSdkSessionAdapter(session, state);
836
+ }
552
837
  if (!this.connection) {
553
838
  throw new Error('Copilot ACP connection is not available');
554
839
  }
@@ -578,18 +863,73 @@ export class CopilotCliClient {
578
863
  throw new Error('Copilot ACP agent does not advertise session restore capabilities');
579
864
  }
580
865
  catch (error) {
581
- session.dispose();
866
+ this.closeSession(session);
582
867
  throw normalizeError(error);
583
868
  }
584
869
  }
870
+ createSdkSessionAdapter(session, state) {
871
+ const persistedRecord = this.persistedSessions.get(state.channelId);
872
+ const initialBackgroundTaskExecutions = persistedRecord?.sessionId === session.sessionId
873
+ ? persistedRecord.backgroundTaskExecutions
874
+ : undefined;
875
+ return new CopilotSdkSessionAdapter(session, (run) => {
876
+ state.backgroundProgress?.({ type: 'background_run', run });
877
+ }, (active) => {
878
+ const currentSession = state.session;
879
+ if (!currentSession || currentSession.sessionId !== session.sessionId) {
880
+ return;
881
+ }
882
+ if (active) {
883
+ this.promoteWorkerRoute(state, currentSession);
884
+ return;
885
+ }
886
+ state.sessionHasBackgroundAgents = false;
887
+ this.reconcileIdleChannelState(state);
888
+ this.reconcileBackendLifecycle();
889
+ }, (error) => {
890
+ this.logger.debugError('failed to refresh Copilot background task registry', {
891
+ channelId: state.channelId,
892
+ error: summarizeError(error),
893
+ });
894
+ }, (reply) => {
895
+ const currentSession = state.session;
896
+ if (!currentSession || currentSession.sessionId !== session.sessionId) {
897
+ return;
898
+ }
899
+ this.autonomousReplyHandler?.({
900
+ channelId: state.channelId,
901
+ eventId: reply.eventId,
902
+ text: reply.text,
903
+ });
904
+ }, (active) => {
905
+ const currentSession = state.session;
906
+ if (!currentSession || currentSession.sessionId !== session.sessionId) {
907
+ return;
908
+ }
909
+ state.sessionHasPendingAutonomousTurn = active;
910
+ if (active) {
911
+ this.promoteWorkerRoute(state, currentSession);
912
+ this.clearIdleTimer(state);
913
+ this.idleBackendShutdown.cancel();
914
+ return;
915
+ }
916
+ this.reconcileIdleChannelState(state);
917
+ this.reconcileBackendLifecycle();
918
+ }, initialBackgroundTaskExecutions, async (backgroundTaskExecutions) => {
919
+ await this.persistSessionBestEffort(state, session.sessionId, state.sessionCwd ?? state.cwd ?? this.runtime.cwd, backgroundTaskExecutions);
920
+ });
921
+ }
585
922
  clearBufferedSessionReplay(session) {
923
+ if (isCopilotSdkSession(session)) {
924
+ return;
925
+ }
586
926
  const updates = session.updates;
587
927
  if (updates && Array.isArray(updates.values)) {
588
928
  updates.values = [];
589
929
  }
590
930
  }
591
931
  async resolveSessionCwd(promptContext) {
592
- return promptContext?.resolvedWorkspace?.rootPath ?? this.runtime.cwd;
932
+ return promptContext?.resolvedWorkingFolder?.rootPath ?? this.runtime.cwd;
593
933
  }
594
934
  async buildPromptInput(turn) {
595
935
  const imageParts = turn.incomingParts.map(asImagePart).filter((part) => part !== null);
@@ -622,7 +962,7 @@ export class CopilotCliClient {
622
962
  uri: response.url || resolvedUrl,
623
963
  };
624
964
  }
625
- async recycleSessionIfCwdChanged(channelId, state) {
965
+ async recycleSessionIfCwdChanged(state) {
626
966
  const session = state.session;
627
967
  if (!session) {
628
968
  return;
@@ -631,25 +971,53 @@ export class CopilotCliClient {
631
971
  if (state.sessionCwd === nextCwd) {
632
972
  return;
633
973
  }
634
- this.logger?.debug('recycling Copilot ACP session after cwd changed', {
635
- channelId,
974
+ if (state.sessionHasBackgroundAgents || state.sessionHasPendingAutonomousTurn) {
975
+ this.logger?.debug('keeping Copilot session cwd while background delivery is active', {
976
+ channelId: state.channelId,
977
+ sessionCwd: state.sessionCwd,
978
+ requestedCwd: nextCwd,
979
+ });
980
+ state.cwd = state.sessionCwd;
981
+ return;
982
+ }
983
+ this.logger?.debug('recycling Copilot session after cwd changed', {
984
+ channelId: state.channelId,
636
985
  previousCwd: state.sessionCwd,
637
986
  nextCwd,
638
987
  });
639
988
  state.session = undefined;
640
989
  state.sessionCwd = undefined;
990
+ state.sessionHasBackgroundAgents = false;
991
+ state.sessionHasPendingAutonomousTurn = false;
641
992
  this.clearIdleTimer(state);
642
993
  if (!this.disposing && !this.backendClosed && state.sessionPersistence === 'persistent') {
643
- await this.clearPersistedSessionBestEffort(channelId);
994
+ await this.clearPersistedSessionBestEffort(state.channelId, session.sessionId);
644
995
  }
645
996
  this.closeSession(session);
646
997
  }
647
- async runTurn(session, prompt, options) {
998
+ async runTurn(session, prompt, options, onBackgroundAgentStarted) {
648
999
  const promptPromise = this.raceWithFatal(session.prompt(prompt));
649
1000
  const promptFailure = new Promise((_, reject) => {
650
1001
  void promptPromise.catch((error) => reject(markSessionTainted(error)));
651
1002
  });
652
1003
  const collector = new AcpProgressCollector(options?.onProgress);
1004
+ const backgroundAgentLaunches = new BackgroundAgentLaunchTracker();
1005
+ let backgroundAgentStarted = false;
1006
+ let backgroundRoutePromoted = false;
1007
+ const promoteBackgroundRoute = () => {
1008
+ if (backgroundRoutePromoted) {
1009
+ return;
1010
+ }
1011
+ backgroundRoutePromoted = true;
1012
+ onBackgroundAgentStarted?.();
1013
+ };
1014
+ const noteBackgroundAgentStarted = () => {
1015
+ if (backgroundAgentStarted) {
1016
+ return;
1017
+ }
1018
+ backgroundAgentStarted = true;
1019
+ promoteBackgroundRoute();
1020
+ };
653
1021
  for (;;) {
654
1022
  let update;
655
1023
  try {
@@ -670,17 +1038,27 @@ export class CopilotCliClient {
670
1038
  throw markSessionTainted(error);
671
1039
  }
672
1040
  if (response.stopReason === 'cancelled') {
673
- throw new ProviderTurnCancelledError('Copilot ACP turn cancelled');
1041
+ throw new ProviderTurnCancelledError('Copilot turn cancelled');
674
1042
  }
675
1043
  if (response.stopReason !== 'end_turn') {
676
- throw new Error(`Copilot ACP turn stopped with stopReason "${response.stopReason}"`);
1044
+ throw new Error(`Copilot turn stopped with stopReason "${response.stopReason}"`);
1045
+ }
1046
+ if (isCopilotSdkSession(session) && await session.hasBackgroundAgents()) {
1047
+ backgroundAgentStarted = true;
1048
+ promoteBackgroundRoute();
677
1049
  }
678
1050
  const output = collector.getFinalText();
679
1051
  if (!output) {
680
- throw new Error('Copilot ACP returned empty output');
1052
+ if (backgroundAgentStarted) {
1053
+ return 'Background worker started.';
1054
+ }
1055
+ throw new Error('Copilot returned empty output');
681
1056
  }
682
1057
  return output;
683
1058
  }
1059
+ if (!isCopilotSdkSession(session) && backgroundAgentLaunches.consume(update)) {
1060
+ noteBackgroundAgentStarted();
1061
+ }
684
1062
  collector.consume(update);
685
1063
  }
686
1064
  }
@@ -691,7 +1069,7 @@ export class CopilotCliClient {
691
1069
  return Promise.race([promise, this.fatalPromise]);
692
1070
  }
693
1071
  handlePermissionRequest(params) {
694
- const state = this.findChannelStateBySessionId(params.sessionId);
1072
+ const lease = this.sessionAuthorizationLeases.get(params.sessionId);
695
1073
  return resolveCopilotPermissionResponse({
696
1074
  gateEnabled: this.permissionPolicy.gateEnabled ?? false,
697
1075
  policyMode: this.permissionPolicy.policyMode ?? 'audit-only',
@@ -699,8 +1077,25 @@ export class CopilotCliClient {
699
1077
  logger: this.logger,
700
1078
  auditSink: this.permissionPolicy.auditSink,
701
1079
  agentId: this.resolveSessionStoreAgentId()?.trim() || undefined,
702
- channelId: state?.channelId,
703
- toolPermissionMode: state?.toolPermissionMode ?? 'default',
1080
+ channelId: lease?.channelId,
1081
+ sessionBound: lease !== undefined,
1082
+ toolPermissionMode: lease?.toolPermissionMode ?? 'default',
1083
+ });
1084
+ }
1085
+ handleSdkPermissionRequest(request, sessionId) {
1086
+ const lease = this.sessionAuthorizationLeases.get(sessionId);
1087
+ return resolveCopilotSdkPermissionResponse({
1088
+ gateEnabled: this.permissionPolicy.gateEnabled ?? false,
1089
+ policyMode: this.permissionPolicy.policyMode ?? 'audit-only',
1090
+ request,
1091
+ sessionId,
1092
+ logger: this.logger,
1093
+ auditSink: this.permissionPolicy.auditSink,
1094
+ auditSurface: 'copilot-sdk-permission',
1095
+ agentId: this.resolveSessionStoreAgentId()?.trim() || undefined,
1096
+ channelId: lease?.channelId,
1097
+ sessionBound: lease !== undefined,
1098
+ toolPermissionMode: lease?.toolPermissionMode ?? 'default',
704
1099
  });
705
1100
  }
706
1101
  failAll(error) {
@@ -710,37 +1105,48 @@ export class CopilotCliClient {
710
1105
  this.fatalError = error;
711
1106
  this.rejectFatalPromise(error);
712
1107
  this.idleBackendShutdown.cancel();
713
- for (const [channelId, state] of this.channels.entries()) {
1108
+ for (const [routingKey, state] of this.channels.entries()) {
714
1109
  this.clearIdleTimer(state);
715
1110
  state.activeTurn?.reject(error);
716
1111
  for (const turn of state.queue) {
717
1112
  turn.reject(error);
718
1113
  }
719
1114
  state.queue.length = 0;
720
- if (state.session) {
721
- this.closeSession(state.session);
1115
+ const session = state.session;
1116
+ if (session) {
1117
+ this.releaseWorkerRoute(state, session.sessionId);
1118
+ this.closeSession(session);
1119
+ }
1120
+ else {
1121
+ this.releaseWorkerRoute(state);
722
1122
  }
723
1123
  state.session = undefined;
724
1124
  state.sessionCwd = undefined;
1125
+ state.sessionHasBackgroundAgents = false;
1126
+ state.sessionHasPendingAutonomousTurn = false;
725
1127
  state.sessionPromise = undefined;
726
1128
  state.activeTurn = undefined;
727
1129
  if (!state.processing) {
728
- this.channels.delete(channelId);
1130
+ this.channels.delete(routingKey);
729
1131
  }
730
1132
  }
731
1133
  this.channels.clear();
1134
+ this.channelWorkerRoutes.clear();
732
1135
  this.shutdownPromise = this.shutdownBackend(error);
733
1136
  }
734
- invalidateSession(channelId, state, session) {
1137
+ invalidateSession(state, session) {
735
1138
  this.clearIdleTimer(state);
736
1139
  if (state.session === session) {
737
1140
  state.session = undefined;
738
1141
  state.sessionCwd = undefined;
1142
+ state.sessionHasBackgroundAgents = false;
1143
+ state.sessionHasPendingAutonomousTurn = false;
739
1144
  }
740
- this.logger?.debug('discarding tainted Copilot ACP session', { channelId });
1145
+ this.releaseWorkerRoute(state, session.sessionId);
1146
+ this.logger?.debug('discarding tainted Copilot session', { channelId: state.channelId });
741
1147
  this.closeSession(session);
742
1148
  if (!this.disposing && !this.backendClosed && state.sessionPersistence === 'persistent') {
743
- void this.clearPersistedSessionBestEffort(channelId);
1149
+ void this.clearPersistedSessionBestEffort(state.channelId, session.sessionId);
744
1150
  }
745
1151
  }
746
1152
  closeSession(session) {
@@ -748,26 +1154,79 @@ export class CopilotCliClient {
748
1154
  return;
749
1155
  }
750
1156
  this.closingSessions.add(session);
751
- const agent = this.connection?.agent;
1157
+ if (isCopilotSdkSession(session)) {
1158
+ let trackedClosePromise;
1159
+ trackedClosePromise = this.waitForSessionClose(session.close())
1160
+ .then((closed) => {
1161
+ if (closed) {
1162
+ this.sessionAuthorizationLeases.delete(session.sessionId);
1163
+ return;
1164
+ }
1165
+ if (!this.backendClosed && !this.backendShutdownInProgress) {
1166
+ this.logger?.debug('Copilot SDK session close timed out; recycling backend', {
1167
+ sessionId: session.sessionId,
1168
+ timeoutMs: this.runtime.sessionCloseTimeoutMs,
1169
+ });
1170
+ this.markSessionOrphaned(session);
1171
+ }
1172
+ })
1173
+ .catch(() => {
1174
+ if (!this.backendClosed && !this.backendShutdownInProgress) {
1175
+ this.markSessionOrphaned(session);
1176
+ }
1177
+ })
1178
+ .finally(() => {
1179
+ this.pendingSessionCloses.delete(trackedClosePromise);
1180
+ });
1181
+ this.pendingSessionCloses.add(trackedClosePromise);
1182
+ return;
1183
+ }
1184
+ session.dispose();
1185
+ if (!this.sessionCapabilities.closeSession || !this.connection) {
1186
+ this.markSessionOrphaned(session);
1187
+ return;
1188
+ }
1189
+ const agent = this.connection.agent;
752
1190
  const closeMethod = this.runtime.methods.agent.session?.close;
753
- const closePromise = typeof agent?.closeSession === 'function'
1191
+ const closePromise = typeof agent.closeSession === 'function'
754
1192
  ? agent.closeSession({ sessionId: session.sessionId })
755
1193
  : closeMethod
756
- ? this.connection?.agent.request(closeMethod, { sessionId: session.sessionId })
1194
+ ? this.connection.agent.request(closeMethod, { sessionId: session.sessionId })
757
1195
  : undefined;
758
- session.dispose();
759
1196
  if (!closePromise) {
1197
+ this.markSessionOrphaned(session);
760
1198
  return;
761
1199
  }
762
1200
  let trackedClosePromise;
763
- trackedClosePromise = Promise.resolve(closePromise)
764
- .then(() => undefined)
765
- .catch(() => { })
1201
+ trackedClosePromise = this.waitForSessionClose(closePromise)
1202
+ .then((closed) => {
1203
+ if (closed) {
1204
+ this.sessionAuthorizationLeases.delete(session.sessionId);
1205
+ return;
1206
+ }
1207
+ if (!this.backendClosed && !this.backendShutdownInProgress) {
1208
+ this.logger?.debug('Copilot session close timed out; recycling backend', {
1209
+ sessionId: session.sessionId,
1210
+ timeoutMs: this.runtime.sessionCloseTimeoutMs,
1211
+ });
1212
+ this.markSessionOrphaned(session);
1213
+ }
1214
+ })
1215
+ .catch(() => {
1216
+ if (!this.backendClosed && !this.backendShutdownInProgress) {
1217
+ this.markSessionOrphaned(session);
1218
+ }
1219
+ })
766
1220
  .finally(() => {
767
1221
  this.pendingSessionCloses.delete(trackedClosePromise);
768
1222
  });
769
1223
  this.pendingSessionCloses.add(trackedClosePromise);
770
1224
  }
1225
+ markSessionOrphaned(session) {
1226
+ this.orphanedSessions.add(session);
1227
+ this.backendRecycleRequired = true;
1228
+ this.reconcileBackendLifecycle();
1229
+ }
771
1230
  rejectQueuedTurnsAfterSessionTaint(state, error) {
772
1231
  if (state.queue.length === 0) {
773
1232
  return;
@@ -788,16 +1247,45 @@ export class CopilotCliClient {
788
1247
  clearTimeout(state.idleTimer);
789
1248
  state.idleTimer = undefined;
790
1249
  }
791
- findChannelStateBySessionId(sessionId) {
792
- for (const state of this.channels.values()) {
793
- if (state.session?.sessionId === sessionId) {
794
- return state;
795
- }
1250
+ bindSessionAuthorization(session, state) {
1251
+ this.sessionAuthorizationLeases.set(session.sessionId, {
1252
+ channelId: state.channelId,
1253
+ toolPermissionMode: state.toolPermissionMode ?? 'default',
1254
+ });
1255
+ }
1256
+ promoteWorkerRoute(state, session) {
1257
+ state.sessionHasBackgroundAgents = true;
1258
+ this.channelWorkerRoutes.set(state.channelId, {
1259
+ phase: 'promoted',
1260
+ state,
1261
+ });
1262
+ state.sessionPersistence = 'persistent';
1263
+ void this.persistSessionBestEffort(state, session.sessionId, state.sessionCwd ?? state.cwd ?? this.runtime.cwd, isCopilotSdkSession(session) ? session.backgroundTaskExecutionState() : undefined);
1264
+ }
1265
+ releaseWorkerRoute(state, sessionId) {
1266
+ const route = this.channelWorkerRoutes.get(state.channelId);
1267
+ if (route?.state !== state) {
1268
+ return;
1269
+ }
1270
+ this.channelWorkerRoutes.delete(state.channelId);
1271
+ if (route.phase === 'promoted' && sessionId) {
1272
+ void this.clearPersistedSessionBestEffort(state.channelId, sessionId);
796
1273
  }
797
- return undefined;
798
1274
  }
799
- reconcileIdleChannelState(channelId, state) {
800
- if (this.channels.get(channelId) !== state) {
1275
+ releaseInactiveWorkerRoute(state) {
1276
+ const route = this.channelWorkerRoutes.get(state.channelId);
1277
+ if (route?.state !== state || route.phase !== 'promoted') {
1278
+ return;
1279
+ }
1280
+ if (state.routingKey === state.channelId) {
1281
+ this.releaseWorkerRoute(state);
1282
+ return;
1283
+ }
1284
+ state.sessionPersistence = 'ephemeral';
1285
+ this.releaseWorkerRoute(state, state.session?.sessionId);
1286
+ }
1287
+ reconcileIdleChannelState(state) {
1288
+ if (this.channels.get(state.routingKey) !== state) {
801
1289
  return;
802
1290
  }
803
1291
  if (this.disposing || this.fatalError || this.backendClosed) {
@@ -810,22 +1298,29 @@ export class CopilotCliClient {
810
1298
  }
811
1299
  if (!state.session) {
812
1300
  this.clearIdleTimer(state);
813
- this.channels.delete(channelId);
1301
+ this.releaseWorkerRoute(state);
1302
+ this.channels.delete(state.routingKey);
814
1303
  return;
815
1304
  }
816
- if (state.sessionPersistence === 'ephemeral') {
1305
+ if (!state.sessionHasBackgroundAgents && !state.sessionHasPendingAutonomousTurn) {
1306
+ this.releaseInactiveWorkerRoute(state);
1307
+ }
1308
+ if (state.sessionPersistence === 'ephemeral'
1309
+ && !state.sessionHasBackgroundAgents
1310
+ && !state.sessionHasPendingAutonomousTurn) {
817
1311
  this.clearIdleTimer(state);
818
1312
  const session = state.session;
819
1313
  state.session = undefined;
820
1314
  state.sessionCwd = undefined;
1315
+ this.releaseWorkerRoute(state);
821
1316
  this.closeSession(session);
822
- this.channels.delete(channelId);
1317
+ this.channels.delete(state.routingKey);
823
1318
  return;
824
1319
  }
825
1320
  this.clearIdleTimer(state);
826
1321
  const generation = state.idleTimerGeneration;
827
1322
  state.idleTimer = setTimeout(() => {
828
- this.evictIdleChannel(channelId, state, generation);
1323
+ this.evictIdleChannel(state, generation);
829
1324
  }, this.runtime.idleSessionTtlMs);
830
1325
  }
831
1326
  async ensureSessionStoreLoaded() {
@@ -860,42 +1355,67 @@ export class CopilotCliClient {
860
1355
  return undefined;
861
1356
  }
862
1357
  if (!record.cwd || record.cwd !== cwd) {
863
- await this.clearPersistedSessionBestEffort(channelId);
1358
+ await this.clearPersistedSessionBestEffort(channelId, record.sessionId);
864
1359
  return undefined;
865
1360
  }
866
1361
  return record.sessionId;
867
1362
  }
868
- async persistSession(channelId, sessionId, cwd) {
1363
+ isPersistentRouteOwner(state) {
1364
+ const workerRoute = this.channelWorkerRoutes.get(state.channelId);
1365
+ if (workerRoute) {
1366
+ return workerRoute.state === state && state.sessionPersistence === 'persistent';
1367
+ }
1368
+ return this.channels.get(state.channelId) === state;
1369
+ }
1370
+ async persistSession(state, sessionId, cwd, backgroundTaskExecutions) {
869
1371
  await this.ensureSessionStoreLoaded();
870
- this.persistedSessions.set(channelId, { sessionId, cwd });
1372
+ if (!this.isPersistentRouteOwner(state) || state.session?.sessionId !== sessionId) {
1373
+ return;
1374
+ }
1375
+ const existingRecord = this.persistedSessions.get(state.channelId);
1376
+ const retainedBackgroundTaskExecutions = backgroundTaskExecutions ??
1377
+ (existingRecord?.sessionId === sessionId
1378
+ ? existingRecord.backgroundTaskExecutions
1379
+ : undefined);
1380
+ this.persistedSessions.set(state.channelId, {
1381
+ sessionId,
1382
+ cwd,
1383
+ ...(retainedBackgroundTaskExecutions
1384
+ ? { backgroundTaskExecutions: { ...retainedBackgroundTaskExecutions } }
1385
+ : {}),
1386
+ });
871
1387
  await this.flushSessionStore();
872
1388
  }
873
- async persistSessionBestEffort(channelId, sessionId, cwd) {
1389
+ async persistSessionBestEffort(state, sessionId, cwd, backgroundTaskExecutions) {
874
1390
  await this.trackSessionStoreOperation((async () => {
875
1391
  try {
876
- await this.persistSession(channelId, sessionId, cwd);
1392
+ await this.persistSession(state, sessionId, cwd, backgroundTaskExecutions);
877
1393
  }
878
1394
  catch (error) {
879
1395
  this.logger.error('failed to persist Copilot session map; keeping reply delivery', {
880
1396
  agentId: this.loadedSessionStoreAgentId,
881
- channelId,
1397
+ channelId: state.channelId,
882
1398
  sessionId,
883
1399
  error: summarizeError(error),
884
1400
  });
885
1401
  }
886
1402
  })());
887
1403
  }
888
- async clearPersistedSession(channelId) {
1404
+ async clearPersistedSession(channelId, expectedSessionId) {
889
1405
  await this.ensureSessionStoreLoaded();
1406
+ if (expectedSessionId
1407
+ && this.persistedSessions.get(channelId)?.sessionId !== expectedSessionId) {
1408
+ return;
1409
+ }
890
1410
  if (!this.persistedSessions.delete(channelId)) {
891
1411
  return;
892
1412
  }
893
1413
  await this.flushSessionStore();
894
1414
  }
895
- async clearPersistedSessionBestEffort(channelId) {
1415
+ async clearPersistedSessionBestEffort(channelId, expectedSessionId) {
896
1416
  await this.trackSessionStoreOperation((async () => {
897
1417
  try {
898
- await this.clearPersistedSession(channelId);
1418
+ await this.clearPersistedSession(channelId, expectedSessionId);
899
1419
  }
900
1420
  catch (error) {
901
1421
  this.logger.error('failed to clear Copilot session map; retrying in-memory only', {
@@ -926,89 +1446,178 @@ export class CopilotCliClient {
926
1446
  this.pendingSessionStoreOperations.add(tracked);
927
1447
  return tracked;
928
1448
  }
929
- evictIdleChannel(channelId, state, generation) {
1449
+ evictIdleChannel(state, generation) {
930
1450
  if (this.disposing || this.fatalError || this.backendClosed) {
931
1451
  return;
932
1452
  }
933
- if (this.channels.get(channelId) !== state || state.idleTimerGeneration !== generation) {
1453
+ if (this.channels.get(state.routingKey) !== state
1454
+ || state.idleTimerGeneration !== generation) {
934
1455
  return;
935
1456
  }
936
1457
  state.idleTimer = undefined;
937
- if (state.activeTurn || state.queue.length > 0 || state.sessionPromise || state.processing) {
1458
+ if (state.activeTurn
1459
+ || state.queue.length > 0
1460
+ || state.sessionPromise
1461
+ || state.processing
1462
+ || state.sessionHasPendingAutonomousTurn) {
938
1463
  return;
939
1464
  }
940
1465
  const session = state.session;
941
1466
  state.session = undefined;
942
1467
  state.sessionCwd = undefined;
1468
+ state.sessionHasBackgroundAgents = false;
1469
+ state.sessionHasPendingAutonomousTurn = false;
943
1470
  if (session) {
944
- this.logger?.debug('evicting idle Copilot ACP session', { channelId });
1471
+ this.logger?.debug('evicting idle Copilot session', { channelId: state.channelId });
1472
+ this.releaseWorkerRoute(state, session.sessionId);
945
1473
  this.closeSession(session);
946
1474
  }
947
- this.channels.delete(channelId);
1475
+ else {
1476
+ this.releaseWorkerRoute(state);
1477
+ }
1478
+ this.channels.delete(state.routingKey);
1479
+ this.reconcileBackendLifecycle();
948
1480
  }
949
1481
  async shutdownBackend(error) {
950
1482
  if (this.backendClosed) {
951
1483
  return;
952
1484
  }
953
1485
  this.backendClosed = true;
954
- await this.closeBackendProcess(error, this.runtime.shutdownGracePeriodMs);
1486
+ const terminated = await this.closeBackendProcess(error, this.runtime.shutdownGracePeriodMs);
1487
+ if (terminated) {
1488
+ this.releaseBackendAuthorizationLeases();
1489
+ }
955
1490
  }
956
1491
  /**
957
1492
  * Backend teardown without the fatal latch: the client stays usable and
958
1493
  * `ensureStarted()` spawns a replacement process on the next turn. Only safe
959
- * while `isBackendIdle()` holds, because every channel's live ACP session is
1494
+ * while `isBackendIdle()` holds, because every channel's live session is
960
1495
  * dropped here and no turn is ever replayed onto its successor.
961
1496
  */
962
1497
  async shutdownIdleBackend() {
1498
+ if (this.backendShutdownInProgress) {
1499
+ return;
1500
+ }
1501
+ this.backendShutdownInProgress = true;
963
1502
  // Detach the running backend before any signal reaches it, so its own exit
964
1503
  // and connection-closed handlers read this teardown as deliberate instead
965
1504
  // of latching a fatal error on a client that stays usable.
966
1505
  this.backendGeneration += 1;
967
1506
  const error = new Error(IDLE_BACKEND_STOPPED_MESSAGE);
968
1507
  const sessions = [];
969
- for (const [channelId, state] of [...this.channels.entries()]) {
1508
+ for (const [routingKey, state] of [...this.channels.entries()]) {
970
1509
  this.clearIdleTimer(state);
971
1510
  if (state.session) {
972
1511
  sessions.push(state.session);
1512
+ this.releaseWorkerRoute(state, state.session.sessionId);
1513
+ }
1514
+ else {
1515
+ this.releaseWorkerRoute(state);
973
1516
  }
974
1517
  state.session = undefined;
975
1518
  state.sessionCwd = undefined;
976
- this.channels.delete(channelId);
1519
+ state.sessionHasBackgroundAgents = false;
1520
+ state.sessionHasPendingAutonomousTurn = false;
1521
+ this.channels.delete(routingKey);
977
1522
  }
978
- this.logger?.debug('stopping idle Copilot ACP backend', {
1523
+ this.channelWorkerRoutes.clear();
1524
+ sessions.push(...this.orphanedSessions);
1525
+ this.logger?.debug('stopping idle Copilot runtime backend', {
979
1526
  channels: sessions.length,
980
1527
  idleShutdownMs: this.runtime.idleBackendShutdownMs,
981
1528
  });
982
1529
  for (const session of sessions) {
983
1530
  this.closeSession(session);
984
1531
  }
985
- const idleShutdownPromise = this.closeBackendProcess(error, this.runtime.idleShutdownGracePeriodMs).finally(() => {
1532
+ const idleShutdownPromise = this.closeBackendProcess(error, this.runtime.idleShutdownGracePeriodMs).then((terminated) => {
1533
+ if (!terminated) {
1534
+ const terminationError = new Error('Copilot runtime backend termination could not be confirmed after SIGKILL');
1535
+ this.backendClosed = true;
1536
+ this.fatalError = terminationError;
1537
+ this.rejectFatalPromise(terminationError);
1538
+ throw terminationError;
1539
+ }
1540
+ this.releaseBackendAuthorizationLeases();
1541
+ }).finally(() => {
986
1542
  this.startPromise = undefined;
987
1543
  this.childExitPromise = undefined;
1544
+ this.sdkClient = undefined;
988
1545
  this.sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
1546
+ this.backendRecycleRequired = this.fatalError !== null;
1547
+ this.backendShutdownInProgress = false;
989
1548
  this.idleShutdownPromise = undefined;
990
1549
  });
991
1550
  this.idleShutdownPromise = idleShutdownPromise;
992
1551
  await idleShutdownPromise;
993
1552
  }
994
1553
  isBackendIdle() {
995
- if (this.disposing || this.fatalError || this.backendClosed || this.idleShutdownPromise) {
1554
+ if (this.disposing
1555
+ || this.fatalError
1556
+ || this.backendClosed
1557
+ || this.idleShutdownPromise
1558
+ || this.backendShutdownInProgress) {
996
1559
  return false;
997
1560
  }
998
- if (!this.child) {
1561
+ if (!this.child && !this.sdkClient) {
999
1562
  return false;
1000
1563
  }
1001
1564
  if (this.pendingSessionStarts.size > 0) {
1002
1565
  return false;
1003
1566
  }
1004
1567
  for (const state of this.channels.values()) {
1005
- if (state.activeTurn || state.queue.length > 0 || state.sessionPromise || state.processing) {
1568
+ // The flag is deliberately sticky until that channel reaches its own
1569
+ // cleanup boundary. Recycling sooner would erase the backend-owned
1570
+ // read_agent/write_agent registry for another channel's worker.
1571
+ if (state.activeTurn
1572
+ || state.queue.length > 0
1573
+ || state.sessionPromise
1574
+ || state.processing
1575
+ || state.sessionHasBackgroundAgents
1576
+ || state.sessionHasPendingAutonomousTurn) {
1006
1577
  return false;
1007
1578
  }
1008
1579
  }
1009
1580
  return true;
1010
1581
  }
1582
+ reconcileBackendLifecycle() {
1583
+ if (this.backendRecycleRequired && this.isBackendIdle()) {
1584
+ if (!this.idleShutdownPromise) {
1585
+ void this.shutdownIdleBackend().catch((error) => {
1586
+ this.logger.error('failed to recycle Copilot runtime backend after session cleanup', {
1587
+ error: summarizeError(error),
1588
+ });
1589
+ });
1590
+ }
1591
+ return;
1592
+ }
1593
+ this.idleBackendShutdown.reconcile();
1594
+ }
1595
+ releaseBackendAuthorizationLeases() {
1596
+ this.sessionAuthorizationLeases.clear();
1597
+ this.orphanedSessions.clear();
1598
+ }
1011
1599
  async closeBackendProcess(error, gracePeriodMs) {
1600
+ if (this.sdkClient) {
1601
+ const sdkClient = this.sdkClient;
1602
+ this.sdkClient = undefined;
1603
+ await this.waitForPendingSessionStarts();
1604
+ await this.waitForPendingSessionCloses();
1605
+ const stopPromise = sdkClient.stop();
1606
+ const stopResult = await Promise.race([
1607
+ stopPromise.then((errors) => ({ completed: true, errors })),
1608
+ new Promise((resolvePromise) => {
1609
+ setTimeout(() => resolvePromise({ completed: false }), gracePeriodMs);
1610
+ }),
1611
+ ]);
1612
+ if (!stopResult.completed) {
1613
+ await sdkClient.forceStop();
1614
+ return true;
1615
+ }
1616
+ if (stopResult.errors.length > 0) {
1617
+ throw new AggregateError(stopResult.errors, error.message);
1618
+ }
1619
+ return true;
1620
+ }
1012
1621
  const connection = this.connection;
1013
1622
  const child = this.child;
1014
1623
  await this.waitForPendingSessionStarts();
@@ -1017,7 +1626,7 @@ export class CopilotCliClient {
1017
1626
  this.connection = undefined;
1018
1627
  if (!child) {
1019
1628
  this.child = undefined;
1020
- return;
1629
+ return true;
1021
1630
  }
1022
1631
  const childExitPromise = this.childExitPromise ?? Promise.resolve();
1023
1632
  this.logger?.debug('sending SIGTERM to Copilot ACP process', { gracePeriodMs });
@@ -1026,12 +1635,13 @@ export class CopilotCliClient {
1026
1635
  if (exitedAfterTerm) {
1027
1636
  this.logger?.debug('Copilot ACP process exited after SIGTERM');
1028
1637
  this.child = undefined;
1029
- return;
1638
+ return true;
1030
1639
  }
1031
1640
  this.logger?.debug('sending SIGKILL to Copilot ACP process after SIGTERM grace timeout');
1032
1641
  child.kill('SIGKILL');
1033
- await this.waitForChildExit(childExitPromise, this.runtime.shutdownForceKillWaitMs);
1642
+ const exitedAfterKill = await this.waitForChildExit(childExitPromise, this.runtime.shutdownForceKillWaitMs);
1034
1643
  this.child = undefined;
1644
+ return exitedAfterKill;
1035
1645
  }
1036
1646
  async waitForPendingSessionStarts() {
1037
1647
  if (this.pendingSessionStarts.size === 0) {
@@ -1055,6 +1665,25 @@ export class CopilotCliClient {
1055
1665
  }),
1056
1666
  ]);
1057
1667
  }
1668
+ async waitForSessionClose(closePromise) {
1669
+ if (this.runtime.sessionCloseTimeoutMs <= 0) {
1670
+ return false;
1671
+ }
1672
+ let timeout;
1673
+ try {
1674
+ return await Promise.race([
1675
+ Promise.resolve(closePromise).then(() => true),
1676
+ new Promise((resolve) => {
1677
+ timeout = setTimeout(() => resolve(false), this.runtime.sessionCloseTimeoutMs);
1678
+ }),
1679
+ ]);
1680
+ }
1681
+ finally {
1682
+ if (timeout) {
1683
+ clearTimeout(timeout);
1684
+ }
1685
+ }
1686
+ }
1058
1687
  async waitForChildExit(childExitPromise, timeoutMs) {
1059
1688
  if (timeoutMs <= 0) {
1060
1689
  return false;