@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,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,143 @@ 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
- import { isDiscussionOnlyResolvedWorkspace } from '../../context/resolved-workspace.js';
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
+ class ClaudeSessionUpdatePump {
22
+ session;
23
+ onFailure;
24
+ onForegroundSettledWithBackground;
25
+ observer;
26
+ activeTurn;
27
+ closed = false;
28
+ failure;
29
+ constructor(session, onFailure, onBackgroundTasksActiveChanged, onForegroundSettledWithBackground, targetedCancellationSupported) {
30
+ this.session = session;
31
+ this.onFailure = onFailure;
32
+ this.onForegroundSettledWithBackground = onForegroundSettledWithBackground;
33
+ this.observer = new ClaudeBackgroundRunObserver(undefined, {
34
+ onBackgroundTasksActiveChanged,
35
+ targetedCancellationSupported,
36
+ });
37
+ void this.run();
38
+ }
39
+ beginTurn(onProgress, foregroundIsolationFence) {
40
+ if (this.closed) {
41
+ throw new Error('Claude ACP session update pump is closed');
42
+ }
43
+ if (this.failure) {
44
+ throw this.failure;
45
+ }
46
+ if (this.activeTurn) {
47
+ throw new Error('Claude ACP session already has an active turn');
48
+ }
49
+ const turn = {
50
+ queue: [],
51
+ foregroundIsolationFence,
52
+ };
53
+ this.activeTurn = turn;
54
+ this.observer.setOnProgress(onProgress);
55
+ return {
56
+ next: () => {
57
+ if (turn.failure) {
58
+ return Promise.reject(turn.failure);
59
+ }
60
+ const queued = turn.queue.shift();
61
+ if (queued) {
62
+ return Promise.resolve(queued);
63
+ }
64
+ if (this.activeTurn !== turn || this.closed) {
65
+ return Promise.reject(new Error('Claude ACP turn update stream is closed'));
66
+ }
67
+ return new Promise((resolve, reject) => {
68
+ turn.waiter = { resolve, reject };
69
+ });
70
+ },
71
+ close: () => {
72
+ if (this.activeTurn !== turn) {
73
+ return;
74
+ }
75
+ this.activeTurn = undefined;
76
+ turn.waiter?.reject(new Error('Claude ACP turn update stream is closed'));
77
+ turn.waiter = undefined;
78
+ turn.queue.length = 0;
79
+ },
80
+ };
81
+ }
82
+ close() {
83
+ if (this.closed) {
84
+ return;
85
+ }
86
+ this.closed = true;
87
+ this.observer.dispose();
88
+ const turn = this.activeTurn;
89
+ this.activeTurn = undefined;
90
+ turn?.waiter?.reject(new Error('Claude ACP session update pump is closed'));
91
+ }
92
+ async run() {
93
+ try {
94
+ while (!this.closed) {
95
+ const update = await this.session.nextUpdate();
96
+ if (this.closed) {
97
+ return;
98
+ }
99
+ const lifecycle = update.kind === 'session_update' && this.observer.consume(update.update);
100
+ if (lifecycle) {
101
+ continue;
102
+ }
103
+ if (update.kind === 'session_update'
104
+ && isClaudeUpdateFromIsolatedTurn(update.update, this.activeTurn?.foregroundIsolationFence)) {
105
+ continue;
106
+ }
107
+ const settledForegroundTurn = update.kind === 'session_update'
108
+ ? readClaudeForegroundSettledTurn(update.update)
109
+ : undefined;
110
+ const turn = this.activeTurn;
111
+ if (turn
112
+ && settledForegroundTurn !== undefined
113
+ && turn.foregroundSettledTurn !== settledForegroundTurn) {
114
+ turn.foregroundSettledTurn = settledForegroundTurn;
115
+ this.onForegroundSettledWithBackground(settledForegroundTurn);
116
+ }
117
+ if (!turn) {
118
+ continue;
119
+ }
120
+ const waiter = turn.waiter;
121
+ if (waiter) {
122
+ turn.waiter = undefined;
123
+ waiter.resolve(update);
124
+ }
125
+ else {
126
+ turn.queue.push(update);
127
+ }
128
+ }
129
+ }
130
+ catch (error) {
131
+ if (!this.closed) {
132
+ const normalized = normalizeError(error);
133
+ this.observer.fail();
134
+ this.failure = normalized;
135
+ const turn = this.activeTurn;
136
+ if (turn) {
137
+ // ActiveSession mirrors prompt failures onto its update queue. Keep
138
+ // that rejection scoped to this turn/session; transport failures use
139
+ // the connection and child-process fatal handlers instead.
140
+ turn.failure = normalized;
141
+ turn.waiter?.reject(normalized);
142
+ turn.waiter = undefined;
143
+ }
144
+ else {
145
+ this.onFailure(normalized);
146
+ }
147
+ }
148
+ }
149
+ }
150
+ }
18
151
  const SESSION_TAINTED_ERRORS = new WeakSet();
