@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,8 +6,19 @@ import spawn from 'cross-spawn';
6
6
  import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from '@agentclientprotocol/sdk';
7
7
  import { HostLogger, summarizeChildStderr, summarizeError } from '../../debug.js';
8
8
  import { assertCodexProjectDocumentSize, buildCodexProjectDocument } from './project-doc.js';
9
+ import { isGatewayCredentialSidecarBasename } from '../../context/injection.js';
10
+ import { IDLE_BACKEND_SHUTDOWN_DISABLED_MS, IdleBackendShutdownScheduler, } from '../idle-backend-shutdown.js';
9
11
  const SESSION_TAINTED_ERRORS = new WeakSet();
10
12
  const DEFAULT_IDLE_SESSION_TTL_MS = 2 * 24 * 60 * 60 * 1000;
13
+ const IDLE_BACKEND_STOPPED_MESSAGE = 'Codex ACP backend stopped after idle timeout';
14
+ /**
15
+ * A fatal teardown has callers waiting on it, so it force-kills quickly. An
16
+ * idle recycle has nobody waiting, and the CLIs unlink their on-disk session
17
+ * locks during SIGTERM cleanup — a lock left behind by SIGKILL makes the next
18
+ * restore believe the session is still in use (github/copilot-cli#3255). So the
19
+ * idle path trades latency nobody observes for a clean exit.
20
+ */
21
+ const DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS = 5_000;
11
22
  const DEFAULT_SHUTDOWN_GRACE_PERIOD_MS = 250;
12
23
  const DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS = 250;
