@borgee/agents-host 0.2.84 → 0.2.97

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 (54) hide show
  1. package/README.md +1 -1
  2. package/dist/agents-host.js +23 -5
  3. package/dist/background-runs.d.ts +6 -1
  4. package/dist/background-runs.js +56 -21
  5. package/dist/chat/chat-control-plane.d.ts +3 -1
  6. package/dist/chat/sdk-chat-control-plane.d.ts +6 -2
  7. package/dist/chat/sdk-chat-control-plane.js +18 -0
  8. package/dist/plugin-sdk.js +149 -21
  9. package/dist/plugin-sdk.js.map +3 -3
  10. package/dist/progress-to-activity.d.ts +3 -3
  11. package/dist/providers/claude/adapter.d.ts +3 -1
  12. package/dist/providers/claude/adapter.js +41 -3
  13. package/dist/providers/claude/background-run-observer.d.ts +32 -0
  14. package/dist/providers/claude/background-run-observer.js +381 -0
  15. package/dist/providers/claude/cli-client.d.ts +15 -1
  16. package/dist/providers/claude/cli-client.js +699 -69
  17. package/dist/providers/claude/foreground-handoff.d.ts +4 -0
  18. package/dist/providers/claude/foreground-handoff.js +45 -0
  19. package/dist/providers/claude/task-cancellation-protocol.d.ts +14 -0
  20. package/dist/providers/claude/task-cancellation-protocol.js +21 -0
  21. package/dist/providers/copilot/activity-metadata.d.ts +3 -0
  22. package/dist/providers/copilot/activity-metadata.js +19 -0
  23. package/dist/providers/copilot/cli-client.js +5 -0
  24. package/dist/providers/provider-adapter.d.ts +8 -0
  25. package/dist/providers/provider-adapter.js +4 -0
  26. package/dist/types.d.ts +8 -0
  27. package/dist/vendor/claude-agent-acp/LICENSE +191 -0
  28. package/dist/vendor/claude-agent-acp/NOTICE +8 -0
  29. package/dist/vendor/claude-agent-acp/dist/acp-agent.d.ts +1017 -0
  30. package/dist/vendor/claude-agent-acp/dist/acp-agent.d.ts.map +1 -0
  31. package/dist/vendor/claude-agent-acp/dist/acp-agent.js +6305 -0
  32. package/dist/vendor/claude-agent-acp/dist/borgee-foreground-handoff-bridge.js +322 -0
  33. package/dist/vendor/claude-agent-acp/dist/borgee-task-cancellation-bridge.js +101 -0
  34. package/dist/vendor/claude-agent-acp/dist/borgee-task-lifecycle-bridge.js +58 -0
  35. package/dist/vendor/claude-agent-acp/dist/elicitation.d.ts +130 -0
  36. package/dist/vendor/claude-agent-acp/dist/elicitation.d.ts.map +1 -0
  37. package/dist/vendor/claude-agent-acp/dist/elicitation.js +304 -0
  38. package/dist/vendor/claude-agent-acp/dist/index.d.ts +3 -0
  39. package/dist/vendor/claude-agent-acp/dist/index.d.ts.map +1 -0
  40. package/dist/vendor/claude-agent-acp/dist/index.js +75 -0
  41. package/dist/vendor/claude-agent-acp/dist/lib.d.ts +6 -0
  42. package/dist/vendor/claude-agent-acp/dist/lib.d.ts.map +1 -0
  43. package/dist/vendor/claude-agent-acp/dist/lib.js +5 -0
  44. package/dist/vendor/claude-agent-acp/dist/settings.d.ts +68 -0
  45. package/dist/vendor/claude-agent-acp/dist/settings.d.ts.map +1 -0
  46. package/dist/vendor/claude-agent-acp/dist/settings.js +185 -0
  47. package/dist/vendor/claude-agent-acp/dist/tools.d.ts +102 -0
  48. package/dist/vendor/claude-agent-acp/dist/tools.d.ts.map +1 -0
  49. package/dist/vendor/claude-agent-acp/dist/tools.js +1000 -0
  50. package/dist/vendor/claude-agent-acp/dist/utils.d.ts +16 -0
  51. package/dist/vendor/claude-agent-acp/dist/utils.d.ts.map +1 -0
  52. package/dist/vendor/claude-agent-acp/dist/utils.js +81 -0
  53. package/dist/vendor/claude-agent-acp/package.json +85 -0
  54. package/package.json +16 -12
@@ -1,8 +1,8 @@
1
1
  import { Readable, Writable } from 'node:stream';
2
2
  import { createHash } from 'node:crypto';
