@borgee/agents-host 0.2.33 → 0.2.44

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 (60) hide show
  1. package/README.md +28 -7
  2. package/dist/agents-host.d.ts +19 -0
  3. package/dist/agents-host.js +163 -44
  4. package/dist/chat/chat-control-plane.d.ts +9 -0
  5. package/dist/chat/sdk-chat-control-plane.d.ts +10 -1
  6. package/dist/chat/sdk-chat-control-plane.js +9 -0
  7. package/dist/cli-args.d.ts +1 -1
  8. package/dist/cli-args.js +5 -0
  9. package/dist/compatibility-gates.d.ts +1 -0
  10. package/dist/compatibility-gates.js +2 -0
  11. package/dist/config.d.ts +4 -1
  12. package/dist/config.js +21 -3
  13. package/dist/context/injection.d.ts +5 -3
  14. package/dist/context/injection.js +45 -29
  15. package/dist/context/main-session-delegation.d.ts +1 -0
  16. package/dist/context/main-session-delegation.js +6 -0
  17. package/dist/context/prompt.js +25 -35
  18. package/dist/context/skill-manual.d.ts +13 -0
  19. package/dist/context/skill-manual.js +18 -0
  20. package/dist/context/turn-preparation.js +13 -4
  21. package/dist/gateway/localhost-gateway.js +75 -1
  22. package/dist/hosted-turn-content.d.ts +15 -0
  23. package/dist/hosted-turn-content.js +50 -0
  24. package/dist/local-config.js +10 -1
  25. package/dist/managed-daemon.d.ts +3 -2
  26. package/dist/managed-daemon.js +82 -29
  27. package/dist/plugin-sdk.js +58 -1
  28. package/dist/plugin-sdk.js.map +2 -2
  29. package/dist/policy/gateway-authorization.d.ts +18 -3
  30. package/dist/policy/gateway-authorization.js +33 -1
  31. package/dist/providers/claude/adapter.d.ts +3 -1
  32. package/dist/providers/claude/adapter.js +13 -1
  33. package/dist/providers/claude/cli-client.d.ts +36 -3
  34. package/dist/providers/claude/cli-client.js +225 -37
  35. package/dist/providers/codex/adapter.d.ts +3 -1
  36. package/dist/providers/codex/adapter.js +13 -1
  37. package/dist/providers/codex/cli-client.d.ts +35 -3
  38. package/dist/providers/codex/cli-client.js +212 -31
  39. package/dist/providers/codex/project-doc.js +16 -29
  40. package/dist/providers/copilot/adapter.d.ts +3 -1
  41. package/dist/providers/copilot/adapter.js +13 -1
  42. package/dist/providers/copilot/cli-client.d.ts +34 -2
  43. package/dist/providers/copilot/cli-client.js +196 -18
  44. package/dist/providers/create-provider.d.ts +1 -1
  45. package/dist/providers/create-provider.js +30 -14
  46. package/dist/providers/idle-backend-shutdown.d.ts +16 -0
  47. package/dist/providers/idle-backend-shutdown.js +53 -0
  48. package/dist/providers/provider-adapter.d.ts +35 -0
  49. package/dist/providers/provider-adapter.js +44 -1
  50. package/dist/state-paths.d.ts +9 -1
  51. package/dist/state-paths.js +22 -3
  52. package/dist/types.d.ts +40 -2
  53. package/package.json +2 -2
  54. package/skills/borgee-agent/SKILL.md +133 -35
  55. package/skills/borgee-agent/references/errors.md +38 -0
  56. package/skills/borgee-agent/references/task-properties.md +30 -0
  57. package/skills/borgee-agent/scripts/borgee-agent.mjs +553 -0
  58. package/skills/borgee-agent/scripts/borgee-agent.py +547 -0
  59. package/skills/borgee-agent/borgee-agent.mjs +0 -507
  60. package/skills/borgee-agent/borgee-agent.py +0 -438
