@borgee/agents-host 0.2.33 → 0.2.35
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 +17 -0
- package/dist/agents-host.js +52 -0
- 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 +6 -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/main-session-delegation.d.ts +1 -0
- package/dist/context/main-session-delegation.js +6 -0
- package/dist/context/prompt.js +4 -2
- package/dist/gateway/localhost-gateway.js +66 -0
- package/dist/local-config.js +10 -1
- package/dist/managed-daemon.js +5 -0
- 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.js +3 -1
- package/dist/providers/claude/cli-client.d.ts +25 -1
- package/dist/providers/claude/cli-client.js +127 -10
- package/dist/providers/codex/adapter.js +3 -1
- package/dist/providers/codex/cli-client.d.ts +25 -1
- package/dist/providers/codex/cli-client.js +127 -10
- package/dist/providers/codex/project-doc.js +3 -0
- package/dist/providers/copilot/adapter.js +3 -1
- package/dist/providers/copilot/cli-client.d.ts +25 -1
- package/dist/providers/copilot/cli-client.js +125 -10
- package/dist/providers/create-provider.js +14 -9
- package/dist/providers/idle-backend-shutdown.d.ts +16 -0
- package/dist/providers/idle-backend-shutdown.js +53 -0
- package/dist/types.d.ts +7 -0
- package/package.json +2 -2
- package/skills/borgee-agent/SKILL.md +19 -2
- package/skills/borgee-agent/borgee-agent.mjs +56 -1
- package/skills/borgee-agent/borgee-agent.py +33 -2
|
@@ -8,6 +8,21 @@ export interface GatewayTaskRoute {
|
|
|
8
8
|
taskId: string;
|
|
9
9
|
resource: 'task' | 'task-history';
|
|
10
10
|
}
|
|
11
|
+
/** One task property, addressed by key. PUT writes it, DELETE removes it. */
|
|
12
|
+
export interface GatewayTaskPropertyRoute {
|
|
13
|
+
taskId: string;
|
|
14
|
+
key: string;
|
|
15
|
+
resource: 'task-property';
|
|
16
|
+
}
|
|
17
|
+
/** The same, on the thread's own task, so an agent need not resolve a task id. */
|
|
18
|
+
export interface GatewayCurrentTaskPropertyRoute {
|
|
19
|
+
channelId: string;
|
|
20
|
+
key: string;
|
|
21
|
+
resource: 'current-task-property';
|
|
22
|
+
}
|
|
23
|
+
export type GatewayRoute = GatewayChannelRoute | GatewayTaskRoute | GatewayTaskPropertyRoute | GatewayCurrentTaskPropertyRoute | {
|
|
24
|
+
resource: 'health';
|
|
25
|
+
};
|
|
11
26
|
export interface GatewayAuthorizationBinding {
|
|
12
27
|
channelId: string;
|
|
13
28
|
agentId?: string;
|
|
@@ -23,9 +38,7 @@ export interface GatewayAuthorizationDecision {
|
|
|
23
38
|
};
|
|
24
39
|
allowHeader?: string;
|
|
25
40
|
binding?: GatewayAuthorizationBinding;
|
|
26
|
-
route?:
|
|
27
|
-
resource: 'health';
|
|
28
|
-
};
|
|
41
|
+
route?: GatewayRoute;
|
|
29
42
|
path: string;
|
|
30
43
|
token?: string;
|
|
31
44
|
}
|
|
@@ -39,4 +52,6 @@ export declare function hasVisibleHeader(value: string | string[] | undefined):
|
|
|
39
52
|
export declare function parseBearerToken(value: string | string[] | undefined): string | null;
|
|
40
53
|
export declare function matchChannelRoute(pathname: string, allowCollaborationRoutes?: boolean): GatewayChannelRoute | null;
|
|
41
54
|
export declare function matchTaskRoute(pathname: string): GatewayTaskRoute | null;
|
|
55
|
+
export declare function matchTaskPropertyRoute(pathname: string): GatewayTaskPropertyRoute | null;
|
|
56
|
+
export declare function matchCurrentTaskPropertyRoute(pathname: string): GatewayCurrentTaskPropertyRoute | null;
|
|
42
57
|
export declare function evaluateGatewayAuthorization(input: GatewayAuthorizationInput): GatewayAuthorizationDecision;
|
|
@@ -61,6 +61,30 @@ export function matchTaskRoute(pathname) {
|
|
|
61
61
|
resource: match[2] ? 'task-history' : 'task',
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
|
+
export function matchTaskPropertyRoute(pathname) {
|
|
65
|
+
const match = /^\/v1\/tasks\/([^/]+)\/properties\/([^/]+)$/.exec(pathname);
|
|
66
|
+
if (!match) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const taskId = decodePathSegment(match[1]);
|
|
70
|
+
const key = decodePathSegment(match[2]);
|
|
71
|
+
if (taskId == null || key == null) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return { taskId, key, resource: 'task-property' };
|
|
75
|
+
}
|
|
76
|
+
export function matchCurrentTaskPropertyRoute(pathname) {
|
|
77
|
+
const match = /^\/v1\/channels\/([^/]+)\/current-task\/properties\/([^/]+)$/.exec(pathname);
|
|
78
|
+
if (!match) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const channelId = decodePathSegment(match[1]);
|
|
82
|
+
const key = decodePathSegment(match[2]);
|
|
83
|
+
if (channelId == null || key == null) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return { channelId, key, resource: 'current-task-property' };
|
|
87
|
+
}
|
|
64
88
|
function allowedMethodsForRoute(route, collaborationRoutesEnabled) {
|
|
65
89
|
switch (route.resource) {
|
|
66
90
|
case 'health':
|
|
@@ -78,6 +102,11 @@ function allowedMethodsForRoute(route, collaborationRoutesEnabled) {
|
|
|
78
102
|
case 'current-task':
|
|
79
103
|
case 'task':
|
|
80
104
|
return ['GET', 'PATCH'];
|
|
105
|
+
// No GET: a property is read back with the task it belongs to, so a
|
|
106
|
+
// per-key read would be a second way to learn the same thing.
|
|
107
|
+
case 'current-task-property':
|
|
108
|
+
case 'task-property':
|
|
109
|
+
return ['PUT', 'DELETE'];
|
|
81
110
|
}
|
|
82
111
|
}
|
|
83
112
|
export function evaluateGatewayAuthorization(input) {
|
|
@@ -96,7 +125,10 @@ export function evaluateGatewayAuthorization(input) {
|
|
|
96
125
|
const collaborationRoutesEnabled = input.allowCollaborationRoutes ?? false;
|
|
97
126
|
const route = url.pathname === '/health'
|
|
98
127
|
? { resource: 'health' }
|
|
99
|
-
: matchChannelRoute(url.pathname, collaborationRoutesEnabled)
|
|
128
|
+
: matchChannelRoute(url.pathname, collaborationRoutesEnabled)
|
|
129
|
+
?? matchCurrentTaskPropertyRoute(url.pathname)
|
|
130
|
+
?? matchTaskPropertyRoute(url.pathname)
|
|
131
|
+
?? matchTaskRoute(url.pathname);
|
|
100
132
|
if (hiddenCollaborationRoute) {
|
|
101
133
|
return {
|
|
102
134
|
reason: 'not-found',
|
|
@@ -11,7 +11,9 @@ export class ClaudeProviderAdapter {
|
|
|
11
11
|
const text = await this.cli.generateReply(preparedTurn, {
|
|
12
12
|
onProgress: createAwaitingUserProgressHandler(options?.onProgress),
|
|
13
13
|
});
|
|
14
|
-
|
|
14
|
+
// Read the session AFTER the turn: a first turn on a channel opens the
|
|
15
|
+
// session as it runs, so before this point there is nothing to report.
|
|
16
|
+
return { ...parseProviderReply(text), sessionId: this.cli.sessionIdForChannel(input.channelId) };
|
|
15
17
|
}
|
|
16
18
|
async dispose() {
|
|
17
19
|
await this.cli.dispose();
|
|
@@ -10,6 +10,8 @@ interface ClaudeAcpRuntime {
|
|
|
10
10
|
methods: typeof methods;
|
|
11
11
|
protocolVersion: typeof PROTOCOL_VERSION;
|
|
12
12
|
cwd: string;
|
|
13
|
+
idleBackendShutdownMs: number;
|
|
14
|
+
idleShutdownGracePeriodMs: number;
|
|
13
15
|
shutdownGracePeriodMs: number;
|
|
14
16
|
shutdownForceKillWaitMs: number;
|
|
15
17
|
}
|
|
@@ -29,6 +31,12 @@ export declare class ClaudeCliClient {
|
|
|
29
31
|
private readonly logger;
|
|
30
32
|
private readonly runtime;
|
|
31
33
|
private readonly channels;
|
|
34
|
+
/**
|
|
35
|
+
* The provider session currently bound to channelId, or undefined before a
|
|
36
|
+
* turn has opened one. Surfaced so agents-host can record which session
|
|
37
|
+
* worked a task without the agent being asked to report its own id.
|
|
38
|
+
*/
|
|
39
|
+
sessionIdForChannel(channelId: string): string | undefined;
|
|
32
40
|
private readonly persistedSessions;
|
|
33
41
|
private readonly closingSessions;
|
|
34
42
|
private readonly pendingSessionStarts;
|
|
@@ -41,7 +49,13 @@ export declare class ClaudeCliClient {
|
|
|
41
49
|
private startPromise?;
|
|
42
50
|
private shutdownPromise?;
|
|
43
51
|
private childExitPromise?;
|
|
44
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Bumped on every spawn so the `exit` / `connection.closed` handlers of a
|
|
54
|
+
* backend we deliberately recycled stay silent instead of latching a fatal
|
|
55
|
+
* error onto the client that already owns its successor.
|
|
56
|
+
*/
|
|
57
|
+
private backendGeneration;
|
|
58
|
+
private idleShutdownPromise?;
|
|
45
59
|
private fatalError;
|
|
46
60
|
private disposing;
|
|
47
61
|
private backendClosed;
|
|
@@ -49,6 +63,7 @@ export declare class ClaudeCliClient {
|
|
|
49
63
|
private sessionStoreLoadPromise;
|
|
50
64
|
private sessionStoreWriteQueue;
|
|
51
65
|
private sessionCapabilities;
|
|
66
|
+
private readonly idleBackendShutdown;
|
|
52
67
|
private childStderr;
|
|
53
68
|
constructor(command: string, args?: string[], runtimeOverrides?: Partial<ClaudeAcpRuntime>, sessionStore?: ClaudeChannelSessionStore | undefined, resolveSessionStoreAgentId?: () => string | undefined, logger?: DebugLogger);
|
|
54
69
|
generateReply(turn: PreparedProviderTurnInput, options?: ProviderGenerateOptions): Promise<string>;
|
|
@@ -83,6 +98,15 @@ export declare class ClaudeCliClient {
|
|
|
83
98
|
private enqueueSessionStoreWrite;
|
|
84
99
|
private trackSessionStoreOperation;
|
|
85
100
|
private shutdownBackend;
|
|
101
|
+
/**
|
|
102
|
+
* Backend teardown without the fatal latch: the client stays usable and
|
|
103
|
+
* `ensureStarted()` spawns a replacement process on the next turn. Only safe
|
|
104
|
+
* while `isBackendIdle()` holds, because every channel's live ACP session is
|
|
105
|
+
* dropped here and no turn is ever replayed onto its successor.
|
|
106
|
+
*/
|
|
107
|
+
private shutdownIdleBackend;
|
|
108
|
+
private isBackendIdle;
|
|
109
|
+
private closeBackendProcess;
|
|
86
110
|
private waitForPendingSessionStarts;
|
|
87
111
|
private waitForPendingSessionCloses;
|
|
88
112
|
private waitForChildExit;
|
|
@@ -6,7 +6,17 @@ 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 { IDLE_BACKEND_SHUTDOWN_DISABLED_MS, IdleBackendShutdownScheduler, } from '../idle-backend-shutdown.js';
|
|
9
10
|
const SESSION_TAINTED_ERRORS = new WeakSet();
|
|
11
|
+
const IDLE_BACKEND_STOPPED_MESSAGE = 'Claude ACP backend stopped after idle timeout';
|
|
12
|
+
/**
|
|
13
|
+
* A fatal teardown has callers waiting on it, so it force-kills quickly. An
|
|
14
|
+
* idle recycle has nobody waiting, and the CLIs unlink their on-disk session
|
|
15
|
+
* locks during SIGTERM cleanup — a lock left behind by SIGKILL makes the next
|
|
16
|
+
* restore believe the session is still in use (github/copilot-cli#3255). So the
|
|
17
|
+
* idle path trades latency nobody observes for a clean exit.
|
|
18
|
+
*/
|
|
19
|
+
const DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS = 5_000;
|
|
10
20
|
const DEFAULT_SHUTDOWN_GRACE_PERIOD_MS = 250;
|
|
11
21
|
const DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS = 250;
|
|
12
22
|
const DEFAULT_SESSION_CAPABILITIES = {
|
|
@@ -21,6 +31,8 @@ const DEFAULT_RUNTIME = {
|
|
|
21
31
|
methods,
|
|
22
32
|
protocolVersion: PROTOCOL_VERSION,
|
|
23
33
|
cwd: process.cwd(),
|
|
34
|
+
idleBackendShutdownMs: IDLE_BACKEND_SHUTDOWN_DISABLED_MS,
|
|
35
|
+
idleShutdownGracePeriodMs: DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS,
|
|
24
36
|
shutdownGracePeriodMs: DEFAULT_SHUTDOWN_GRACE_PERIOD_MS,
|
|
25
37
|
shutdownForceKillWaitMs: DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS,
|
|
26
38
|
};
|
|
@@ -359,6 +371,14 @@ export class ClaudeCliClient {
|
|
|
359
371
|
logger;
|
|
360
372
|
runtime;
|
|
361
373
|
channels = new Map();
|
|
374
|
+
/**
|
|
375
|
+
* The provider session currently bound to channelId, or undefined before a
|
|
376
|
+
* turn has opened one. Surfaced so agents-host can record which session
|
|
377
|
+
* worked a task without the agent being asked to report its own id.
|
|
378
|
+
*/
|
|
379
|
+
sessionIdForChannel(channelId) {
|
|
380
|
+
return this.channels.get(channelId)?.session?.sessionId;
|
|
381
|
+
}
|
|
362
382
|
persistedSessions = new Map();
|
|
363
383
|
closingSessions = new WeakSet();
|
|
364
384
|
pendingSessionStarts = new Set();
|
|
@@ -371,7 +391,13 @@ export class ClaudeCliClient {
|
|
|
371
391
|
startPromise;
|
|
372
392
|
shutdownPromise;
|
|
373
393
|
childExitPromise;
|
|
374
|
-
|
|
394
|
+
/**
|
|
395
|
+
* Bumped on every spawn so the `exit` / `connection.closed` handlers of a
|
|
396
|
+
* backend we deliberately recycled stay silent instead of latching a fatal
|
|
397
|
+
* error onto the client that already owns its successor.
|
|
398
|
+
*/
|
|
399
|
+
backendGeneration = 0;
|
|
400
|
+
idleShutdownPromise;
|
|
375
401
|
fatalError = null;
|
|
376
402
|
disposing = false;
|
|
377
403
|
backendClosed = false;
|
|
@@ -379,6 +405,7 @@ export class ClaudeCliClient {
|
|
|
379
405
|
sessionStoreLoadPromise = null;
|
|
380
406
|
sessionStoreWriteQueue = Promise.resolve();
|
|
381
407
|
sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
|
|
408
|
+
idleBackendShutdown;
|
|
382
409
|
childStderr = '';
|
|
383
410
|
constructor(command, args = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger()) {
|
|
384
411
|
this.command = command;
|
|
@@ -388,6 +415,16 @@ export class ClaudeCliClient {
|
|
|
388
415
|
this.logger = logger;
|
|
389
416
|
this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
|
|
390
417
|
assertClaudeCommandCompatibility(command, args, 'Claude provider runtime');
|
|
418
|
+
this.idleBackendShutdown = new IdleBackendShutdownScheduler({
|
|
419
|
+
idleShutdownMs: this.runtime.idleBackendShutdownMs,
|
|
420
|
+
isIdle: () => this.isBackendIdle(),
|
|
421
|
+
shutdown: () => this.shutdownIdleBackend(),
|
|
422
|
+
onShutdownFailed: (error) => {
|
|
423
|
+
this.logger.error('failed to stop the idle Claude ACP backend', {
|
|
424
|
+
error: summarizeError(error),
|
|
425
|
+
});
|
|
426
|
+
},
|
|
427
|
+
});
|
|
391
428
|
this.fatalPromise = new Promise((_, reject) => {
|
|
392
429
|
this.rejectFatalPromise = reject;
|
|
393
430
|
});
|
|
@@ -408,6 +445,7 @@ export class ClaudeCliClient {
|
|
|
408
445
|
: promptOrOptions;
|
|
409
446
|
const sessionRouting = resolveSessionRouting(preparedTurn);
|
|
410
447
|
const state = this.getOrCreateChannelState(sessionRouting.key, preparedTurn.channelId, sessionRouting.persistence);
|
|
448
|
+
this.idleBackendShutdown.cancel();
|
|
411
449
|
const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn.prompt, sessionRouting.persistence, preparedTurn.promptContext, options);
|
|
412
450
|
state.queue.push(turn);
|
|
413
451
|
this.processChannelQueue(sessionRouting.key, state);
|
|
@@ -416,12 +454,17 @@ export class ClaudeCliClient {
|
|
|
416
454
|
async dispose() {
|
|
417
455
|
const error = new Error('Claude ACP backend stopped');
|
|
418
456
|
this.disposing = true;
|
|
457
|
+
this.idleBackendShutdown.cancel();
|
|
419
458
|
this.logger?.debug('stopping Claude ACP backend');
|
|
420
459
|
const closed = this.connection?.closed ?? Promise.resolve();
|
|
421
460
|
this.failAll(error);
|
|
422
461
|
let thrown;
|
|
423
462
|
try {
|
|
424
|
-
await Promise.all([
|
|
463
|
+
await Promise.all([
|
|
464
|
+
closed,
|
|
465
|
+
this.idleShutdownPromise ?? Promise.resolve(),
|
|
466
|
+
this.shutdownPromise ?? Promise.resolve(),
|
|
467
|
+
]);
|
|
425
468
|
}
|
|
426
469
|
catch (disposeError) {
|
|
427
470
|
thrown = disposeError;
|
|
@@ -442,6 +485,12 @@ export class ClaudeCliClient {
|
|
|
442
485
|
if (this.fatalError) {
|
|
443
486
|
throw this.fatalError;
|
|
444
487
|
}
|
|
488
|
+
if (this.idleShutdownPromise) {
|
|
489
|
+
await this.idleShutdownPromise;
|
|
490
|
+
if (this.fatalError) {
|
|
491
|
+
throw this.fatalError;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
445
494
|
if (!this.startPromise) {
|
|
446
495
|
this.startPromise = this.startBackend();
|
|
447
496
|
}
|
|
@@ -454,6 +503,8 @@ export class ClaudeCliClient {
|
|
|
454
503
|
}
|
|
455
504
|
}
|
|
456
505
|
async startBackend() {
|
|
506
|
+
const generation = ++this.backendGeneration;
|
|
507
|
+
const ownsCurrentBackend = () => (this.backendGeneration === generation && !this.disposing);
|
|
457
508
|
const launch = resolveClaudeLaunch(this.command, this.args, this.runtime.cwd);
|
|
458
509
|
this.childStderr = '';
|
|
459
510
|
this.logger?.debug('starting Claude ACP process', {
|
|
@@ -473,13 +524,20 @@ export class ClaudeCliClient {
|
|
|
473
524
|
});
|
|
474
525
|
child.stderr.resume();
|
|
475
526
|
this.logger?.debug('spawned Claude ACP process', { pid: child.pid });
|
|
527
|
+
let resolveChildExit;
|
|
528
|
+
this.childExitPromise = new Promise((resolve) => {
|
|
529
|
+
resolveChildExit = resolve;
|
|
530
|
+
});
|
|
476
531
|
child.once('error', (error) => {
|
|
477
532
|
this.logger?.debugError('Claude ACP process error', error);
|
|
533
|
+
if (!ownsCurrentBackend()) {
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
478
536
|
this.failAll(new Error(`Claude ACP process error: ${normalizeError(error).message}`));
|
|
479
537
|
});
|
|
480
538
|
child.once('exit', (code, signal) => {
|
|
481
|
-
|
|
482
|
-
if (
|
|
539
|
+
resolveChildExit();
|
|
540
|
+
if (!ownsCurrentBackend()) {
|
|
483
541
|
this.logger?.debug('Claude ACP process exited during shutdown', { code, signal });
|
|
484
542
|
return;
|
|
485
543
|
}
|
|
@@ -493,9 +551,6 @@ export class ClaudeCliClient {
|
|
|
493
551
|
});
|
|
494
552
|
this.failAll(failure);
|
|
495
553
|
});
|
|
496
|
-
this.childExitPromise = new Promise((resolveChildExit) => {
|
|
497
|
-
this.resolveChildExit = resolveChildExit;
|
|
498
|
-
});
|
|
499
554
|
const output = Writable.toWeb(child.stdin);
|
|
500
555
|
const input = Readable.toWeb(child.stdout);
|
|
501
556
|
const stream = this.runtime.ndJsonStream(output, input);
|
|
@@ -505,7 +560,7 @@ export class ClaudeCliClient {
|
|
|
505
560
|
const connection = app.connect(stream);
|
|
506
561
|
this.connection = connection;
|
|
507
562
|
void connection.closed.then(() => {
|
|
508
|
-
if (
|
|
563
|
+
if (ownsCurrentBackend()) {
|
|
509
564
|
this.logger?.debugError('Claude ACP connection closed unexpectedly');
|
|
510
565
|
this.failAll(new Error('Claude ACP connection closed unexpectedly'));
|
|
511
566
|
}
|
|
@@ -604,6 +659,9 @@ export class ClaudeCliClient {
|
|
|
604
659
|
if (state.queue.length > 0 && !this.fatalError) {
|
|
605
660
|
this.processChannelQueue(channelId, state);
|
|
606
661
|
}
|
|
662
|
+
else {
|
|
663
|
+
this.idleBackendShutdown.reconcile();
|
|
664
|
+
}
|
|
607
665
|
}
|
|
608
666
|
})();
|
|
609
667
|
}
|
|
@@ -807,6 +865,7 @@ export class ClaudeCliClient {
|
|
|
807
865
|
}
|
|
808
866
|
this.fatalError = error;
|
|
809
867
|
this.rejectFatalPromise(error);
|
|
868
|
+
this.idleBackendShutdown.cancel();
|
|
810
869
|
for (const [channelId, state] of this.channels.entries()) {
|
|
811
870
|
state.activeTurn?.reject(error);
|
|
812
871
|
for (const turn of state.queue) {
|
|
@@ -1020,6 +1079,64 @@ export class ClaudeCliClient {
|
|
|
1020
1079
|
return;
|
|
1021
1080
|
}
|
|
1022
1081
|
this.backendClosed = true;
|
|
1082
|
+
await this.closeBackendProcess(error, this.runtime.shutdownGracePeriodMs);
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Backend teardown without the fatal latch: the client stays usable and
|
|
1086
|
+
* `ensureStarted()` spawns a replacement process on the next turn. Only safe
|
|
1087
|
+
* while `isBackendIdle()` holds, because every channel's live ACP session is
|
|
1088
|
+
* dropped here and no turn is ever replayed onto its successor.
|
|
1089
|
+
*/
|
|
1090
|
+
async shutdownIdleBackend() {
|
|
1091
|
+
// Detach the running backend before any signal reaches it, so its own exit
|
|
1092
|
+
// and connection-closed handlers read this teardown as deliberate instead
|
|
1093
|
+
// of latching a fatal error on a client that stays usable.
|
|
1094
|
+
this.backendGeneration += 1;
|
|
1095
|
+
const error = new Error(IDLE_BACKEND_STOPPED_MESSAGE);
|
|
1096
|
+
const sessions = [];
|
|
1097
|
+
for (const [channelId, state] of [...this.channels.entries()]) {
|
|
1098
|
+
if (state.session) {
|
|
1099
|
+
sessions.push(state.session);
|
|
1100
|
+
}
|
|
1101
|
+
state.session = undefined;
|
|
1102
|
+
state.sessionCwd = undefined;
|
|
1103
|
+
state.sessionVisibilityKey = undefined;
|
|
1104
|
+
this.channels.delete(channelId);
|
|
1105
|
+
}
|
|
1106
|
+
this.logger?.debug('stopping idle Claude ACP backend', {
|
|
1107
|
+
channels: sessions.length,
|
|
1108
|
+
idleShutdownMs: this.runtime.idleBackendShutdownMs,
|
|
1109
|
+
});
|
|
1110
|
+
for (const session of sessions) {
|
|
1111
|
+
this.closeSession(session);
|
|
1112
|
+
}
|
|
1113
|
+
const idleShutdownPromise = this.closeBackendProcess(error, this.runtime.idleShutdownGracePeriodMs).finally(() => {
|
|
1114
|
+
this.startPromise = undefined;
|
|
1115
|
+
this.childExitPromise = undefined;
|
|
1116
|
+
this.sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
|
|
1117
|
+
this.idleShutdownPromise = undefined;
|
|
1118
|
+
});
|
|
1119
|
+
this.idleShutdownPromise = idleShutdownPromise;
|
|
1120
|
+
await idleShutdownPromise;
|
|
1121
|
+
}
|
|
1122
|
+
isBackendIdle() {
|
|
1123
|
+
if (this.disposing || this.fatalError || this.backendClosed || this.idleShutdownPromise) {
|
|
1124
|
+
return false;
|
|
1125
|
+
}
|
|
1126
|
+
if (!this.child) {
|
|
1127
|
+
return false;
|
|
1128
|
+
}
|
|
1129
|
+
if (this.pendingSessionStarts.size > 0) {
|
|
1130
|
+
return false;
|
|
1131
|
+
}
|
|
1132
|
+
for (const state of this.channels.values()) {
|
|
1133
|
+
if (state.activeTurn || state.queue.length > 0 || state.sessionPromise || state.processing) {
|
|
1134
|
+
return false;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
return true;
|
|
1138
|
+
}
|
|
1139
|
+
async closeBackendProcess(error, gracePeriodMs) {
|
|
1023
1140
|
const connection = this.connection;
|
|
1024
1141
|
const child = this.child;
|
|
1025
1142
|
await this.waitForPendingSessionStarts();
|
|
@@ -1031,9 +1148,9 @@ export class ClaudeCliClient {
|
|
|
1031
1148
|
return;
|
|
1032
1149
|
}
|
|
1033
1150
|
const childExitPromise = this.childExitPromise ?? Promise.resolve();
|
|
1034
|
-
this.logger?.debug('sending SIGTERM to Claude ACP process');
|
|
1151
|
+
this.logger?.debug('sending SIGTERM to Claude ACP process', { gracePeriodMs });
|
|
1035
1152
|
child.kill('SIGTERM');
|
|
1036
|
-
const exitedAfterTerm = await this.waitForChildExit(childExitPromise,
|
|
1153
|
+
const exitedAfterTerm = await this.waitForChildExit(childExitPromise, gracePeriodMs);
|
|
1037
1154
|
if (exitedAfterTerm) {
|
|
1038
1155
|
this.logger?.debug('Claude ACP process exited after SIGTERM');
|
|
1039
1156
|
this.child = undefined;
|
|
@@ -11,7 +11,9 @@ export class CodexProviderAdapter {
|
|
|
11
11
|
const text = await this.cli.generateReply(preparedTurn, {
|
|
12
12
|
onProgress: createAwaitingUserProgressHandler(options?.onProgress),
|
|
13
13
|
});
|
|
14
|
-
|
|
14
|
+
// Read the session AFTER the turn: a first turn on a channel opens the
|
|
15
|
+
// session as it runs, so before this point there is nothing to report.
|
|
16
|
+
return { ...parseProviderReply(text), sessionId: this.cli.sessionIdForChannel(input.channelId) };
|
|
15
17
|
}
|
|
16
18
|
async dispose() {
|
|
17
19
|
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?: {
|
|
@@ -35,6 +37,12 @@ export declare class CodexCliClient {
|
|
|
35
37
|
private readonly logger;
|
|
36
38
|
private readonly runtime;
|
|
37
39
|
private readonly channels;
|
|
40
|
+
/**
|
|
41
|
+
* The provider session currently bound to channelId, or undefined before a
|
|
42
|
+
* turn has opened one. Surfaced so agents-host can record which session
|
|
43
|
+
* worked a task without the agent being asked to report its own id.
|
|
44
|
+
*/
|
|
45
|
+
sessionIdForChannel(channelId: string): string | undefined;
|
|
38
46
|
private readonly persistedSessions;
|
|
39
47
|
private readonly closingSessions;
|
|
40
48
|
private readonly pendingSessionStarts;
|
|
@@ -47,7 +55,13 @@ export declare class CodexCliClient {
|
|
|
47
55
|
private startPromise?;
|
|
48
56
|
private shutdownPromise?;
|
|
49
57
|
private childExitPromise?;
|
|
50
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Bumped on every spawn so the `exit` / `connection.closed` handlers of a
|
|
60
|
+
* backend we deliberately recycled stay silent instead of latching a fatal
|
|
61
|
+
* error onto the client that already owns its successor.
|
|
62
|
+
*/
|
|
63
|
+
private backendGeneration;
|
|
64
|
+
private idleShutdownPromise?;
|
|
51
65
|
private fatalError;
|
|
52
66
|
private disposing;
|
|
53
67
|
private backendClosed;
|
|
@@ -55,6 +69,7 @@ export declare class CodexCliClient {
|
|
|
55
69
|
private sessionStoreLoadPromise;
|
|
56
70
|
private sessionStoreWriteQueue;
|
|
57
71
|
private sessionCapabilities;
|
|
72
|
+
private readonly idleBackendShutdown;
|
|
58
73
|
constructor(command: string, args?: string[], runtimeOverrides?: Partial<CodexAcpRuntime>, sessionStore?: CodexChannelSessionStore | undefined, resolveSessionStoreAgentId?: () => string | undefined, logger?: DebugLogger);
|
|
59
74
|
generateReply(turn: PreparedProviderTurnInput, options?: ProviderGenerateOptions): Promise<string>;
|
|
60
75
|
generateReply(channelId: string, prompt: string, options?: ProviderGenerateOptions): Promise<string>;
|
|
@@ -96,6 +111,15 @@ export declare class CodexCliClient {
|
|
|
96
111
|
private trackSessionStoreOperation;
|
|
97
112
|
private evictIdleChannel;
|
|
98
113
|
private shutdownBackend;
|
|
114
|
+
/**
|
|
115
|
+
* Backend teardown without the fatal latch: the client stays usable and
|
|
116
|
+
* `ensureStarted()` spawns a replacement process on the next turn. Only safe
|
|
117
|
+
* while `isBackendIdle()` holds, because every channel's live ACP session is
|
|
118
|
+
* dropped here and no turn is ever replayed onto its successor.
|
|
119
|
+
*/
|
|
120
|
+
private shutdownIdleBackend;
|
|
121
|
+
private isBackendIdle;
|
|
122
|
+
private closeBackendProcess;
|
|
99
123
|
private waitForPendingSessionStarts;
|
|
100
124
|
private waitForPendingSessionCloses;
|
|
101
125
|
private waitForChildExit;
|