19
152
  const IDLE_BACKEND_STOPPED_MESSAGE = 'Claude ACP backend stopped after idle timeout';
20
153
  /**
@@ -32,7 +165,6 @@ const DEFAULT_SESSION_CAPABILITIES = {
32
165
  resumeSession: false,
33
166
  };
34
167
  const CLAUDE_SDK_COMPACTION_EXPERIMENT_CONTEXT_TOKEN_THRESHOLD_ENV = 'CLAUDE_SDK_COMPACTION_EXPERIMENT_CONTEXT_TOKEN_THRESHOLD';
35
- const require = createRequire(import.meta.url);
36
168
  const CLAUDE_CANCELLED_REPLY_TEXT = 'Info: Operation cancelled by user';
37
169
  const DEFAULT_RUNTIME = {
38
170
  spawn,
@@ -283,7 +415,12 @@ function isSessionTainted(error) {
283
415
  return error instanceof Error && SESSION_TAINTED_ERRORS.has(error);
284
416
  }
285
417
  function resolveBundledClaudeAdapterPath() {
286
- return require.resolve('@agentclientprotocol/claude-agent-acp/dist/index.js');
418
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
419
+ const adapterPath = resolve(packageRoot, 'dist', 'vendor', 'claude-agent-acp', 'dist', 'index.js');
420
+ if (!existsSync(adapterPath)) {
421
+ throw new Error(`Shipped Claude ACP adapter is missing at ${adapterPath}; reinstall @borgee/agents-host`);
422
+ }
423
+ return adapterPath;
287
424
  }
288
425
  function isRelativePathLike(value) {
289
426
  return value.startsWith('./') || value.startsWith('../');
@@ -489,16 +626,45 @@ export class ClaudeCliClient {
489
626
  imageInputConfig;
490
627
  runtime;
491
628
  channels = new Map();
629
+ channelWorkerRoutes = new Map();
630
+ targetedBackgroundRunCancellationConfigured;
492
631
  /**
493
632
  * The provider session currently bound to channelId, or undefined before a
494
633
  * turn has opened one. Surfaced so agents-host can record which session
495
634
  * worked a task without the agent being asked to report its own id.
496
635
  */
497
636
  sessionIdForChannel(channelId) {
498
- return this.channels.get(channelId)?.session?.sessionId;
637
+ return this.channelWorkerRoutes.get(channelId)?.state.session?.sessionId
638
+ ?? this.channels.get(channelId)?.session?.sessionId;
639
+ }
640
+ async handoffForegroundTurn(channelId) {
641
+ const state = this.channelWorkerRoutes.get(channelId)?.state ?? this.channels.get(channelId);
642
+ const session = state?.session;
643
+ if (!state?.activeTurn || !session || !this.connection) {
644
+ return { outcome: 'not_found' };
645
+ }
646
+ const response = await this.connection.agent.request(CLAUDE_FOREGROUND_HANDOFF_METHOD, { sessionId: session.sessionId });
647
+ const result = asObject(response);
648
+ const outcome = result?.outcome;
649
+ if (outcome === 'admitted' || outcome === 'released') {
650
+ const foregroundTurn = result?.foregroundTurn;
651
+ if (!Number.isSafeInteger(foregroundTurn)
652
+ || foregroundTurn <= 0) {
653
+ throw new Error('Claude ACP foreground handoff omitted its ownership fence');
654
+ }
655
+ if (state.session !== session) {
656
+ return { outcome: 'admitted' };
657
+ }
658
+ state.foregroundIsolationFence = Math.max(state.foregroundIsolationFence ?? 0, foregroundTurn);
659
+ return { outcome: 'admitted' };
660
+ }
661
+ if (outcome === 'not_found') {
662
+ return { outcome: 'not_found' };
663
+ }
664
+ throw new Error('Claude ACP returned an unusable foreground handoff result');
499
665
  }
