@borgee/agents-host 0.2.35 → 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 (42) hide show
  1. package/dist/agents-host.d.ts +2 -0
  2. package/dist/agents-host.js +111 -44
  3. package/dist/chat/sdk-chat-control-plane.js +3 -0
  4. package/dist/context/injection.d.ts +5 -3
  5. package/dist/context/injection.js +45 -29
  6. package/dist/context/prompt.js +23 -35
  7. package/dist/context/skill-manual.d.ts +13 -0
  8. package/dist/context/skill-manual.js +18 -0
  9. package/dist/context/turn-preparation.js +13 -4
  10. package/dist/gateway/localhost-gateway.js +16 -8
  11. package/dist/hosted-turn-content.d.ts +15 -0
  12. package/dist/hosted-turn-content.js +50 -0
  13. package/dist/managed-daemon.d.ts +3 -2
  14. package/dist/managed-daemon.js +77 -29
  15. package/dist/providers/claude/adapter.d.ts +3 -1
  16. package/dist/providers/claude/adapter.js +10 -0
  17. package/dist/providers/claude/cli-client.d.ts +11 -2
  18. package/dist/providers/claude/cli-client.js +98 -27
  19. package/dist/providers/codex/adapter.d.ts +3 -1
  20. package/dist/providers/codex/adapter.js +10 -0
  21. package/dist/providers/codex/cli-client.d.ts +10 -2
  22. package/dist/providers/codex/cli-client.js +85 -21
  23. package/dist/providers/codex/project-doc.js +13 -29
  24. package/dist/providers/copilot/adapter.d.ts +3 -1
  25. package/dist/providers/copilot/adapter.js +10 -0
  26. package/dist/providers/copilot/cli-client.d.ts +9 -1
  27. package/dist/providers/copilot/cli-client.js +71 -8
  28. package/dist/providers/create-provider.d.ts +1 -1
  29. package/dist/providers/create-provider.js +16 -5
  30. package/dist/providers/provider-adapter.d.ts +35 -0
  31. package/dist/providers/provider-adapter.js +44 -1
  32. package/dist/state-paths.d.ts +9 -1
  33. package/dist/state-paths.js +22 -3
  34. package/dist/types.d.ts +33 -2
  35. package/package.json +1 -1
  36. package/skills/borgee-agent/SKILL.md +119 -38
  37. package/skills/borgee-agent/references/errors.md +38 -0
  38. package/skills/borgee-agent/references/task-properties.md +30 -0
  39. package/skills/borgee-agent/scripts/borgee-agent.mjs +553 -0
  40. package/skills/borgee-agent/scripts/borgee-agent.py +547 -0
  41. package/skills/borgee-agent/borgee-agent.mjs +0 -562
  42. package/skills/borgee-agent/borgee-agent.py +0 -469
@@ -1,7 +1,7 @@
1
1
  import spawn from 'cross-spawn';
2
2
  import { PROTOCOL_VERSION, client, methods, ndJsonStream } from '@agentclientprotocol/sdk';
3
3
  import { type DebugLogger } from '../../debug.js';
4
- import type { PreparedProviderTurnInput, ProviderGenerateOptions } from '../../types.js';
4
+ import type { PreparedPromptContext, PreparedProviderTurnInput, ProviderGenerateOptions } from '../../types.js';
5
5
  import type { ClaudeChannelSessionStore } from './session-store.js';
