@borgee/agents-host 0.2.74 → 0.2.94

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