13
24
  const DEFAULT_SESSION_CAPABILITIES = {
@@ -17,8 +28,6 @@ const DEFAULT_SESSION_CAPABILITIES = {
17
28
  const DEFAULT_CODEX_COMMAND = 'codex-acp';
18
29
  const DEFAULT_CODEX_ARGS = [];
19
30
  const CODEX_CONTEXT_ROOT_DIRNAME = 'codex-context';
20
- const PROJECTED_GATEWAY_AUTH_FILENAME = '.localhost-gateway-auth.json';
21
- const PROJECTED_GATEWAY_AUTH_PREFIX = '.localhost-gateway-auth.';
22
31
  const PROJECTION_UNAVAILABLE_PLACEHOLDER = '[codex-projection-unavailable]';
23
32
  const QUEUED_TURN_DROPPED_MESSAGE = 'Codex ACP session was reset after a failed turn; queued turns were dropped instead of replaying them on a fresh session';
24
33
  const require = createRequire(import.meta.url);
@@ -30,6 +39,8 @@ const DEFAULT_RUNTIME = {
30
39
  protocolVersion: PROTOCOL_VERSION,
31
40
  cwd: process.cwd(),
32
41
  idleSessionTtlMs: DEFAULT_IDLE_SESSION_TTL_MS,
42
+ idleBackendShutdownMs: IDLE_BACKEND_SHUTDOWN_DISABLED_MS,
43
+ idleShutdownGracePeriodMs: DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS,
33
44
  shutdownGracePeriodMs: DEFAULT_SHUTDOWN_GRACE_PERIOD_MS,
34
45
  shutdownForceKillWaitMs: DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS,
35
46
  readFile: async (path, options) => {
@@ -47,6 +58,7 @@ const DEFAULT_RUNTIME = {
47
58
  unlink: async (path) => {
48
59
  await fs.unlink(path);
49
60
  },
61
+ fetch: async (input, init) => fetch(input, init),
50
62
  };
51
63
  function hasVisibleText(value) {
52
64
  return typeof value === 'string' && value.trim().length > 0;
@@ -169,7 +181,7 @@ class CodexProgressCollector {
169
181
  this.onProgress({ text });
170
182
  }
171
183
  }
172
- function createDeferredTurn(channelId, prompt, promptContext, sessionPersistence, options) {
184
+ function createDeferredTurn(channelId, preparedTurn, sessionPersistence, options) {
173
185
  let settled = false;
174
186
  let resolvePromise;
175
187
  let rejectPromise;
@@ -179,8 +191,7 @@ function createDeferredTurn(channelId, prompt, promptContext, sessionPersistence
179
191
  });
180
192
  return {
181
193
  channelId,
182
- prompt,
183
- promptContext,
194
+ preparedTurn,
184
195
  sessionPersistence,
185
196
  options,
186
197
  promise,
@@ -198,6 +209,28 @@ function createDeferredTurn(channelId, prompt, promptContext, sessionPersistence
198
209
  },
199
210
  };
200
211
  }
212
+ function isAbsoluteHttpUrl(value) {
213
+ return value.startsWith('http://') || value.startsWith('https://');
214
+ }
215
+ function resolveHostedAttachmentUrl(rawUrl, borgeeBaseUrl) {
216
+ if (isAbsoluteHttpUrl(rawUrl)) {
217
+ return rawUrl;
218
+ }
219
+ const base = borgeeBaseUrl.replace(/\/$/, '');
220
+ const path = rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`;
221
+ return `${base}${path}`;
222
+ }
223
+ function isSameOrigin(left, right) {
224
+ try {
225
+ return new URL(left).origin === new URL(right).origin;
226
+ }
227
+ catch {
228
+ return false;
229
+ }
230
+ }
231
+ function asImagePart(part) {
232
+ return part.type === 'image' ? part : null;
233
+ }
201
234
  function normalizeError(error) {
202
235
  return error instanceof Error ? error : new Error(String(error));
203
236
  }
@@ -284,8 +317,17 @@ export class CodexCliClient {
284
317
  sessionStore;
285
318
  resolveSessionStoreAgentId;
286
319
  logger;
320
+ imageInputConfig;
287
321
  runtime;
288
322
  channels = new Map();
323
+ /**
324
+ * The provider session currently bound to channelId, or undefined before a
325
+ * turn has opened one. Surfaced so agents-host can record which session
326
+ * worked a task without the agent being asked to report its own id.
327
+ */
328
+ sessionIdForChannel(channelId) {
329
+ return this.channels.get(channelId)?.session?.sessionId;
330
+ }
289
331
  persistedSessions = new Map();
290
332
  closingSessions = new WeakSet();
291
333
  pendingSessionStarts = new Set();
@@ -298,7 +340,13 @@ export class CodexCliClient {
298
340
  startPromise;
299
341
  shutdownPromise;
300
342
  childExitPromise;
301
- resolveChildExit;
343
+ /**
344
+ * Bumped on every spawn so the `exit` / `connection.closed` handlers of a
345
+ * backend we deliberately recycled stay silent instead of latching a fatal
346
+ * error onto the client that already owns its successor.
347
+ */
348
+ backendGeneration = 0;
349
+ idleShutdownPromise;
302
350
  fatalError = null;
303
351
  disposing = false;
304
352
  backendClosed = false;
@@ -306,13 +354,25 @@ export class CodexCliClient {
306
354
  sessionStoreLoadPromise = null;
307
355
  sessionStoreWriteQueue = Promise.resolve();
308
356
  sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
309
- constructor(command, args = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger()) {
357
+ idleBackendShutdown;
358
+ constructor(command, args = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger(), imageInputConfig = {}) {
310
359
  this.command = command;
311
360
  this.args = args;
312
361
  this.sessionStore = sessionStore;
313
362
  this.resolveSessionStoreAgentId = resolveSessionStoreAgentId;
314
363
  this.logger = logger;
364
+ this.imageInputConfig = imageInputConfig;
315
365
  this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
366
+ this.idleBackendShutdown = new IdleBackendShutdownScheduler({
367
+ idleShutdownMs: this.runtime.idleBackendShutdownMs,
368
+ isIdle: () => this.isBackendIdle(),
369
+ shutdown: () => this.shutdownIdleBackend(),
370
+ onShutdownFailed: (error) => {
371
+ this.logger.error('failed to stop the idle Codex ACP backend', {
372
+ error: summarizeError(error),
373
+ });
374
+ },
375
+ });
316
376
  this.fatalPromise = new Promise((_, reject) => {
317
377
  this.rejectFatalPromise = reject;
318
378
  });
@@ -322,19 +382,27 @@ export class CodexCliClient {
322
382
  if (this.fatalError) {
323
383
  throw this.fatalError;
324
384
  }
325
- const preparedTurn = typeof channelIdOrTurn === 'string'
385
+ const preparedTurnBase = typeof channelIdOrTurn === 'string'
326
386
  ? {
327
387
  channelId: channelIdOrTurn,
388
+ incomingContent: '',
389
+ incomingParts: [],
328
390
  prompt: typeof promptOrOptions === 'string' ? promptOrOptions : '',
329
391
  }
330
392
  : channelIdOrTurn;
393
+ const preparedTurn = {
394
+ ...preparedTurnBase,
395
+ incomingContent: preparedTurnBase.incomingContent ?? '',
396
+ incomingParts: preparedTurnBase.incomingParts ?? [],
397
+ };
331
398
  const options = typeof channelIdOrTurn === 'string'
332
399
  ? maybeOptions
333
400
  : promptOrOptions;
334
401
  const sessionRouting = resolveSessionRouting(preparedTurn);
335
402
  const state = this.getOrCreateChannelState(sessionRouting.key, preparedTurn.channelId, sessionRouting.persistence);
336
403
  this.clearIdleTimer(state);
337
- const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn.prompt, preparedTurn.promptContext, sessionRouting.persistence, options);
404
+ this.idleBackendShutdown.cancel();
405
+ const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn, sessionRouting.persistence, options);
338
406
  state.queue.push(turn);
339
407
  this.processChannelQueue(sessionRouting.key, state);
340
408
  return turn.promise;
@@ -342,12 +410,17 @@ export class CodexCliClient {
342
410
  async dispose() {
343
411
  const error = new Error('Codex ACP backend stopped');
344
412
  this.disposing = true;
413
+ this.idleBackendShutdown.cancel();
345
414
  this.logger?.debug('stopping Codex ACP backend');
346
415
  const closed = this.connection?.closed ?? Promise.resolve();
347
416
  this.failAll(error);
348
417
  let thrown;
349
418
  try {
350
- await Promise.all([closed, this.shutdownPromise ?? Promise.resolve()]);
419
+ await Promise.all([
420
+ closed,
421
+ this.idleShutdownPromise ?? Promise.resolve(),
422
+ this.shutdownPromise ?? Promise.resolve(),
423
+ ]);
351
424
  }
352
425
  catch (disposeError) {
353
426
  thrown = disposeError;
@@ -368,6 +441,12 @@ export class CodexCliClient {
368
441
  if (this.fatalError) {
369
442
  throw this.fatalError;
370
443
  }
444
+ if (this.idleShutdownPromise) {
445
+ await this.idleShutdownPromise;
446
+ if (this.fatalError) {
447
+ throw this.fatalError;
448
+ }
449
+ }
371
450
  if (!this.startPromise) {
372
451
  this.startPromise = this.startBackend();
373
452
  }
@@ -380,6 +459,8 @@ export class CodexCliClient {
380
459
  }
381
460
  }
382
461
  async startBackend() {
462
+ const generation = ++this.backendGeneration;
463
+ const ownsCurrentBackend = () => (this.backendGeneration === generation && !this.disposing);
383
464
  const launch = resolveAdapterLaunch(this.command, this.args);
384
465
  this.logger?.debug('starting Codex ACP process', {
385
466
  command: launch.command,
@@ -396,13 +477,20 @@ export class CodexCliClient {
396
477
  });
397
478
  child.stderr.resume();
398
479
  this.logger?.debug('spawned Codex ACP process', { pid: child.pid });
480
+ let resolveChildExit;
481
+ this.childExitPromise = new Promise((resolve) => {
482
+ resolveChildExit = resolve;
483
+ });
399
484
  child.once('error', (error) => {
400
485
  this.logger?.debugError('Codex ACP process error', error);
486
+ if (!ownsCurrentBackend()) {
487
+ return;
488
+ }
401
489
  this.failAll(new Error(`Codex ACP process error: ${normalizeError(error).message}`));
402
490
  });
403
491
  child.once('exit', (code, signal) => {
404
- this.resolveChildExit?.();
405
- if (this.disposing) {
492
+ resolveChildExit();
493
+ if (!ownsCurrentBackend()) {
406
494
  this.logger?.debug('Codex ACP process exited during shutdown', { code, signal });
407
495
  return;
408
496
  }
@@ -410,9 +498,6 @@ export class CodexCliClient {
410
498
  const suffix = signal ? `signal ${signal}` : `code ${String(code ?? 'unknown')}`;
411
499
  this.failAll(new Error(`Codex ACP process exited unexpectedly (${suffix})`));
412
500
  });
413
- this.childExitPromise = new Promise((resolveChildExit) => {
414
- this.resolveChildExit = resolveChildExit;
415
- });
416
501
  const output = Writable.toWeb(child.stdin);
417
502
  const input = Readable.toWeb(child.stdout);
418
503
  const stream = this.runtime.ndJsonStream(output, input);
@@ -422,7 +507,7 @@ export class CodexCliClient {
422
507
  const connection = app.connect(stream);
423
508
  this.connection = connection;
424
509
  void connection.closed.then(() => {
425
- if (!this.disposing) {
510
+ if (ownsCurrentBackend()) {
426
511
  this.logger?.debugError('Codex ACP connection closed unexpectedly');
427
512
  this.failAll(new Error('Codex ACP connection closed unexpectedly'));
428
513
  }
@@ -481,8 +566,8 @@ export class CodexCliClient {
481
566
  }
482
567
  state.activeTurn = turn;
483
568
  try {
484
- state.cwd = await this.resolveSessionCwd(turn.promptContext);
485
- const projectedPromptContext = await this.refreshProjectedPromptContextBestEffort(turn.channelId, turn.promptContext);
569
+ state.cwd = await this.resolveSessionCwd(turn.preparedTurn.promptContext);
570
+ const projectedPromptContext = await this.refreshProjectedPromptContextBestEffort(turn.channelId, turn.preparedTurn.promptContext);
486
571
  state.additionalDirectories = this.resolveSessionAdditionalDirectories(projectedPromptContext);
487
572
  state.visibilityKey = this.resolveSessionVisibilityKey(projectedPromptContext);
488
573
  await this.ensureStarted();
@@ -490,7 +575,8 @@ export class CodexCliClient {
490
575
  const session = await this.getOrCreateSession(channelId, state);
491
576
  let reply;
492
577
  try {
493
- reply = await this.runTurn(session, this.rewritePromptContextPaths(turn.prompt, turn.promptContext, projectedPromptContext?.promptContext), turn.options);
578
+ const promptInput = await this.buildPromptInput(turn.preparedTurn, projectedPromptContext?.promptContext);
579
+ reply = await this.runTurn(session, promptInput, turn.options);
494
580
  }
495
581
  catch (error) {
496
582
  if (isSessionTainted(error)) {
@@ -512,6 +598,7 @@ export class CodexCliClient {
512
598
  finally {
513
599
  state.processing = false;
514
600
  this.reconcileIdleChannelState(channelId, state);
601
+ this.idleBackendShutdown.reconcile();
515
602
  }
516
603
  })();
517
604
  }
@@ -684,7 +771,7 @@ export class CodexCliClient {
684
771
  return JSON.stringify({
685
772
  directoryPath: projectedPromptContext.directoryPath,
686
773
  channelContextPayloadPath: projectedPromptContext.promptContext.channelContextPayloadPath,
687
- gatewayAuthPath: projectedPromptContext.promptContext.gatewayAuthPath ?? null,
774
+ gatewayCredentialPath: projectedPromptContext.promptContext.gatewayCredentialPath ?? null,
688
775
  });
689
776
  }
690
777
  resolveProjectedContextDirectory(payloadPath) {
@@ -699,8 +786,8 @@ export class CodexCliClient {
699
786
  return {
700
787
  ...promptContext,
701
788
  channelContextPayloadPath: join(projectedDirectoryPath, basename(payloadPath)),
702
- gatewayAuthPath: promptContext.gatewayAuthPath
703
- ? join(projectedDirectoryPath, basename(promptContext.gatewayAuthPath))
789
+ gatewayCredentialPath: promptContext.gatewayCredentialPath
790
+ ? join(projectedDirectoryPath, basename(promptContext.gatewayCredentialPath))
704
791
  : undefined,
705
792
  };
706
793
  }
@@ -711,12 +798,11 @@ export class CodexCliClient {
711
798
  mode: 0o600,
712
799
  });
713
800
  }
714
- async pruneProjectedGatewayAuthFiles(directoryPath, keepPath) {
801
+ async pruneProjectedGatewayCredentials(directoryPath, keepPath) {
715
802
  const keepFilename = keepPath ? basename(keepPath) : null;
716
803
  const entries = await this.runtime.readdir(directoryPath);
717
804
  await Promise.all(entries
718
- .filter((entry) => entry === PROJECTED_GATEWAY_AUTH_FILENAME
719
- || (entry.startsWith(PROJECTED_GATEWAY_AUTH_PREFIX) && entry.endsWith('.json')))
805
+ .filter((entry) => isGatewayCredentialSidecarBasename(entry))
720
806
  .filter((entry) => entry !== keepFilename)
721
807
  .map(async (entry) => {
722
808
  await this.runtime.unlink(join(directoryPath, entry)).catch((error) => {
@@ -739,10 +825,10 @@ export class CodexCliClient {
739
825
  }
740
826
  try {
741
827
  await this.runtime.mkdir(projectedDirectoryPath, { recursive: true, mode: 0o700 });
742
- await this.pruneProjectedGatewayAuthFiles(projectedDirectoryPath, projectedPromptContext.gatewayAuthPath);
828
+ await this.pruneProjectedGatewayCredentials(projectedDirectoryPath, projectedPromptContext.gatewayCredentialPath);
743
829
  await this.copyProjectedFile(payloadPath, projectedPromptContext.channelContextPayloadPath);
744
- if (resolvedPromptContext.gatewayAuthPath && projectedPromptContext.gatewayAuthPath) {
745
- await this.copyProjectedFile(resolvedPromptContext.gatewayAuthPath, projectedPromptContext.gatewayAuthPath);
830
+ if (resolvedPromptContext.gatewayCredentialPath && projectedPromptContext.gatewayCredentialPath) {
831
+ await this.copyProjectedFile(resolvedPromptContext.gatewayCredentialPath, projectedPromptContext.gatewayCredentialPath);
746
832
  }
747
833
  const content = buildCodexProjectDocument(projectedPromptContext);
748
834
  assertCodexProjectDocumentSize(content);
@@ -771,7 +857,7 @@ export class CodexCliClient {
771
857
  let rewritten = prompt;
772
858
  const replacements = [
773
859
  [sourceContext.channelContextPayloadPath, targetContext?.channelContextPayloadPath],
774
- [sourceContext.gatewayAuthPath, targetContext?.gatewayAuthPath],
860
+ [sourceContext.gatewayCredentialPath, targetContext?.gatewayCredentialPath],
775
861
  ];
776
862
  for (const [sourcePath, targetPath] of replacements) {
777
863
  if (!sourcePath || sourcePath === targetPath) {
@@ -781,6 +867,40 @@ export class CodexCliClient {
781
867
  }
782
868
  return rewritten;
783
869
  }
870
+ async buildPromptInput(turn, projectedPromptContext) {
871
+ const prompt = this.rewritePromptContextPaths(turn.prompt, turn.promptContext, projectedPromptContext);
872
+ const imageParts = turn.incomingParts.map(asImagePart).filter((part) => part !== null);
873
+ if (imageParts.length === 0) {
874
+ return prompt;
875
+ }
876
+ const imageBlocks = await Promise.all(imageParts.map(async (part) => this.fetchImageBlock(part.attachment.url, part.attachment.contentType)));
877
+ return [
878
+ { type: 'text', text: prompt },
879
+ ...imageBlocks,
880
+ ];
881
+ }
882
+ async fetchImageBlock(rawUrl, contentType) {
883
+ const baseUrl = this.imageInputConfig.borgeeBaseUrl?.trim();
884
+ const agentApiKey = this.imageInputConfig.agentApiKey?.trim();
885
+ if (!baseUrl || !agentApiKey) {
886
+ throw new Error('Codex image input requires Borgee base URL and agent API key');
887
+ }
888
+ const resolvedUrl = resolveHostedAttachmentUrl(rawUrl, baseUrl);
889
+ if (!isSameOrigin(resolvedUrl, baseUrl)) {
890
+ throw new Error(`Codex image input only supports Borgee-origin attachments: ${resolvedUrl}`);
891
+ }
892
+ const init = { headers: { Authorization: `Bearer ${agentApiKey}` } };
893
+ const response = await this.runtime.fetch(resolvedUrl, init);
894
+ if (!response.ok) {
895
+ throw new Error(`Codex image fetch failed with ${response.status} ${response.statusText}`.trim());
896
+ }
897
+ const blob = Buffer.from(await response.arrayBuffer()).toString('base64');
898
+ return {
899
+ type: 'image',
900
+ mimeType: contentType,
901
+ data: blob,
902
+ };
903
+ }
784
904
  async runTurn(session, prompt, options) {
785
905
  const promptPromise = this.raceWithFatal(session.prompt(prompt));
786
906
  const promptFailure = new Promise((_, reject) => {
@@ -856,6 +976,7 @@ export class CodexCliClient {
856
976
  }
857
977
  this.fatalError = error;
858
978
  this.rejectFatalPromise(error);
979
+ this.idleBackendShutdown.cancel();
859
980
  for (const [channelId, state] of this.channels.entries()) {
860
981
  this.clearIdleTimer(state);
861
982
  state.activeTurn?.reject(error);
@@ -1073,6 +1194,66 @@ export class CodexCliClient {
1073
1194
  return;
1074
1195
  }
1075
1196
  this.backendClosed = true;
1197
+ await this.closeBackendProcess(error, this.runtime.shutdownGracePeriodMs);
1198
+ }
1199
+ /**
1200
+ * Backend teardown without the fatal latch: the client stays usable and
1201
+ * `ensureStarted()` spawns a replacement process on the next turn. Only safe
1202
+ * while `isBackendIdle()` holds, because every channel's live ACP session is
1203
+ * dropped here and no turn is ever replayed onto its successor.
1204
+ */
1205
+ async shutdownIdleBackend() {
1206
+ // Detach the running backend before any signal reaches it, so its own exit
1207
+ // and connection-closed handlers read this teardown as deliberate instead
1208
+ // of latching a fatal error on a client that stays usable.
1209
+ this.backendGeneration += 1;
1210
+ const error = new Error(IDLE_BACKEND_STOPPED_MESSAGE);
1211
+ const sessions = [];
1212
+ for (const [channelId, state] of [...this.channels.entries()]) {
1213
+ this.clearIdleTimer(state);
1214
+ if (state.session) {
1215
+ sessions.push(state.session);
1216
+ }
1217
+ state.session = undefined;
1218
+ state.sessionCwd = undefined;
1219
+ state.sessionAdditionalDirectories = undefined;
1220
+ state.sessionVisibilityKey = undefined;
1221
+ this.channels.delete(channelId);
1222
+ }
1223
+ this.logger?.debug('stopping idle Codex ACP backend', {
1224
+ channels: sessions.length,
1225
+ idleShutdownMs: this.runtime.idleBackendShutdownMs,
1226
+ });
1227
+ for (const session of sessions) {
1228
+ this.closeSession(session);
1229
+ }
1230
+ const idleShutdownPromise = this.closeBackendProcess(error, this.runtime.idleShutdownGracePeriodMs).finally(() => {
1231
+ this.startPromise = undefined;
1232
+ this.childExitPromise = undefined;
1233
+ this.sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
1234
+ this.idleShutdownPromise = undefined;
1235
+ });
1236
+ this.idleShutdownPromise = idleShutdownPromise;
1237
+ await idleShutdownPromise;
1238
+ }
1239
+ isBackendIdle() {
1240
+ if (this.disposing || this.fatalError || this.backendClosed || this.idleShutdownPromise) {
1241
+ return false;
1242
+ }
1243
+ if (!this.child) {
1244
+ return false;
1245
+ }
1246
+ if (this.pendingSessionStarts.size > 0) {
1247
+ return false;
1248
+ }
1249
+ for (const state of this.channels.values()) {
1250
+ if (state.activeTurn || state.queue.length > 0 || state.sessionPromise || state.processing) {
1251
+ return false;
1252
+ }
1253
+ }
1254
+ return true;
1255
+ }
1256
+ async closeBackendProcess(error, gracePeriodMs) {
1076
1257
  const connection = this.connection;
1077
1258
  const child = this.child;
1078
1259
  await this.waitForPendingSessionStarts();
@@ -1084,9 +1265,9 @@ export class CodexCliClient {
1084
1265
  return;
1085
1266
  }
1086
1267
  const childExitPromise = this.childExitPromise ?? Promise.resolve();
1087
- this.logger?.debug('sending SIGTERM to Codex ACP process');
1268
+ this.logger?.debug('sending SIGTERM to Codex ACP process', { gracePeriodMs });
1088
1269
  child.kill('SIGTERM');
1089
- const exitedAfterTerm = await this.waitForChildExit(childExitPromise, this.runtime.shutdownGracePeriodMs);
1270
+ const exitedAfterTerm = await this.waitForChildExit(childExitPromise, gracePeriodMs);
1090
1271
  if (exitedAfterTerm) {
1091
1272
  this.logger?.debug('Codex ACP process exited after SIGTERM');
1092
1273
  this.child = undefined;
@@ -1,35 +1,27 @@
1
- import { basename } from 'node:path';
2
1
  import { buildAttentionSummaryLines } from '../../context/attention.js';
3
2
  import { buildCollaborationCapabilityDeclarationSummaryLines, buildMissedCollaborationDiagnosticSummaryLines, } from '../../context/collaboration-capabilities-diagnostics.js';
4
3
  import { buildCollaborationOutcomeSummaryLines } from '../../context/collaboration-outcome.js';
4
+ import { MAIN_SESSION_DELEGATION_LINES } from '../../context/main-session-delegation.js';
5
+ import { buildSkillManualLines } from '../../context/skill-manual.js';
5
6
  import { buildTaskThreadCollaborationSummaryLines } from '../../context/task-thread-collaboration.js';
6
7
  const PROJECT_DOC_MAX_BYTES = 32 * 1024;
8
+ // Every command the CLI carries needs the gateway credential file, so the capability is only
9
+ // worth naming in a workspace that also names one; otherwise the model is handed an invocation
10
+ // template it cannot fill.
7
11
  function buildSkillRuntimeLines(context) {
8
- if (!context?.skillRuntime || !context.channelContextPayloadPath) {
12
+ if (!context?.skillRuntime || !context.gatewayCredentialPath) {
9
13
  return [];
10
14
  }
11
- return [
12
- 'Read-only local skill runtime bootstrap is available in this workspace.',
13
- `Context payload: ${context.channelContextPayloadPath}`,
14
- `Skill guide: ${context.skillRuntime.skillMarkdownPath}`,
15
- `Node CLI: node ${context.skillRuntime.nodeCliPath} --context ${context.channelContextPayloadPath} --print-bootstrap`,
16
- `Python CLI: python3 ${context.skillRuntime.pythonCliPath} --context ${context.channelContextPayloadPath} --print-bootstrap`,
17
- 'These CLIs are local-only, may only access the loopback gateway surfaced by the host, and must not mutate files or the local environment.',
18
- ];
15
+ return buildSkillManualLines(context.skillRuntime);
19
16
  }
20
- function buildGatewayLines(context) {
21
- if (!context?.localhostGateway || !context.channelContextPayloadPath) {
17
+ // The credential file is the whole handoff: it carries the gateway's base URL along with the
18
+ // channel id and the token, and the CLI reads all three out of it. Naming the base URL separately
19
+ // would only invite a hand-rolled request that carries the token off the CLI's one path.
20
+ function buildGatewayCredentialLines(context) {
21
+ if (!context?.localhostGateway || !context.gatewayCredentialPath) {
22
22
  return [];
23
23
  }
24
- const lines = [
25
- 'A loopback-only localhost gateway may be available for this channel.',
26
- `Gateway base URL: ${context.localhostGateway.baseUrl}`,
27
- 'Use the packaged local CLI with the context payload above for gateway access.',
28
- ];
29
- if (context.gatewayAuthPath) {
30
- lines.push(`Per-turn gateway auth sidecar: ${context.gatewayAuthPath}`, 'If a later turn prompt advertises a different auth sidecar path, prefer the prompt-visible path for that turn.');
31
- }
32
- return lines;
24
+ return [`Gateway credential file: ${context.gatewayCredentialPath}`];
33
25
  }
34
26
  export function buildCodexProjectDocument(context) {
35
27
  const lines = [
@@ -37,13 +29,8 @@ export function buildCodexProjectDocument(context) {
37
29
  '',
38
30
  'This workspace belongs to one Borgee channel session hosted by agents-host.',
39
31
  'Keep visible replies concise, do not claim actions you did not perform, and prefer the per-turn prompt when it provides newer turn-local details.',
40
- ...(context?.channelContextPayloadPath
41
- ? [
42
- '',
43
- `Context payload file: ${context.channelContextPayloadPath}`,
44
- `Workspace anchor: ${basename(context.channelContextPayloadPath)}`,
45
- ]
46
- : []),
32
+ '',
33
+ ...MAIN_SESSION_DELEGATION_LINES,
47
34
  ...(context?.taskWorkspace
48
35
  ? [
49
36
  '',
@@ -76,7 +63,7 @@ export function buildCodexProjectDocument(context) {
76
63
  return skillRuntimeLines.length > 0 ? ['', ...skillRuntimeLines] : [];
77
64
  })(),
78
65
  ...(() => {
79
- const gatewayLines = buildGatewayLines(context);
66
+ const gatewayLines = buildGatewayCredentialLines(context);
80
67
  return gatewayLines.length > 0 ? ['', ...gatewayLines] : [];
81
68
  })(),
82
69
  ];
@@ -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 { CopilotCliClient } from './cli-client.js';
5
+ export declare const COPILOT_HOSTED_PROVIDER_CAPABILITIES: import("../provider-adapter.js").ProviderCapabilities;
5
6
  export declare class CopilotProviderAdapter implements ProviderAdapter {
6
7
  private readonly cli;
7
8
  private readonly turnPreparer;
9
+ readonly capabilities: import("../provider-adapter.js").ProviderCapabilities;
8
10
  constructor(cli: CopilotCliClient, 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 COPILOT_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 CopilotProviderAdapter {
3
12
  cli;
4
13
  turnPreparer;
14
+ capabilities = COPILOT_HOSTED_PROVIDER_CAPABILITIES;
5
15
  constructor(cli, turnPreparer) {
6
16
  this.cli = cli;
7
17
  this.turnPreparer = turnPreparer;
@@ -11,7 +21,9 @@ export class CopilotProviderAdapter {
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();