6
6
  interface ClaudeAcpRuntime {
7
7
  spawn: typeof spawn;
@@ -9,12 +9,18 @@ interface ClaudeAcpRuntime {
9
9
  ndJsonStream: typeof ndJsonStream;
10
10
  methods: typeof methods;
11
11
  protocolVersion: typeof PROTOCOL_VERSION;
12
+ fetch: typeof fetch;
12
13
  cwd: string;
13
14
  idleBackendShutdownMs: number;
14
15
  idleShutdownGracePeriodMs: number;
15
16
  shutdownGracePeriodMs: number;
16
17
  shutdownForceKillWaitMs: number;
17
18
  }
19
+ interface ClaudeHostedImageInputConfig {
20
+ borgeeBaseUrl?: string;
21
+ agentApiKey?: string;
22
+ }
23
+ export declare function buildClaudeSessionSystemPrompt(promptContext?: PreparedPromptContext): string | undefined;
18
24
  /**
19
25
  * Persistent ACP-backed client for the Claude ACP adapter.
20
26
  *
@@ -29,6 +35,7 @@ export declare class ClaudeCliClient {
29
35
  private readonly sessionStore?;
30
36
  private readonly resolveSessionStoreAgentId;
31
37
  private readonly logger;
38
+ private readonly imageInputConfig;
32
39
  private readonly runtime;
33
40
  private readonly channels;
34
41
  /**
@@ -65,7 +72,7 @@ export declare class ClaudeCliClient {
65
72
  private sessionCapabilities;
66
73
  private readonly idleBackendShutdown;
67
74
  private childStderr;
68
- constructor(command: string, args?: string[], runtimeOverrides?: Partial<ClaudeAcpRuntime>, sessionStore?: ClaudeChannelSessionStore | undefined, resolveSessionStoreAgentId?: () => string | undefined, logger?: DebugLogger);
75
+ constructor(command: string, args?: string[], runtimeOverrides?: Partial<ClaudeAcpRuntime>, sessionStore?: ClaudeChannelSessionStore | undefined, resolveSessionStoreAgentId?: () => string | undefined, logger?: DebugLogger, imageInputConfig?: ClaudeHostedImageInputConfig);
69
76
  generateReply(turn: PreparedProviderTurnInput, options?: ProviderGenerateOptions): Promise<string>;
70
77
  generateReply(channelId: string, prompt: string, options?: ProviderGenerateOptions): Promise<string>;
71
78
  dispose(): Promise<void>;
@@ -110,5 +117,7 @@ export declare class ClaudeCliClient {
110
117
  private waitForPendingSessionStarts;
111
118
  private waitForPendingSessionCloses;
112
119
  private waitForChildExit;
120
+ private buildPromptInput;
121
+ private fetchImageBlock;
113
122
  }
114
123
  export {};
@@ -6,6 +6,7 @@ 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';
9
10
  import { IDLE_BACKEND_SHUTDOWN_DISABLED_MS, IdleBackendShutdownScheduler, } from '../idle-backend-shutdown.js';
10
11
  const SESSION_TAINTED_ERRORS = new WeakSet();
11
12
  const IDLE_BACKEND_STOPPED_MESSAGE = 'Claude ACP backend stopped after idle timeout';
@@ -30,6 +31,7 @@ const DEFAULT_RUNTIME = {
30
31
  ndJsonStream,
31
32
  methods,
32
33
  protocolVersion: PROTOCOL_VERSION,
34
+ fetch,
33
35
  cwd: process.cwd(),
34
36
  idleBackendShutdownMs: IDLE_BACKEND_SHUTDOWN_DISABLED_MS,
35
37
  idleShutdownGracePeriodMs: DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS,
@@ -42,6 +44,25 @@ function hasVisibleText(value) {
42
44
  function normalizeError(error) {
43
45
  return error instanceof Error ? error : new Error(String(error));
44
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
+ }
45
66
  function parsePersistedSessionRecord(rawValue) {
46
67
  const trimmed = rawValue.trim();
47
68
  if (trimmed.startsWith('{')) {
@@ -168,7 +189,7 @@ class ClaudeProgressCollector {
168
189
  this.onProgress({ text });
169
190
  }
170
191
  }
171
- function createDeferredTurn(channelId, prompt, sessionPersistence, promptContext, options) {
192
+ function createDeferredTurn(channelId, preparedTurn, sessionPersistence, options) {
172
193
  let settled = false;
173
194
  let resolvePromise;
174
195
  let rejectPromise;
@@ -178,9 +199,8 @@ function createDeferredTurn(channelId, prompt, sessionPersistence, promptContext
178
199
  });
179
200
  return {
180
201
  channelId,
181
- prompt,
202
+ preparedTurn,
182
203
  sessionPersistence,
183
- promptContext,
184
204
  options,
185
205
  promise,
186
206
  resolve(value) {
@@ -204,6 +224,9 @@ function resolveSessionRouting(turn) {
204
224
  persistence: turn.providerSessionRouting?.persistence ?? 'persistent',
205
225
  };
206
226
  }
227
+ function asImagePart(part) {
228
+ return part.type === 'image' ? part : null;
229
+ }
207
230
  function asObject(value) {
208
231
  return typeof value === 'object' && value !== null ? value : null;
209
232
  }
@@ -267,25 +290,18 @@ function resolveClaudeLaunch(command, args, baseCwd) {
267
290
  args: args.map((arg) => (isRelativePathLike(arg) ? resolve(baseCwd, arg) : arg)),
268
291
  };
269
292
  }
270
- function buildClaudeSessionSystemPrompt(promptContext) {
293
+ export function buildClaudeSessionSystemPrompt(promptContext) {
271
294
  if (!promptContext) {
272
295
  return undefined;
273
296
  }
274
297
  const lines = [];
275
- if (promptContext.channelContextPayloadPath) {
276
- lines.push('Channel-scoped local context is available for this session.');
277
- lines.push(`Channel context payload: ${promptContext.channelContextPayloadPath}`);
278
- }
279
- if (promptContext.skillRuntime) {
280
- lines.push(`Skill guide: ${promptContext.skillRuntime.skillMarkdownPath}`);
281
- lines.push(`Node bootstrap CLI: node ${promptContext.skillRuntime.nodeCliPath} --context ${promptContext.channelContextPayloadPath} --print-bootstrap`);
282
- lines.push(`Python bootstrap CLI: python3 ${promptContext.skillRuntime.pythonCliPath} --context ${promptContext.channelContextPayloadPath} --print-bootstrap`);
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));
283
302
  }
284
- if (promptContext.localhostGateway && promptContext.gatewayAuthPath) {
285
- lines.push(`Gateway auth sidecar for this session: ${promptContext.gatewayAuthPath}`);
286
- if (promptContext.localhostGateway.baseUrl) {
287
- lines.push(`Loopback gateway base URL: ${promptContext.localhostGateway.baseUrl}`);
288
- }
303
+ if (promptContext.localhostGateway && promptContext.gatewayCredentialPath) {
304
+ lines.push(`Gateway credential file for this session: ${promptContext.gatewayCredentialPath}`);
289
305
  }
290
306
  if (promptContext.taskWorkspace) {
291
307
  lines.push(`Task-scoped writable workspace root: ${promptContext.taskWorkspace.rootPath}.`);
@@ -312,20 +328,32 @@ function resolveSessionAdditionalDirectories(promptContext) {
312
328
  if (promptContext?.channelContextPayloadPath) {
313
329
  directories.add(dirname(promptContext.channelContextPayloadPath));
314
330
  }
315
- if (promptContext?.gatewayAuthPath) {
316
- directories.add(dirname(promptContext.gatewayAuthPath));
331
+ if (promptContext?.gatewayCredentialPath) {
332
+ directories.add(dirname(promptContext.gatewayCredentialPath));
317
333
  }
318
334
  if (promptContext?.skillRuntime?.skillDirectoryPath) {
319
335
  directories.add(promptContext.skillRuntime.skillDirectoryPath);
320
336
  }
321
337
  return directories.size > 0 ? [...directories].sort((left, right) => left.localeCompare(right)) : undefined;
322
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
+ */
323
346
  function resolveSessionVisibilityKey(promptContext) {
324
347
  const additionalDirectories = resolveSessionAdditionalDirectories(promptContext);
325
348
  if (!additionalDirectories) {
326
349
  return undefined;
327
350
  }
328
- return JSON.stringify({ additionalDirectories });
351
+ return JSON.stringify({
352
+ additionalDirectories,
353
+ ...(promptContext?.gatewayCredentialPath
354
+ ? { gatewayCredentialPath: promptContext.gatewayCredentialPath }
355
+ : {}),
356
+ });
329
357
  }
330
358
  function buildSessionRequest(cwd, promptContext, additionalDirectories) {
331
359
  const meta = buildClaudeSessionMeta(promptContext);
@@ -369,6 +397,7 @@ export class ClaudeCliClient {
369
397
  sessionStore;
370
398
  resolveSessionStoreAgentId;
371
399
  logger;
400
+ imageInputConfig;
372
401
  runtime;
373
402
  channels = new Map();
374
403
  /**
@@ -407,12 +436,13 @@ export class ClaudeCliClient {
407
436
  sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
408
437
  idleBackendShutdown;
409
438
  childStderr = '';
410
- constructor(command, args = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger()) {
439
+ constructor(command, args = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger(), imageInputConfig = {}) {
411
440
  this.command = command;
412
441
  this.args = args;
413
442
  this.sessionStore = sessionStore;
414
443
  this.resolveSessionStoreAgentId = resolveSessionStoreAgentId;
415
444
  this.logger = logger;
445
+ this.imageInputConfig = imageInputConfig;
416
446
  this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
417
447
  assertClaudeCommandCompatibility(command, args, 'Claude provider runtime');
418
448
  this.idleBackendShutdown = new IdleBackendShutdownScheduler({
@@ -434,19 +464,26 @@ export class ClaudeCliClient {
434
464
  if (this.fatalError) {
435
465
  throw this.fatalError;
436
466
  }
437
- const preparedTurn = typeof channelIdOrTurn === 'string'
467
+ const preparedTurnBase = typeof channelIdOrTurn === 'string'
438
468
  ? {
439
469
  channelId: channelIdOrTurn,
470
+ incomingContent: '',
471
+ incomingParts: [],
440
472
  prompt: typeof promptOrOptions === 'string' ? promptOrOptions : '',
441
473
  }
442
474
  : channelIdOrTurn;
475
+ const preparedTurn = {
476
+ ...preparedTurnBase,
477
+ incomingContent: preparedTurnBase.incomingContent ?? '',
478
+ incomingParts: preparedTurnBase.incomingParts ?? [],
479
+ };
443
480
  const options = typeof channelIdOrTurn === 'string'
444
481
  ? maybeOptions
445
482
  : promptOrOptions;
446
483
  const sessionRouting = resolveSessionRouting(preparedTurn);
447
484
  const state = this.getOrCreateChannelState(sessionRouting.key, preparedTurn.channelId, sessionRouting.persistence);
448
485
  this.idleBackendShutdown.cancel();
449
- const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn.prompt, sessionRouting.persistence, preparedTurn.promptContext, options);
486
+ const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn, sessionRouting.persistence, options);
450
487
  state.queue.push(turn);
451
488
  this.processChannelQueue(sessionRouting.key, state);
452
489
  return turn.promise;
@@ -618,14 +655,15 @@ export class ClaudeCliClient {
618
655
  }
619
656
  state.activeTurn = turn;
620
657
  try {
621
- state.cwd = this.resolveSessionCwd(turn.promptContext);
622
- state.visibilityKey = resolveSessionVisibilityKey(turn.promptContext);
658
+ state.cwd = this.resolveSessionCwd(turn.preparedTurn.promptContext);
659
+ state.visibilityKey = resolveSessionVisibilityKey(turn.preparedTurn.promptContext);
623
660
  await this.ensureStarted();
624
661
  await this.recycleSessionIfScopeChanged(channelId, state);
625
- const session = await this.getOrCreateSession(channelId, state, turn.promptContext);
662
+ const session = await this.getOrCreateSession(channelId, state, turn.preparedTurn.promptContext);
626
663
  let reply;
627
664
  try {
628
- 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);
629
667
  }
630
668
  catch (error) {
631
669
  if (isSessionTainted(error)) {
@@ -1199,4 +1237,37 @@ export class ClaudeCliClient {
1199
1237
  }
1200
1238
  }
1201
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
+ }
1202
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;
@@ -28,6 +28,11 @@ interface CodexAcpRuntime {
28
28
  mode?: number;
29
29
  }): Promise<void>;
30
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;
31
36
  }
32
37
  export declare class CodexCliClient {
33
38
  private readonly command;
@@ -35,6 +40,7 @@ export declare class CodexCliClient {
35
40
  private readonly sessionStore?;
36
41
  private readonly resolveSessionStoreAgentId;
37
42
  private readonly logger;
43
+ private readonly imageInputConfig;
38
44
  private readonly runtime;
39
45
  private readonly channels;
40
46
  /**
@@ -70,7 +76,7 @@ export declare class CodexCliClient {
70
76
  private sessionStoreWriteQueue;
71
77
  private sessionCapabilities;
72
78
  private readonly idleBackendShutdown;
73
- constructor(command: string, args?: string[], runtimeOverrides?: Partial<CodexAcpRuntime>, sessionStore?: CodexChannelSessionStore | undefined, resolveSessionStoreAgentId?: () => string | undefined, logger?: DebugLogger);
79
+ constructor(command: string, args?: string[], runtimeOverrides?: Partial<CodexAcpRuntime>, sessionStore?: CodexChannelSessionStore | undefined, resolveSessionStoreAgentId?: () => string | undefined, logger?: DebugLogger, imageInputConfig?: CodexHostedImageInputConfig);
74
80
  generateReply(turn: PreparedProviderTurnInput, options?: ProviderGenerateOptions): Promise<string>;
75
81
  generateReply(channelId: string, prompt: string, options?: ProviderGenerateOptions): Promise<string>;
76
82
  dispose(): Promise<void>;
@@ -90,9 +96,11 @@ export declare class CodexCliClient {
90
96
  private resolveProjectedContextDirectory;
91
97
  private buildProjectedPromptContext;
92
98
  private copyProjectedFile;
93
- private pruneProjectedGatewayAuthFiles;
99
+ private pruneProjectedGatewayCredentials;
94
100
  private refreshProjectedPromptContextBestEffort;
95
101
  private rewritePromptContextPaths;
102
+ private buildPromptInput;
103
+ private fetchImageBlock;
96
104
  private runTurn;
97
105
  private raceWithFatal;
98
106
  private invalidateSession;
@@ -6,6 +6,7 @@ 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';
9
10
  import { IDLE_BACKEND_SHUTDOWN_DISABLED_MS, IdleBackendShutdownScheduler, } from '../idle-backend-shutdown.js';
10
11
  const SESSION_TAINTED_ERRORS = new WeakSet();
11
12
  const DEFAULT_IDLE_SESSION_TTL_MS = 2 * 24 * 60 * 60 * 1000;
@@ -27,8 +28,6 @@ const DEFAULT_SESSION_CAPABILITIES = {
27
28
  const DEFAULT_CODEX_COMMAND = 'codex-acp';
28
29
  const DEFAULT_CODEX_ARGS = [];
29
30
  const CODEX_CONTEXT_ROOT_DIRNAME = 'codex-context';
30
- const PROJECTED_GATEWAY_AUTH_FILENAME = '.localhost-gateway-auth.json';
31
- const PROJECTED_GATEWAY_AUTH_PREFIX = '.localhost-gateway-auth.';
32
31
  const PROJECTION_UNAVAILABLE_PLACEHOLDER = '[codex-projection-unavailable]';
33
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';
34
33
  const require = createRequire(import.meta.url);
@@ -59,6 +58,7 @@ const DEFAULT_RUNTIME = {
59
58
  unlink: async (path) => {
60
59
  await fs.unlink(path);
61
60
  },
61
+ fetch: async (input, init) => fetch(input, init),
62
62
  };
63
63
  function hasVisibleText(value) {
64
64
  return typeof value === 'string' && value.trim().length > 0;
@@ -181,7 +181,7 @@ class CodexProgressCollector {
181
181
  this.onProgress({ text });
182
182
  }
183
183
  }
184
- function createDeferredTurn(channelId, prompt, promptContext, sessionPersistence, options) {
184
+ function createDeferredTurn(channelId, preparedTurn, sessionPersistence, options) {
185
185
  let settled = false;
186
186
  let resolvePromise;
187
187
  let rejectPromise;
@@ -191,8 +191,7 @@ function createDeferredTurn(channelId, prompt, promptContext, sessionPersistence
191
191
  });
192
192
  return {
193
193
  channelId,
194
- prompt,
195
- promptContext,
194
+ preparedTurn,
196
195
  sessionPersistence,
197
196
  options,
198
197
  promise,
@@ -210,6 +209,28 @@ function createDeferredTurn(channelId, prompt, promptContext, sessionPersistence
210
209
  },
211
210
  };
212
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
+ }
213
234
  function normalizeError(error) {
214
235
  return error instanceof Error ? error : new Error(String(error));
215
236
  }
@@ -296,6 +317,7 @@ export class CodexCliClient {
296
317
  sessionStore;
297
318
  resolveSessionStoreAgentId;
298
319
  logger;
320
+ imageInputConfig;
299
321
  runtime;
300
322
  channels = new Map();
301
323
  /**
@@ -333,12 +355,13 @@ export class CodexCliClient {
333
355
  sessionStoreWriteQueue = Promise.resolve();
334
356
  sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
335
357
  idleBackendShutdown;
336
- constructor(command, args = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger()) {
358
+ constructor(command, args = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger(), imageInputConfig = {}) {
337
359
  this.command = command;
338
360
  this.args = args;
339
361
  this.sessionStore = sessionStore;
340
362
  this.resolveSessionStoreAgentId = resolveSessionStoreAgentId;
341
363
  this.logger = logger;
364
+ this.imageInputConfig = imageInputConfig;
342
365
  this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
343
366
  this.idleBackendShutdown = new IdleBackendShutdownScheduler({
344
367
  idleShutdownMs: this.runtime.idleBackendShutdownMs,
@@ -359,12 +382,19 @@ export class CodexCliClient {
359
382
  if (this.fatalError) {
360
383
  throw this.fatalError;
361
384
  }
362
- const preparedTurn = typeof channelIdOrTurn === 'string'
385
+ const preparedTurnBase = typeof channelIdOrTurn === 'string'
363
386
  ? {
364
387
  channelId: channelIdOrTurn,
388
+ incomingContent: '',
389
+ incomingParts: [],
365
390
  prompt: typeof promptOrOptions === 'string' ? promptOrOptions : '',
366
391
  }
367
392
  : channelIdOrTurn;
393
+ const preparedTurn = {
394
+ ...preparedTurnBase,
395
+ incomingContent: preparedTurnBase.incomingContent ?? '',
396
+ incomingParts: preparedTurnBase.incomingParts ?? [],
397
+ };
368
398
  const options = typeof channelIdOrTurn === 'string'
369
399
  ? maybeOptions
370
400
  : promptOrOptions;
@@ -372,7 +402,7 @@ export class CodexCliClient {
372
402
  const state = this.getOrCreateChannelState(sessionRouting.key, preparedTurn.channelId, sessionRouting.persistence);
373
403
  this.clearIdleTimer(state);
374
404
  this.idleBackendShutdown.cancel();
375
- const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn.prompt, preparedTurn.promptContext, sessionRouting.persistence, options);
405
+ const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn, sessionRouting.persistence, options);
376
406
  state.queue.push(turn);
377
407
  this.processChannelQueue(sessionRouting.key, state);
378
408
  return turn.promise;
@@ -536,8 +566,8 @@ export class CodexCliClient {
536
566
  }
537
567
  state.activeTurn = turn;
538
568
  try {
539
- state.cwd = await this.resolveSessionCwd(turn.promptContext);
540
- 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);
541
571
  state.additionalDirectories = this.resolveSessionAdditionalDirectories(projectedPromptContext);
542
572
  state.visibilityKey = this.resolveSessionVisibilityKey(projectedPromptContext);
543
573
  await this.ensureStarted();
@@ -545,7 +575,8 @@ export class CodexCliClient {
545
575
  const session = await this.getOrCreateSession(channelId, state);
546
576
  let reply;
547
577
  try {
548
- 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);
549
580
  }
550
581
  catch (error) {
551
582
  if (isSessionTainted(error)) {
@@ -740,7 +771,7 @@ export class CodexCliClient {
740
771
  return JSON.stringify({
741
772
  directoryPath: projectedPromptContext.directoryPath,
742
773
  channelContextPayloadPath: projectedPromptContext.promptContext.channelContextPayloadPath,
743
- gatewayAuthPath: projectedPromptContext.promptContext.gatewayAuthPath ?? null,
774
+ gatewayCredentialPath: projectedPromptContext.promptContext.gatewayCredentialPath ?? null,
744
775
  });
745
776
  }
746
777
  resolveProjectedContextDirectory(payloadPath) {
@@ -755,8 +786,8 @@ export class CodexCliClient {
755
786
  return {
756
787
  ...promptContext,
757
788
  channelContextPayloadPath: join(projectedDirectoryPath, basename(payloadPath)),
758
- gatewayAuthPath: promptContext.gatewayAuthPath
759
- ? join(projectedDirectoryPath, basename(promptContext.gatewayAuthPath))
789
+ gatewayCredentialPath: promptContext.gatewayCredentialPath
790
+ ? join(projectedDirectoryPath, basename(promptContext.gatewayCredentialPath))
760
791
  : undefined,
761
792
  };
762
793
  }
@@ -767,12 +798,11 @@ export class CodexCliClient {
767
798
  mode: 0o600,
768
799
  });
769
800
  }
770
- async pruneProjectedGatewayAuthFiles(directoryPath, keepPath) {
801
+ async pruneProjectedGatewayCredentials(directoryPath, keepPath) {
771
802
  const keepFilename = keepPath ? basename(keepPath) : null;
772
803
  const entries = await this.runtime.readdir(directoryPath);
773
804
  await Promise.all(entries
774
- .filter((entry) => entry === PROJECTED_GATEWAY_AUTH_FILENAME
775
- || (entry.startsWith(PROJECTED_GATEWAY_AUTH_PREFIX) && entry.endsWith('.json')))
805
+ .filter((entry) => isGatewayCredentialSidecarBasename(entry))
776
806
  .filter((entry) => entry !== keepFilename)
777
807
  .map(async (entry) => {
778
808
  await this.runtime.unlink(join(directoryPath, entry)).catch((error) => {
@@ -795,10 +825,10 @@ export class CodexCliClient {
795
825
  }
796
826
  try {
797
827
  await this.runtime.mkdir(projectedDirectoryPath, { recursive: true, mode: 0o700 });
798
- await this.pruneProjectedGatewayAuthFiles(projectedDirectoryPath, projectedPromptContext.gatewayAuthPath);
828
+ await this.pruneProjectedGatewayCredentials(projectedDirectoryPath, projectedPromptContext.gatewayCredentialPath);
799
829
  await this.copyProjectedFile(payloadPath, projectedPromptContext.channelContextPayloadPath);
800
- if (resolvedPromptContext.gatewayAuthPath && projectedPromptContext.gatewayAuthPath) {
801
- await this.copyProjectedFile(resolvedPromptContext.gatewayAuthPath, projectedPromptContext.gatewayAuthPath);
830
+ if (resolvedPromptContext.gatewayCredentialPath && projectedPromptContext.gatewayCredentialPath) {
831
+ await this.copyProjectedFile(resolvedPromptContext.gatewayCredentialPath, projectedPromptContext.gatewayCredentialPath);
802
832
  }
803
833
  const content = buildCodexProjectDocument(projectedPromptContext);
804
834
  assertCodexProjectDocumentSize(content);
@@ -827,7 +857,7 @@ export class CodexCliClient {
827
857
  let rewritten = prompt;
828
858
  const replacements = [
829
859
  [sourceContext.channelContextPayloadPath, targetContext?.channelContextPayloadPath],
830
- [sourceContext.gatewayAuthPath, targetContext?.gatewayAuthPath],
860
+ [sourceContext.gatewayCredentialPath, targetContext?.gatewayCredentialPath],
831
861
  ];
832
862
  for (const [sourcePath, targetPath] of replacements) {
833
863
  if (!sourcePath || sourcePath === targetPath) {
@@ -837,6 +867,40 @@ export class CodexCliClient {
837
867
  }
838
868
  return rewritten;
839
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
+ }
840
904
  async runTurn(session, prompt, options) {
841
905
  const promptPromise = this.raceWithFatal(session.prompt(prompt));
842
906
  const promptFailure = new Promise((_, reject) => {