@borgee/agents-host 0.2.28 → 0.2.31

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.
@@ -1,49 +1,31 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { resolve } from 'node:path';
1
+ import { Readable, Writable } from 'node:stream';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, resolve } from 'node:path';
3
4
  import spawn from 'cross-spawn';
5
+ import { assertClaudeCommandCompatibility, DEFAULT_CLAUDE_COMMAND, isLegacyClaudeCompatibilityAlias, } from '../../config.js';
6
+ import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from '@agentclientprotocol/sdk';
4
7
  import { HostLogger, summarizeChildStderr, summarizeError } from '../../debug.js';
8
+ import { resolveCopilotPermissionResponse } from '../../policy/copilot-permission.js';
9
+ const SESSION_TAINTED_ERRORS = new WeakSet();
10
+ const DEFAULT_SHUTDOWN_GRACE_PERIOD_MS = 250;
11
+ const DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS = 250;
12
+ const DEFAULT_SESSION_CAPABILITIES = {
13
+ loadSession: false,
14
+ resumeSession: false,
15
+ };
16
+ const require = createRequire(import.meta.url);
5
17
  const DEFAULT_RUNTIME = {
6
18
  spawn,
19
+ client,
20
+ ndJsonStream,
21
+ methods,
22
+ protocolVersion: PROTOCOL_VERSION,
7
23
  cwd: process.cwd(),
24
+ shutdownGracePeriodMs: DEFAULT_SHUTDOWN_GRACE_PERIOD_MS,
25
+ shutdownForceKillWaitMs: DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS,
8
26
  };
9
- const STREAM_JSON_ARGS = ['--verbose', '--output-format', 'stream-json', '--include-partial-messages'];
10
- class ClaudeCliProcessError extends Error {
11
- exitCode;
12
- signal;
13
- stderrBytes;
14
- stderrLineCount;
15
- staleResume;
16
- constructor(exitCode, signal, stderrBytes, stderrLineCount, staleResume = false) {
17
- super(formatClaudeCliFailureMessage(exitCode, signal, stderrBytes, stderrLineCount));
18
- this.name = 'ClaudeCliProcessError';
19
- if (exitCode !== null) {
20
- this.exitCode = exitCode;
21
- }
22
- if (signal !== null) {
23
- this.signal = signal;
24
- }
25
- this.stderrBytes = stderrBytes;
26
- this.stderrLineCount = stderrLineCount;
27
- this.staleResume = staleResume;
28
- }
29
- }
30
- function formatClaudeCliFailureMessage(exitCode, signal, stderrBytes, stderrLineCount) {
31
- const processOutcome = signal ? `signal ${signal}` : `code ${String(exitCode ?? 'unknown')}`;
32
- const lineLabel = stderrLineCount === 1 ? 'line' : 'lines';
33
- return `Claude CLI failed with ${processOutcome} (stderr: ${stderrBytes} bytes across ${stderrLineCount} ${lineLabel})`;
34
- }
35
- function matchesStaleResumeText(text) {
36
- const normalized = text.toLowerCase();
37
- return normalized.includes('session not found')
38
- || normalized.includes('cannot resume')
39
- || normalized.includes('no conversation found with session id');
40
- }
41
- function isStaleResumeFailure(error) {
42
- if (error instanceof ClaudeCliProcessError) {
43
- return error.staleResume;
44
- }
45
- const message = normalizeError(error).message.toLowerCase();
46
- return matchesStaleResumeText(message);
27
+ function hasVisibleText(value) {
28
+ return typeof value === 'string' && value.trim().length > 0;
47
29
  }
48
30
  function normalizeError(error) {
49
31
  return error instanceof Error ? error : new Error(String(error));
@@ -58,6 +40,9 @@ function parsePersistedSessionRecord(rawValue) {
58
40
  cwd: typeof parsed.cwd === 'string' && parsed.cwd.trim().length > 0
59
41
  ? parsed.cwd
60
42
  : undefined,
43
+ visibilityKey: typeof parsed.visibilityKey === 'string' && parsed.visibilityKey.trim().length > 0
44
+ ? parsed.visibilityKey
45
+ : undefined,
61
46
  };
62
47
  }
63
48
  }
@@ -67,15 +52,117 @@ function serializePersistedSessionRecord(record) {
67
52
  return JSON.stringify({
68
53
  sessionId: record.sessionId,
69
54
  ...(record.cwd ? { cwd: record.cwd } : {}),
55
+ ...(record.visibilityKey ? { visibilityKey: record.visibilityKey } : {}),
70
56
  });
71
57
  }