@@ -6,7 +6,18 @@ import { assertClaudeCommandCompatibility, DEFAULT_CLAUDE_COMMAND, isLegacyClaud
6
6
  import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from '@agentclientprotocol/sdk';
7
7
  import { HostLogger, summarizeChildStderr, summarizeError } from '../../debug.js';
8
8
  import { resolveCopilotPermissionResponse } from '../../policy/copilot-permission.js';
9
+ import { buildSkillManualLines } from '../../context/skill-manual.js';
10
+ import { IDLE_BACKEND_SHUTDOWN_DISABLED_MS, IdleBackendShutdownScheduler, } from '../idle-backend-shutdown.js';
9
11
  const SESSION_TAINTED_ERRORS = new WeakSet();
12
+ const IDLE_BACKEND_STOPPED_MESSAGE = 'Claude ACP backend stopped after idle timeout';
13
+ /**
14
+ * A fatal teardown has callers waiting on it, so it force-kills quickly. An
15
+ * idle recycle has nobody waiting, and the CLIs unlink their on-disk session
16
+ * locks during SIGTERM cleanup — a lock left behind by SIGKILL makes the next
17
+ * restore believe the session is still in use (github/copilot-cli#3255). So the
18
+ * idle path trades latency nobody observes for a clean exit.
19
+ */
20
+ const DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS = 5_000;
10
21
  const DEFAULT_SHUTDOWN_GRACE_PERIOD_MS = 250;
11
22
  const DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS = 250;
12
23
  const DEFAULT_SESSION_CAPABILITIES = {
@@ -20,7 +31,10 @@ const DEFAULT_RUNTIME = {
20
31
  ndJsonStream,
21
32
  methods,
22
33
  protocolVersion: PROTOCOL_VERSION,
34
+ fetch,
23
35
  cwd: process.cwd(),
36
+ idleBackendShutdownMs: IDLE_BACKEND_SHUTDOWN_DISABLED_MS,
37
+ idleShutdownGracePeriodMs: DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS,
24
38
  shutdownGracePeriodMs: DEFAULT_SHUTDOWN_GRACE_PERIOD_MS,
25
39
  shutdownForceKillWaitMs: DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS,
26
40
  };
@@ -30,6 +44,25 @@ function hasVisibleText(value) {
30
44
  function normalizeError(error) {
31
45
  return error instanceof Error ? error : new Error(String(error));
32
46
  }
47
+ function isAbsoluteHttpUrl(value) {
48
+ return value.startsWith('http://') || value.startsWith('https://');
49
+ }
50
+ function resolveHostedAttachmentUrl(rawUrl, borgeeBaseUrl) {
51
+ if (isAbsoluteHttpUrl(rawUrl)) {
52
+ return rawUrl;
53
+ }
54
+ const base = borgeeBaseUrl.replace(/\/$/, '');
55
+ const path = rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`;
56
+ return `${base}${path}`;
57
+ }
58
+ function isSameOrigin(left, right) {
59
+ try {
60
+ return new URL(left).origin === new URL(right).origin;
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
33
66
  function parsePersistedSessionRecord(rawValue) {
34
67
  const trimmed = rawValue.trim();
35
68
  if (trimmed.startsWith('{')) {
@@ -156,7 +189,7 @@ class ClaudeProgressCollector {
156
189
  this.onProgress({ text });
157
190
  }
158
191
  }
159
- function createDeferredTurn(channelId, prompt, sessionPersistence, promptContext, options) {
192
+ function createDeferredTurn(channelId, preparedTurn, sessionPersistence, options) {
160
193
  let settled = false;
161
194
  let resolvePromise;
162
195
  let rejectPromise;
@@ -166,9 +199,8 @@ function createDeferredTurn(channelId, prompt, sessionPersistence, promptContext
166
199
  });
167
200
  return {
168
201
  channelId,
169
- prompt,
202
+ preparedTurn,
170
203
  sessionPersistence,
171
- promptContext,
172
204
  options,
173
205
  promise,
174
206
  resolve(value) {
@@ -192,6 +224,9 @@ function resolveSessionRouting(turn) {
192
224
  persistence: turn.providerSessionRouting?.persistence ?? 'persistent',
193
225
  };
194
226
  }
227
+ function asImagePart(part) {
228
+ return part.type === 'image' ? part : null;
229
+ }
195
230
  function asObject(value) {
196
231
  return typeof value === 'object' && value !== null ? value : null;
197
232
  }
@@ -255,25 +290,18 @@ function resolveClaudeLaunch(command, args, baseCwd) {
255
290
  args: args.map((arg) => (isRelativePathLike(arg) ? resolve(baseCwd, arg) : arg)),
256
291
  };
257
292
  }
258
- function buildClaudeSessionSystemPrompt(promptContext) {
293
+ export function buildClaudeSessionSystemPrompt(promptContext) {
259
294
  if (!promptContext) {
260
295
  return undefined;
261
296
  }
262
297
  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}`);
298
+ // Every command the CLI carries needs the gateway credential file, so the session names it only
299
+ // when one exists; otherwise the model is handed an invocation template it cannot fill.
300
+ if (promptContext.skillRuntime && promptContext.gatewayCredentialPath) {
301
+ lines.push(...buildSkillManualLines(promptContext.skillRuntime));
266
302
  }
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`);
271
- }
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}`);
276
- }
303
+ if (promptContext.localhostGateway && promptContext.gatewayCredentialPath) {
304
+ lines.push(`Gateway credential file for this session: ${promptContext.gatewayCredentialPath}`);
277
305
  }
278
306
  if (promptContext.taskWorkspace) {
279
307
  lines.push(`Task-scoped writable workspace root: ${promptContext.taskWorkspace.rootPath}.`);
@@ -300,20 +328,32 @@ function resolveSessionAdditionalDirectories(promptContext) {
300
328
  if (promptContext?.channelContextPayloadPath) {
301
329
  directories.add(dirname(promptContext.channelContextPayloadPath));
302
330
  }
303
- if (promptContext?.gatewayAuthPath) {
304
- directories.add(dirname(promptContext.gatewayAuthPath));
331
+ if (promptContext?.gatewayCredentialPath) {
332
+ directories.add(dirname(promptContext.gatewayCredentialPath));
305
333
  }
306
334
  if (promptContext?.skillRuntime?.skillDirectoryPath) {
307
335
  directories.add(promptContext.skillRuntime.skillDirectoryPath);
308
336
  }
309
337
  return directories.size > 0 ? [...directories].sort((left, right) => left.localeCompare(right)) : undefined;
310
338
  }
339
+ /**
340
+ * A session carries its scope from `session/new`: the directories it may read, plus the append that
341
+ * names the CLI manual and this channel's gateway credential file. The credential file sits in the
342
+ * channel context directory the payload already contributes, so its absence is invisible in the
343
+ * directory set alone — without the path itself in the key, a session opened while the credential
344
+ * write failed keeps an append that never names the CLI for as long as the session lives.
345
+ */
311
346
  function resolveSessionVisibilityKey(promptContext) {
312
347
  const additionalDirectories = resolveSessionAdditionalDirectories(promptContext);
313
348
  if (!additionalDirectories) {
314
349
  return undefined;
315
350
  }
316
- return JSON.stringify({ additionalDirectories });
351
+ return JSON.stringify({
352
+ additionalDirectories,
353
+ ...(promptContext?.gatewayCredentialPath
354
+ ? { gatewayCredentialPath: promptContext.gatewayCredentialPath }
355
+ : {}),
356
+ });
317
357
  }
318
358
  function buildSessionRequest(cwd, promptContext, additionalDirectories) {
319
359
  const meta = buildClaudeSessionMeta(promptContext);
@@ -357,8 +397,17 @@ export class ClaudeCliClient {
357
397
  sessionStore;
358
398
  resolveSessionStoreAgentId;
359
399
  logger;
400
+ imageInputConfig;
360
401
  runtime;
361
402
  channels = new Map();
403
+ /**
404
+ * The provider session currently bound to channelId, or undefined before a
405
+ * turn has opened one. Surfaced so agents-host can record which session
406
+ * worked a task without the agent being asked to report its own id.
407
+ */
408
+ sessionIdForChannel(channelId) {
409
+ return this.channels.get(channelId)?.session?.sessionId;
410
+ }
362
411
  persistedSessions = new Map();
363
412
  closingSessions = new WeakSet();
364
413
  pendingSessionStarts = new Set();
@@ -371,7 +420,13 @@ export class ClaudeCliClient {
371
420
  startPromise;
372
421
  shutdownPromise;
373
422
  childExitPromise;
374
- resolveChildExit;
423
+ /**
424
+ * Bumped on every spawn so the `exit` / `connection.closed` handlers of a
425
+ * backend we deliberately recycled stay silent instead of latching a fatal
426
+ * error onto the client that already owns its successor.
427
+ */
428
+ backendGeneration = 0;
429
+ idleShutdownPromise;
375
430
  fatalError = null;
376
431
  disposing = false;
377
432
  backendClosed = false;
@@ -379,15 +434,27 @@ export class ClaudeCliClient {
379
434
  sessionStoreLoadPromise = null;
380
435
  sessionStoreWriteQueue = Promise.resolve();
381
436
  sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
437
+ idleBackendShutdown;
382
438
  childStderr = '';
383
- constructor(command, args = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger()) {
439
+ constructor(command, args = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger(), imageInputConfig = {}) {
384
440
  this.command = command;
385
441
  this.args = args;
386
442
  this.sessionStore = sessionStore;
387
443
  this.resolveSessionStoreAgentId = resolveSessionStoreAgentId;
388
444
  this.logger = logger;
445
+ this.imageInputConfig = imageInputConfig;
389
446
  this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
390
447
  assertClaudeCommandCompatibility(command, args, 'Claude provider runtime');
448
+ this.idleBackendShutdown = new IdleBackendShutdownScheduler({
449
+ idleShutdownMs: this.runtime.idleBackendShutdownMs,
450
+ isIdle: () => this.isBackendIdle(),
451
+ shutdown: () => this.shutdownIdleBackend(),
452
+ onShutdownFailed: (error) => {
453
+ this.logger.error('failed to stop the idle Claude ACP backend', {
454
+ error: summarizeError(error),
455
+ });
456
+ },
457
+ });
391
458
  this.fatalPromise = new Promise((_, reject) => {
392
459
  this.rejectFatalPromise = reject;
393
460
  });
@@ -397,18 +464,26 @@ export class ClaudeCliClient {
397
464
  if (this.fatalError) {
398
465
  throw this.fatalError;
399
466
  }
400
- const preparedTurn = typeof channelIdOrTurn === 'string'
467
+ const preparedTurnBase = typeof channelIdOrTurn === 'string'
401
468
  ? {
402
469
  channelId: channelIdOrTurn,
470
+ incomingContent: '',
471
+ incomingParts: [],
403
472
  prompt: typeof promptOrOptions === 'string' ? promptOrOptions : '',
404
473
  }
405
474
  : channelIdOrTurn;
475
+ const preparedTurn = {
476
+ ...preparedTurnBase,
477
+ incomingContent: preparedTurnBase.incomingContent ?? '',
478
+ incomingParts: preparedTurnBase.incomingParts ?? [],
479
+ };
406
480
  const options = typeof channelIdOrTurn === 'string'
407
481
  ? maybeOptions
408
482
  : promptOrOptions;
409
483
  const sessionRouting = resolveSessionRouting(preparedTurn);
410
484
  const state = this.getOrCreateChannelState(sessionRouting.key, preparedTurn.channelId, sessionRouting.persistence);
411
- const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn.prompt, sessionRouting.persistence, preparedTurn.promptContext, options);
485
+ this.idleBackendShutdown.cancel();
486
+ const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn, sessionRouting.persistence, options);
412
487
  state.queue.push(turn);
413
488
  this.processChannelQueue(sessionRouting.key, state);
414
489
  return turn.promise;
@@ -416,12 +491,17 @@ export class ClaudeCliClient {
416
491
  async dispose() {
417
492
  const error = new Error('Claude ACP backend stopped');
418
493
  this.disposing = true;
494
+ this.idleBackendShutdown.cancel();
419
495
  this.logger?.debug('stopping Claude ACP backend');
420
496
  const closed = this.connection?.closed ?? Promise.resolve();
421
497
  this.failAll(error);
422
498
  let thrown;
423
499
  try {
424
- await Promise.all([closed, this.shutdownPromise ?? Promise.resolve()]);
500
+ await Promise.all([
501
+ closed,
502
+ this.idleShutdownPromise ?? Promise.resolve(),
503
+ this.shutdownPromise ?? Promise.resolve(),
504
+ ]);
425
505
  }
426
506
  catch (disposeError) {
427
507
  thrown = disposeError;
@@ -442,6 +522,12 @@ export class ClaudeCliClient {
442
522
  if (this.fatalError) {
443
523
  throw this.fatalError;
444
524
  }
525
+ if (this.idleShutdownPromise) {
526
+ await this.idleShutdownPromise;
527
+ if (this.fatalError) {
528
+ throw this.fatalError;
529
+ }
530
+ }
445
531
  if (!this.startPromise) {
446
532
  this.startPromise = this.startBackend();
447
533
  }
@@ -454,6 +540,8 @@ export class ClaudeCliClient {
454
540
  }
455
541
  }
456
542
  async startBackend() {
543
+ const generation = ++this.backendGeneration;
544
+ const ownsCurrentBackend = () => (this.backendGeneration === generation && !this.disposing);
457
545
  const launch = resolveClaudeLaunch(this.command, this.args, this.runtime.cwd);
458
546
  this.childStderr = '';
459
547
  this.logger?.debug('starting Claude ACP process', {
@@ -473,13 +561,20 @@ export class ClaudeCliClient {
473
561
  });
474
562
  child.stderr.resume();
475
563
  this.logger?.debug('spawned Claude ACP process', { pid: child.pid });
564
+ let resolveChildExit;
565
+ this.childExitPromise = new Promise((resolve) => {
566
+ resolveChildExit = resolve;
567
+ });
476
568
  child.once('error', (error) => {
477
569
  this.logger?.debugError('Claude ACP process error', error);
570
+ if (!ownsCurrentBackend()) {
571
+ return;
572
+ }
478
573
  this.failAll(new Error(`Claude ACP process error: ${normalizeError(error).message}`));
479
574
  });
480
575
  child.once('exit', (code, signal) => {
481
- this.resolveChildExit?.();
482
- if (this.disposing) {
576
+ resolveChildExit();
577
+ if (!ownsCurrentBackend()) {
483
578
  this.logger?.debug('Claude ACP process exited during shutdown', { code, signal });
484
579
  return;
485
580
  }
@@ -493,9 +588,6 @@ export class ClaudeCliClient {
493
588
  });
494
589
  this.failAll(failure);
495
590
  });
496
- this.childExitPromise = new Promise((resolveChildExit) => {
497
- this.resolveChildExit = resolveChildExit;
498
- });
499
591
  const output = Writable.toWeb(child.stdin);
500
592
  const input = Readable.toWeb(child.stdout);
501
593
  const stream = this.runtime.ndJsonStream(output, input);
@@ -505,7 +597,7 @@ export class ClaudeCliClient {
505
597
  const connection = app.connect(stream);
506
598
  this.connection = connection;
507
599
  void connection.closed.then(() => {
508
- if (!this.disposing) {
600
+ if (ownsCurrentBackend()) {
509
601
  this.logger?.debugError('Claude ACP connection closed unexpectedly');
510
602
  this.failAll(new Error('Claude ACP connection closed unexpectedly'));
511
603
  }
@@ -563,14 +655,15 @@ export class ClaudeCliClient {
563
655
  }
564
656
  state.activeTurn = turn;
565
657
  try {
566
- state.cwd = this.resolveSessionCwd(turn.promptContext);
567
- state.visibilityKey = resolveSessionVisibilityKey(turn.promptContext);
658
+ state.cwd = this.resolveSessionCwd(turn.preparedTurn.promptContext);
659
+ state.visibilityKey = resolveSessionVisibilityKey(turn.preparedTurn.promptContext);
568
660
  await this.ensureStarted();
569
661
  await this.recycleSessionIfScopeChanged(channelId, state);
570
- const session = await this.getOrCreateSession(channelId, state, turn.promptContext);
662
+ const session = await this.getOrCreateSession(channelId, state, turn.preparedTurn.promptContext);
571
663
  let reply;
572
664
  try {
573
- reply = await this.runTurn(session, turn.prompt, turn.options);
665
+ const promptInput = await this.buildPromptInput(turn.preparedTurn);
666
+ reply = await this.runTurn(session, promptInput, turn.options);
574
667
  }
575
668
  catch (error) {
576
669
  if (isSessionTainted(error)) {
@@ -604,6 +697,9 @@ export class ClaudeCliClient {
604
697
  if (state.queue.length > 0 && !this.fatalError) {
605
698
  this.processChannelQueue(channelId, state);
606
699
  }
700
+ else {
701
+ this.idleBackendShutdown.reconcile();
702
+ }
607
703
  }
608
704
  })();
609
705
  }
@@ -807,6 +903,7 @@ export class ClaudeCliClient {
807
903
  }
808
904
  this.fatalError = error;
809
905
  this.rejectFatalPromise(error);
906
+ this.idleBackendShutdown.cancel();
810
907
  for (const [channelId, state] of this.channels.entries()) {
811
908
  state.activeTurn?.reject(error);
812
909
  for (const turn of state.queue) {
@@ -1020,6 +1117,64 @@ export class ClaudeCliClient {
1020
1117
  return;
1021
1118
  }
1022
1119
  this.backendClosed = true;
1120
+ await this.closeBackendProcess(error, this.runtime.shutdownGracePeriodMs);
1121
+ }
1122
+ /**
1123
+ * Backend teardown without the fatal latch: the client stays usable and
1124
+ * `ensureStarted()` spawns a replacement process on the next turn. Only safe
1125
+ * while `isBackendIdle()` holds, because every channel's live ACP session is
1126
+ * dropped here and no turn is ever replayed onto its successor.
1127
+ */
1128
+ async shutdownIdleBackend() {
1129
+ // Detach the running backend before any signal reaches it, so its own exit
1130
+ // and connection-closed handlers read this teardown as deliberate instead
1131
+ // of latching a fatal error on a client that stays usable.
1132
+ this.backendGeneration += 1;
1133
+ const error = new Error(IDLE_BACKEND_STOPPED_MESSAGE);
1134
+ const sessions = [];
1135
+ for (const [channelId, state] of [...this.channels.entries()]) {
1136
+ if (state.session) {
1137
+ sessions.push(state.session);
1138
+ }
1139
+ state.session = undefined;
1140
+ state.sessionCwd = undefined;
1141
+ state.sessionVisibilityKey = undefined;
1142
+ this.channels.delete(channelId);
1143
+ }
1144
+ this.logger?.debug('stopping idle Claude ACP backend', {
1145
+ channels: sessions.length,
1146
+ idleShutdownMs: this.runtime.idleBackendShutdownMs,
1147
+ });
1148
+ for (const session of sessions) {
1149
+ this.closeSession(session);
1150
+ }
1151
+ const idleShutdownPromise = this.closeBackendProcess(error, this.runtime.idleShutdownGracePeriodMs).finally(() => {
1152
+ this.startPromise = undefined;
1153
+ this.childExitPromise = undefined;
1154
+ this.sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
1155
+ this.idleShutdownPromise = undefined;
1156
+ });
1157
+ this.idleShutdownPromise = idleShutdownPromise;
1158
+ await idleShutdownPromise;
1159
+ }
1160
+ isBackendIdle() {
1161
+ if (this.disposing || this.fatalError || this.backendClosed || this.idleShutdownPromise) {
1162
+ return false;
1163
+ }
1164
+ if (!this.child) {
1165
+ return false;
1166
+ }
1167
+ if (this.pendingSessionStarts.size > 0) {
1168
+ return false;
1169
+ }
1170
+ for (const state of this.channels.values()) {
1171
+ if (state.activeTurn || state.queue.length > 0 || state.sessionPromise || state.processing) {
1172
+ return false;
1173
+ }
1174
+ }
1175
+ return true;
1176
+ }
1177
+ async closeBackendProcess(error, gracePeriodMs) {
1023
1178
  const connection = this.connection;
1024
1179
  const child = this.child;
1025
1180
  await this.waitForPendingSessionStarts();
@@ -1031,9 +1186,9 @@ export class ClaudeCliClient {
1031
1186
  return;
1032
1187
  }
1033
1188
  const childExitPromise = this.childExitPromise ?? Promise.resolve();
1034
- this.logger?.debug('sending SIGTERM to Claude ACP process');
1189
+ this.logger?.debug('sending SIGTERM to Claude ACP process', { gracePeriodMs });
1035
1190
  child.kill('SIGTERM');
1036
- const exitedAfterTerm = await this.waitForChildExit(childExitPromise, this.runtime.shutdownGracePeriodMs);
1191
+ const exitedAfterTerm = await this.waitForChildExit(childExitPromise, gracePeriodMs);
1037
1192
  if (exitedAfterTerm) {
1038
1193
  this.logger?.debug('Claude ACP process exited after SIGTERM');
1039
1194
  this.child = undefined;
@@ -1082,4 +1237,37 @@ export class ClaudeCliClient {
1082
1237
  }
1083
1238
  }
1084
1239
  }
1240
+ async buildPromptInput(turn) {
1241
+ const imageParts = turn.incomingParts.map(asImagePart).filter((part) => part !== null);
1242
+ if (imageParts.length === 0) {
1243
+ return turn.prompt;
1244
+ }
1245
+ const imageBlocks = await Promise.all(imageParts.map(async (part) => this.fetchImageBlock(part.attachment.url, part.attachment.contentType)));
1246
+ return [
1247
+ { type: 'text', text: turn.prompt },
1248
+ ...imageBlocks,
1249
+ ];
1250
+ }
1251
+ async fetchImageBlock(rawUrl, contentType) {
1252
+ const baseUrl = this.imageInputConfig.borgeeBaseUrl?.trim();
1253
+ const agentApiKey = this.imageInputConfig.agentApiKey?.trim();
1254
+ if (!baseUrl || !agentApiKey) {
1255
+ throw new Error('Claude image input requires Borgee base URL and agent API key');
1256
+ }
1257
+ const resolvedUrl = resolveHostedAttachmentUrl(rawUrl, baseUrl);
1258
+ if (!isSameOrigin(resolvedUrl, baseUrl)) {
1259
+ throw new Error(`Claude image input only supports Borgee-origin attachments: ${resolvedUrl}`);
1260
+ }
1261
+ const init = { headers: { Authorization: `Bearer ${agentApiKey}` } };
1262
+ const response = await this.runtime.fetch(resolvedUrl, init);
1263
+ if (!response.ok) {
1264
+ throw new Error(`Claude image fetch failed with ${response.status} ${response.statusText}`.trim());
1265
+ }
1266
+ const blob = Buffer.from(await response.arrayBuffer()).toString('base64');
1267
+ return {
1268
+ type: 'image',
1269
+ mimeType: contentType,
1270
+ data: blob,
1271
+ };
1272
+ }
1085
1273
  }
@@ -1,10 +1,12 @@
1
- import type { ProviderAdapter } from '../provider-adapter.js';
1
+ import { type ProviderAdapter } from '../provider-adapter.js';
2
2
  import type { ProviderGenerateOptions, ProviderInput, ProviderReply } from '../../types.js';
3
3
  import { ProviderTurnPreparer } from '../../context/turn-preparation.js';
4
4
  import { CodexCliClient } from './cli-client.js';
5
+ export declare const CODEX_HOSTED_PROVIDER_CAPABILITIES: import("../provider-adapter.js").ProviderCapabilities;
5
6
  export declare class CodexProviderAdapter implements ProviderAdapter {
6
7
  private readonly cli;
7
8
  private readonly turnPreparer;
9
+ readonly capabilities: import("../provider-adapter.js").ProviderCapabilities;
8
10
  constructor(cli: CodexCliClient, turnPreparer: ProviderTurnPreparer);
9
11
  generateReply(input: ProviderInput, options?: ProviderGenerateOptions): Promise<ProviderReply>;
10
12
  dispose(): Promise<void>;
@@ -1,7 +1,17 @@
1
+ import { createHostedProviderCapabilities } from '../provider-adapter.js';
1
2
  import { createAwaitingUserProgressHandler, parseProviderReply } from '../awaiting-user.js';
3
+ export const CODEX_HOSTED_PROVIDER_CAPABILITIES = createHostedProviderCapabilities({
4
+ imageInputTransport: {
5
+ source: 'transport-derived',
6
+ support: 'supported',
7
+ reason: 'real-media-delivered',
8
+ delivery: ['blob'],
9
+ },
10
+ });
2
11
  export class CodexProviderAdapter {
3
12
  cli;
4
13
  turnPreparer;
14
+ capabilities = CODEX_HOSTED_PROVIDER_CAPABILITIES;
5
15
  constructor(cli, turnPreparer) {
6
16
  this.cli = cli;
7
17
  this.turnPreparer = turnPreparer;
@@ -11,7 +21,9 @@ export class CodexProviderAdapter {
11
21
  const text = await this.cli.generateReply(preparedTurn, {
12
22
  onProgress: createAwaitingUserProgressHandler(options?.onProgress),
13
23
  });
14
- return parseProviderReply(text);
24
+ // Read the session AFTER the turn: a first turn on a channel opens the
25
+ // session as it runs, so before this point there is nothing to report.
26
+ return { ...parseProviderReply(text), sessionId: this.cli.sessionIdForChannel(input.channelId) };
15
27
  }
16
28
  async dispose() {
17
29
  await this.cli.dispose();
@@ -11,6 +11,8 @@ interface CodexAcpRuntime {
11
11
  protocolVersion: typeof PROTOCOL_VERSION;
12
12
  cwd: string;
13
13
  idleSessionTtlMs: number;
14
+ idleBackendShutdownMs: number;
15
+ idleShutdownGracePeriodMs: number;
14
16
  shutdownGracePeriodMs: number;
15
17
  shutdownForceKillWaitMs: number;
16
18
  readFile(path: string, options?: {
@@ -26,6 +28,11 @@ interface CodexAcpRuntime {
26
28
  mode?: number;
27
29
  }): Promise<void>;
28
30
  unlink(path: string): Promise<void>;
31
+ fetch(input: string | URL, init?: RequestInit): Promise<Response>;
32
+ }
33
+ interface CodexHostedImageInputConfig {
34
+ borgeeBaseUrl?: string;
35
+ agentApiKey?: string;
29
36
  }
30
37
  export declare class CodexCliClient {
31
38
  private readonly command;
@@ -33,8 +40,15 @@ export declare class CodexCliClient {
33
40
  private readonly sessionStore?;
34
41
  private readonly resolveSessionStoreAgentId;
35
42
  private readonly logger;
43
+ private readonly imageInputConfig;
36
44
  private readonly runtime;
37
45
  private readonly channels;
46
+ /**
47
+ * The provider session currently bound to channelId, or undefined before a
48
+ * turn has opened one. Surfaced so agents-host can record which session
49
+ * worked a task without the agent being asked to report its own id.
50
+ */
51
+ sessionIdForChannel(channelId: string): string | undefined;
38
52
  private readonly persistedSessions;
39
53
  private readonly closingSessions;
40
54
  private readonly pendingSessionStarts;
@@ -47,7 +61,13 @@ export declare class CodexCliClient {
47
61
  private startPromise?;
48
62
  private shutdownPromise?;
49
63
  private childExitPromise?;
50
- private resolveChildExit?;
64
+ /**
65
+ * Bumped on every spawn so the `exit` / `connection.closed` handlers of a
66
+ * backend we deliberately recycled stay silent instead of latching a fatal
67
+ * error onto the client that already owns its successor.
68
+ */
69
+ private backendGeneration;
70
+ private idleShutdownPromise?;
51
71
  private fatalError;
52
72
  private disposing;
53
73
  private backendClosed;
@@ -55,7 +75,8 @@ export declare class CodexCliClient {
55
75
  private sessionStoreLoadPromise;
56
76
  private sessionStoreWriteQueue;
57
77
  private sessionCapabilities;
58
- constructor(command: string, args?: string[], runtimeOverrides?: Partial<CodexAcpRuntime>, sessionStore?: CodexChannelSessionStore | undefined, resolveSessionStoreAgentId?: () => string | undefined, logger?: DebugLogger);
78
+ private readonly idleBackendShutdown;
79
+ constructor(command: string, args?: string[], runtimeOverrides?: Partial<CodexAcpRuntime>, sessionStore?: CodexChannelSessionStore | undefined, resolveSessionStoreAgentId?: () => string | undefined, logger?: DebugLogger, imageInputConfig?: CodexHostedImageInputConfig);
59
80
  generateReply(turn: PreparedProviderTurnInput, options?: ProviderGenerateOptions): Promise<string>;
60
81
  generateReply(channelId: string, prompt: string, options?: ProviderGenerateOptions): Promise<string>;
61
82
  dispose(): Promise<void>;
@@ -75,9 +96,11 @@ export declare class CodexCliClient {
75
96
  private resolveProjectedContextDirectory;
76
97
  private buildProjectedPromptContext;
77
98
  private copyProjectedFile;
78
- private pruneProjectedGatewayAuthFiles;
99
+ private pruneProjectedGatewayCredentials;
79
100
  private refreshProjectedPromptContextBestEffort;
80
101
  private rewritePromptContextPaths;
102
+ private buildPromptInput;
103
+ private fetchImageBlock;
81
104
  private runTurn;
82
105
  private raceWithFatal;
83
106
  private invalidateSession;
@@ -96,6 +119,15 @@ export declare class CodexCliClient {
96
119
  private trackSessionStoreOperation;
97
120
  private evictIdleChannel;
98
121
  private shutdownBackend;
122
+ /**
123
+ * Backend teardown without the fatal latch: the client stays usable and
124
+ * `ensureStarted()` spawns a replacement process on the next turn. Only safe
125
+ * while `isBackendIdle()` holds, because every channel's live ACP session is
126
+ * dropped here and no turn is ever replayed onto its successor.
127
+ */
128
+ private shutdownIdleBackend;
129
+ private isBackendIdle;
130
+ private closeBackendProcess;
99
131
  private waitForPendingSessionStarts;
100
132
  private waitForPendingSessionCloses;
101
133
  private waitForChildExit;