3
- import { readFileSync } from 'node:fs';
4
- import { createRequire } from 'node:module';
3
+ import { existsSync, readFileSync } from 'node:fs';
5
4
  import { dirname, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
6
  import spawn from 'cross-spawn';
7
7
  import { DEFAULT_PROJECTION_STRATEGY, normalizeProjectionStrategy, } from '../../projection-strategy-values.js';
8
8
  import { assertClaudeCommandCompatibility, DEFAULT_CLAUDE_COMMAND, isLegacyClaudeCompatibilityAlias, } from '../../config.js';
@@ -11,10 +11,347 @@ import { HostLogger, summarizeChildStderr, summarizeError } from '../../debug.js
11
11
  import { AcpProgressCollector } from '../acp-progress-collector.js';
12
12
  import { ProviderTurnCancelledError } from '../provider-adapter.js';
13
13
  import { readClaudeActivityMetadata } from './activity-metadata.js';
14
+ import { ClaudeBackgroundRunObserver } from './background-run-observer.js';
15
+ import { CLAUDE_FOREGROUND_HANDOFF_METHOD, isClaudeUpdateFromIsolatedTurn, readClaudeForegroundSettledTurn, } from './foreground-handoff.js';
14
16
  import { resolveCopilotPermissionResponse } from '../../policy/copilot-permission.js';
15
17
  import { buildClaudeSessionPromptAppend } from '../../context/prompt.js';
16
18
  import { isDiscussionOnlyResolvedWorkingFolder } from '../../context/resolved-working-folder.js';
17
19
  import { IDLE_BACKEND_SHUTDOWN_DISABLED_MS, IdleBackendShutdownScheduler, } from '../idle-backend-shutdown.js';
20
+ import { CLAUDE_TASK_CANCELLATION_METHOD, supportsClaudeTaskCancellation, } from './task-cancellation-protocol.js';
21
+ const BACKGROUND_COMPLETION_SIGNAL_TTL_MS = 30_000;
22
+ const MAX_BACKGROUND_COMPLETION_SIGNALS = 32;
23
+ const AUTONOMOUS_REPLY_IDLE_FLUSH_MS = 100;
24
+ class ClaudeSessionUpdatePump {
25
+ session;
26
+ onFailure;
27
+ autonomousEvents;
28
+ onForegroundSettledWithBackground;
29
+ observer;
30
+ activeTurn;
31
+ autonomousTurns = new Map();
32
+ backgroundCompletionSignals = [];
33
+ backgroundCompletionSignalExpiry;
34
+ autonomousTurnPending = false;
35
+ closed = false;
36
+ failure;
37
+ constructor(session, onFailure, onBackgroundTasksActiveChanged, autonomousEvents, onForegroundSettledWithBackground, targetedCancellationSupported) {
38
+ this.session = session;
39
+ this.onFailure = onFailure;
40
+ this.autonomousEvents = autonomousEvents;
41
+ this.onForegroundSettledWithBackground = onForegroundSettledWithBackground;
42
+ this.observer = new ClaudeBackgroundRunObserver(undefined, {
43
+ onBackgroundTasksActiveChanged,
44
+ targetedCancellationSupported,
45
+ });
46
+ void this.run();
47
+ }
48
+ beginTurn(onProgress, foregroundIsolationFence) {
49
+ if (this.closed) {
50
+ throw new Error('Claude ACP session update pump is closed');
51
+ }
52
+ if (this.failure) {
53
+ throw this.failure;
54
+ }
55
+ if (this.activeTurn) {
56
+ throw new Error('Claude ACP session already has an active turn');
57
+ }
58
+ const turn = {
59
+ queue: [],
60
+ foregroundIsolationFence,
61
+ ownedForegroundTurn: this.resolveForegroundTurn(foregroundIsolationFence),
62
+ };
63
+ this.activeTurn = turn;
64
+ this.observer.setOnProgress(onProgress);
65
+ return {
66
+ next: () => {
67
+ if (turn.failure) {
68
+ return Promise.reject(turn.failure);
69
+ }
70
+ const queued = turn.queue.shift();
71
+ if (queued) {
72
+ return Promise.resolve(queued);
73
+ }
74
+ if (this.activeTurn !== turn || this.closed) {
75
+ return Promise.reject(new Error('Claude ACP turn update stream is closed'));
76
+ }
77
+ return new Promise((resolve, reject) => {
78
+ turn.waiter = { resolve, reject };
79
+ });
80
+ },
81
+ close: () => {
82
+ if (this.activeTurn !== turn) {
83
+ return;
84
+ }
85
+ this.activeTurn = undefined;
86
+ turn.waiter?.reject(new Error('Claude ACP turn update stream is closed'));
87
+ turn.waiter = undefined;
88
+ turn.queue.length = 0;
89
+ },
90
+ };
91
+ }
92
+ close() {
93
+ if (this.closed) {
94
+ return;
95
+ }
96
+ this.closed = true;
97
+ this.observer.dispose();
98
+ if (this.backgroundCompletionSignalExpiry) {
99
+ clearTimeout(this.backgroundCompletionSignalExpiry);
100
+ this.backgroundCompletionSignalExpiry = undefined;
101
+ }
102
+ this.backgroundCompletionSignals.length = 0;
103
+ for (const turn of this.autonomousTurns.values()) {
104
+ if (turn.flushTimer) {
105
+ clearTimeout(turn.flushTimer);
106
+ }
107
+ if (turn.discardTimer) {
108
+ clearTimeout(turn.discardTimer);
109
+ }
110
+ }
111
+ this.autonomousTurns.clear();
112
+ this.refreshAutonomousTurnPresence();
113
+ const turn = this.activeTurn;
114
+ this.activeTurn = undefined;
115
+ turn?.waiter?.reject(new Error('Claude ACP session update stream is closed'));
116
+ }
117
+ resolveForegroundTurn(foregroundIsolationFence) {
118
+ const nextForegroundTurn = foregroundIsolationFence === undefined ? 1 : foregroundIsolationFence + 1;
119
+ return Number.isSafeInteger(nextForegroundTurn) && nextForegroundTurn > 0
120
+ ? nextForegroundTurn
121
+ : undefined;
122
+ }
123
+ autonomousTurnFor(foregroundTurn) {
124
+ let turn = this.autonomousTurns.get(foregroundTurn);
125
+ if (turn) {
126
+ return turn;
127
+ }
128
+ turn = {
129
+ eventId: `claude-background-${foregroundTurn}`,
130
+ confirmed: false,
131
+ text: '',
132
+ };
133
+ this.autonomousTurns.set(foregroundTurn, turn);
134
+ return turn;
135
+ }
136
+ noteBackgroundCompletionSignal(foregroundTurn, eventId) {
137
+ const turn = this.autonomousTurnFor(foregroundTurn);
138
+ if (turn.discardTimer) {
139
+ clearTimeout(turn.discardTimer);
140
+ turn.discardTimer = undefined;
141
+ }
142
+ this.pruneBackgroundCompletionSignals();
143
+ const existingIndex = this.backgroundCompletionSignals.findIndex((signal) => signal.foregroundTurn === foregroundTurn);
144
+ if (existingIndex >= 0) {
145
+ this.backgroundCompletionSignals.splice(existingIndex, 1);
146
+ }
147
+ this.backgroundCompletionSignals.push({
148
+ eventId,
149
+ foregroundTurn,
150
+ expiresAt: Date.now() + BACKGROUND_COMPLETION_SIGNAL_TTL_MS,
151
+ });
152
+ if (this.backgroundCompletionSignals.length > MAX_BACKGROUND_COMPLETION_SIGNALS) {
153
+ this.backgroundCompletionSignals.splice(0, this.backgroundCompletionSignals.length - MAX_BACKGROUND_COMPLETION_SIGNALS);
154
+ }
155
+ this.scheduleBackgroundCompletionSignalExpiry();
156
+ turn.confirmed = true;
157
+ turn.eventId = eventId;
158
+ if (turn.text.trim()) {
159
+ this.scheduleAutonomousReplyFlush(foregroundTurn, turn);
160
+ }
161
+ this.refreshAutonomousTurnPresence();
162
+ return turn;
163
+ }
164
+ consumeAutonomousLifecycle(update) {
165
+ if (update.update.sessionUpdate !== 'session_info_update') {
166
+ return;
167
+ }
168
+ const provider = asObject(update.update._meta)?.claudeCode;
169
+ const lifecycle = asObject(asObject(provider)?.taskLifecycle);
170
+ if (lifecycle?.event !== 'notification' || lifecycle?.status !== 'completed') {
171
+ return;
172
+ }
173
+ const foregroundTurn = asObject(provider)?.foregroundTurn;
174
+ if (!Number.isSafeInteger(foregroundTurn) || foregroundTurn <= 0) {
175
+ return;
176
+ }
177
+ const taskId = typeof lifecycle.taskId === 'string' ? lifecycle.taskId : 'task';
178
+ this.noteBackgroundCompletionSignal(foregroundTurn, `${taskId}:completed`);
179
+ }
180
+ consumeAutonomousUpdate(update) {
181
+ if (update.update.sessionUpdate !== 'agent_message_chunk' || update.update.content.type !== 'text') {
182
+ return;
183
+ }
184
+ const provider = asObject(update.update._meta)?.claudeCode;
185
+ const foregroundTurn = asObject(provider)?.foregroundTurn;
186
+ if (!Number.isSafeInteger(foregroundTurn) || foregroundTurn <= 0) {
187
+ return;
188
+ }
189
+ const lane = asObject(provider)?.foregroundLane;
190
+ const activeForegroundTurn = this.activeTurn?.ownedForegroundTurn;
191
+ if (foregroundTurn === activeForegroundTurn) {
192
+ return;
193
+ }
194
+ if (lane !== 'background' && !this.backgroundCompletionSignals.some((signal) => signal.foregroundTurn === foregroundTurn)) {
195
+ return;
196
+ }
197
+ const turn = this.autonomousTurnFor(foregroundTurn);
198
+ turn.text += update.update.content.text;
199
+ if (turn.confirmed) {
200
+ this.scheduleAutonomousReplyFlush(foregroundTurn, turn);
201
+ }
202
+ }
203
+ scheduleAutonomousReplyFlush(foregroundTurn, turn) {
204
+ if (turn.flushTimer) {
205
+ clearTimeout(turn.flushTimer);
206
+ }
207
+ turn.flushTimer = setTimeout(() => {
208
+ turn.flushTimer = undefined;
209
+ this.flushAutonomousReply(foregroundTurn, turn);
210
+ }, AUTONOMOUS_REPLY_IDLE_FLUSH_MS);
211
+ turn.flushTimer.unref?.();
212
+ }
213
+ flushAutonomousReply(foregroundTurn, turn) {
214
+ const text = turn.text.trim();
215
+ if (turn.confirmed && text) {
216
+ this.autonomousEvents.onReply?.({
217
+ eventId: turn.eventId,
218
+ text,
219
+ });
220
+ }
221
+ else if (turn.confirmed) {
222
+ turn.discardTimer = setTimeout(() => {
223
+ turn.discardTimer = undefined;
224
+ this.discardAutonomousTurn(foregroundTurn, turn);
225
+ }, BACKGROUND_COMPLETION_SIGNAL_TTL_MS);
226
+ turn.discardTimer.unref?.();
227
+ this.refreshAutonomousTurnPresence();
228
+ return;
229
+ }
230
+ this.discardAutonomousTurn(foregroundTurn, turn);
231
+ }
232
+ discardAutonomousTurn(foregroundTurn, turn) {
233
+ if (turn.flushTimer) {
234
+ clearTimeout(turn.flushTimer);
235
+ turn.flushTimer = undefined;
236
+ }
237
+ if (turn.discardTimer) {
238
+ clearTimeout(turn.discardTimer);
239
+ turn.discardTimer = undefined;
240
+ }
241
+ if (this.autonomousTurns.get(foregroundTurn) === turn) {
242
+ this.autonomousTurns.delete(foregroundTurn);
243
+ }
244
+ const signalIndex = this.backgroundCompletionSignals.findIndex((signal) => signal.foregroundTurn === foregroundTurn);
245
+ if (signalIndex >= 0) {
246
+ this.backgroundCompletionSignals.splice(signalIndex, 1);
247
+ this.scheduleBackgroundCompletionSignalExpiry();
248
+ return;
249
+ }
250
+ this.refreshAutonomousTurnPresence();
251
+ }
252
+ pruneBackgroundCompletionSignals() {
253
+ const now = Date.now();
254
+ while (this.backgroundCompletionSignals.length > 0
255
+ && this.backgroundCompletionSignals[0].expiresAt <= now) {
256
+ const expired = this.backgroundCompletionSignals.shift();
257
+ if (!expired) {
258
+ continue;
259
+ }
260
+ const turn = this.autonomousTurns.get(expired.foregroundTurn);
261
+ if (turn && !turn.text.trim()) {
262
+ this.discardAutonomousTurn(expired.foregroundTurn, turn);
263
+ }
264
+ }
265
+ }
266
+ scheduleBackgroundCompletionSignalExpiry() {
267
+ if (this.backgroundCompletionSignalExpiry) {
268
+ clearTimeout(this.backgroundCompletionSignalExpiry);
269
+ this.backgroundCompletionSignalExpiry = undefined;
270
+ }
271
+ this.pruneBackgroundCompletionSignals();
272
+ const next = this.backgroundCompletionSignals[0];
273
+ if (!next) {
274
+ this.refreshAutonomousTurnPresence();
275
+ return;
276
+ }
277
+ this.backgroundCompletionSignalExpiry = setTimeout(() => {
278
+ this.backgroundCompletionSignalExpiry = undefined;
279
+ this.pruneBackgroundCompletionSignals();
280
+ this.scheduleBackgroundCompletionSignalExpiry();
281
+ }, Math.max(0, next.expiresAt - Date.now()));
282
+ this.backgroundCompletionSignalExpiry.unref?.();
283
+ }
284
+ refreshAutonomousTurnPresence() {
285
+ const pending = this.autonomousTurns.size > 0 || this.backgroundCompletionSignals.length > 0;
286
+ if (pending === this.autonomousTurnPending) {
287
+ return;
288
+ }
289
+ this.autonomousTurnPending = pending;
290
+ this.autonomousEvents.onPendingChanged?.(pending);
291
+ }
292
+ async run() {
293
+ try {
294
+ while (!this.closed) {
295
+ const update = await this.session.nextUpdate();
296
+ if (this.closed) {
297
+ return;
298
+ }
299
+ const lifecycle = update.kind === 'session_update' && this.observer.consume(update.update);
300
+ if (lifecycle) {
301
+ this.consumeAutonomousLifecycle(update);
302
+ continue;
303
+ }
304
+ if (update.kind === 'session_update') {
305
+ this.consumeAutonomousUpdate(update);
306
+ }
307
+ if (update.kind === 'session_update'
308
+ && isClaudeUpdateFromIsolatedTurn(update.update, this.activeTurn?.foregroundIsolationFence)) {
309
+ continue;
310
+ }
311
+ const settledForegroundTurn = update.kind === 'session_update'
312
+ ? readClaudeForegroundSettledTurn(update.update)
313
+ : undefined;
314
+ const turn = this.activeTurn;
315
+ if (turn
316
+ && settledForegroundTurn !== undefined
317
+ && turn.foregroundSettledTurn !== settledForegroundTurn) {
318
+ turn.foregroundSettledTurn = settledForegroundTurn;
319
+ this.onForegroundSettledWithBackground(settledForegroundTurn);
320
+ }
321
+ if (!turn) {
322
+ continue;
323
+ }
324
+ const waiter = turn.waiter;
325
+ if (waiter) {
326
+ turn.waiter = undefined;
327
+ waiter.resolve(update);
328
+ }
329
+ else {
330
+ turn.queue.push(update);
331
+ }
332
+ }
333
+ }
334
+ catch (error) {
335
+ if (!this.closed) {
336
+ const normalized = normalizeError(error);
337
+ this.observer.fail();
338
+ this.failure = normalized;
339
+ const turn = this.activeTurn;
340
+ if (turn) {
341
+ // ActiveSession mirrors prompt failures onto its update queue. Keep
342
+ // that rejection scoped to this turn/session; transport failures use
343
+ // the connection and child-process fatal handlers instead.
344
+ turn.failure = normalized;
345
+ turn.waiter?.reject(normalized);
346
+ turn.waiter = undefined;
347
+ }
348
+ else {
349
+ this.onFailure(normalized);
350
+ }
351
+ }
352
+ }
353
+ }
354
+ }
18
355
  const SESSION_TAINTED_ERRORS = new WeakSet();
19
356
  const IDLE_BACKEND_STOPPED_MESSAGE = 'Claude ACP backend stopped after idle timeout';
20
357
  /**
@@ -32,7 +369,6 @@ const DEFAULT_SESSION_CAPABILITIES = {
32
369
  resumeSession: false,
33
370
  };
34
371
  const CLAUDE_SDK_COMPACTION_EXPERIMENT_CONTEXT_TOKEN_THRESHOLD_ENV = 'CLAUDE_SDK_COMPACTION_EXPERIMENT_CONTEXT_TOKEN_THRESHOLD';
35
- const require = createRequire(import.meta.url);
36
372
  const CLAUDE_CANCELLED_REPLY_TEXT = 'Info: Operation cancelled by user';
37
373
  const DEFAULT_RUNTIME = {
38
374
  spawn,
@@ -283,7 +619,12 @@ function isSessionTainted(error) {
283
619
  return error instanceof Error && SESSION_TAINTED_ERRORS.has(error);
284
620
  }
285
621
  function resolveBundledClaudeAdapterPath() {
286
- return require.resolve('@agentclientprotocol/claude-agent-acp/dist/index.js');
622
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
623
+ const adapterPath = resolve(packageRoot, 'dist', 'vendor', 'claude-agent-acp', 'dist', 'index.js');
624
+ if (!existsSync(adapterPath)) {
625
+ throw new Error(`Shipped Claude ACP adapter is missing at ${adapterPath}; reinstall @borgee/agents-host`);
626
+ }
627
+ return adapterPath;
287
628
  }
288
629
  function isRelativePathLike(value) {
289
630
  return value.startsWith('./') || value.startsWith('../');
@@ -489,16 +830,45 @@ export class ClaudeCliClient {
489
830
  imageInputConfig;
490
831
  runtime;
491
832
  channels = new Map();
833
+ channelWorkerRoutes = new Map();
834
+ targetedBackgroundRunCancellationConfigured;
492
835
  /**
493
836
  * The provider session currently bound to channelId, or undefined before a
494
837
  * turn has opened one. Surfaced so agents-host can record which session
495
838
  * worked a task without the agent being asked to report its own id.
496
839
  */
497
840
  sessionIdForChannel(channelId) {
498
- return this.channels.get(channelId)?.session?.sessionId;
841
+ return this.channelWorkerRoutes.get(channelId)?.state.session?.sessionId
842
+ ?? this.channels.get(channelId)?.session?.sessionId;
843
+ }
844
+ async handoffForegroundTurn(channelId) {
845
+ const state = this.channelWorkerRoutes.get(channelId)?.state ?? this.channels.get(channelId);
846
+ const session = state?.session;
847
+ if (!state?.activeTurn || !session || !this.connection) {
848
+ return { outcome: 'not_found' };
849
+ }
850
+ const response = await this.connection.agent.request(CLAUDE_FOREGROUND_HANDOFF_METHOD, { sessionId: session.sessionId });
851
+ const result = asObject(response);
852
+ const outcome = result?.outcome;
853
+ if (outcome === 'admitted' || outcome === 'released') {
854
+ const foregroundTurn = result?.foregroundTurn;
855
+ if (!Number.isSafeInteger(foregroundTurn)
856
+ || foregroundTurn <= 0) {
857
+ throw new Error('Claude ACP foreground handoff omitted its ownership fence');
858
+ }
859
+ if (state.session !== session) {
860
+ return { outcome: 'admitted' };
861
+ }
862
+ state.foregroundIsolationFence = Math.max(state.foregroundIsolationFence ?? 0, foregroundTurn);
863
+ return { outcome: 'admitted' };
864
+ }
865
+ if (outcome === 'not_found') {
866
+ return { outcome: 'not_found' };
867
+ }
868
+ throw new Error('Claude ACP returned an unusable foreground handoff result');
499
869
  }
500
870
  async cancelTurn(channelId) {
501
- const state = this.channels.get(channelId);
871
+ const state = this.channelWorkerRoutes.get(channelId)?.state ?? this.channels.get(channelId);
502
872
  const session = state?.session;
503
873
  if (!state?.activeTurn || !session) {
504
874
  return false;
@@ -513,8 +883,33 @@ export class ClaudeCliClient {
513
883
  await this.connection?.agent.notify(cancelMethod, { sessionId: session.sessionId });
514
884
  return true;
515
885
  }
886
+ async cancelBackgroundRun(channelId, providerRunId) {
887
+ if (!this.targetedBackgroundRunCancellationConfigured
888
+ || !this.targetedBackgroundRunCancellationAvailable
889
+ || providerRunId.length === 0) {
890
+ return false;
891
+ }
892
+ const state = this.channelWorkerRoutes.get(channelId)?.state ?? this.channels.get(channelId);
893
+ const session = state?.session;
894
+ if (!state || state.channelId !== channelId || !session || !this.connection) {
895
+ return false;
896
+ }
897
+ this.idleBackendShutdown.cancel();
898
+ const response = await this.connection.agent.request(CLAUDE_TASK_CANCELLATION_METHOD, {
899
+ sessionId: session.sessionId,
900
+ taskId: providerRunId,
901
+ });
902
+ if (response?.outcome === 'accepted') {
903
+ return true;
904
+ }
905
+ if (response?.outcome === 'not_found' || response?.outcome === 'unsupported') {
906
+ return false;
907
+ }
908
+ throw new Error('Claude ACP returned an invalid targeted task cancellation response');
909
+ }
516
910
  persistedSessions = new Map();
517
911
  closingSessions = new WeakSet();
912
+ sessionUpdatePumps = new WeakMap();
518
913
  pendingSessionStarts = new Set();
519
914
  pendingSessionCloses = new Set();
520
915
  pendingSessionStoreOperations = new Set();
@@ -539,6 +934,8 @@ export class ClaudeCliClient {
539
934
  sessionStoreLoadPromise = null;
540
935
  sessionStoreWriteQueue = Promise.resolve();
541
936
  sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
937
+ targetedBackgroundRunCancellationAvailable = false;
938
+ autonomousReplyHandler;
542
939
  idleBackendShutdown;
543
940
  childStderr = '';
544
941
  constructor(command, args = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger(), imageInputConfig = {}) {
@@ -550,6 +947,8 @@ export class ClaudeCliClient {
550
947
  this.imageInputConfig = imageInputConfig;
551
948
  this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
552
949
  assertClaudeCommandCompatibility(command, args, 'Claude provider runtime');
950
+ this.targetedBackgroundRunCancellationConfigured =
951
+ useBundledClaudeAdapter(command, args) || useLegacyClaudeCompatibilityAlias(command, args);
553
952
  this.idleBackendShutdown = new IdleBackendShutdownScheduler({
554
953
  idleShutdownMs: this.runtime.idleBackendShutdownMs,
555
954
  isIdle: () => this.isBackendIdle(),
@@ -565,6 +964,9 @@ export class ClaudeCliClient {
565
964
  });
566
965
  void this.fatalPromise.catch(() => { });
567
966
  }
967
+ onAutonomousReply(handler) {
968
+ this.autonomousReplyHandler = handler;
969
+ }
568
970
  async generateReply(channelIdOrTurn, promptOrOptions, maybeOptions) {
569
971
  if (this.fatalError) {
570
972
  throw this.fatalError;
@@ -586,16 +988,17 @@ export class ClaudeCliClient {
586
988
  ? maybeOptions
587
989
  : promptOrOptions;
588
990
  const sessionRouting = resolveSessionRouting(preparedTurn);
589
- const state = this.getOrCreateChannelState(sessionRouting.key, preparedTurn.channelId, sessionRouting.persistence);
991
+ const state = this.resolveChannelState(sessionRouting);
590
992
  this.idleBackendShutdown.cancel();
591
993
  const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn, sessionRouting.persistence, options);
592
994
  state.queue.push(turn);
593
- this.processChannelQueue(sessionRouting.key, state);
995
+ this.processChannelQueue(state);
594
996
  return turn.promise;
595
997
  }
596
998
  async dispose() {
597
999
  const error = new Error('Claude ACP backend stopped');
598
1000
  this.disposing = true;
1001
+ this.autonomousReplyHandler = undefined;
599
1002
  this.idleBackendShutdown.cancel();
600
1003
  this.logger?.debug('stopping Claude ACP backend');
601
1004
  const closed = this.connection?.closed ?? Promise.resolve();
@@ -717,9 +1120,13 @@ export class ClaudeCliClient {
717
1120
  },
718
1121
  });
719
1122
  this.sessionCapabilities = readSessionCapabilities(initializeResponse);
1123
+ this.targetedBackgroundRunCancellationAvailable =
1124
+ this.targetedBackgroundRunCancellationConfigured
1125
+ && supportsClaudeTaskCancellation(initializeResponse);
720
1126
  this.logger?.debug('initialized Claude ACP connection', {
721
1127
  loadSession: this.sessionCapabilities.loadSession,
722
1128
  resumeSession: this.sessionCapabilities.resumeSession,
1129
+ targetedBackgroundRunCancellation: this.targetedBackgroundRunCancellationAvailable,
723
1130
  });
724
1131
  }
725
1132
  catch (error) {
@@ -729,24 +1136,46 @@ export class ClaudeCliClient {
729
1136
  throw normalized;
730
1137
  }
731
1138
  }
732
- getOrCreateChannelState(channelId, resolvedChannelId, sessionPersistence) {
733
- let state = this.channels.get(channelId);
1139
+ getOrCreateChannelState(routingKey, resolvedChannelId, sessionPersistence) {
1140
+ let state = this.channels.get(routingKey);
734
1141
  if (!state) {
735
1142
  state = {
1143
+ routingKey,
736
1144
  channelId: resolvedChannelId,
737
1145
  sessionPersistence,
1146
+ backgroundTasksActive: false,
1147
+ backgroundContinuationPending: false,
1148
+ sessionHasPendingAutonomousTurn: false,
1149
+ persistentFollowUpAdopted: false,
738
1150
  processing: false,
739
1151
  queue: [],
740
1152
  };
741
- this.channels.set(channelId, state);
1153
+ this.channels.set(routingKey, state);
742
1154
  }
743
1155
  else if (state.channelId !== resolvedChannelId
744
1156
  || state.sessionPersistence !== sessionPersistence) {
745
- throw new Error(`Claude session routing changed for key "${channelId}"`);
1157
+ throw new Error(`Claude session routing changed for key "${routingKey}"`);
746
1158
  }
747
1159
  return state;
748
1160
  }
749
- processChannelQueue(channelId, state) {
1161
+ resolveChannelState(routing) {
1162
+ const workerRoute = this.channelWorkerRoutes.get(routing.channelId);
1163
+ if (workerRoute) {
1164
+ return workerRoute.state;
1165
+ }
1166
+ if (routing.persistence === 'ephemeral') {
1167
+ const foregroundState = this.channels.get(routing.channelId);
1168
+ const state = foregroundState
1169
+ && foregroundState.channelId === routing.channelId
1170
+ && foregroundState.sessionPersistence === 'persistent'
1171
+ ? foregroundState
1172
+ : this.getOrCreateChannelState(routing.key, routing.channelId, routing.persistence);
1173
+ this.channelWorkerRoutes.set(routing.channelId, { phase: 'candidate', state });
1174
+ return state;
1175
+ }
1176
+ return this.getOrCreateChannelState(routing.key, routing.channelId, routing.persistence);
1177
+ }
1178
+ processChannelQueue(state) {
750
1179
  if (state.processing) {
751
1180
  return;
752
1181
  }
@@ -766,17 +1195,24 @@ export class ClaudeCliClient {
766
1195
  : 'default';
767
1196
  const sessionPromptAppend = resolveTurnSessionPromptAppend(turn.preparedTurn);
768
1197
  state.visibilityKey = resolveSessionVisibilityKey(turn.preparedTurn.promptContext, sessionPromptAppend, turn.preparedTurn.projectionStrategy);
1198
+ const adoptingPersistentRoute = turn.sessionPersistence === 'persistent'
1199
+ && state.routingKey !== state.channelId
1200
+ && !state.persistentFollowUpAdopted;
1201
+ if (adoptingPersistentRoute) {
1202
+ state.persistentFollowUpAdopted = true;
1203
+ state.sessionPersistence = 'persistent';
1204
+ }
769
1205
  await this.ensureStarted();
770
- await this.recycleSessionIfScopeChanged(channelId, state);
771
- const session = await this.getOrCreateSession(channelId, state, turn.preparedTurn, sessionPromptAppend);
1206
+ await this.recycleSessionIfScopeChanged(state.routingKey, state);
1207
+ const session = await this.getOrCreateSession(state, turn.preparedTurn, sessionPromptAppend);
772
1208
  let reply;
773
1209
  try {
774
1210
  const promptInput = await this.buildPromptInput(turn.preparedTurn);
775
- reply = await this.runTurn(session, promptInput, turn.options);
1211
+ reply = await this.runTurn(session, promptInput, turn.options, state.foregroundIsolationFence);
776
1212
  }
777
1213
  catch (error) {
778
1214
  if (isSessionTainted(error)) {
779
- this.invalidateSession(channelId, state, session);
1215
+ this.invalidateSession(state.routingKey, state, session);
780
1216
  this.rejectQueuedTurnsAfterSessionTaint(state, error);
781
1217
  }
782
1218
  throw error;
@@ -793,26 +1229,17 @@ export class ClaudeCliClient {
793
1229
  }
794
1230
  finally {
795
1231
  state.processing = false;
796
- if (state.sessionPersistence === 'ephemeral' && !this.fatalError && !this.disposing && state.queue.length === 0) {
797
- const session = state.session;
798
- state.session = undefined;
799
- state.sessionCwd = undefined;
800
- state.sessionVisibilityKey = undefined;
801
- if (session) {
802
- this.closeSession(session);
803
- }
804
- this.channels.delete(channelId);
805
- }
806
1232
  if (state.queue.length > 0 && !this.fatalError) {
807
- this.processChannelQueue(channelId, state);
1233
+ this.processChannelQueue(state);
808
1234
  }
809
1235
  else {
1236
+ this.reconcileIdleChannelState(state);
810
1237
  this.idleBackendShutdown.reconcile();
811
1238
  }
812
1239
  }
813
1240
  })();
814
1241
  }
815
- async getOrCreateSession(channelId, state, turn, systemPromptAppend) {
1242
+ async getOrCreateSession(state, turn, systemPromptAppend) {
816
1243
  if (state.session) {
817
1244
  return state.session;
818
1245
  }
@@ -824,12 +1251,12 @@ export class ClaudeCliClient {
824
1251
  }
825
1252
  const cwd = state.cwd ?? this.runtime.cwd;
826
1253
  const persistedSessionId = state.sessionPersistence === 'persistent'
827
- ? await this.readPersistedSessionId(channelId, cwd, state.visibilityKey)
1254
+ ? await this.readPersistedSessionId(state.channelId, cwd, state.visibilityKey)
828
1255
  : undefined;
829
1256
  const additionalDirectories = resolveSessionAdditionalDirectories(turn.promptContext);
830
1257
  const sessionPromise = persistedSessionId
831
- ? this.restoreOrCreateSession(channelId, persistedSessionId, cwd, systemPromptAppend, additionalDirectories)
832
- : this.startFreshSession(channelId, cwd, systemPromptAppend, additionalDirectories);
1258
+ ? this.restoreOrCreateSession(state.channelId, persistedSessionId, cwd, systemPromptAppend, additionalDirectories)
1259
+ : this.startFreshSession(state.channelId, cwd, systemPromptAppend, additionalDirectories);
833
1260
  this.pendingSessionStarts.add(sessionPromise);
834
1261
  state.sessionPromise = sessionPromise;
835
1262
  let sessionAdopted = false;
@@ -854,10 +1281,16 @@ export class ClaudeCliClient {
854
1281
  state.session = session;
855
1282
  state.sessionCwd = cwd;
856
1283
  state.sessionVisibilityKey = state.visibilityKey;
1284
+ if (state.sessionPersistence === 'persistent') {
1285
+ await this.persistSessionBestEffort(state.channelId, session.sessionId, state.visibilityKey, cwd);
1286
+ }
857
1287
  return session;
858
1288
  }
859
1289
  catch (error) {
860
1290
  state.session = undefined;
1291
+ state.backgroundTasksActive = false;
1292
+ state.backgroundContinuationPending = false;
1293
+ state.foregroundIsolationFence = undefined;
861
1294
  state.sessionCwd = undefined;
862
1295
  state.sessionVisibilityKey = undefined;
863
1296
  throw error;
@@ -874,10 +1307,6 @@ export class ClaudeCliClient {
874
1307
  }
875
1308
  const session = await this.connection.agent.buildSession(buildSessionRequest(cwd, systemPromptAppend, additionalDirectories)).start();
876
1309
  this.logger?.debug('started fresh Claude ACP session', { channelId, cwd });
877
- const state = this.channels.get(channelId);
878
- if (state?.sessionPersistence === 'persistent') {
879
- await this.persistSessionBestEffort(channelId, session.sessionId, state.visibilityKey, cwd);
880
- }
881
1310
  return session;
882
1311
  }
883
1312
  async restoreOrCreateSession(channelId, sessionId, cwd, systemPromptAppend, additionalDirectories) {
@@ -951,6 +1380,11 @@ export class ClaudeCliClient {
951
1380
  && state.sessionVisibilityKey === state.visibilityKey) {
952
1381
  return;
953
1382
  }
1383
+ if (state.backgroundTasksActive
1384
+ || state.backgroundContinuationPending
1385
+ || state.sessionHasPendingAutonomousTurn) {
1386
+ throw new Error('Claude session scope cannot change while provider-owned background delivery is running');
1387
+ }
954
1388
  this.logger?.debug('recycling Claude ACP session after session scope changed', {
955
1389
  channelId,
956
1390
  previousCwd: state.sessionCwd,
@@ -959,57 +1393,142 @@ export class ClaudeCliClient {
959
1393
  nextVisibilityKey: state.visibilityKey,
960
1394
  });
961
1395
  state.session = undefined;
1396
+ state.backgroundTasksActive = false;
1397
+ state.backgroundContinuationPending = false;
1398
+ state.sessionHasPendingAutonomousTurn = false;
1399
+ state.foregroundIsolationFence = undefined;
962
1400
  state.sessionCwd = undefined;
963
1401
  state.sessionVisibilityKey = undefined;
964
1402
  if (!this.disposing && !this.backendClosed && state.sessionPersistence === 'persistent') {
965
- await this.clearPersistedSessionBestEffort(channelId);
1403
+ await this.clearPersistedSessionBestEffort(state.channelId);
966
1404
  }
967
1405
  this.closeSession(session);
968
1406
  }
969
- async runTurn(session, prompt, options) {
1407
+ async runTurn(session, prompt, options, foregroundIsolationFence) {
1408
+ let updatePump = this.sessionUpdatePumps.get(session);
1409
+ if (!updatePump) {
1410
+ updatePump = new ClaudeSessionUpdatePump(session, (error) => {
1411
+ const binding = [...this.channels.entries()].find(([, candidate]) => candidate.session === session);
1412
+ if (binding) {
1413
+ const [, state] = binding;
1414
+ this.logger?.debug('discarding failed Claude ACP session update pump', {
1415
+ channelId: state.channelId,
1416
+ error: error.message,
1417
+ });
1418
+ this.invalidateSession(state.routingKey, state, session);
1419
+ }
1420
+ }, (active) => {
1421
+ const binding = [...this.channels.entries()].find(([, candidate]) => candidate.session === session);
1422
+ if (!binding) {
1423
+ return;
1424
+ }
1425
+ const [, state] = binding;
1426
+ state.backgroundTasksActive = active;
1427
+ state.backgroundContinuationPending = false;
1428
+ if (active) {
1429
+ this.promoteWorkerRoute(state, session);
1430
+ this.idleBackendShutdown.cancel();
1431
+ return;
1432
+ }
1433
+ this.reconcileIdleChannelState(state);
1434
+ this.idleBackendShutdown.reconcile();
1435
+ }, {
1436
+ onReply: (reply) => {
1437
+ const binding = [...this.channels.entries()].find(([, candidate]) => candidate.session === session);
1438
+ if (!binding) {
1439
+ return;
1440
+ }
1441
+ const [, state] = binding;
1442
+ if (state.session !== session) {
1443
+ return;
1444
+ }
1445
+ this.autonomousReplyHandler?.({
1446
+ channelId: state.channelId,
1447
+ eventId: reply.eventId,
1448
+ text: reply.text,
1449
+ });
1450
+ },
1451
+ onPendingChanged: (active) => {
1452
+ const binding = [...this.channels.entries()].find(([, candidate]) => candidate.session === session);
1453
+ if (!binding) {
1454
+ return;
1455
+ }
1456
+ const [, state] = binding;
1457
+ if (state.session !== session) {
1458
+ return;
1459
+ }
1460
+ state.sessionHasPendingAutonomousTurn = active;
1461
+ if (active) {
1462
+ this.promoteWorkerRoute(state, session);
1463
+ this.idleBackendShutdown.cancel();
1464
+ return;
1465
+ }
1466
+ this.reconcileIdleChannelState(state);
1467
+ this.idleBackendShutdown.reconcile();
1468
+ },
1469
+ }, (foregroundTurn) => {
1470
+ const binding = [...this.channels.entries()].find(([, candidate]) => candidate.session === session);
1471
+ if (!binding) {
1472
+ return;
1473
+ }
1474
+ const [, state] = binding;
1475
+ state.backgroundContinuationPending = true;
1476
+ state.foregroundIsolationFence = Math.max(state.foregroundIsolationFence ?? 0, foregroundTurn);
1477
+ this.promoteWorkerRoute(state, session);
1478
+ this.idleBackendShutdown.cancel();
1479
+ }, this.targetedBackgroundRunCancellationAvailable);
1480
+ this.sessionUpdatePumps.set(session, updatePump);
1481
+ }
1482
+ const turnUpdates = updatePump.beginTurn(options?.onProgress, foregroundIsolationFence);
970
1483
  const promptPromise = this.raceWithFatal(session.prompt(prompt));
971
1484
  const promptFailure = new Promise((_, reject) => {
972
1485
  void promptPromise.catch((error) => reject(markSessionTainted(error)));
973
1486
  });
974
1487
  const collector = new AcpProgressCollector(options?.onProgress, readClaudeActivityMetadata);
975
1488
  const compactionObserver = new ClaudeCompactionObserver(options?.onProgress);
976
- for (;;) {
977
- let update;
978
- try {
979
- update = await this.raceWithFatal(Promise.race([session.nextUpdate(), promptFailure]));
980
- }
981
- catch (error) {
982
- if (this.fatalError && error === this.fatalError) {
983
- throw error;
984
- }
985
- throw markSessionTainted(error);
986
- }
987
- if (update.kind === 'stop') {
988
- let response;
1489
+ try {
1490
+ for (;;) {
1491
+ let update;
989
1492
  try {
990
- response = await promptPromise;
1493
+ update = await this.raceWithFatal(Promise.race([turnUpdates.next(), promptFailure]));
991
1494
  }
992
1495
  catch (error) {
1496
+ if (this.fatalError && error === this.fatalError) {
1497
+ throw error;
1498
+ }
993
1499
  throw markSessionTainted(error);
994
1500
  }
995
- const output = collector.getFinalText();
996
- if (response.stopReason === 'cancelled') {
997
- throw new ProviderTurnCancelledError('Claude ACP turn cancelled', {
998
- publicReplyText: hasVisibleText(output) ? output : CLAUDE_CANCELLED_REPLY_TEXT,
999
- });
1501
+ if (update.kind === 'stop') {
1502
+ let response;
1503
+ try {
1504
+ response = await promptPromise;
1505
+ }
1506
+ catch (error) {
1507
+ throw markSessionTainted(error);
1508
+ }
1509
+ const output = collector.getFinalText();
1510
+ if (response.stopReason === 'cancelled') {
1511
+ throw new ProviderTurnCancelledError('Claude ACP turn cancelled', {
1512
+ publicReplyText: hasVisibleText(output) ? output : CLAUDE_CANCELLED_REPLY_TEXT,
1513
+ });
1514
+ }
1515
+ if (response.stopReason !== 'end_turn' && !hasVisibleText(output)) {
1516
+ throw new Error(`Claude ACP turn stopped with stopReason "${response.stopReason}"`);
1517
+ }
1518
+ return output;
1000
1519
  }
1001
- if (response.stopReason !== 'end_turn' && !hasVisibleText(output)) {
1002
- throw new Error(`Claude ACP turn stopped with stopReason "${response.stopReason}"`);
1520
+ if (update.update.sessionUpdate === 'agent_message_chunk' &&
1521
+ update.update.content.type === 'text') {
1522
+ compactionObserver.consumeAnswerChunk(update.update.content.text);
1003
1523
  }
1004
- return output;
1005
- }
1006
- if (update.update.sessionUpdate === 'agent_message_chunk' && update.update.content.type === 'text') {
1007
- compactionObserver.consumeAnswerChunk(update.update.content.text);
1008
- }
1009
- if (update.update.sessionUpdate === 'usage_update') {
1010
- compactionObserver.consumeUsageUpdate(update.update.used, update.update.size);
1524
+ if (update.update.sessionUpdate === 'usage_update') {
1525
+ compactionObserver.consumeUsageUpdate(update.update.used, update.update.size);
1526
+ }
1527
+ collector.consume(update);
1011
1528
  }
1012
- collector.consume(update);
1529
+ }
1530
+ finally {
1531
+ turnUpdates.close();
1013
1532
  }
1014
1533
  }
1015
1534
  async raceWithFatal(promise) {
@@ -1035,15 +1554,21 @@ export class ClaudeCliClient {
1035
1554
  this.closeSession(state.session);
1036
1555
  }
1037
1556
  state.session = undefined;
1557
+ state.backgroundTasksActive = false;
1558
+ state.backgroundContinuationPending = false;
1559
+ state.sessionHasPendingAutonomousTurn = false;
1560
+ state.foregroundIsolationFence = undefined;
1038
1561
  state.sessionCwd = undefined;
1039
1562
  state.sessionVisibilityKey = undefined;
1040
1563
  state.sessionPromise = undefined;
1041
1564
  state.activeTurn = undefined;
1565
+ state.persistentFollowUpAdopted = false;
1042
1566
  if (!state.processing) {
1043
1567
  this.channels.delete(channelId);
1044
1568
  }
1045
1569
  }
1046
1570
  this.channels.clear();
1571
+ this.channelWorkerRoutes.clear();
1047
1572
  this.shutdownPromise = this.shutdownBackend(error);
1048
1573
  }
1049
1574
  handlePermissionRequest(params) {
@@ -1062,13 +1587,103 @@ export class ClaudeCliClient {
1062
1587
  invalidateSession(channelId, state, session) {
1063
1588
  if (state.session === session) {
1064
1589
  state.session = undefined;
1590
+ state.backgroundTasksActive = false;
1591
+ state.backgroundContinuationPending = false;
1592
+ state.sessionHasPendingAutonomousTurn = false;
1593
+ state.foregroundIsolationFence = undefined;
1065
1594
  state.sessionCwd = undefined;
1066
1595
  state.sessionVisibilityKey = undefined;
1596
+ state.persistentFollowUpAdopted = false;
1067
1597
  }
1068
1598
  this.logger?.debug('discarding tainted Claude ACP session', { channelId });
1069
1599
  this.closeSession(session);
1070
1600
  if (!this.disposing && !this.backendClosed && state.sessionPersistence === 'persistent') {
1071
- void this.clearPersistedSessionBestEffort(channelId);
1601
+ void this.clearPersistedSessionBestEffort(state.channelId);
1602
+ }
1603
+ }
1604
+ promoteWorkerRoute(state, session) {
1605
+ this.channelWorkerRoutes.set(state.channelId, {
1606
+ phase: 'promoted',
1607
+ state,
1608
+ });
1609
+ state.sessionPersistence = 'persistent';
1610
+ void (async () => {
1611
+ if (!await this.ensureSessionStoreLoaded()) {
1612
+ return;
1613
+ }
1614
+ const route = this.channelWorkerRoutes.get(state.channelId);
1615
+ if (route?.state !== state
1616
+ || route.phase !== 'promoted'
1617
+ || state.session !== session
1618
+ || (!state.backgroundTasksActive && !state.sessionHasPendingAutonomousTurn && !state.persistentFollowUpAdopted)) {
1619
+ return;
1620
+ }
1621
+ await this.persistSessionBestEffort(state.channelId, session.sessionId, state.visibilityKey, state.sessionCwd ?? state.cwd ?? this.runtime.cwd);
1622
+ })().catch((error) => {
1623
+ this.logger.error('failed to initialize Claude session persistence for background work', {
1624
+ agentId: this.loadedSessionStoreAgentId,
1625
+ channelId: state.channelId,
1626
+ sessionId: session.sessionId,
1627
+ error: summarizeError(error),
1628
+ });
1629
+ });
1630
+ }
1631
+ releaseWorkerRoute(state, clearPersistent = false) {
1632
+ const route = this.channelWorkerRoutes.get(state.channelId);
1633
+ if (route?.state !== state) {
1634
+ return;
1635
+ }
1636
+ this.channelWorkerRoutes.delete(state.channelId);
1637
+ if (clearPersistent && state.session) {
1638
+ void this.clearPersistedSessionBestEffort(state.channelId);
1639
+ }
1640
+ }
1641
+ reconcileIdleChannelState(state) {
1642
+ if (this.channels.get(state.routingKey) !== state
1643
+ || this.disposing
1644
+ || this.fatalError
1645
+ || this.backendClosed
1646
+ || state.processing
1647
+ || state.activeTurn
1648
+ || state.queue.length > 0
1649
+ || state.sessionPromise) {
1650
+ return;
1651
+ }
1652
+ const route = this.channelWorkerRoutes.get(state.channelId);
1653
+ if (route?.state === state
1654
+ && route.phase === 'promoted'
1655
+ && !state.backgroundTasksActive
1656
+ && !state.backgroundContinuationPending
1657
+ && !state.sessionHasPendingAutonomousTurn) {
1658
+ if (state.routingKey === state.channelId) {
1659
+ this.releaseWorkerRoute(state);
1660
+ }
1661
+ else if (!state.persistentFollowUpAdopted) {
1662
+ state.sessionPersistence = 'ephemeral';
1663
+ this.releaseWorkerRoute(state, true);
1664
+ }
1665
+ }
1666
+ else if (route?.state === state
1667
+ && route.phase === 'candidate'
1668
+ && (state.routingKey === state.channelId
1669
+ || state.sessionPersistence === 'ephemeral')) {
1670
+ this.releaseWorkerRoute(state);
1671
+ }
1672
+ if (state.sessionPersistence !== 'ephemeral'
1673
+ || state.backgroundTasksActive
1674
+ || state.backgroundContinuationPending
1675
+ || state.sessionHasPendingAutonomousTurn) {
1676
+ return;
1677
+ }
1678
+ const session = state.session;
1679
+ state.session = undefined;
1680
+ state.foregroundIsolationFence = undefined;
1681
+ state.sessionCwd = undefined;
1682
+ state.sessionVisibilityKey = undefined;
1683
+ this.releaseWorkerRoute(state);
1684
+ this.channels.delete(state.routingKey);
1685
+ if (session) {
1686
+ this.closeSession(session);
1072
1687
  }
1073
1688
  }
1074
1689
  closeSession(session) {
@@ -1076,6 +1691,8 @@ export class ClaudeCliClient {
1076
1691
  return;
1077
1692
  }
1078
1693
  this.closingSessions.add(session);
1694
+ this.sessionUpdatePumps.get(session)?.close();
1695
+ this.sessionUpdatePumps.delete(session);
1079
1696
  const agent = this.connection?.agent;
1080
1697
  const closeMethod = this.runtime.methods.agent.session?.close;
1081
1698
  const closePromise = typeof agent?.closeSession === 'function'
@@ -1261,10 +1878,16 @@ export class ClaudeCliClient {
1261
1878
  sessions.push(state.session);
1262
1879
  }
1263
1880
  state.session = undefined;
1881
+ state.backgroundTasksActive = false;
1882
+ state.backgroundContinuationPending = false;
1883
+ state.sessionHasPendingAutonomousTurn = false;
1884
+ state.foregroundIsolationFence = undefined;
1264
1885
  state.sessionCwd = undefined;
1265
1886
  state.sessionVisibilityKey = undefined;
1887
+ state.persistentFollowUpAdopted = false;
1266
1888
  this.channels.delete(channelId);
1267
1889
  }
1890
+ this.channelWorkerRoutes.clear();
1268
1891
  this.logger?.debug('stopping idle Claude ACP backend', {
1269
1892
  channels: sessions.length,
1270
1893
  idleShutdownMs: this.runtime.idleBackendShutdownMs,
@@ -1276,6 +1899,7 @@ export class ClaudeCliClient {
1276
1899
  this.startPromise = undefined;
1277
1900
  this.childExitPromise = undefined;
1278
1901
  this.sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
1902
+ this.targetedBackgroundRunCancellationAvailable = false;
1279
1903
  this.idleShutdownPromise = undefined;
1280
1904
  });
1281
1905
  this.idleShutdownPromise = idleShutdownPromise;
@@ -1292,7 +1916,13 @@ export class ClaudeCliClient {
1292
1916
  return false;
1293
1917
  }
1294
1918
  for (const state of this.channels.values()) {
1295
- if (state.activeTurn || state.queue.length > 0 || state.sessionPromise || state.processing) {
1919
+ if (state.activeTurn ||
1920
+ state.backgroundTasksActive ||
1921
+ state.backgroundContinuationPending ||
1922
+ state.sessionHasPendingAutonomousTurn ||
1923
+ state.queue.length > 0 ||
1924
+ state.sessionPromise ||
1925
+ state.processing) {
1296
1926
  return false;
1297
1927
  }
1298
1928
  }