@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
|
@@ -13,14 +13,21 @@ interface CopilotAcpRuntime {
|
|
|
13
13
|
protocolVersion: typeof PROTOCOL_VERSION;
|
|
14
14
|
cwd: string;
|
|
15
15
|
idleSessionTtlMs: number;
|
|
16
|
+
idleBackendShutdownMs: number;
|
|
17
|
+
idleShutdownGracePeriodMs: number;
|
|
16
18
|
shutdownGracePeriodMs: number;
|
|
17
19
|
shutdownForceKillWaitMs: number;
|
|
20
|
+
fetch(input: string | URL, init?: RequestInit): Promise<Response>;
|
|
18
21
|
}
|
|
19
22
|
interface CopilotPermissionPolicyOptions {
|
|
20
23
|
gateEnabled?: boolean;
|
|
21
24
|
policyMode?: InternalPolicyMode;
|
|
22
25
|
auditSink?: AuthorizationAuditSinkLike;
|
|
23
26
|
}
|
|
27
|
+
interface CopilotHostedImageInputConfig {
|
|
28
|
+
borgeeBaseUrl?: string;
|
|
29
|
+
agentApiKey?: string;
|
|
30
|
+
}
|
|
24
31
|
/**
|
|
25
32
|
* Persistent ACP-backed client for the GitHub Copilot CLI (`copilot --acp`).
|
|
26
33
|
*
|
|
@@ -35,8 +42,15 @@ export declare class CopilotCliClient {
|
|
|
35
42
|
private readonly resolveSessionStoreAgentId;
|
|
36
43
|
private readonly logger;
|
|
37
44
|
private readonly permissionPolicy;
|
|
45
|
+
private readonly imageInputConfig;
|
|
38
46
|
private readonly runtime;
|
|
39
47
|
private readonly channels;
|
|
48
|
+
/**
|
|
49
|
+
* The provider session currently bound to channelId, or undefined before a
|
|
50
|
+
* turn has opened one. Surfaced so agents-host can record which session
|
|
51
|
+
* worked a task without the agent being asked to report its own id.
|
|
52
|
+
*/
|
|
53
|
+
sessionIdForChannel(channelId: string): string | undefined;
|
|
40
54
|
private readonly persistedSessions;
|
|
41
55
|
private readonly closingSessions;
|
|
42
56
|
private readonly pendingSessionStarts;
|
|
@@ -49,7 +63,13 @@ export declare class CopilotCliClient {
|
|
|
49
63
|
private startPromise?;
|
|
50
64
|
private shutdownPromise?;
|
|
51
65
|
private childExitPromise?;
|
|
52
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Bumped on every spawn so the `exit` / `connection.closed` handlers of a
|
|
68
|
+
* backend we deliberately recycled stay silent instead of latching a fatal
|
|
69
|
+
* error onto the client that already owns its successor.
|
|
70
|
+
*/
|
|
71
|
+
private backendGeneration;
|
|
72
|
+
private idleShutdownPromise?;
|
|
53
73
|
private fatalError;
|
|
54
74
|
private disposing;
|
|
55
75
|
private backendClosed;
|
|
@@ -57,7 +77,8 @@ export declare class CopilotCliClient {
|
|
|
57
77
|
private sessionStoreLoadPromise;
|
|
58
78
|
private sessionStoreWriteQueue;
|
|
59
79
|
private sessionCapabilities;
|
|
60
|
-
|
|
80
|
+
private readonly idleBackendShutdown;
|
|
81
|
+
constructor(command: string, _ignoredArgs?: string[], runtimeOverrides?: Partial<CopilotAcpRuntime>, sessionStore?: CopilotChannelSessionStore | undefined, resolveSessionStoreAgentId?: () => string | undefined, logger?: DebugLogger, permissionPolicy?: CopilotPermissionPolicyOptions, imageInputConfig?: CopilotHostedImageInputConfig);
|
|
61
82
|
generateReply(turn: PreparedProviderTurnInput, options?: ProviderGenerateOptions): Promise<string>;
|
|
62
83
|
generateReply(channelId: string, prompt: string, options?: ProviderGenerateOptions): Promise<string>;
|
|
63
84
|
dispose(): Promise<void>;
|
|
@@ -71,6 +92,8 @@ export declare class CopilotCliClient {
|
|
|
71
92
|
private restoreSession;
|
|
72
93
|
private clearBufferedSessionReplay;
|
|
73
94
|
private resolveSessionCwd;
|
|
95
|
+
private buildPromptInput;
|
|
96
|
+
private fetchImageBlock;
|
|
74
97
|
private recycleSessionIfCwdChanged;
|
|
75
98
|
private runTurn;
|
|
76
99
|
private raceWithFatal;
|
|
@@ -92,6 +115,15 @@ export declare class CopilotCliClient {
|
|
|
92
115
|
private trackSessionStoreOperation;
|
|
93
116
|
private evictIdleChannel;
|
|
94
117
|
private shutdownBackend;
|
|
118
|
+
/**
|
|
119
|
+
* Backend teardown without the fatal latch: the client stays usable and
|
|
120
|
+
* `ensureStarted()` spawns a replacement process on the next turn. Only safe
|
|
121
|
+
* while `isBackendIdle()` holds, because every channel's live ACP session is
|
|
122
|
+
* dropped here and no turn is ever replayed onto its successor.
|
|
123
|
+
*/
|
|
124
|
+
private shutdownIdleBackend;
|
|
125
|
+
private isBackendIdle;
|
|
126
|
+
private closeBackendProcess;
|
|
95
127
|
private waitForPendingSessionStarts;
|
|
96
128
|
private waitForPendingSessionCloses;
|
|
97
129
|
private waitForChildExit;
|
|
@@ -3,8 +3,18 @@ import spawn from 'cross-spawn';
|
|
|
3
3
|
import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from '@agentclientprotocol/sdk';
|
|
4
4
|
import { HostLogger, summarizeChildStderr, summarizeError } from '../../debug.js';
|
|
5
5
|
import { resolveCopilotPermissionResponse } from '../../policy/copilot-permission.js';
|
|
6
|
+
import { IDLE_BACKEND_SHUTDOWN_DISABLED_MS, IdleBackendShutdownScheduler, } from '../idle-backend-shutdown.js';
|
|
6
7
|
const SESSION_TAINTED_ERRORS = new WeakSet();
|
|
7
8
|
const DEFAULT_IDLE_SESSION_TTL_MS = 2 * 24 * 60 * 60 * 1000;
|
|
9
|
+
const IDLE_BACKEND_STOPPED_MESSAGE = 'Copilot ACP backend stopped after idle timeout';
|
|
10
|
+
/**
|
|
11
|
+
* A fatal teardown has callers waiting on it, so it force-kills quickly. An
|
|
12
|
+
* idle recycle has nobody waiting, and the CLIs unlink their on-disk session
|
|
13
|
+
* locks during SIGTERM cleanup — a lock left behind by SIGKILL makes the next
|
|
14
|
+
* restore believe the session is still in use (github/copilot-cli#3255). So the
|
|
15
|
+
* idle path trades latency nobody observes for a clean exit.
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS = 5_000;
|
|
8
18
|
const DEFAULT_SHUTDOWN_GRACE_PERIOD_MS = 250;
|
|
9
19
|
const DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS = 250;
|
|
10
20
|
const QUEUED_TURN_DROPPED_MESSAGE = 'Copilot ACP session was reset after a failed turn; queued turns were dropped instead of replaying them on a fresh session';
|
|
@@ -16,8 +26,11 @@ const DEFAULT_RUNTIME = {
|
|
|
16
26
|
protocolVersion: PROTOCOL_VERSION,
|
|
17
27
|
cwd: process.cwd(),
|
|
18
28
|
idleSessionTtlMs: DEFAULT_IDLE_SESSION_TTL_MS,
|
|
29
|
+
idleBackendShutdownMs: IDLE_BACKEND_SHUTDOWN_DISABLED_MS,
|
|
30
|
+
idleShutdownGracePeriodMs: DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS,
|
|
19
31
|
shutdownGracePeriodMs: DEFAULT_SHUTDOWN_GRACE_PERIOD_MS,
|
|
20
32
|
shutdownForceKillWaitMs: DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS,
|
|
33
|
+
fetch: async (input, init) => fetch(input, init),
|
|
21
34
|
};
|
|
22
35
|
const DEFAULT_SESSION_CAPABILITIES = {
|
|
23
36
|
loadSession: false,
|
|
@@ -26,6 +39,28 @@ const DEFAULT_SESSION_CAPABILITIES = {
|
|
|
26
39
|
function hasVisibleText(value) {
|
|
27
40
|
return typeof value === 'string' && value.trim().length > 0;
|
|
28
41
|
}
|
|
42
|
+
function isAbsoluteHttpUrl(value) {
|
|
43
|
+
return value.startsWith('http://') || value.startsWith('https://');
|
|
44
|
+
}
|
|
45
|
+
function resolveHostedAttachmentUrl(rawUrl, borgeeBaseUrl) {
|
|
46
|
+
if (isAbsoluteHttpUrl(rawUrl)) {
|
|
47
|
+
return rawUrl;
|
|
48
|
+
}
|
|
49
|
+
const base = borgeeBaseUrl.replace(/\/$/, '');
|
|
50
|
+
const path = rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`;
|
|
51
|
+
return `${base}${path}`;
|
|
52
|
+
}
|
|
53
|
+
function isSameOrigin(left, right) {
|
|
54
|
+
try {
|
|
55
|
+
return new URL(left).origin === new URL(right).origin;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function asImagePart(part) {
|
|
62
|
+
return part.type === 'image' ? part : null;
|
|
63
|
+
}
|
|
29
64
|
function formatToolProgress(title, status) {
|
|
30
65
|
const normalizedTitle = hasVisibleText(title) ? title.trim() : null;
|
|
31
66
|
switch (status) {
|
|
@@ -104,7 +139,7 @@ class CopilotProgressCollector {
|
|
|
104
139
|
this.onProgress({ text });
|
|
105
140
|
}
|
|
106
141
|
}
|
|
107
|
-
function createDeferredTurn(channelId,
|
|
142
|
+
function createDeferredTurn(channelId, preparedTurn, sessionPersistence, options) {
|
|
108
143
|
let settled = false;
|
|
109
144
|
let resolvePromise;
|
|
110
145
|
let rejectPromise;
|
|
@@ -114,9 +149,8 @@ function createDeferredTurn(channelId, prompt, sessionPersistence, promptContext
|
|
|
114
149
|
});
|
|
115
150
|
return {
|
|
116
151
|
channelId,
|
|
117
|
-
|
|
152
|
+
preparedTurn,
|
|
118
153
|
sessionPersistence,
|
|
119
|
-
promptContext,
|
|
120
154
|
options,
|
|
121
155
|
promise,
|
|
122
156
|
resolve(value) {
|
|
@@ -204,8 +238,17 @@ export class CopilotCliClient {
|
|
|
204
238
|
resolveSessionStoreAgentId;
|
|
205
239
|
logger;
|
|
206
240
|
permissionPolicy;
|
|
241
|
+
imageInputConfig;
|
|
207
242
|
runtime;
|
|
208
243
|
channels = new Map();
|
|
244
|
+
/**
|
|
245
|
+
* The provider session currently bound to channelId, or undefined before a
|
|
246
|
+
* turn has opened one. Surfaced so agents-host can record which session
|
|
247
|
+
* worked a task without the agent being asked to report its own id.
|
|
248
|
+
*/
|
|
249
|
+
sessionIdForChannel(channelId) {
|
|
250
|
+
return this.channels.get(channelId)?.session?.sessionId;
|
|
251
|
+
}
|
|
209
252
|
persistedSessions = new Map();
|
|
210
253
|
closingSessions = new WeakSet();
|
|
211
254
|
pendingSessionStarts = new Set();
|
|
@@ -218,7 +261,13 @@ export class CopilotCliClient {
|
|
|
218
261
|
startPromise;
|
|
219
262
|
shutdownPromise;
|
|
220
263
|
childExitPromise;
|
|
221
|
-
|
|
264
|
+
/**
|
|
265
|
+
* Bumped on every spawn so the `exit` / `connection.closed` handlers of a
|
|
266
|
+
* backend we deliberately recycled stay silent instead of latching a fatal
|
|
267
|
+
* error onto the client that already owns its successor.
|
|
268
|
+
*/
|
|
269
|
+
backendGeneration = 0;
|
|
270
|
+
idleShutdownPromise;
|
|
222
271
|
fatalError = null;
|
|
223
272
|
disposing = false;
|
|
224
273
|
backendClosed = false;
|
|
@@ -226,13 +275,25 @@ export class CopilotCliClient {
|
|
|
226
275
|
sessionStoreLoadPromise = null;
|
|
227
276
|
sessionStoreWriteQueue = Promise.resolve();
|
|
228
277
|
sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
|
|
229
|
-
|
|
278
|
+
idleBackendShutdown;
|
|
279
|
+
constructor(command, _ignoredArgs = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger(), permissionPolicy = {}, imageInputConfig = {}) {
|
|
230
280
|
this.command = command;
|
|
231
281
|
this.sessionStore = sessionStore;
|
|
232
282
|
this.resolveSessionStoreAgentId = resolveSessionStoreAgentId;
|
|
233
283
|
this.logger = logger;
|
|
234
284
|
this.permissionPolicy = permissionPolicy;
|
|
285
|
+
this.imageInputConfig = imageInputConfig;
|
|
235
286
|
this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
|
|
287
|
+
this.idleBackendShutdown = new IdleBackendShutdownScheduler({
|
|
288
|
+
idleShutdownMs: this.runtime.idleBackendShutdownMs,
|
|
289
|
+
isIdle: () => this.isBackendIdle(),
|
|
290
|
+
shutdown: () => this.shutdownIdleBackend(),
|
|
291
|
+
onShutdownFailed: (error) => {
|
|
292
|
+
this.logger.error('failed to stop the idle Copilot ACP backend', {
|
|
293
|
+
error: summarizeError(error),
|
|
294
|
+
});
|
|
295
|
+
},
|
|
296
|
+
});
|
|
236
297
|
this.fatalPromise = new Promise((_, reject) => {
|
|
237
298
|
this.rejectFatalPromise = reject;
|
|
238
299
|
});
|
|
@@ -242,19 +303,27 @@ export class CopilotCliClient {
|
|
|
242
303
|
if (this.fatalError) {
|
|
243
304
|
throw this.fatalError;
|
|
244
305
|
}
|
|
245
|
-
const
|
|
306
|
+
const preparedTurnBase = typeof channelIdOrTurn === 'string'
|
|
246
307
|
? {
|
|
247
308
|
channelId: channelIdOrTurn,
|
|
309
|
+
incomingContent: '',
|
|
310
|
+
incomingParts: [],
|
|
248
311
|
prompt: typeof promptOrOptions === 'string' ? promptOrOptions : '',
|
|
249
312
|
}
|
|
250
313
|
: channelIdOrTurn;
|
|
314
|
+
const preparedTurn = {
|
|
315
|
+
...preparedTurnBase,
|
|
316
|
+
incomingContent: preparedTurnBase.incomingContent ?? '',
|
|
317
|
+
incomingParts: preparedTurnBase.incomingParts ?? [],
|
|
318
|
+
};
|
|
251
319
|
const options = typeof channelIdOrTurn === 'string'
|
|
252
320
|
? maybeOptions
|
|
253
321
|
: promptOrOptions;
|
|
254
322
|
const sessionRouting = resolveSessionRouting(preparedTurn);
|
|
255
323
|
const state = this.getOrCreateChannelState(sessionRouting.key, preparedTurn.channelId, sessionRouting.persistence);
|
|
256
324
|
this.clearIdleTimer(state);
|
|
257
|
-
|
|
325
|
+
this.idleBackendShutdown.cancel();
|
|
326
|
+
const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn, sessionRouting.persistence, options);
|
|
258
327
|
state.queue.push(turn);
|
|
259
328
|
this.processChannelQueue(sessionRouting.key, state);
|
|
260
329
|
return turn.promise;
|
|
@@ -262,12 +331,17 @@ export class CopilotCliClient {
|
|
|
262
331
|
async dispose() {
|
|
263
332
|
const error = new Error('Copilot ACP backend stopped');
|
|
264
333
|
this.disposing = true;
|
|
334
|
+
this.idleBackendShutdown.cancel();
|
|
265
335
|
this.logger?.debug('stopping Copilot ACP backend');
|
|
266
336
|
const closed = this.connection?.closed ?? Promise.resolve();
|
|
267
337
|
this.failAll(error);
|
|
268
338
|
let thrown;
|
|
269
339
|
try {
|
|
270
|
-
await Promise.all([
|
|
340
|
+
await Promise.all([
|
|
341
|
+
closed,
|
|
342
|
+
this.idleShutdownPromise ?? Promise.resolve(),
|
|
343
|
+
this.shutdownPromise ?? Promise.resolve(),
|
|
344
|
+
]);
|
|
271
345
|
}
|
|
272
346
|
catch (disposeError) {
|
|
273
347
|
thrown = disposeError;
|
|
@@ -288,6 +362,12 @@ export class CopilotCliClient {
|
|
|
288
362
|
if (this.fatalError) {
|
|
289
363
|
throw this.fatalError;
|
|
290
364
|
}
|
|
365
|
+
if (this.idleShutdownPromise) {
|
|
366
|
+
await this.idleShutdownPromise;
|
|
367
|
+
if (this.fatalError) {
|
|
368
|
+
throw this.fatalError;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
291
371
|
if (!this.startPromise) {
|
|
292
372
|
this.startPromise = this.startBackend();
|
|
293
373
|
}
|
|
@@ -300,6 +380,8 @@ export class CopilotCliClient {
|
|
|
300
380
|
}
|
|
301
381
|
}
|
|
302
382
|
async startBackend() {
|
|
383
|
+
const generation = ++this.backendGeneration;
|
|
384
|
+
const ownsCurrentBackend = () => (this.backendGeneration === generation && !this.disposing);
|
|
303
385
|
this.logger?.debug('starting Copilot ACP process', {
|
|
304
386
|
command: this.command,
|
|
305
387
|
args: ['--acp'],
|
|
@@ -315,13 +397,20 @@ export class CopilotCliClient {
|
|
|
315
397
|
});
|
|
316
398
|
child.stderr.resume();
|
|
317
399
|
this.logger?.debug('spawned Copilot ACP process', { pid: child.pid });
|
|
400
|
+
let resolveChildExit;
|
|
401
|
+
this.childExitPromise = new Promise((resolve) => {
|
|
402
|
+
resolveChildExit = resolve;
|
|
403
|
+
});
|
|
318
404
|
child.once('error', (error) => {
|
|
319
405
|
this.logger?.debugError('Copilot ACP process error', error);
|
|
406
|
+
if (!ownsCurrentBackend()) {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
320
409
|
this.failAll(new Error(`Copilot ACP process error: ${normalizeError(error).message}`));
|
|
321
410
|
});
|
|
322
411
|
child.once('exit', (code, signal) => {
|
|
323
|
-
|
|
324
|
-
if (
|
|
412
|
+
resolveChildExit();
|
|
413
|
+
if (!ownsCurrentBackend()) {
|
|
325
414
|
this.logger?.debug('Copilot ACP process exited during shutdown', { code, signal });
|
|
326
415
|
return;
|
|
327
416
|
}
|
|
@@ -329,9 +418,6 @@ export class CopilotCliClient {
|
|
|
329
418
|
const suffix = signal ? `signal ${signal}` : `code ${String(code ?? 'unknown')}`;
|
|
330
419
|
this.failAll(new Error(`Copilot ACP process exited unexpectedly (${suffix})`));
|
|
331
420
|
});
|
|
332
|
-
this.childExitPromise = new Promise((resolve) => {
|
|
333
|
-
this.resolveChildExit = resolve;
|
|
334
|
-
});
|
|
335
421
|
const output = Writable.toWeb(child.stdin);
|
|
336
422
|
const input = Readable.toWeb(child.stdout);
|
|
337
423
|
const stream = this.runtime.ndJsonStream(output, input);
|
|
@@ -341,7 +427,7 @@ export class CopilotCliClient {
|
|
|
341
427
|
const connection = app.connect(stream);
|
|
342
428
|
this.connection = connection;
|
|
343
429
|
void connection.closed.then(() => {
|
|
344
|
-
if (
|
|
430
|
+
if (ownsCurrentBackend()) {
|
|
345
431
|
this.logger?.debugError('Copilot ACP connection closed unexpectedly');
|
|
346
432
|
this.failAll(new Error('Copilot ACP connection closed unexpectedly'));
|
|
347
433
|
}
|
|
@@ -400,13 +486,14 @@ export class CopilotCliClient {
|
|
|
400
486
|
}
|
|
401
487
|
state.activeTurn = turn;
|
|
402
488
|
try {
|
|
403
|
-
state.cwd = await this.resolveSessionCwd(turn.promptContext);
|
|
489
|
+
state.cwd = await this.resolveSessionCwd(turn.preparedTurn.promptContext);
|
|
404
490
|
await this.ensureStarted();
|
|
405
491
|
await this.recycleSessionIfCwdChanged(channelId, state);
|
|
406
492
|
const session = await this.getOrCreateSession(channelId, state);
|
|
407
493
|
let reply;
|
|
408
494
|
try {
|
|
409
|
-
|
|
495
|
+
const promptInput = await this.buildPromptInput(turn.preparedTurn);
|
|
496
|
+
reply = await this.runTurn(session, promptInput, turn.options);
|
|
410
497
|
}
|
|
411
498
|
catch (error) {
|
|
412
499
|
if (isSessionTainted(error)) {
|
|
@@ -428,6 +515,7 @@ export class CopilotCliClient {
|
|
|
428
515
|
finally {
|
|
429
516
|
state.processing = false;
|
|
430
517
|
this.reconcileIdleChannelState(channelId, state);
|
|
518
|
+
this.idleBackendShutdown.reconcile();
|
|
431
519
|
}
|
|
432
520
|
})();
|
|
433
521
|
}
|
|
@@ -561,6 +649,37 @@ export class CopilotCliClient {
|
|
|
561
649
|
async resolveSessionCwd(promptContext) {
|
|
562
650
|
return promptContext?.taskWorkspace?.rootPath ?? this.runtime.cwd;
|
|
563
651
|
}
|
|
652
|
+
async buildPromptInput(turn) {
|
|
653
|
+
const imageParts = turn.incomingParts.map(asImagePart).filter((part) => part !== null);
|
|
654
|
+
if (imageParts.length === 0) {
|
|
655
|
+
return turn.prompt;
|
|
656
|
+
}
|
|
657
|
+
const imageBlocks = await Promise.all(imageParts.map(async (part) => this.fetchImageBlock(part.attachment.url, part.attachment.contentType)));
|
|
658
|
+
return [{ type: 'text', text: turn.prompt }, ...imageBlocks];
|
|
659
|
+
}
|
|
660
|
+
async fetchImageBlock(rawUrl, contentType) {
|
|
661
|
+
const baseUrl = this.imageInputConfig.borgeeBaseUrl?.trim();
|
|
662
|
+
const agentApiKey = this.imageInputConfig.agentApiKey?.trim();
|
|
663
|
+
if (!baseUrl || !agentApiKey) {
|
|
664
|
+
throw new Error('Copilot image input requires Borgee base URL and agent API key');
|
|
665
|
+
}
|
|
666
|
+
const resolvedUrl = resolveHostedAttachmentUrl(rawUrl, baseUrl);
|
|
667
|
+
if (!isSameOrigin(resolvedUrl, baseUrl)) {
|
|
668
|
+
throw new Error(`Copilot image input only supports Borgee-origin attachments: ${resolvedUrl}`);
|
|
669
|
+
}
|
|
670
|
+
const response = await this.runtime.fetch(resolvedUrl, {
|
|
671
|
+
headers: { Authorization: `Bearer ${agentApiKey}` },
|
|
672
|
+
});
|
|
673
|
+
if (!response.ok) {
|
|
674
|
+
throw new Error(`Copilot image fetch failed with ${response.status} ${response.statusText}`.trim());
|
|
675
|
+
}
|
|
676
|
+
return {
|
|
677
|
+
type: 'image',
|
|
678
|
+
mimeType: contentType,
|
|
679
|
+
data: Buffer.from(await response.arrayBuffer()).toString('base64'),
|
|
680
|
+
uri: response.url || resolvedUrl,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
564
683
|
async recycleSessionIfCwdChanged(channelId, state) {
|
|
565
684
|
const session = state.session;
|
|
566
685
|
if (!session) {
|
|
@@ -643,6 +762,7 @@ export class CopilotCliClient {
|
|
|
643
762
|
}
|
|
644
763
|
this.fatalError = error;
|
|
645
764
|
this.rejectFatalPromise(error);
|
|
765
|
+
this.idleBackendShutdown.cancel();
|
|
646
766
|
for (const [channelId, state] of this.channels.entries()) {
|
|
647
767
|
this.clearIdleTimer(state);
|
|
648
768
|
state.activeTurn?.reject(error);
|
|
@@ -884,6 +1004,64 @@ export class CopilotCliClient {
|
|
|
884
1004
|
return;
|
|
885
1005
|
}
|
|
886
1006
|
this.backendClosed = true;
|
|
1007
|
+
await this.closeBackendProcess(error, this.runtime.shutdownGracePeriodMs);
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Backend teardown without the fatal latch: the client stays usable and
|
|
1011
|
+
* `ensureStarted()` spawns a replacement process on the next turn. Only safe
|
|
1012
|
+
* while `isBackendIdle()` holds, because every channel's live ACP session is
|
|
1013
|
+
* dropped here and no turn is ever replayed onto its successor.
|
|
1014
|
+
*/
|
|
1015
|
+
async shutdownIdleBackend() {
|
|
1016
|
+
// Detach the running backend before any signal reaches it, so its own exit
|
|
1017
|
+
// and connection-closed handlers read this teardown as deliberate instead
|
|
1018
|
+
// of latching a fatal error on a client that stays usable.
|
|
1019
|
+
this.backendGeneration += 1;
|
|
1020
|
+
const error = new Error(IDLE_BACKEND_STOPPED_MESSAGE);
|
|
1021
|
+
const sessions = [];
|
|
1022
|
+
for (const [channelId, state] of [...this.channels.entries()]) {
|
|
1023
|
+
this.clearIdleTimer(state);
|
|
1024
|
+
if (state.session) {
|
|
1025
|
+
sessions.push(state.session);
|
|
1026
|
+
}
|
|
1027
|
+
state.session = undefined;
|
|
1028
|
+
state.sessionCwd = undefined;
|
|
1029
|
+
this.channels.delete(channelId);
|
|
1030
|
+
}
|
|
1031
|
+
this.logger?.debug('stopping idle Copilot ACP backend', {
|
|
1032
|
+
channels: sessions.length,
|
|
1033
|
+
idleShutdownMs: this.runtime.idleBackendShutdownMs,
|
|
1034
|
+
});
|
|
1035
|
+
for (const session of sessions) {
|
|
1036
|
+
this.closeSession(session);
|
|
1037
|
+
}
|
|
1038
|
+
const idleShutdownPromise = this.closeBackendProcess(error, this.runtime.idleShutdownGracePeriodMs).finally(() => {
|
|
1039
|
+
this.startPromise = undefined;
|
|
1040
|
+
this.childExitPromise = undefined;
|
|
1041
|
+
this.sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
|
|
1042
|
+
this.idleShutdownPromise = undefined;
|
|
1043
|
+
});
|
|
1044
|
+
this.idleShutdownPromise = idleShutdownPromise;
|
|
1045
|
+
await idleShutdownPromise;
|
|
1046
|
+
}
|
|
1047
|
+
isBackendIdle() {
|
|
1048
|
+
if (this.disposing || this.fatalError || this.backendClosed || this.idleShutdownPromise) {
|
|
1049
|
+
return false;
|
|
1050
|
+
}
|
|
1051
|
+
if (!this.child) {
|
|
1052
|
+
return false;
|
|
1053
|
+
}
|
|
1054
|
+
if (this.pendingSessionStarts.size > 0) {
|
|
1055
|
+
return false;
|
|
1056
|
+
}
|
|
1057
|
+
for (const state of this.channels.values()) {
|
|
1058
|
+
if (state.activeTurn || state.queue.length > 0 || state.sessionPromise || state.processing) {
|
|
1059
|
+
return false;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
return true;
|
|
1063
|
+
}
|
|
1064
|
+
async closeBackendProcess(error, gracePeriodMs) {
|
|
887
1065
|
const connection = this.connection;
|
|
888
1066
|
const child = this.child;
|
|
889
1067
|
await this.waitForPendingSessionStarts();
|
|
@@ -895,9 +1073,9 @@ export class CopilotCliClient {
|
|
|
895
1073
|
return;
|
|
896
1074
|
}
|
|
897
1075
|
const childExitPromise = this.childExitPromise ?? Promise.resolve();
|
|
898
|
-
this.logger?.debug('sending SIGTERM to Copilot ACP process');
|
|
1076
|
+
this.logger?.debug('sending SIGTERM to Copilot ACP process', { gracePeriodMs });
|
|
899
1077
|
child.kill('SIGTERM');
|
|
900
|
-
const exitedAfterTerm = await this.waitForChildExit(childExitPromise,
|
|
1078
|
+
const exitedAfterTerm = await this.waitForChildExit(childExitPromise, gracePeriodMs);
|
|
901
1079
|
if (exitedAfterTerm) {
|
|
902
1080
|
this.logger?.debug('Copilot ACP process exited after SIGTERM');
|
|
903
1081
|
this.child = undefined;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DebugLogger } from '../debug.js';
|
|
2
2
|
import type { ProviderRuntimeConfig } from '../types.js';
|
|
3
|
-
import type
|
|
3
|
+
import { type ProviderAdapter } from './provider-adapter.js';
|
|
4
4
|
import type { LocalhostGatewayContextPublisher } from '../context/injection.js';
|
|
5
5
|
import type { AuthorizationAuditSinkLike } from '../policy/authorization-audit.js';
|
|
6
6
|
interface CreateProviderOptions {
|
|
@@ -2,6 +2,7 @@ import { FileChannelContextStore } from '../context/injection.js';
|
|
|
2
2
|
import { createProviderConnectionsSessionStore } from '../connections-state-store.js';
|
|
3
3
|
import { ProviderTurnPreparer } from '../context/turn-preparation.js';
|
|
4
4
|
import { CONNECTIONS_STATE_LAYER_COMPATIBILITY_GATE, CONTEXT_INJECTION_COMPATIBILITY_GATE, CODEX_PROVIDER_COMPATIBILITY_GATE, COPILOT_PROVIDER_V2_COMPATIBILITY_GATE, INTERNAL_PROVIDER_IMPLEMENTATIONS_ENV, LOCALHOST_GATEWAY_COMPATIBILITY_GATE, parseInternalProviderImplementationOverrides, POLICY_AUDIT_ENFORCEMENT_COMPATIBILITY_GATE, resolveInternalPolicyMode, resolveInternalCompatibilityGates, SKILL_RUNTIME_COMPATIBILITY_GATE, } from '../compatibility-gates.js';
|
|
5
|
+
import { resolveIdleBackendShutdownMs } from './idle-backend-shutdown.js';
|
|
5
6
|
import { ClaudeCliClient } from './claude/cli-client.js';
|
|
6
7
|
import { ClaudeProviderAdapter } from './claude/adapter.js';
|
|
7
8
|
import { FileClaudeChannelSessionStore } from './claude/session-store.js';
|
|
@@ -9,16 +10,18 @@ import { CodexCliClient } from './codex/cli-client.js';
|
|
|
9
10
|
import { CodexProviderAdapter } from './codex/adapter.js';
|
|
10
11
|
import { FileCodexChannelSessionStore } from './codex/session-store.js';
|
|
11
12
|
import { CopilotCliClient } from './copilot/cli-client.js';
|
|
12
|
-
import { CopilotProviderAdapter } from './copilot/adapter.js';
|
|
13
|
+
import { COPILOT_HOSTED_PROVIDER_CAPABILITIES, CopilotProviderAdapter } from './copilot/adapter.js';
|
|
13
14
|
import { FileCopilotChannelSessionStore } from './copilot/session-store.js';
|
|
14
15
|
import { resolveClaudeSessionMapPath, resolveCodexSessionMapPath, resolveCopilotSessionMapPath, } from '../state-paths.js';
|
|
15
16
|
import { createAwaitingUserProgressHandler, parseProviderReply } from './awaiting-user.js';
|
|
16
17
|
class ProviderV2CompatibilityAdapter {
|
|
17
18
|
provider;
|
|
18
19
|
turnPreparer;
|
|
19
|
-
|
|
20
|
+
capabilities;
|
|
21
|
+
constructor(provider, turnPreparer, capabilities) {
|
|
20
22
|
this.provider = provider;
|
|
21
23
|
this.turnPreparer = turnPreparer;
|
|
24
|
+
this.capabilities = capabilities;
|
|
22
25
|
}
|
|
23
26
|
async generateReply(input, options) {
|
|
24
27
|
const preparedTurn = await this.turnPreparer.prepare(input);
|
|
@@ -39,7 +42,9 @@ class CliBackedProviderV2 {
|
|
|
39
42
|
const text = await this.cli.generateReply(input.turn, {
|
|
40
43
|
onProgress: createAwaitingUserProgressHandler(options?.onProgress),
|
|
41
44
|
});
|
|
42
|
-
|
|
45
|
+
// Read the session AFTER the turn: a first turn on a channel opens the
|
|
46
|
+
// session as it runs, so before this point there is nothing to report.
|
|
47
|
+
return { ...parseProviderReply(text), sessionId: this.cli.sessionIdForChannel(input.turn.channelId) };
|
|
43
48
|
}
|
|
44
49
|
async shutdown() {
|
|
45
50
|
await this.cli.dispose();
|
|
@@ -57,8 +62,8 @@ function resolveCopilotImplementation(routingConfig) {
|
|
|
57
62
|
}
|
|
58
63
|
return routingConfig.implementationOverrides.copilot ?? 'v2';
|
|
59
64
|
}
|
|
60
|
-
function createClaudeProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer) {
|
|
61
|
-
const cli = new ClaudeCliClient(config.claudeCommand, config.claudeArgs, {}, createProviderConnectionsSessionStore({
|
|
65
|
+
function createClaudeProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer, idleBackendShutdownMs) {
|
|
66
|
+
const cli = new ClaudeCliClient(config.claudeCommand, config.claudeArgs, { idleBackendShutdownMs }, createProviderConnectionsSessionStore({
|
|
62
67
|
provider: 'claude',
|
|
63
68
|
legacyStore: new FileClaudeChannelSessionStore({
|
|
64
69
|
resolvePath: (agentId) => resolveClaudeSessionMapPath(config.stateRootDir, agentId),
|
|
@@ -66,13 +71,17 @@ function createClaudeProvider(config, debugLogger, connectionsStateGateEnabled,
|
|
|
66
71
|
stateRootDir: config.stateRootDir,
|
|
67
72
|
gateEnabled: connectionsStateGateEnabled,
|
|
68
73
|
logger: debugLogger,
|
|
69
|
-
}), config.resolveStableAgentId, debugLogger
|
|
74
|
+
}), config.resolveStableAgentId, debugLogger, {
|
|
75
|
+
borgeeBaseUrl: config.borgeeBaseUrl,
|
|
76
|
+
agentApiKey: config.agentApiKey,
|
|
77
|
+
});
|
|
70
78
|
return new ClaudeProviderAdapter(cli, turnPreparer);
|
|
71
79
|
}
|
|
72
|
-
function createCopilotProvider(config, debugLogger, connectionsStateGateEnabled, implementation, turnPreparer, policyAuditGateEnabled, authorizationAuditSink) {
|
|
80
|
+
function createCopilotProvider(config, debugLogger, connectionsStateGateEnabled, implementation, turnPreparer, policyAuditGateEnabled, authorizationAuditSink, idleBackendShutdownMs) {
|
|
73
81
|
const policyMode = resolveInternalPolicyMode(policyAuditGateEnabled);
|
|
74
82
|
const cli = new CopilotCliClient(config.copilotCommand, config.copilotArgs, {
|
|
75
83
|
idleSessionTtlMs: config.copilotSessionTtlMinutes * 60 * 1000,
|
|
84
|
+
idleBackendShutdownMs,
|
|
76
85
|
}, createProviderConnectionsSessionStore({
|
|
77
86
|
provider: 'copilot',
|
|
78
87
|
legacyStore: new FileCopilotChannelSessionStore({
|
|
@@ -85,13 +94,16 @@ function createCopilotProvider(config, debugLogger, connectionsStateGateEnabled,
|
|
|
85
94
|
gateEnabled: policyAuditGateEnabled,
|
|
86
95
|
policyMode,
|
|
87
96
|
auditSink: authorizationAuditSink,
|
|
97
|
+
}, {
|
|
98
|
+
borgeeBaseUrl: config.borgeeBaseUrl,
|
|
99
|
+
agentApiKey: config.agentApiKey,
|
|
88
100
|
});
|
|
89
101
|
return implementation === 'v2'
|
|
90
|
-
? new ProviderV2CompatibilityAdapter(new CopilotProviderV2(cli), turnPreparer)
|
|
102
|
+
? new ProviderV2CompatibilityAdapter(new CopilotProviderV2(cli), turnPreparer, COPILOT_HOSTED_PROVIDER_CAPABILITIES)
|
|
91
103
|
: new CopilotProviderAdapter(cli, turnPreparer);
|
|
92
104
|
}
|
|
93
|
-
function createCodexProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer) {
|
|
94
|
-
const cli = new CodexCliClient(config.codexCommand, config.codexArgs, {}, createProviderConnectionsSessionStore({
|
|
105
|
+
function createCodexProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer, idleBackendShutdownMs) {
|
|
106
|
+
const cli = new CodexCliClient(config.codexCommand, config.codexArgs, { idleBackendShutdownMs }, createProviderConnectionsSessionStore({
|
|
95
107
|
provider: 'codex',
|
|
96
108
|
legacyStore: new FileCodexChannelSessionStore({
|
|
97
109
|
resolvePath: (agentId) => resolveCodexSessionMapPath(config.stateRootDir, agentId),
|
|
@@ -99,7 +111,10 @@ function createCodexProvider(config, debugLogger, connectionsStateGateEnabled, t
|
|
|
99
111
|
stateRootDir: config.stateRootDir,
|
|
100
112
|
gateEnabled: connectionsStateGateEnabled,
|
|
101
113
|
logger: debugLogger,
|
|
102
|
-
}), config.resolveStableAgentId, debugLogger
|
|
114
|
+
}), config.resolveStableAgentId, debugLogger, {
|
|
115
|
+
borgeeBaseUrl: config.borgeeBaseUrl,
|
|
116
|
+
agentApiKey: config.agentApiKey,
|
|
117
|
+
});
|
|
103
118
|
return new CodexProviderAdapter(cli, turnPreparer);
|
|
104
119
|
}
|
|
105
120
|
export function createProvider(config, debugLogger, options = {}) {
|
|
@@ -112,6 +127,7 @@ export function createProvider(config, debugLogger, options = {}) {
|
|
|
112
127
|
const skillRuntimeGateEnabled = routingConfig.compatibilityGates.has(SKILL_RUNTIME_COMPATIBILITY_GATE);
|
|
113
128
|
const localhostGatewayGateEnabled = routingConfig.compatibilityGates.has(LOCALHOST_GATEWAY_COMPATIBILITY_GATE);
|
|
114
129
|
const policyAuditGateEnabled = routingConfig.compatibilityGates.has(POLICY_AUDIT_ENFORCEMENT_COMPATIBILITY_GATE);
|
|
130
|
+
const idleBackendShutdownMs = resolveIdleBackendShutdownMs(config.providerIdleShutdownMinutes, routingConfig.compatibilityGates);
|
|
115
131
|
const channelContextStore = contextInjectionGateEnabled
|
|
116
132
|
? new FileChannelContextStore(config.stateRootDir, {
|
|
117
133
|
skillRuntimeEnabled: skillRuntimeGateEnabled,
|
|
@@ -124,16 +140,16 @@ export function createProvider(config, debugLogger, options = {}) {
|
|
|
124
140
|
const turnPreparer = new ProviderTurnPreparer(channelContextStore, debugLogger);
|
|
125
141
|
switch (config.provider) {
|
|
126
142
|
case 'claude': {
|
|
127
|
-
return createClaudeProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer);
|
|
143
|
+
return createClaudeProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer, idleBackendShutdownMs);
|
|
128
144
|
}
|
|
129
145
|
case 'codex': {
|
|
130
146
|
if (!routingConfig.compatibilityGates.has(CODEX_PROVIDER_COMPATIBILITY_GATE)) {
|
|
131
147
|
throw new Error(`Unsupported provider: ${String(config.provider)} (enable ${CODEX_PROVIDER_COMPATIBILITY_GATE})`);
|
|
132
148
|
}
|
|
133
|
-
return createCodexProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer);
|
|
149
|
+
return createCodexProvider(config, debugLogger, connectionsStateGateEnabled, turnPreparer, idleBackendShutdownMs);
|
|
134
150
|
}
|
|
135
151
|
case 'copilot': {
|
|
136
|
-
return createCopilotProvider(config, debugLogger, connectionsStateGateEnabled, resolveCopilotImplementation(routingConfig), turnPreparer, policyAuditGateEnabled, options.authorizationAuditSink);
|
|
152
|
+
return createCopilotProvider(config, debugLogger, connectionsStateGateEnabled, resolveCopilotImplementation(routingConfig), turnPreparer, policyAuditGateEnabled, options.authorizationAuditSink, idleBackendShutdownMs);
|
|
137
153
|
}
|
|
138
154
|
default:
|
|
139
155
|
throw new Error(`Unsupported provider: ${String(config.provider)}`);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const IDLE_BACKEND_SHUTDOWN_DISABLED_MS = 0;
|
|
2
|
+
export declare function resolveIdleBackendShutdownMs(idleShutdownMinutes: number, compatibilityGates: ReadonlySet<string>): number;
|
|
3
|
+
export interface IdleBackendShutdownSchedulerOptions {
|
|
4
|
+
idleShutdownMs: number;
|
|
5
|
+
isIdle: () => boolean;
|
|
6
|
+
shutdown: () => Promise<void>;
|
|
7
|
+
onShutdownFailed: (error: unknown) => void;
|
|
8
|
+
}
|
|
9
|
+
export declare class IdleBackendShutdownScheduler {
|
|
10
|
+
private readonly options;
|
|
11
|
+
private timer?;
|
|
12
|
+
constructor(options: IdleBackendShutdownSchedulerOptions);
|
|
13
|
+
get enabled(): boolean;
|
|
14
|
+
cancel(): void;
|
|
15
|
+
reconcile(): void;
|
|
16
|
+
}
|