500
666
  async cancelTurn(channelId) {
501
- const state = this.channels.get(channelId);
667
+ const state = this.channelWorkerRoutes.get(channelId)?.state ?? this.channels.get(channelId);
502
668
  const session = state?.session;
503
669
  if (!state?.activeTurn || !session) {
504
670
  return false;
@@ -513,8 +679,33 @@ export class ClaudeCliClient {
513
679
  await this.connection?.agent.notify(cancelMethod, { sessionId: session.sessionId });
514
680
  return true;
515
681
  }
682
+ async cancelBackgroundRun(channelId, providerRunId) {
683
+ if (!this.targetedBackgroundRunCancellationConfigured
684
+ || !this.targetedBackgroundRunCancellationAvailable
685
+ || providerRunId.length === 0) {
686
+ return false;
687
+ }
688
+ const state = this.channelWorkerRoutes.get(channelId)?.state ?? this.channels.get(channelId);
689
+ const session = state?.session;
690
+ if (!state || state.channelId !== channelId || !session || !this.connection) {
691
+ return false;
692
+ }
693
+ this.idleBackendShutdown.cancel();
694
+ const response = await this.connection.agent.request(CLAUDE_TASK_CANCELLATION_METHOD, {
695
+ sessionId: session.sessionId,
696
+ taskId: providerRunId,
697
+ });
698
+ if (response?.outcome === 'accepted') {
699
+ return true;
700
+ }
701
+ if (response?.outcome === 'not_found' || response?.outcome === 'unsupported') {
702
+ return false;
703
+ }
704
+ throw new Error('Claude ACP returned an invalid targeted task cancellation response');
705
+ }
516
706
  persistedSessions = new Map();
517
707
  closingSessions = new WeakSet();
708
+ sessionUpdatePumps = new WeakMap();
518
709
  pendingSessionStarts = new Set();
519
710
  pendingSessionCloses = new Set();
520
711
  pendingSessionStoreOperations = new Set();
@@ -539,6 +730,7 @@ export class ClaudeCliClient {
539
730
  sessionStoreLoadPromise = null;
540
731
  sessionStoreWriteQueue = Promise.resolve();
541
732
  sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
733
+ targetedBackgroundRunCancellationAvailable = false;
542
734
  idleBackendShutdown;
543
735
  childStderr = '';
544
736
  constructor(command, args = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger(), imageInputConfig = {}) {
@@ -550,6 +742,8 @@ export class ClaudeCliClient {
550
742
  this.imageInputConfig = imageInputConfig;
551
743
  this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
552
744
  assertClaudeCommandCompatibility(command, args, 'Claude provider runtime');
745
+ this.targetedBackgroundRunCancellationConfigured =
746
+ useBundledClaudeAdapter(command, args) || useLegacyClaudeCompatibilityAlias(command, args);
553
747
  this.idleBackendShutdown = new IdleBackendShutdownScheduler({
554
748
  idleShutdownMs: this.runtime.idleBackendShutdownMs,
555
749
  isIdle: () => this.isBackendIdle(),
@@ -586,11 +780,11 @@ export class ClaudeCliClient {
586
780
  ? maybeOptions
587
781
  : promptOrOptions;
588
782
  const sessionRouting = resolveSessionRouting(preparedTurn);
589
- const state = this.getOrCreateChannelState(sessionRouting.key, preparedTurn.channelId, sessionRouting.persistence);
783
+ const state = this.resolveChannelState(sessionRouting);
590
784
  this.idleBackendShutdown.cancel();
591
785
  const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn, sessionRouting.persistence, options);
592
786
  state.queue.push(turn);
593
- this.processChannelQueue(sessionRouting.key, state);
787
+ this.processChannelQueue(state);
594
788
  return turn.promise;
595
789
  }
596
790
  async dispose() {
@@ -717,9 +911,13 @@ export class ClaudeCliClient {
717
911
  },
718
912
  });
719
913
  this.sessionCapabilities = readSessionCapabilities(initializeResponse);
914
+ this.targetedBackgroundRunCancellationAvailable =
915
+ this.targetedBackgroundRunCancellationConfigured
916
+ && supportsClaudeTaskCancellation(initializeResponse);
720
917
  this.logger?.debug('initialized Claude ACP connection', {
721
918
  loadSession: this.sessionCapabilities.loadSession,
722
919
  resumeSession: this.sessionCapabilities.resumeSession,
920
+ targetedBackgroundRunCancellation: this.targetedBackgroundRunCancellationAvailable,
723
921
  });
724
922
  }
725
923
  catch (error) {
@@ -729,24 +927,45 @@ export class ClaudeCliClient {
729
927
  throw normalized;
730
928
  }
731
929
  }
732
- getOrCreateChannelState(channelId, resolvedChannelId, sessionPersistence) {
733
- let state = this.channels.get(channelId);
930
+ getOrCreateChannelState(routingKey, resolvedChannelId, sessionPersistence) {
931
+ let state = this.channels.get(routingKey);
734
932
  if (!state) {
735
933
  state = {
934
+ routingKey,
736
935
  channelId: resolvedChannelId,
737
936
  sessionPersistence,
937
+ backgroundTasksActive: false,
938
+ backgroundContinuationPending: false,
939
+ persistentFollowUpAdopted: false,
738
940
  processing: false,
739
941
  queue: [],
740
942
  };
741
- this.channels.set(channelId, state);
943
+ this.channels.set(routingKey, state);
742
944
  }
743
945
  else if (state.channelId !== resolvedChannelId
744
946
  || state.sessionPersistence !== sessionPersistence) {
745
- throw new Error(`Claude session routing changed for key "${channelId}"`);
947
+ throw new Error(`Claude session routing changed for key "${routingKey}"`);
746
948
  }
747
949
  return state;
748
950
  }
749
- processChannelQueue(channelId, state) {
951
+ resolveChannelState(routing) {
952
+ const workerRoute = this.channelWorkerRoutes.get(routing.channelId);
953
+ if (workerRoute) {
954
+ return workerRoute.state;
955
+ }
956
+ if (routing.persistence === 'ephemeral') {
957
+ const foregroundState = this.channels.get(routing.channelId);
958
+ const state = foregroundState
959
+ && foregroundState.channelId === routing.channelId
960
+ && foregroundState.sessionPersistence === 'persistent'
961
+ ? foregroundState
962
+ : this.getOrCreateChannelState(routing.key, routing.channelId, routing.persistence);
963
+ this.channelWorkerRoutes.set(routing.channelId, { phase: 'candidate', state });
964
+ return state;
965
+ }
966
+ return this.getOrCreateChannelState(routing.key, routing.channelId, routing.persistence);
967
+ }
968
+ processChannelQueue(state) {
750
969
  if (state.processing) {
751
970
  return;
752
971
  }
@@ -761,22 +980,29 @@ export class ClaudeCliClient {
761
980
  state.activeTurn = turn;
762
981
  try {
763
982
  state.cwd = this.resolveSessionCwd(turn.preparedTurn.promptContext);
764
- state.toolPermissionMode = isDiscussionOnlyResolvedWorkspace(turn.preparedTurn.promptContext)
983
+ state.toolPermissionMode = isDiscussionOnlyResolvedWorkingFolder(turn.preparedTurn.promptContext)
765
984
  ? 'deny-all'
766
985
  : 'default';
767
986
  const sessionPromptAppend = resolveTurnSessionPromptAppend(turn.preparedTurn);
768
987
  state.visibilityKey = resolveSessionVisibilityKey(turn.preparedTurn.promptContext, sessionPromptAppend, turn.preparedTurn.projectionStrategy);
988
+ const adoptingPersistentRoute = turn.sessionPersistence === 'persistent'
989
+ && state.routingKey !== state.channelId
990
+ && !state.persistentFollowUpAdopted;
991
+ if (adoptingPersistentRoute) {
992
+ state.persistentFollowUpAdopted = true;
993
+ state.sessionPersistence = 'persistent';
994
+ }
769
995
  await this.ensureStarted();
770
- await this.recycleSessionIfScopeChanged(channelId, state);
771
- const session = await this.getOrCreateSession(channelId, state, turn.preparedTurn, sessionPromptAppend);
996
+ await this.recycleSessionIfScopeChanged(state.routingKey, state);
997
+ const session = await this.getOrCreateSession(state, turn.preparedTurn, sessionPromptAppend);
772
998
  let reply;
773
999
  try {
774
1000
  const promptInput = await this.buildPromptInput(turn.preparedTurn);
775
- reply = await this.runTurn(session, promptInput, turn.options);
1001
+ reply = await this.runTurn(session, promptInput, turn.options, state.foregroundIsolationFence);
776
1002
  }
777
1003
  catch (error) {
778
1004
  if (isSessionTainted(error)) {
779
- this.invalidateSession(channelId, state, session);
1005
+ this.invalidateSession(state.routingKey, state, session);
780
1006
  this.rejectQueuedTurnsAfterSessionTaint(state, error);
781
1007
  }
782
1008
  throw error;
@@ -793,26 +1019,17 @@ export class ClaudeCliClient {
793
1019
  }
794
1020
  finally {
795
1021
  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
1022
  if (state.queue.length > 0 && !this.fatalError) {
807
- this.processChannelQueue(channelId, state);
1023
+ this.processChannelQueue(state);
808
1024
  }
809
1025
  else {
1026
+ this.reconcileIdleChannelState(state);
810
1027
  this.idleBackendShutdown.reconcile();
811
1028
  }
812
1029
  }
813
1030
  })();
814
1031
  }
815
- async getOrCreateSession(channelId, state, turn, systemPromptAppend) {
1032
+ async getOrCreateSession(state, turn, systemPromptAppend) {
816
1033
  if (state.session) {
817
1034
  return state.session;
818
1035
  }
@@ -824,12 +1041,12 @@ export class ClaudeCliClient {
824
1041
  }
825
1042
  const cwd = state.cwd ?? this.runtime.cwd;
826
1043
  const persistedSessionId = state.sessionPersistence === 'persistent'
827
- ? await this.readPersistedSessionId(channelId, cwd, state.visibilityKey)
1044
+ ? await this.readPersistedSessionId(state.channelId, cwd, state.visibilityKey)
828
1045
  : undefined;
829
1046
  const additionalDirectories = resolveSessionAdditionalDirectories(turn.promptContext);
830
1047
  const sessionPromise = persistedSessionId
831
- ? this.restoreOrCreateSession(channelId, persistedSessionId, cwd, systemPromptAppend, additionalDirectories)
832
- : this.startFreshSession(channelId, cwd, systemPromptAppend, additionalDirectories);
1048
+ ? this.restoreOrCreateSession(state.channelId, persistedSessionId, cwd, systemPromptAppend, additionalDirectories)
1049
+ : this.startFreshSession(state.channelId, cwd, systemPromptAppend, additionalDirectories);
833
1050
  this.pendingSessionStarts.add(sessionPromise);
834
1051
  state.sessionPromise = sessionPromise;
835
1052
  let sessionAdopted = false;
@@ -854,10 +1071,16 @@ export class ClaudeCliClient {
854
1071
  state.session = session;
855
1072
  state.sessionCwd = cwd;
856
1073
  state.sessionVisibilityKey = state.visibilityKey;
1074
+ if (state.sessionPersistence === 'persistent') {
1075
+ await this.persistSessionBestEffort(state.channelId, session.sessionId, state.visibilityKey, cwd);
1076
+ }
857
1077
  return session;
858
1078
  }
859
1079
  catch (error) {
860
1080
  state.session = undefined;
1081
+ state.backgroundTasksActive = false;
1082
+ state.backgroundContinuationPending = false;
1083
+ state.foregroundIsolationFence = undefined;
861
1084
  state.sessionCwd = undefined;
862
1085
  state.sessionVisibilityKey = undefined;
863
1086
  throw error;
@@ -874,10 +1097,6 @@ export class ClaudeCliClient {
874
1097
  }
875
1098
  const session = await this.connection.agent.buildSession(buildSessionRequest(cwd, systemPromptAppend, additionalDirectories)).start();
876
1099
  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
1100
  return session;
882
1101
  }
883
1102
  async restoreOrCreateSession(channelId, sessionId, cwd, systemPromptAppend, additionalDirectories) {
@@ -939,7 +1158,7 @@ export class ClaudeCliClient {
939
1158
  }
940
1159
  }
941
1160
  resolveSessionCwd(promptContext) {
942
- return promptContext?.resolvedWorkspace?.rootPath ?? this.runtime.cwd;
1161
+ return promptContext?.resolvedWorkingFolder?.rootPath ?? this.runtime.cwd;
943
1162
  }
944
1163
  async recycleSessionIfScopeChanged(channelId, state) {
945
1164
  const session = state.session;
@@ -951,6 +1170,9 @@ export class ClaudeCliClient {
951
1170
  && state.sessionVisibilityKey === state.visibilityKey) {
952
1171
  return;
953
1172
  }
1173
+ if (state.backgroundTasksActive || state.backgroundContinuationPending) {
1174
+ throw new Error('Claude session scope cannot change while provider-owned background work is running');
1175
+ }
954
1176
  this.logger?.debug('recycling Claude ACP session after session scope changed', {
955
1177
  channelId,
956
1178
  previousCwd: state.sessionCwd,
@@ -959,57 +1181,113 @@ export class ClaudeCliClient {
959
1181
  nextVisibilityKey: state.visibilityKey,
960
1182
  });
961
1183
  state.session = undefined;
1184
+ state.backgroundTasksActive = false;
1185
+ state.backgroundContinuationPending = false;
1186
+ state.foregroundIsolationFence = undefined;
962
1187
  state.sessionCwd = undefined;
963
1188
  state.sessionVisibilityKey = undefined;
964
1189
  if (!this.disposing && !this.backendClosed && state.sessionPersistence === 'persistent') {
965
- await this.clearPersistedSessionBestEffort(channelId);
1190
+ await this.clearPersistedSessionBestEffort(state.channelId);
966
1191
  }
967
1192
  this.closeSession(session);
968
1193
  }
969
- async runTurn(session, prompt, options) {
1194
+ async runTurn(session, prompt, options, foregroundIsolationFence) {
1195
+ let updatePump = this.sessionUpdatePumps.get(session);
1196
+ if (!updatePump) {
1197
+ updatePump = new ClaudeSessionUpdatePump(session, (error) => {
1198
+ const binding = [...this.channels.entries()].find(([, candidate]) => candidate.session === session);
1199
+ if (binding) {
1200
+ const [, state] = binding;
1201
+ this.logger?.debug('discarding failed Claude ACP session update pump', {
1202
+ channelId: state.channelId,
1203
+ error: error.message,
1204
+ });
1205
+ this.invalidateSession(state.routingKey, state, session);
1206
+ }
1207
+ }, (active) => {
1208
+ const binding = [...this.channels.entries()].find(([, candidate]) => candidate.session === session);
1209
+ if (!binding) {
1210
+ return;
1211
+ }
1212
+ const [, state] = binding;
1213
+ state.backgroundTasksActive = active;
1214
+ state.backgroundContinuationPending = false;
1215
+ if (active) {
1216
+ this.promoteWorkerRoute(state, session);
1217
+ this.idleBackendShutdown.cancel();
1218
+ return;
1219
+ }
1220
+ this.reconcileIdleChannelState(state);
1221
+ this.idleBackendShutdown.reconcile();
1222
+ }, (foregroundTurn) => {
1223
+ const binding = [...this.channels.entries()].find(([, candidate]) => candidate.session === session);
1224
+ if (!binding) {
1225
+ return;
1226
+ }
1227
+ const [, state] = binding;
1228
+ state.backgroundContinuationPending = true;
1229
+ state.foregroundIsolationFence = Math.max(state.foregroundIsolationFence ?? 0, foregroundTurn);
1230
+ this.promoteWorkerRoute(state, session);
1231
+ this.idleBackendShutdown.cancel();
1232
+ }, this.targetedBackgroundRunCancellationAvailable);
1233
+ this.sessionUpdatePumps.set(session, updatePump);
1234
+ }
1235
+ const turnUpdates = updatePump.beginTurn(options?.onProgress, foregroundIsolationFence);
970
1236
  const promptPromise = this.raceWithFatal(session.prompt(prompt));
971
1237
  const promptFailure = new Promise((_, reject) => {
972
1238
  void promptPromise.catch((error) => reject(markSessionTainted(error)));
973
1239
  });
974
1240
  const collector = new AcpProgressCollector(options?.onProgress, readClaudeActivityMetadata);
975
1241
  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;
1242
+ try {
1243
+ for (;;) {
1244
+ let update;
989
1245
  try {
990
- response = await promptPromise;
1246
+ update = await this.raceWithFatal(Promise.race([turnUpdates.next(), promptFailure]));
991
1247
  }
992
1248
  catch (error) {
1249
+ if (this.fatalError && error === this.fatalError) {
1250
+ throw error;
1251
+ }
993
1252
  throw markSessionTainted(error);
994
1253
  }
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
- });
1254
+ if (update.kind === 'stop') {
1255
+ let response;
1256
+ try {
1257
+ response = await promptPromise;
1258
+ }
1259
+ catch (error) {
1260
+ throw markSessionTainted(error);
1261
+ }
1262
+ const output = collector.getFinalText();
1263
+ if (response.stopReason === 'cancelled') {
1264
+ throw new ProviderTurnCancelledError('Claude ACP turn cancelled', {
1265
+ publicReplyText: hasVisibleText(output) ? output : CLAUDE_CANCELLED_REPLY_TEXT,
1266
+ });
1267
+ }
1268
+ if (response.stopReason !== 'end_turn' && !hasVisibleText(output)) {
1269
+ throw new Error(`Claude ACP turn stopped with stopReason "${response.stopReason}"`);
1270
+ }
1271
+ return output;
1000
1272
  }
1001
- if (response.stopReason !== 'end_turn' && !hasVisibleText(output)) {
1002
- throw new Error(`Claude ACP turn stopped with stopReason "${response.stopReason}"`);
1273
+ const settledForegroundTurn = update.kind === 'session_update'
1274
+ ? readClaudeForegroundSettledTurn(update.update)
1275
+ : undefined;
1276
+ if (settledForegroundTurn !== undefined) {
1277
+ return collector.getFinalText();
1003
1278
  }
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);
1279
+ if (update.update.sessionUpdate === 'agent_message_chunk' &&
1280
+ update.update.content.type === 'text') {
1281
+ compactionObserver.consumeAnswerChunk(update.update.content.text);
1282
+ }
1283
+ if (update.update.sessionUpdate === 'usage_update') {
1284
+ compactionObserver.consumeUsageUpdate(update.update.used, update.update.size);
1285
+ }
1286
+ collector.consume(update);
1011
1287
  }
1012
- collector.consume(update);
1288
+ }
1289
+ finally {
1290
+ turnUpdates.close();
1013
1291
  }
1014
1292
  }
1015
1293
  async raceWithFatal(promise) {
@@ -1035,6 +1313,9 @@ export class ClaudeCliClient {
1035
1313
  this.closeSession(state.session);
1036
1314
  }
1037
1315
  state.session = undefined;
1316
+ state.backgroundTasksActive = false;
1317
+ state.backgroundContinuationPending = false;
1318
+ state.foregroundIsolationFence = undefined;
1038
1319
  state.sessionCwd = undefined;
1039
1320
  state.sessionVisibilityKey = undefined;
1040
1321
  state.sessionPromise = undefined;
@@ -1044,6 +1325,7 @@ export class ClaudeCliClient {
1044
1325
  }
1045
1326
  }
1046
1327
  this.channels.clear();
1328
+ this.channelWorkerRoutes.clear();
1047
1329
  this.shutdownPromise = this.shutdownBackend(error);
1048
1330
  }
1049
1331
  handlePermissionRequest(params) {
@@ -1055,19 +1337,106 @@ export class ClaudeCliClient {
1055
1337
  logger: this.logger,
1056
1338
  agentId: this.resolveSessionStoreAgentId()?.trim() || undefined,
1057
1339
  channelId: state?.channelId,
1340
+ sessionBound: state !== undefined,
1058
1341
  toolPermissionMode: state?.toolPermissionMode ?? 'default',
1059
1342
  });
1060
1343
  }
1061
1344
  invalidateSession(channelId, state, session) {
1062
1345
  if (state.session === session) {
1063
1346
  state.session = undefined;
1347
+ state.backgroundTasksActive = false;
1348
+ state.backgroundContinuationPending = false;
1349
+ state.foregroundIsolationFence = undefined;
1064
1350
  state.sessionCwd = undefined;
1065
1351
  state.sessionVisibilityKey = undefined;
1066
1352
  }
1067
1353
  this.logger?.debug('discarding tainted Claude ACP session', { channelId });
1068
1354
  this.closeSession(session);
1069
1355
  if (!this.disposing && !this.backendClosed && state.sessionPersistence === 'persistent') {
1070
- void this.clearPersistedSessionBestEffort(channelId);
1356
+ void this.clearPersistedSessionBestEffort(state.channelId);
1357
+ }
1358
+ }
1359
+ promoteWorkerRoute(state, session) {
1360
+ this.channelWorkerRoutes.set(state.channelId, {
1361
+ phase: 'promoted',
1362
+ state,
1363
+ });
1364
+ state.sessionPersistence = 'persistent';
1365
+ void (async () => {
1366
+ if (!await this.ensureSessionStoreLoaded()) {
1367
+ return;
1368
+ }
1369
+ const route = this.channelWorkerRoutes.get(state.channelId);
1370
+ if (route?.state !== state
1371
+ || route.phase !== 'promoted'
1372
+ || state.session !== session
1373
+ || (!state.backgroundTasksActive && !state.persistentFollowUpAdopted)) {
1374
+ return;
1375
+ }
1376
+ await this.persistSessionBestEffort(state.channelId, session.sessionId, state.visibilityKey, state.sessionCwd ?? state.cwd ?? this.runtime.cwd);
1377
+ })().catch((error) => {
1378
+ this.logger.error('failed to initialize Claude session persistence for background work', {
1379
+ agentId: this.loadedSessionStoreAgentId,
1380
+ channelId: state.channelId,
1381
+ sessionId: session.sessionId,
1382
+ error: summarizeError(error),
1383
+ });
1384
+ });
1385
+ }
1386
+ releaseWorkerRoute(state, clearPersistent = false) {
1387
+ const route = this.channelWorkerRoutes.get(state.channelId);
1388
+ if (route?.state !== state) {
1389
+ return;
1390
+ }
1391
+ this.channelWorkerRoutes.delete(state.channelId);
1392
+ if (clearPersistent && state.session) {
1393
+ void this.clearPersistedSessionBestEffort(state.channelId);
1394
+ }
1395
+ }
1396
+ reconcileIdleChannelState(state) {
1397
+ if (this.channels.get(state.routingKey) !== state
1398
+ || this.disposing
1399
+ || this.fatalError
1400
+ || this.backendClosed
1401
+ || state.processing
1402
+ || state.activeTurn
1403
+ || state.queue.length > 0
1404
+ || state.sessionPromise) {
1405
+ return;
1406
+ }
1407
+ const route = this.channelWorkerRoutes.get(state.channelId);
1408
+ if (route?.state === state
1409
+ && route.phase === 'promoted'
1410
+ && !state.backgroundTasksActive
1411
+ && !state.backgroundContinuationPending) {
1412
+ if (state.routingKey === state.channelId) {
1413
+ this.releaseWorkerRoute(state);
1414
+ }
1415
+ else if (!state.persistentFollowUpAdopted) {
1416
+ state.sessionPersistence = 'ephemeral';
1417
+ this.releaseWorkerRoute(state, true);
1418
+ }
1419
+ }
1420
+ else if (route?.state === state
1421
+ && route.phase === 'candidate'
1422
+ && (state.routingKey === state.channelId
1423
+ || state.sessionPersistence === 'ephemeral')) {
1424
+ this.releaseWorkerRoute(state);
1425
+ }
1426
+ if (state.sessionPersistence !== 'ephemeral'
1427
+ || state.backgroundTasksActive
1428
+ || state.backgroundContinuationPending) {
1429
+ return;
1430
+ }
1431
+ const session = state.session;
1432
+ state.session = undefined;
1433
+ state.foregroundIsolationFence = undefined;
1434
+ state.sessionCwd = undefined;
1435
+ state.sessionVisibilityKey = undefined;
1436
+ this.releaseWorkerRoute(state);
1437
+ this.channels.delete(state.routingKey);
1438
+ if (session) {
1439
+ this.closeSession(session);
1071
1440
  }
1072
1441
  }
1073
1442
  closeSession(session) {
@@ -1075,6 +1444,8 @@ export class ClaudeCliClient {
1075
1444
  return;
1076
1445
  }
1077
1446
  this.closingSessions.add(session);
1447
+ this.sessionUpdatePumps.get(session)?.close();
1448
+ this.sessionUpdatePumps.delete(session);
1078
1449
  const agent = this.connection?.agent;
1079
1450
  const closeMethod = this.runtime.methods.agent.session?.close;
1080
1451
  const closePromise = typeof agent?.closeSession === 'function'
@@ -1260,10 +1631,14 @@ export class ClaudeCliClient {
1260
1631
  sessions.push(state.session);
1261
1632
  }
1262
1633
  state.session = undefined;
1634
+ state.backgroundTasksActive = false;
1635
+ state.backgroundContinuationPending = false;
1636
+ state.foregroundIsolationFence = undefined;
1263
1637
  state.sessionCwd = undefined;
1264
1638
  state.sessionVisibilityKey = undefined;
1265
1639
  this.channels.delete(channelId);
1266
1640
  }
1641
+ this.channelWorkerRoutes.clear();
1267
1642
  this.logger?.debug('stopping idle Claude ACP backend', {
1268
1643
  channels: sessions.length,
1269
1644
  idleShutdownMs: this.runtime.idleBackendShutdownMs,
@@ -1275,6 +1650,7 @@ export class ClaudeCliClient {
1275
1650
  this.startPromise = undefined;
1276
1651
  this.childExitPromise = undefined;
1277
1652
  this.sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
1653
+ this.targetedBackgroundRunCancellationAvailable = false;
1278
1654
  this.idleShutdownPromise = undefined;
1279
1655
  });
1280
1656
  this.idleShutdownPromise = idleShutdownPromise;
@@ -1291,7 +1667,12 @@ export class ClaudeCliClient {
1291
1667
  return false;
1292
1668
  }
1293
1669
  for (const state of this.channels.values()) {
1294
- if (state.activeTurn || state.queue.length > 0 || state.sessionPromise || state.processing) {
1670
+ if (state.activeTurn ||
1671
+ state.backgroundTasksActive ||
1672
+ state.backgroundContinuationPending ||
1673
+ state.queue.length > 0 ||
1674
+ state.sessionPromise ||
1675
+ state.processing) {
1295
1676
  return false;
1296
1677
  }
1297
1678
  }