@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.
- package/README.md +28 -7
- package/dist/agents-host.d.ts +19 -0
- package/dist/agents-host.js +163 -44
- package/dist/chat/chat-control-plane.d.ts +9 -0
- package/dist/chat/sdk-chat-control-plane.d.ts +10 -1
- package/dist/chat/sdk-chat-control-plane.js +9 -0
- package/dist/cli-args.d.ts +1 -1
- package/dist/cli-args.js +5 -0
- package/dist/compatibility-gates.d.ts +1 -0
- package/dist/compatibility-gates.js +2 -0
- package/dist/config.d.ts +4 -1
- package/dist/config.js +21 -3
- package/dist/context/injection.d.ts +5 -3
- package/dist/context/injection.js +45 -29
- package/dist/context/main-session-delegation.d.ts +1 -0
- package/dist/context/main-session-delegation.js +6 -0
- package/dist/context/prompt.js +25 -35
- package/dist/context/skill-manual.d.ts +13 -0
- package/dist/context/skill-manual.js +18 -0
- package/dist/context/turn-preparation.js +13 -4
- package/dist/gateway/localhost-gateway.js +75 -1
- package/dist/hosted-turn-content.d.ts +15 -0
- package/dist/hosted-turn-content.js +50 -0
- package/dist/local-config.js +10 -1
- package/dist/managed-daemon.d.ts +3 -2
- package/dist/managed-daemon.js +82 -29
- package/dist/plugin-sdk.js +58 -1
- package/dist/plugin-sdk.js.map +2 -2
- package/dist/policy/gateway-authorization.d.ts +18 -3
- package/dist/policy/gateway-authorization.js +33 -1
- package/dist/providers/claude/adapter.d.ts +3 -1
- package/dist/providers/claude/adapter.js +13 -1
- package/dist/providers/claude/cli-client.d.ts +36 -3
- package/dist/providers/claude/cli-client.js +225 -37
- package/dist/providers/codex/adapter.d.ts +3 -1
- package/dist/providers/codex/adapter.js +13 -1
- package/dist/providers/codex/cli-client.d.ts +35 -3
- package/dist/providers/codex/cli-client.js +212 -31
- package/dist/providers/codex/project-doc.js +16 -29
- package/dist/providers/copilot/adapter.d.ts +3 -1
- package/dist/providers/copilot/adapter.js +13 -1
- package/dist/providers/copilot/cli-client.d.ts +34 -2
- package/dist/providers/copilot/cli-client.js +196 -18
- package/dist/providers/create-provider.d.ts +1 -1
- package/dist/providers/create-provider.js +30 -14
- package/dist/providers/idle-backend-shutdown.d.ts +16 -0
- package/dist/providers/idle-backend-shutdown.js +53 -0
- package/dist/providers/provider-adapter.d.ts +35 -0
- package/dist/providers/provider-adapter.js +44 -1
- package/dist/state-paths.d.ts +9 -1
- package/dist/state-paths.js +22 -3
- package/dist/types.d.ts +40 -2
- package/package.json +2 -2
- package/skills/borgee-agent/SKILL.md +133 -35
- package/skills/borgee-agent/references/errors.md +38 -0
- package/skills/borgee-agent/references/task-properties.md +30 -0
- package/skills/borgee-agent/scripts/borgee-agent.mjs +553 -0
- package/skills/borgee-agent/scripts/borgee-agent.py +547 -0
- package/skills/borgee-agent/borgee-agent.mjs +0 -507
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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.
|
|
268
|
-
lines.push(`
|
|
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?.
|
|
304
|
-
directories.add(dirname(promptContext.
|
|
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({
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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([
|
|
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
|
-
|
|
482
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|