58
+ function formatClaudeAcpFailureMessage(exitCode, signal, stderrBytes, stderrLineCount) {
59
+ const processOutcome = signal ? `signal ${signal}` : `code ${String(exitCode ?? 'unknown')}`;
60
+ const lineLabel = stderrLineCount === 1 ? 'line' : 'lines';
61
+ return `Claude ACP process failed with ${processOutcome} (stderr: ${stderrBytes} bytes across ${stderrLineCount} ${lineLabel})`;
62
+ }
63
+ class ClaudeCliProcessError extends Error {
64
+ exitCode;
65
+ signal;
66
+ stderrBytes;
67
+ stderrLineCount;
68
+ constructor(exitCode, signal, stderrBytes, stderrLineCount) {
69
+ super(formatClaudeAcpFailureMessage(exitCode, signal, stderrBytes, stderrLineCount));
70
+ this.name = 'ClaudeCliProcessError';
71
+ if (exitCode !== null) {
72
+ this.exitCode = exitCode;
73
+ }
74
+ if (signal !== null) {
75
+ this.signal = signal;
76
+ }
77
+ this.stderrBytes = stderrBytes;
78
+ this.stderrLineCount = stderrLineCount;
79
+ }
80
+ }
81
+ function formatToolProgress(title, status) {
82
+ const normalizedTitle = hasVisibleText(title) ? title.trim() : null;
83
+ switch (status) {
84
+ case 'completed':
85
+ return normalizedTitle ? `Completed ${normalizedTitle}` : 'Completed tool call';
86
+ case 'failed':
87
+ return normalizedTitle ? `Failed ${normalizedTitle}` : 'Tool call failed';
88
+ case 'pending':
89
+ case 'in_progress':
90
+ case undefined:
91
+ case null:
92
+ return normalizedTitle ? `Running ${normalizedTitle}…` : 'Running tool…';
93
+ default:
94
+ return normalizedTitle ? `${status} ${normalizedTitle}` : status;
95
+ }
96
+ }
97
+ function formatPlanProgress(entries) {
98
+ const current = entries.find((entry) => entry.status === 'in_progress') ??
99
+ entries.find((entry) => entry.status === 'pending') ??
100
+ entries[0];
101
+ return hasVisibleText(current?.content) ? `Plan: ${current.content.trim()}` : null;
102
+ }
103
+ class ClaudeProgressCollector {
104
+ onProgress;
105
+ publicText = '';
106
+ lastPublished = null;
107
+ toolTitles = new Map();
108
+ constructor(onProgress) {
109
+ this.onProgress = onProgress;
110
+ }
111
+ consume(update) {
112
+ switch (update.update.sessionUpdate) {
113
+ case 'agent_message_chunk':
114
+ if (update.update.content.type !== 'text') {
115
+ return;
116
+ }
117
+ this.publicText += update.update.content.text;
118
+ this.publish(this.publicText);
119
+ return;
120
+ case 'tool_call':
121
+ this.toolTitles.set(update.update.toolCallId, update.update.title);
122
+ this.publishFallback(formatToolProgress(update.update.title, update.update.status));
123
+ return;
124
+ case 'tool_call_update': {
125
+ const nextTitle = update.update.title ?? this.toolTitles.get(update.update.toolCallId);
126
+ if (hasVisibleText(nextTitle)) {
127
+ this.toolTitles.set(update.update.toolCallId, nextTitle);
128
+ }
129
+ this.publishFallback(formatToolProgress(nextTitle, update.update.status));
130
+ return;
131
+ }
132
+ case 'plan':
133
+ this.publishFallback(formatPlanProgress(update.update.entries.map((entry) => ({
134
+ content: entry.content,
135
+ status: entry.status,
136
+ }))));
137
+ return;
138
+ default:
139
+ return;
140
+ }
141
+ }
142
+ getFinalText() {
143
+ return this.publicText.trim();
144
+ }
145
+ publishFallback(text) {
146
+ if (hasVisibleText(this.publicText)) {
147
+ return;
148
+ }
149
+ this.publish(text);
150
+ }
151
+ publish(text) {
152
+ if (!this.onProgress || !hasVisibleText(text) || text === this.lastPublished) {
153
+ return;
154
+ }
155
+ this.lastPublished = text;
156
+ this.onProgress({ text });
157
+ }
158
+ }
72
159
  function createDeferredTurn(channelId, prompt, sessionPersistence, promptContext, options) {
73
160
  let settled = false;
74
161
  let resolvePromise;
75
162
  let rejectPromise;
76
- const promise = new Promise((resolve, reject) => {
77
- resolvePromise = resolve;
78
- rejectPromise = reject;
163
+ const promise = new Promise((resolvePromiseParam, rejectPromiseParam) => {
164
+ resolvePromise = resolvePromiseParam;
165
+ rejectPromise = rejectPromiseParam;
79
166
  });
80
167
  return {
81
168
  channelId,
@@ -98,145 +185,171 @@ function createDeferredTurn(channelId, prompt, sessionPersistence, promptContext
98
185
  },
99
186
  };
100
187
  }
188
+ function resolveSessionRouting(turn) {
189
+ return {
190
+ channelId: turn.channelId,
191
+ key: turn.providerSessionRouting?.key ?? turn.channelId,
192
+ persistence: turn.providerSessionRouting?.persistence ?? 'persistent',
193
+ };
194
+ }
101
195
  function asObject(value) {
102
196
  return typeof value === 'object' && value !== null ? value : null;
103
197
  }
104
- function asString(value) {
105
- return typeof value === 'string' ? value : undefined;
198
+ function readSessionCapabilities(response) {
199
+ const agentCapabilities = asObject(asObject(response)?.agentCapabilities);
200
+ const sessionCapabilities = asObject(agentCapabilities?.sessionCapabilities ?? agentCapabilities?.session);
201
+ return {
202
+ loadSession: agentCapabilities?.loadSession === true,
203
+ resumeSession: asObject(sessionCapabilities?.resume) !== null,
204
+ };
106
205
  }
107
- function hasVisibleText(value) {
108
- return typeof value === 'string' && value.trim().length > 0;
206
+ function isStaleRestoreFailure(error) {
207
+ const normalized = normalizeError(error);
208
+ const code = asObject(error)?.code;
209
+ const dataCode = asObject(asObject(error)?.data)?.code;
210
+ if (code === 'session_not_found' || dataCode === 'session_not_found') {
211
+ return true;
212
+ }
213
+ const message = normalized.message.toLowerCase();
214
+ return message.includes('session not found')
215
+ || message.includes('unknown session')
216
+ || message.includes('cannot resume')
217
+ || message.includes('no conversation found with session id')
218
+ || message.includes('not found');
219
+ }
220
+ function markSessionTainted(error) {
221
+ const normalized = normalizeError(error);
222
+ SESSION_TAINTED_ERRORS.add(normalized);
223
+ return normalized;
224
+ }
225
+ function isSessionTainted(error) {
226
+ return error instanceof Error && SESSION_TAINTED_ERRORS.has(error);
227
+ }
228
+ function resolveBundledClaudeAdapterPath() {
229
+ return require.resolve('@agentclientprotocol/claude-agent-acp/dist/index.js');
109
230
  }
110
231
  function isRelativePathLike(value) {
111
232
  return value.startsWith('./') || value.startsWith('../');
112
233
  }
234
+ function useBundledClaudeAdapter(command, args) {
235
+ return command === DEFAULT_CLAUDE_COMMAND;
236
+ }
237
+ function useLegacyClaudeCompatibilityAlias(command, args) {
238
+ return isLegacyClaudeCompatibilityAlias(command, args);
239
+ }
113
240
  function resolveClaudeLaunch(command, args, baseCwd) {
114
- const resolvedCommand = isRelativePathLike(command) ? resolve(baseCwd, command) : command;
115
- const resolvedArgs = args.map((arg) => (isRelativePathLike(arg) ? resolve(baseCwd, arg) : arg));
241
+ if (useBundledClaudeAdapter(command, args)) {
242
+ return {
243
+ command: process.execPath,
244
+ args: [resolveBundledClaudeAdapterPath(), ...args],
245
+ };
246
+ }
247
+ if (useLegacyClaudeCompatibilityAlias(command, args)) {
248
+ return {
249
+ command: process.execPath,
250
+ args: [resolveBundledClaudeAdapterPath()],
251
+ };
252
+ }
116
253
  return {
117
- command: resolvedCommand,
118
- args: resolvedArgs,
254
+ command: isRelativePathLike(command) ? resolve(baseCwd, command) : command,
255
+ args: args.map((arg) => (isRelativePathLike(arg) ? resolve(baseCwd, arg) : arg)),
119
256
  };
120
257
  }
121
- function readTextBlocks(blocks) {
122
- if (!Array.isArray(blocks)) {
123
- return [];
258
+ function buildClaudeSessionSystemPrompt(promptContext) {
259
+ if (!promptContext) {
260
+ return undefined;
124
261
  }
125
- const segments = [];
126
- for (const entry of blocks) {
127
- const block = asObject(entry);
128
- if (!block)
129
- continue;
130
- if (block.type === 'text' && hasVisibleText(asString(block.text))) {
131
- segments.push(asString(block.text));
132
- }
262
+ const lines = [];
263
+ if (promptContext.channelContextPayloadPath) {
264
+ lines.push('Channel-scoped local context is available for this session.');
265
+ lines.push(`Channel context payload: ${promptContext.channelContextPayloadPath}`);
133
266
  }
134
- return segments;
135
- }
136
- function extractTextDelta(event) {
137
- if (event.type === 'content_block_delta') {
138
- const delta = asObject(event.delta);
139
- if (!delta)
140
- return null;
141
- const deltaType = asString(delta.type);
142
- if (deltaType !== undefined && deltaType !== 'text_delta') {
143
- return null;
144
- }
145
- return asString(delta.text) ?? null;
267
+ if (promptContext.skillRuntime) {
268
+ lines.push(`Skill guide: ${promptContext.skillRuntime.skillMarkdownPath}`);
269
+ lines.push(`Node bootstrap CLI: node ${promptContext.skillRuntime.nodeCliPath} --context ${promptContext.channelContextPayloadPath} --print-bootstrap`);
270
+ lines.push(`Python bootstrap CLI: python3 ${promptContext.skillRuntime.pythonCliPath} --context ${promptContext.channelContextPayloadPath} --print-bootstrap`);
146
271
  }
147
- if (event.type === 'content_block_start') {
148
- const block = asObject(event.content_block) ?? asObject(event.block);
149
- if (!block || block.type !== 'text') {
150
- return null;
272
+ if (promptContext.localhostGateway && promptContext.gatewayAuthPath) {
273
+ lines.push(`Gateway auth sidecar for this session: ${promptContext.gatewayAuthPath}`);
274
+ if (promptContext.localhostGateway.baseUrl) {
275
+ lines.push(`Loopback gateway base URL: ${promptContext.localhostGateway.baseUrl}`);
151
276
  }
152
- return asString(block.text) ?? null;
153
277
  }
154
- return null;
155
- }
156
- function extractToolSummary(event) {
157
- if (event.type === 'tool_use') {
158
- const toolName = asString(event.name) ?? asString(event.tool_name);
159
- return hasVisibleText(toolName) ? `Running ${toolName}…` : 'Running tool…';
278
+ if (promptContext.taskWorkspace) {
279
+ lines.push(`Task-scoped writable workspace root: ${promptContext.taskWorkspace.rootPath}.`);
280
+ lines.push('This writable workspace is local to agents-host for the current task thread and does not imply that the target repository is already checked out there.');
160
281
  }
161
- if (event.type === 'content_block_start') {
162
- const block = asObject(event.content_block) ?? asObject(event.block);
163
- if (!block || block.type !== 'tool_use') {
164
- return null;
165
- }
166
- const toolName = asString(block.name);
167
- return hasVisibleText(toolName) ? `Running ${toolName}…` : 'Running tool…';
282
+ if (promptContext.taskAssignmentContext?.currentTaskId) {
283
+ lines.push(`Current assigned task id: ${promptContext.taskAssignmentContext.currentTaskId}.`);
168
284
  }
169
- return null;
285
+ return lines.length > 0 ? lines.join('\n') : undefined;
170
286
  }
171
- function extractFinalText(event) {
172
- if (event.type === 'result') {
173
- const result = asString(event.result);
174
- if (result !== undefined) {
175
- return result;
176
- }
177
- }
178
- if (event.type === 'message' || event.type === 'assistant') {
179
- const message = asObject(event.message) ?? event;
180
- const joined = readTextBlocks(message.content).join('');
181
- if (joined.length > 0) {
182
- return joined;
183
- }
287
+ function buildClaudeSessionMeta(promptContext) {
288
+ const systemPromptAppend = buildClaudeSessionSystemPrompt(promptContext);
289
+ if (!systemPromptAppend) {
290
+ return undefined;
184
291
  }
185
- return null;
292
+ return {
293
+ systemPrompt: {
294
+ append: systemPromptAppend,
295
+ },
296
+ };
186
297
  }
187
- class ClaudeStreamCollector {
188
- onProgress;
189
- publicText = '';
190
- finalText = null;
191
- lastPublished = null;
192
- constructor(onProgress) {
193
- this.onProgress = onProgress;
298
+ function resolveSessionAdditionalDirectories(promptContext) {
299
+ const directories = new Set();
300
+ if (promptContext?.channelContextPayloadPath) {
301
+ directories.add(dirname(promptContext.channelContextPayloadPath));
194
302
  }
195
- consume(event) {
196
- const payload = event.type === 'stream_event' ? asObject(event.event) ?? event : event;
197
- const textDelta = extractTextDelta(payload);
198
- if (textDelta !== null) {
199
- this.publicText += textDelta;
200
- this.publish(this.publicText);
201
- }
202
- if (!hasVisibleText(this.publicText)) {
203
- const toolSummary = extractToolSummary(payload);
204
- if (toolSummary) {
205
- this.publish(toolSummary);
206
- }
207
- }
208
- const finalText = extractFinalText(event) ?? extractFinalText(payload);
209
- if (finalText !== null) {
210
- this.finalText = finalText;
211
- }
303
+ if (promptContext?.gatewayAuthPath) {
304
+ directories.add(dirname(promptContext.gatewayAuthPath));
212
305
  }
213
- getFinalText() {
214
- const text = this.finalText ?? this.publicText;
215
- return hasVisibleText(text) ? text : '';
306
+ if (promptContext?.skillRuntime?.skillDirectoryPath) {
307
+ directories.add(promptContext.skillRuntime.skillDirectoryPath);
216
308
  }
217
- publish(text) {
218
- if (!this.onProgress || !hasVisibleText(text) || text === this.lastPublished) {
219
- return;
220
- }
221
- this.lastPublished = text;
222
- this.onProgress({ text });
309
+ return directories.size > 0 ? [...directories].sort((left, right) => left.localeCompare(right)) : undefined;
310
+ }
311
+ function resolveSessionVisibilityKey(promptContext) {
312
+ const additionalDirectories = resolveSessionAdditionalDirectories(promptContext);
313
+ if (!additionalDirectories) {
314
+ return undefined;
223
315
  }
316
+ return JSON.stringify({ additionalDirectories });
224
317
  }
225
- function resolveSessionRouting(turn) {
318
+ function buildSessionRequest(cwd, promptContext, additionalDirectories) {
319
+ const meta = buildClaudeSessionMeta(promptContext);
226
320
  return {
227
- channelId: turn.channelId,
228
- key: turn.providerSessionRouting?.key ?? turn.channelId,
229
- persistence: turn.providerSessionRouting?.persistence ?? 'persistent',
321
+ cwd,
322
+ mcpServers: [],
323
+ ...(additionalDirectories && additionalDirectories.length > 0
324
+ ? { additionalDirectories }
325
+ : {}),
326
+ ...(meta ? { _meta: meta } : {}),
230
327
  };
231
328
  }
329
+ function shouldReusePersistedSession(record, cwd, runtimeCwd, visibilityKey) {
330
+ if (record.cwd) {
331
+ if (record.cwd !== cwd) {
332
+ return false;
333
+ }
334
+ }
335
+ else if (cwd !== runtimeCwd) {
336
+ return false;
337
+ }
338
+ if (visibilityKey !== undefined) {
339
+ return record.visibilityKey === visibilityKey;
340
+ }
341
+ if (record.visibilityKey !== undefined) {
342
+ return false;
343
+ }
344
+ return true;
345
+ }
232
346
  /**
233
- * CLI client for the Claude Code CLI (`claude`) non-interactive mode, with
234
- * native provider-session continuity.
347
+ * Persistent ACP-backed client for the Claude ACP adapter.
235
348
  *
236
- * Claude session memory remains entirely inside the Claude CLI. This client
237
- * only pins one native session id per routed provider session and serializes
238
- * turns per route so `--resume` is never called concurrently for the same
239
- * native session.
349
+ * The public caller surface intentionally stays stable: callers still create a
350
+ * `ClaudeCliClient`, then call `generateReply()` and `dispose()`. Internally,
351
+ * the shipped runtime now runs one Claude ACP adapter process and reuses one
352
+ * ACP session per routed Borgee channel.
240
353
  */
241
354
  export class ClaudeCliClient {
242
355
  command;
@@ -247,21 +360,42 @@ export class ClaudeCliClient {
247
360
  runtime;
248
361
  channels = new Map();
249
362
  persistedSessions = new Map();
363
+ closingSessions = new WeakSet();
364
+ pendingSessionStarts = new Set();
365
+ pendingSessionCloses = new Set();
366
+ pendingSessionStoreOperations = new Set();
367
+ fatalPromise;
368
+ rejectFatalPromise;
369
+ child;
370
+ connection;
371
+ startPromise;
372
+ shutdownPromise;
373
+ childExitPromise;
374
+ resolveChildExit;
375
+ fatalError = null;
376
+ disposing = false;
377
+ backendClosed = false;
250
378
  loadedSessionStoreAgentId = null;
251
- stopped = false;
252
379
  sessionStoreLoadPromise = null;
253
380
  sessionStoreWriteQueue = Promise.resolve();
254
- constructor(command, args, runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger()) {
381
+ sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
382
+ childStderr = '';
383
+ constructor(command, args = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger()) {
255
384
  this.command = command;
256
385
  this.args = args;
257
386
  this.sessionStore = sessionStore;
258
387
  this.resolveSessionStoreAgentId = resolveSessionStoreAgentId;
259
388
  this.logger = logger;
260
389
  this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
390
+ assertClaudeCommandCompatibility(command, args, 'Claude provider runtime');
391
+ this.fatalPromise = new Promise((_, reject) => {
392
+ this.rejectFatalPromise = reject;
393
+ });
394
+ void this.fatalPromise.catch(() => { });
261
395
  }
262
396
  async generateReply(channelIdOrTurn, promptOrOptions, maybeOptions) {
263
- if (this.stopped) {
264
- throw new Error('Claude CLI backend stopped');
397
+ if (this.fatalError) {
398
+ throw this.fatalError;
265
399
  }
266
400
  const preparedTurn = typeof channelIdOrTurn === 'string'
267
401
  ? {
@@ -273,42 +407,145 @@ export class ClaudeCliClient {
273
407
  ? maybeOptions
274
408
  : promptOrOptions;
275
409
  const sessionRouting = resolveSessionRouting(preparedTurn);
276
- const state = this.getOrCreateChannelState(sessionRouting.key, sessionRouting.persistence);
277
- const pendingTurn = createDeferredTurn(preparedTurn.channelId, preparedTurn.prompt, sessionRouting.persistence, preparedTurn.promptContext, options);
278
- state.queue.push(pendingTurn);
410
+ const state = this.getOrCreateChannelState(sessionRouting.key, preparedTurn.channelId, sessionRouting.persistence);
411
+ const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn.prompt, sessionRouting.persistence, preparedTurn.promptContext, options);
412
+ state.queue.push(turn);
279
413
  this.processChannelQueue(sessionRouting.key, state);
280
- return pendingTurn.promise;
414
+ return turn.promise;
281
415
  }
282
416
  async dispose() {
283
- if (this.stopped)
284
- return;
285
- this.stopped = true;
286
- const stoppedError = new Error('Claude CLI backend stopped');
287
- for (const state of this.channels.values()) {
288
- state.activeTurn?.reject(stoppedError);
289
- for (const queuedTurn of state.queue) {
290
- queuedTurn.reject(stoppedError);
417
+ const error = new Error('Claude ACP backend stopped');
418
+ this.disposing = true;
419
+ this.logger?.debug('stopping Claude ACP backend');
420
+ const closed = this.connection?.closed ?? Promise.resolve();
421
+ this.failAll(error);
422
+ let thrown;
423
+ try {
424
+ await Promise.all([closed, this.shutdownPromise ?? Promise.resolve()]);
425
+ }
426
+ catch (disposeError) {
427
+ thrown = disposeError;
428
+ }
429
+ try {
430
+ await Promise.allSettled(this.pendingSessionStoreOperations);
431
+ await this.sessionStoreWriteQueue;
432
+ await this.sessionStore?.close?.();
433
+ }
434
+ catch (closeError) {
435
+ thrown ??= closeError;
436
+ }
437
+ if (thrown) {
438
+ throw thrown;
439
+ }
440
+ }
441
+ async ensureStarted() {
442
+ if (this.fatalError) {
443
+ throw this.fatalError;
444
+ }
445
+ if (!this.startPromise) {
446
+ this.startPromise = this.startBackend();
447
+ }
448
+ await this.startPromise;
449
+ if (this.fatalError) {
450
+ throw this.fatalError;
451
+ }
452
+ if (!this.connection) {
453
+ throw new Error('Claude ACP connection is not available');
454
+ }
455
+ }
456
+ async startBackend() {
457
+ const launch = resolveClaudeLaunch(this.command, this.args, this.runtime.cwd);
458
+ this.childStderr = '';
459
+ this.logger?.debug('starting Claude ACP process', {
460
+ command: launch.command,
461
+ args: launch.args,
462
+ cwd: this.runtime.cwd,
463
+ });
464
+ const child = this.runtime.spawn(launch.command, launch.args, {
465
+ stdio: ['pipe', 'pipe', 'pipe'],
466
+ cwd: this.runtime.cwd,
467
+ });
468
+ this.child = child;
469
+ child.stderr.setEncoding('utf8');
470
+ child.stderr.on('data', (chunk) => {
471
+ this.childStderr += chunk;
472
+ this.logger?.childStderr('claude stderr', summarizeChildStderr(chunk));
473
+ });
474
+ child.stderr.resume();
475
+ this.logger?.debug('spawned Claude ACP process', { pid: child.pid });
476
+ child.once('error', (error) => {
477
+ this.logger?.debugError('Claude ACP process error', error);
478
+ this.failAll(new Error(`Claude ACP process error: ${normalizeError(error).message}`));
479
+ });
480
+ child.once('exit', (code, signal) => {
481
+ this.resolveChildExit?.();
482
+ if (this.disposing) {
483
+ this.logger?.debug('Claude ACP process exited during shutdown', { code, signal });
484
+ return;
291
485
  }
292
- state.queue = [];
293
- state.activeChild?.kill('SIGTERM');
486
+ const stderrSummary = summarizeChildStderr(this.childStderr);
487
+ const failure = new ClaudeCliProcessError(code, signal, stderrSummary.bytes, stderrSummary.lineCount);
488
+ this.logger?.debugError('Claude ACP process exited unexpectedly', {
489
+ code,
490
+ signal,
491
+ stderrBytes: stderrSummary.bytes,
492
+ stderrLineCount: stderrSummary.lineCount,
493
+ });
494
+ this.failAll(failure);
495
+ });
496
+ this.childExitPromise = new Promise((resolveChildExit) => {
497
+ this.resolveChildExit = resolveChildExit;
498
+ });
499
+ const output = Writable.toWeb(child.stdin);
500
+ const input = Readable.toWeb(child.stdout);
501
+ const stream = this.runtime.ndJsonStream(output, input);
502
+ const app = this.runtime
503
+ .client({ name: 'borgee-agents-host' })
504
+ .onRequest(this.runtime.methods.client.session.requestPermission, ({ params }) => (this.handlePermissionRequest(params)));
505
+ const connection = app.connect(stream);
506
+ this.connection = connection;
507
+ void connection.closed.then(() => {
508
+ if (!this.disposing) {
509
+ this.logger?.debugError('Claude ACP connection closed unexpectedly');
510
+ this.failAll(new Error('Claude ACP connection closed unexpectedly'));
511
+ }
512
+ });
513
+ try {
514
+ const initializeResponse = await connection.agent.request(this.runtime.methods.agent.initialize, {
515
+ protocolVersion: this.runtime.protocolVersion,
516
+ clientCapabilities: {},
517
+ clientInfo: {
518
+ name: '@borgee/agents-host',
519
+ version: '0.2.29',
520
+ },
521
+ });
522
+ this.sessionCapabilities = readSessionCapabilities(initializeResponse);
523
+ this.logger?.debug('initialized Claude ACP connection', {
524
+ loadSession: this.sessionCapabilities.loadSession,
525
+ resumeSession: this.sessionCapabilities.resumeSession,
526
+ });
527
+ }
528
+ catch (error) {
529
+ const normalized = new Error(`Claude ACP initialize failed: ${normalizeError(error).message}`);
530
+ this.logger?.debugError('Claude ACP initialize failed', normalized);
531
+ this.failAll(normalized);
532
+ throw normalized;
294
533
  }
295
- await this.sessionStoreWriteQueue;
296
- await this.sessionStore?.close?.();
297
534
  }
298
- getOrCreateChannelState(channelId, sessionPersistence) {
535
+ getOrCreateChannelState(channelId, resolvedChannelId, sessionPersistence) {
299
536
  let state = this.channels.get(channelId);
300
537
  if (!state) {
301
538
  state = {
539
+ channelId: resolvedChannelId,
302
540
  sessionPersistence,
303
541
  processing: false,
304
542
  queue: [],
305
- sessionHydrated: false,
306
- sessionEstablished: false,
307
543
  };
308
544
  this.channels.set(channelId, state);
309
545
  }
310
- else if (state.sessionPersistence !== sessionPersistence) {
311
- throw new Error(`Claude session routing persistence changed for key "${channelId}"`);
546
+ else if (state.channelId !== resolvedChannelId
547
+ || state.sessionPersistence !== sessionPersistence) {
548
+ throw new Error(`Claude session routing changed for key "${channelId}"`);
312
549
  }
313
550
  return state;
314
551
  }
@@ -319,28 +556,30 @@ export class ClaudeCliClient {
319
556
  state.processing = true;
320
557
  void (async () => {
321
558
  try {
322
- while (!this.stopped) {
323
- const queuedTurn = state.queue[0];
324
- if (!queuedTurn) {
325
- return;
326
- }
327
- try {
328
- if (state.sessionPersistence === 'persistent' && await this.ensureSessionStoreLoaded()) {
329
- this.hydrateChannelState(channelId, state);
330
- }
331
- }
332
- catch (error) {
333
- state.queue.shift()?.reject(error);
334
- continue;
335
- }
559
+ while (!this.fatalError) {
336
560
  const turn = state.queue.shift();
337
561
  if (!turn) {
338
562
  return;
339
563
  }
340
564
  state.activeTurn = turn;
341
565
  try {
342
- const text = await this.runTurn(channelId, state, turn);
343
- turn.resolve(text);
566
+ state.cwd = this.resolveSessionCwd(turn.promptContext);
567
+ state.visibilityKey = resolveSessionVisibilityKey(turn.promptContext);
568
+ await this.ensureStarted();
569
+ await this.recycleSessionIfScopeChanged(channelId, state);
570
+ const session = await this.getOrCreateSession(channelId, state, turn.promptContext);
571
+ let reply;
572
+ try {
573
+ reply = await this.runTurn(session, turn.prompt, turn.options);
574
+ }
575
+ catch (error) {
576
+ if (isSessionTainted(error)) {
577
+ this.invalidateSession(channelId, state, session);
578
+ this.rejectQueuedTurnsAfterSessionTaint(state, error);
579
+ }
580
+ throw error;
581
+ }
582
+ turn.resolve(reply);
344
583
  }
345
584
  catch (error) {
346
585
  turn.reject(error);
@@ -352,54 +591,309 @@ export class ClaudeCliClient {
352
591
  }
353
592
  finally {
354
593
  state.processing = false;
355
- if (state.sessionPersistence === 'ephemeral' && !this.stopped && state.queue.length === 0) {
356
- state.sessionEstablished = false;
357
- state.sessionHydrated = false;
358
- state.sessionId = undefined;
594
+ if (state.sessionPersistence === 'ephemeral' && !this.fatalError && !this.disposing && state.queue.length === 0) {
595
+ const session = state.session;
596
+ state.session = undefined;
359
597
  state.sessionCwd = undefined;
598
+ state.sessionVisibilityKey = undefined;
599
+ if (session) {
600
+ this.closeSession(session);
601
+ }
360
602
  this.channels.delete(channelId);
361
603
  }
362
- if (state.queue.length > 0 && !this.stopped) {
604
+ if (state.queue.length > 0 && !this.fatalError) {
363
605
  this.processChannelQueue(channelId, state);
364
606
  }
365
607
  }
366
608
  })();
367
609
  }
368
- async runTurn(channelId, state, turn) {
369
- await this.resetSessionIfCwdChanged(channelId, state, turn);
370
- return this.runTurnAttempt(channelId, state, turn, true);
610
+ async getOrCreateSession(channelId, state, promptContext) {
611
+ if (state.session) {
612
+ return state.session;
613
+ }
614
+ if (state.sessionPromise) {
615
+ return state.sessionPromise;
616
+ }
617
+ if (!this.connection) {
618
+ throw new Error('Claude ACP connection is not available');
619
+ }
620
+ const cwd = state.cwd ?? this.runtime.cwd;
621
+ const persistedSessionId = state.sessionPersistence === 'persistent'
622
+ ? await this.readPersistedSessionId(channelId, cwd, state.visibilityKey)
623
+ : undefined;
624
+ const additionalDirectories = resolveSessionAdditionalDirectories(promptContext);
625
+ const sessionPromise = persistedSessionId
626
+ ? this.restoreOrCreateSession(channelId, persistedSessionId, cwd, promptContext, additionalDirectories)
627
+ : this.startFreshSession(channelId, cwd, promptContext, additionalDirectories);
628
+ this.pendingSessionStarts.add(sessionPromise);
629
+ state.sessionPromise = sessionPromise;
630
+ let sessionAdopted = false;
631
+ void sessionPromise
632
+ .then((session) => {
633
+ if (!sessionAdopted &&
634
+ (this.fatalError !== null || this.disposing || state.sessionPromise !== sessionPromise)) {
635
+ this.closeSession(session);
636
+ }
637
+ })
638
+ .finally(() => {
639
+ this.pendingSessionStarts.delete(sessionPromise);
640
+ })
641
+ .catch(() => { });
642
+ try {
643
+ const session = await this.raceWithFatal(sessionPromise);
644
+ if (this.fatalError) {
645
+ this.closeSession(session);
646
+ throw this.fatalError;
647
+ }
648
+ sessionAdopted = true;
649
+ state.session = session;
650
+ state.sessionCwd = cwd;
651
+ state.sessionVisibilityKey = state.visibilityKey;
652
+ return session;
653
+ }
654
+ catch (error) {
655
+ state.session = undefined;
656
+ state.sessionCwd = undefined;
657
+ state.sessionVisibilityKey = undefined;
658
+ throw error;
659
+ }
660
+ finally {
661
+ if (state.sessionPromise === sessionPromise) {
662
+ state.sessionPromise = undefined;
663
+ }
664
+ }
665
+ }
666
+ async startFreshSession(channelId, cwd, promptContext, additionalDirectories) {
667
+ if (!this.connection) {
668
+ throw new Error('Claude ACP connection is not available');
669
+ }
670
+ const session = await this.connection.agent.buildSession(buildSessionRequest(cwd, promptContext, additionalDirectories)).start();
671
+ this.logger?.debug('started fresh Claude ACP session', { channelId, cwd });
672
+ const state = this.channels.get(channelId);
673
+ if (state?.sessionPersistence === 'persistent') {
674
+ await this.persistSessionBestEffort(channelId, session.sessionId, state.visibilityKey, cwd);
675
+ }
676
+ return session;
677
+ }
678
+ async restoreOrCreateSession(channelId, sessionId, cwd, promptContext, additionalDirectories) {
679
+ if (!this.sessionCapabilities.resumeSession) {
680
+ this.logger?.debug('Claude ACP restore unsupported; starting fresh session', { channelId });
681
+ return this.startFreshSession(channelId, cwd, promptContext, additionalDirectories);
682
+ }
683
+ try {
684
+ const session = await this.restoreSession(sessionId, cwd, promptContext, additionalDirectories);
685
+ this.logger?.debug('restored Claude ACP session', {
686
+ channelId,
687
+ restoreMethod: this.sessionCapabilities.resumeSession ? 'session/resume' : 'session/load',
688
+ });
689
+ return session;
690
+ }
691
+ catch (error) {
692
+ if (!isStaleRestoreFailure(error)) {
693
+ throw normalizeError(error);
694
+ }
695
+ this.logger?.debug('discarded stale Claude ACP session and started fresh', { channelId });
696
+ await this.clearPersistedSessionBestEffort(channelId);
697
+ return this.startFreshSession(channelId, cwd, promptContext, additionalDirectories);
698
+ }
371
699
  }
372
- async runTurnAttempt(channelId, state, turn, allowFreshRetryAfterStaleResume) {
373
- if (this.stopped) {
374
- throw new Error('Claude CLI backend stopped');
700
+ async restoreSession(sessionId, cwd, promptContext, additionalDirectories) {
701
+ if (!this.connection) {
702
+ throw new Error('Claude ACP connection is not available');
703
+ }
704
+ const agent = this.connection.agent;
705
+ if (typeof agent.attachSession !== 'function') {
706
+ throw new Error('Claude ACP SDK does not expose session attachment helpers');
375
707
  }
376
- const sessionId = state.sessionEstablished ? state.sessionId : randomUUID();
377
- const sessionArgs = state.sessionEstablished ? ['--resume', sessionId] : ['--session-id', sessionId];
708
+ const session = agent.attachSession({ sessionId });
378
709
  try {
379
- const text = await this.run(state, [...this.args, ...sessionArgs, ...STREAM_JSON_ARGS], turn.prompt, turn.promptContext, turn.options);
380
- state.sessionId = sessionId;
381
- state.sessionEstablished = true;
382
- state.sessionCwd = this.resolveTurnCwd(turn);
383
- if (turn.sessionPersistence === 'persistent') {
384
- await this.persistSessionBestEffort(channelId, sessionId, state.sessionCwd);
710
+ const request = {
711
+ sessionId,
712
+ ...buildSessionRequest(cwd, promptContext, additionalDirectories),
713
+ };
714
+ if (this.sessionCapabilities.resumeSession) {
715
+ await this.connection.agent.request(this.runtime.methods.agent.session.resume, request);
716
+ return session;
717
+ }
718
+ if (this.sessionCapabilities.loadSession) {
719
+ await this.connection.agent.request(this.runtime.methods.agent.session.load, request);
720
+ this.clearBufferedSessionReplay(session);
721
+ return session;
385
722
  }
386
- return text;
723
+ throw new Error('Claude ACP agent does not advertise session restore capabilities');
387
724
  }
388
725
  catch (error) {
389
- if (state.sessionEstablished && isStaleResumeFailure(error)) {
390
- if (turn.sessionPersistence === 'persistent') {
391
- await this.resetPersistedSessionBestEffort(channelId, state);
726
+ session.dispose();
727
+ throw normalizeError(error);
728
+ }
729
+ }
730
+ clearBufferedSessionReplay(session) {
731
+ const updates = session.updates;
732
+ if (updates && Array.isArray(updates.values)) {
733
+ updates.values = [];
734
+ }
735
+ }
736
+ resolveSessionCwd(promptContext) {
737
+ return promptContext?.taskWorkspace?.rootPath ?? this.runtime.cwd;
738
+ }
739
+ async recycleSessionIfScopeChanged(channelId, state) {
740
+ const session = state.session;
741
+ if (!session) {
742
+ return;
743
+ }
744
+ const nextCwd = state.cwd ?? this.runtime.cwd;
745
+ if (state.sessionCwd === nextCwd
746
+ && state.sessionVisibilityKey === state.visibilityKey) {
747
+ return;
748
+ }
749
+ this.logger?.debug('recycling Claude ACP session after session scope changed', {
750
+ channelId,
751
+ previousCwd: state.sessionCwd,
752
+ nextCwd,
753
+ previousVisibilityKey: state.sessionVisibilityKey,
754
+ nextVisibilityKey: state.visibilityKey,
755
+ });
756
+ state.session = undefined;
757
+ state.sessionCwd = undefined;
758
+ state.sessionVisibilityKey = undefined;
759
+ if (!this.disposing && !this.backendClosed && state.sessionPersistence === 'persistent') {
760
+ await this.clearPersistedSessionBestEffort(channelId);
761
+ }
762
+ this.closeSession(session);
763
+ }
764
+ async runTurn(session, prompt, options) {
765
+ const promptPromise = this.raceWithFatal(session.prompt(prompt));
766
+ const promptFailure = new Promise((_, reject) => {
767
+ void promptPromise.catch((error) => reject(markSessionTainted(error)));
768
+ });
769
+ const collector = new ClaudeProgressCollector(options?.onProgress);
770
+ for (;;) {
771
+ let update;
772
+ try {
773
+ update = await this.raceWithFatal(Promise.race([session.nextUpdate(), promptFailure]));
774
+ }
775
+ catch (error) {
776
+ if (this.fatalError && error === this.fatalError) {
777
+ throw error;
392
778
  }
393
- else {
394
- state.sessionEstablished = false;
395
- state.sessionId = undefined;
779
+ throw markSessionTainted(error);
780
+ }
781
+ if (update.kind === 'stop') {
782
+ let response;
783
+ try {
784
+ response = await promptPromise;
396
785
  }
397
- if (allowFreshRetryAfterStaleResume) {
398
- return this.runTurnAttempt(channelId, state, turn, false);
786
+ catch (error) {
787
+ throw markSessionTainted(error);
399
788
  }
789
+ const output = collector.getFinalText();
790
+ if (response.stopReason !== 'end_turn' && !hasVisibleText(output)) {
791
+ throw new Error(`Claude ACP turn stopped with stopReason "${response.stopReason}"`);
792
+ }
793
+ return output;
794
+ }
795
+ collector.consume(update);
796
+ }
797
+ }
798
+ async raceWithFatal(promise) {
799
+ if (this.fatalError) {
800
+ throw this.fatalError;
801
+ }
802
+ return Promise.race([promise, this.fatalPromise]);
803
+ }
804
+ failAll(error) {
805
+ if (this.fatalError) {
806
+ return;
807
+ }
808
+ this.fatalError = error;
809
+ this.rejectFatalPromise(error);
810
+ for (const [channelId, state] of this.channels.entries()) {
811
+ state.activeTurn?.reject(error);
812
+ for (const turn of state.queue) {
813
+ turn.reject(error);
814
+ }
815
+ state.queue.length = 0;
816
+ if (state.session) {
817
+ this.closeSession(state.session);
818
+ }
819
+ state.session = undefined;
820
+ state.sessionCwd = undefined;
821
+ state.sessionVisibilityKey = undefined;
822
+ state.sessionPromise = undefined;
823
+ state.activeTurn = undefined;
824
+ if (!state.processing) {
825
+ this.channels.delete(channelId);
400
826
  }
401
- throw error;
402
827
  }
828
+ this.channels.clear();
829
+ this.shutdownPromise = this.shutdownBackend(error);
830
+ }
831
+ handlePermissionRequest(params) {
832
+ return resolveCopilotPermissionResponse({
833
+ gateEnabled: true,
834
+ policyMode: 'enforce',
835
+ params,
836
+ logger: this.logger,
837
+ agentId: this.resolveSessionStoreAgentId()?.trim() || undefined,
838
+ channelId: this.findChannelIdBySessionId(params.sessionId),
839
+ });
840
+ }
841
+ invalidateSession(channelId, state, session) {
842
+ if (state.session === session) {
843
+ state.session = undefined;
844
+ state.sessionCwd = undefined;
845
+ state.sessionVisibilityKey = undefined;
846
+ }
847
+ this.logger?.debug('discarding tainted Claude ACP session', { channelId });
848
+ this.closeSession(session);
849
+ if (!this.disposing && !this.backendClosed && state.sessionPersistence === 'persistent') {
850
+ void this.clearPersistedSessionBestEffort(channelId);
851
+ }
852
+ }
853
+ closeSession(session) {
854
+ if (this.closingSessions.has(session)) {
855
+ return;
856
+ }
857
+ this.closingSessions.add(session);
858
+ const agent = this.connection?.agent;
859
+ const closeMethod = this.runtime.methods.agent.session?.close;
860
+ const closePromise = typeof agent?.closeSession === 'function'
861
+ ? agent.closeSession({ sessionId: session.sessionId })
862
+ : closeMethod
863
+ ? this.connection?.agent.request(closeMethod, { sessionId: session.sessionId })
864
+ : undefined;
865
+ session.dispose();
866
+ if (!closePromise) {
867
+ return;
868
+ }
869
+ let trackedClosePromise;
870
+ trackedClosePromise = Promise.resolve(closePromise)
871
+ .then(() => undefined)
872
+ .catch(() => { })
873
+ .finally(() => {
874
+ this.pendingSessionCloses.delete(trackedClosePromise);
875
+ });
876
+ this.pendingSessionCloses.add(trackedClosePromise);
877
+ }
878
+ rejectQueuedTurnsAfterSessionTaint(state, error) {
879
+ if (state.queue.length === 0) {
880
+ return;
881
+ }
882
+ const rejection = new Error('Claude ACP session was reset after a failed turn; queued turns were dropped instead of replaying them on a fresh session', {
883
+ cause: normalizeError(error),
884
+ });
885
+ for (const turn of state.queue) {
886
+ turn.reject(rejection);
887
+ }
888
+ state.queue.length = 0;
889
+ }
890
+ findChannelIdBySessionId(sessionId) {
891
+ for (const state of this.channels.values()) {
892
+ if (state.session?.sessionId === sessionId) {
893
+ return state.channelId;
894
+ }
895
+ }
896
+ return undefined;
403
897
  }
404
898
  currentSessionStoreAgentId() {
405
899
  const agentId = this.resolveSessionStoreAgentId()?.trim();
@@ -417,9 +911,6 @@ export class ClaudeCliClient {
417
911
  this.loadedSessionStoreAgentId = null;
418
912
  this.sessionStoreLoadPromise = null;
419
913
  this.persistedSessions.clear();
420
- for (const state of this.channels.values()) {
421
- state.sessionHydrated = false;
422
- }
423
914
  }
424
915
  if (!this.sessionStoreLoadPromise) {
425
916
  this.sessionStoreLoadPromise = (async () => {
@@ -440,38 +931,41 @@ export class ClaudeCliClient {
440
931
  await this.sessionStoreLoadPromise;
441
932
  return true;
442
933
  }
443
- hydrateChannelState(channelId, state) {
444
- if (state.sessionHydrated) {
445
- return;
934
+ async readPersistedSessionId(channelId, cwd, visibilityKey) {
935
+ if (!await this.ensureSessionStoreLoaded()) {
936
+ return undefined;
446
937
  }
447
- state.sessionHydrated = true;
448
- const persistedSession = this.persistedSessions.get(channelId);
449
- if (!persistedSession) {
450
- return;
938
+ const record = this.persistedSessions.get(channelId);
939
+ if (!record) {
940
+ return undefined;
451
941
  }
452
- state.sessionId = persistedSession.sessionId;
453
- state.sessionCwd = persistedSession.cwd;
454
- state.sessionEstablished = true;
942
+ if (!shouldReusePersistedSession(record, cwd, this.runtime.cwd, visibilityKey)) {
943
+ await this.clearPersistedSessionBestEffort(channelId);
944
+ return undefined;
945
+ }
946
+ return record.sessionId;
455
947
  }
456
- async persistSession(channelId, sessionId, cwd) {
948
+ async persistSession(channelId, sessionId, visibilityKey, cwd) {
457
949
  if (!this.sessionStore || !this.loadedSessionStoreAgentId) {
458
950
  return;
459
951
  }
460
952
  await this.enqueueSessionStoreWrite(async () => {
461
953
  const current = this.persistedSessions.get(channelId);
462
- if (current?.sessionId === sessionId && current.cwd === cwd) {
954
+ if (current?.sessionId === sessionId
955
+ && current.cwd === cwd
956
+ && current.visibilityKey === visibilityKey) {
463
957
  return;
464
958
  }
465
- this.persistedSessions.set(channelId, { sessionId, cwd });
959
+ this.persistedSessions.set(channelId, { sessionId, cwd, visibilityKey });
466
960
  await this.sessionStore.save(this.loadedSessionStoreAgentId, Object.fromEntries([...this.persistedSessions.entries()].map(([persistedChannelId, record]) => [
467
961
  persistedChannelId,
468
962
  serializePersistedSessionRecord(record),
469
963
  ])));
470
964
  });
471
965
  }
472
- async persistSessionBestEffort(channelId, sessionId, cwd) {
966
+ async persistSessionBestEffort(channelId, sessionId, visibilityKey, cwd) {
473
967
  try {
474
- await this.persistSession(channelId, sessionId, cwd);
968
+ await this.trackSessionStoreOperation(this.persistSession(channelId, sessionId, visibilityKey, cwd));
475
969
  }
476
970
  catch (error) {
477
971
  this.logger.error('failed to persist Claude session map; keeping reply delivery', {
@@ -482,10 +976,7 @@ export class ClaudeCliClient {
482
976
  });
483
977
  }
484
978
  }
485
- async resetPersistedSession(channelId, state) {
486
- state.sessionEstablished = false;
487
- state.sessionId = undefined;
488
- state.sessionCwd = undefined;
979
+ async clearPersistedSession(channelId) {
489
980
  if (!this.sessionStore || !this.loadedSessionStoreAgentId) {
490
981
  return;
491
982
  }
@@ -500,28 +991,9 @@ export class ClaudeCliClient {
500
991
  ])));
501
992
  });
502
993
  }
503
- resolveTurnCwd(turn) {
504
- return turn.promptContext?.taskWorkspace?.rootPath ?? this.runtime.cwd;
505
- }
506
- async resetSessionIfCwdChanged(channelId, state, turn) {
507
- if (!state.sessionEstablished) {
508
- return;
509
- }
510
- const nextCwd = this.resolveTurnCwd(turn);
511
- if (state.sessionCwd === nextCwd) {
512
- return;
513
- }
514
- if (turn.sessionPersistence === 'persistent') {
515
- await this.resetPersistedSessionBestEffort(channelId, state);
516
- return;
517
- }
518
- state.sessionEstablished = false;
519
- state.sessionId = undefined;
520
- state.sessionCwd = undefined;
521
- }
522
- async resetPersistedSessionBestEffort(channelId, state) {
994
+ async clearPersistedSessionBestEffort(channelId) {
523
995
  try {
524
- await this.resetPersistedSession(channelId, state);
996
+ await this.trackSessionStoreOperation(this.clearPersistedSession(channelId));
525
997
  }
526
998
  catch (error) {
527
999
  this.logger.error('failed to clear stale Claude session map; retrying in-memory only', {
@@ -532,88 +1004,82 @@ export class ClaudeCliClient {
532
1004
  }
533
1005
  }
534
1006
  async enqueueSessionStoreWrite(writeOperation) {
535
- const queuedWrite = this.sessionStoreWriteQueue.then(writeOperation);
536
- this.sessionStoreWriteQueue = queuedWrite.catch(() => undefined);
537
- await queuedWrite;
538
- }
539
- async run(state, args, prompt, promptContext, options) {
540
- return new Promise((resolve, reject) => {
541
- const launch = resolveClaudeLaunch(this.command, args, this.runtime.cwd);
542
- const child = this.runtime.spawn(launch.command, launch.args, {
543
- stdio: ['pipe', 'pipe', 'pipe'],
544
- cwd: promptContext?.taskWorkspace?.rootPath ?? this.runtime.cwd,
545
- });
546
- state.activeChild = child;
547
- const collector = new ClaudeStreamCollector(options?.onProgress);
548
- let stderr = '';
549
- let lineBuffer = '';
550
- let settled = false;
551
- const settleReject = (error) => {
552
- if (settled)
553
- return;
554
- settled = true;
555
- reject(normalizeError(error));
556
- };
557
- const settleResolve = (value) => {
558
- if (settled)
559
- return;
560
- settled = true;
561
- resolve(value);
562
- };
563
- child.stdout.setEncoding('utf8');
564
- child.stderr.setEncoding('utf8');
565
- child.stdout.on('data', (chunk) => {
566
- lineBuffer += chunk;
567
- while (true) {
568
- const newlineIndex = lineBuffer.indexOf('\n');
569
- if (newlineIndex === -1) {
570
- break;
571
- }
572
- const line = lineBuffer.slice(0, newlineIndex).trim();
573
- lineBuffer = lineBuffer.slice(newlineIndex + 1);
574
- if (!line)
575
- continue;
576
- try {
577
- const event = JSON.parse(line);
578
- collector.consume(event);
579
- }
580
- catch (error) {
581
- settleReject(new Error(`Claude stream-json parse failed: ${normalizeError(error).message}`));
582
- child.kill('SIGTERM');
583
- return;
584
- }
585
- }
586
- });
587
- child.stderr.on('data', (chunk) => {
588
- stderr += chunk;
589
- this.logger?.childStderr('claude stderr', summarizeChildStderr(chunk));
590
- });
591
- child.once('error', (error) => {
592
- settleReject(error);
593
- });
594
- child.once('close', (code, signal) => {
595
- if (state.activeChild === child) {
596
- state.activeChild = undefined;
597
- }
598
- if (lineBuffer.trim().length > 0 && !settled) {
599
- try {
600
- collector.consume(JSON.parse(lineBuffer.trim()));
601
- lineBuffer = '';
602
- }
603
- catch (error) {
604
- settleReject(new Error(`Claude stream-json parse failed: ${normalizeError(error).message}`));
605
- return;
606
- }
607
- }
608
- if (code !== 0) {
609
- const stderrSummary = summarizeChildStderr(stderr);
610
- settleReject(new ClaudeCliProcessError(code, signal, stderrSummary.bytes, stderrSummary.lineCount, matchesStaleResumeText(stderr)));
611
- return;
612
- }
613
- settleResolve(collector.getFinalText());
614
- });
615
- child.stdin.write(prompt);
616
- child.stdin.end();
1007
+ const write = this.sessionStoreWriteQueue.then(writeOperation);
1008
+ this.sessionStoreWriteQueue = write.catch(() => { });
1009
+ await write;
1010
+ }
1011
+ async trackSessionStoreOperation(operation) {
1012
+ const tracked = operation.finally(() => {
1013
+ this.pendingSessionStoreOperations.delete(tracked);
617
1014
  });
1015
+ this.pendingSessionStoreOperations.add(tracked);
1016
+ return tracked;
1017
+ }
1018
+ async shutdownBackend(error) {
1019
+ if (this.backendClosed) {
1020
+ return;
1021
+ }
1022
+ this.backendClosed = true;
1023
+ const connection = this.connection;
1024
+ const child = this.child;
1025
+ await this.waitForPendingSessionStarts();
1026
+ await this.waitForPendingSessionCloses();
1027
+ connection?.close(error);
1028
+ this.connection = undefined;
1029
+ if (!child) {
1030
+ this.child = undefined;
1031
+ return;
1032
+ }
1033
+ const childExitPromise = this.childExitPromise ?? Promise.resolve();
1034
+ this.logger?.debug('sending SIGTERM to Claude ACP process');
1035
+ child.kill('SIGTERM');
1036
+ const exitedAfterTerm = await this.waitForChildExit(childExitPromise, this.runtime.shutdownGracePeriodMs);
1037
+ if (exitedAfterTerm) {
1038
+ this.logger?.debug('Claude ACP process exited after SIGTERM');
1039
+ this.child = undefined;
1040
+ return;
1041
+ }
1042
+ this.logger?.debug('sending SIGKILL to Claude ACP process after SIGTERM grace timeout');
1043
+ child.kill('SIGKILL');
1044
+ await this.waitForChildExit(childExitPromise, this.runtime.shutdownForceKillWaitMs);
1045
+ this.child = undefined;
1046
+ }
1047
+ async waitForPendingSessionStarts() {
1048
+ if (this.pendingSessionStarts.size === 0) {
1049
+ return;
1050
+ }
1051
+ await Promise.race([
1052
+ Promise.allSettled([...this.pendingSessionStarts]).then(() => undefined),
1053
+ new Promise((resolveDelay) => {
1054
+ setTimeout(resolveDelay, this.runtime.shutdownGracePeriodMs);
1055
+ }),
1056
+ ]);
1057
+ }
1058
+ async waitForPendingSessionCloses() {
1059
+ if (this.pendingSessionCloses.size === 0) {
1060
+ return;
1061
+ }
1062
+ await Promise.race([
1063
+ Promise.allSettled([...this.pendingSessionCloses]).then(() => undefined),
1064
+ new Promise((resolveDelay) => {
1065
+ setTimeout(resolveDelay, this.runtime.shutdownGracePeriodMs);
1066
+ }),
1067
+ ]);
1068
+ }
1069
+ async waitForChildExit(childExitPromise, timeoutMs) {
1070
+ let timeoutHandle;
1071
+ try {
1072
+ return await Promise.race([
1073
+ childExitPromise.then(() => true),
1074
+ new Promise((resolveDelay) => {
1075
+ timeoutHandle = setTimeout(() => resolveDelay(false), timeoutMs);
1076
+ }),
1077
+ ]);
1078
+ }
1079
+ finally {
1080
+ if (timeoutHandle) {
1081
+ clearTimeout(timeoutHandle);
1082
+ }
1083
+ }
618
1084
  }
619
1085
  }