@borgee/agents-host 0.2.32 → 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.
Files changed (42) hide show
  1. package/README.md +28 -7
  2. package/dist/agents-host.d.ts +17 -0
  3. package/dist/agents-host.js +52 -0
  4. package/dist/chat/chat-control-plane.d.ts +9 -0
  5. package/dist/chat/sdk-chat-control-plane.d.ts +11 -2
  6. package/dist/chat/sdk-chat-control-plane.js +7 -1
  7. package/dist/cli-args.d.ts +1 -1
  8. package/dist/cli-args.js +5 -0
  9. package/dist/compatibility-gates.d.ts +1 -0
  10. package/dist/compatibility-gates.js +2 -0
  11. package/dist/config.d.ts +4 -1
  12. package/dist/config.js +21 -3
  13. package/dist/context/main-session-delegation.d.ts +1 -0
  14. package/dist/context/main-session-delegation.js +6 -0
  15. package/dist/context/prompt.js +4 -2
  16. package/dist/durable-cursor-store.d.ts +1 -1
  17. package/dist/durable-cursor-store.js +1 -1
  18. package/dist/gateway/localhost-gateway.js +67 -1
  19. package/dist/local-config.js +10 -1
  20. package/dist/managed-daemon.js +5 -0
  21. package/dist/plugin-sdk.js +5188 -0
  22. package/dist/plugin-sdk.js.map +7 -0
  23. package/dist/policy/gateway-authorization.d.ts +18 -3
  24. package/dist/policy/gateway-authorization.js +33 -1
  25. package/dist/providers/claude/adapter.js +3 -1
  26. package/dist/providers/claude/cli-client.d.ts +25 -1
  27. package/dist/providers/claude/cli-client.js +127 -10
  28. package/dist/providers/codex/adapter.js +3 -1
  29. package/dist/providers/codex/cli-client.d.ts +25 -1
  30. package/dist/providers/codex/cli-client.js +127 -10
  31. package/dist/providers/codex/project-doc.js +3 -0
  32. package/dist/providers/copilot/adapter.js +3 -1
  33. package/dist/providers/copilot/cli-client.d.ts +25 -1
  34. package/dist/providers/copilot/cli-client.js +125 -10
  35. package/dist/providers/create-provider.js +14 -9
  36. package/dist/providers/idle-backend-shutdown.d.ts +16 -0
  37. package/dist/providers/idle-backend-shutdown.js +53 -0
  38. package/dist/types.d.ts +7 -0
  39. package/package.json +6 -5
  40. package/skills/borgee-agent/SKILL.md +19 -2
  41. package/skills/borgee-agent/borgee-agent.mjs +56 -1
  42. 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?: GatewayChannelRoute | GatewayTaskRoute | {
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) ?? matchTaskRoute(url.pathname);
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
- return parseProviderReply(text);
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
- private resolveChildExit?;
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
- resolveChildExit;
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([closed, this.shutdownPromise ?? Promise.resolve()]);
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
- this.resolveChildExit?.();
482
- if (this.disposing) {
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 (!this.disposing) {
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, this.runtime.shutdownGracePeriodMs);
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
- return parseProviderReply(text);
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
- private resolveChildExit?;
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;