@borgee/agents-host 0.1.5 → 0.1.6

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 CHANGED
@@ -20,9 +20,8 @@ Borgee channel message
20
20
  ```
21
21
 
22
22
  **In scope:** one agent per process, per-channel conversation memory via each
23
- CLI's own native session resume
24
- (Claude `--resume`, Copilot `--session-id`), and dispatching to a local CLI
25
- provider.
23
+ provider's native session mechanism (Claude CLI resume, Copilot ACP sessions),
24
+ and dispatching to a local CLI provider.
26
25
 
27
26
  **Out of scope** (see the larger `feat/multi-agent-runtime-host` work for
28
27
  these): remote command execution / shell dispatch, node provisioning,
@@ -54,18 +53,20 @@ pnpm --filter @borgee/agents-host dev
54
53
  | `BORGEE_AGENT_NAME` | no | `Assistant` | Display name used in prompts |
55
54
  | `RUNTIME_PROVIDER` | no | `claude` | `claude` or `copilot` |
56
55
  | `CLAUDE_COMMAND` / `CLAUDE_ARGS` | no | `claude` / `--print` | Local Claude CLI command + args |
57
- | `COPILOT_COMMAND` / `COPILOT_ARGS` | no | `copilot` / `-s --no-color --allow-all-tools --output-format text` | Local Copilot CLI command + args |
56
+ | `COPILOT_COMMAND` / `COPILOT_ARGS` | no | `copilot` / empty | Local Copilot CLI command. `COPILOT_ARGS` is accepted for backward-compatible config/CLI parsing but ignored by the persistent Copilot ACP prototype, which always launches `copilot --acp`. |
58
57
 
59
58
  ## Conversation memory
60
59
 
61
- Each Borgee channel is mapped 1:1 to a native CLI session, keyed by a
62
- generated UUID kept in memory for the process's lifetime:
60
+ Each Borgee channel is mapped 1:1 to a provider-native session for the
61
+ process's lifetime:
63
62
 
64
63
  - **Claude**: first turn for a channel uses `--session-id <uuid>`; every
65
64
  turn after uses `-r/--resume <uuid>` (Claude treats these as distinct
66
65
  operations).
67
- - **Copilot**: every turn uses `--session-id=<uuid>` — Copilot's CLI treats
68
- that flag as create-or-resume for the same UUID.
66
+ - **Copilot**: one persistent `copilot --acp` subprocess is shared by the
67
+ host, with one ACP session per Borgee channel. Same-channel turns are
68
+ serialized so they cannot interleave; different channels keep isolated ACP
69
+ sessions.
69
70
 
70
71
  There is no separate history/context store on our side; if the runtime host
71
72
  process restarts, each channel starts a fresh CLI session (no session-id
@@ -59,6 +59,7 @@ export class AgentsHost {
59
59
  return;
60
60
  this.started = false;
61
61
  await this.borgee.close();
62
+ await this.provider.dispose?.();
62
63
  }
63
64
  async handleMessage(msg) {
64
65
  console.log('[agents-host] received message', {
@@ -19,4 +19,4 @@ export declare class CliUsageError extends Error {
19
19
  * process, so callers (and tests) can decide how to report it.
20
20
  */
21
21
  export declare function parseStartArgs(argv: string[]): ParsedStartArgs;
22
- export declare const USAGE = "Usage: agents-host start <serverUrl> <apiKey> [options]\n\nOptions:\n --name <name> Display name (default: Assistant)\n --provider <claude|copilot> Runtime provider (default: claude)\n --claude-command <cmd> Local Claude CLI command (default: claude)\n --claude-args <args> Local Claude CLI args (default: --print)\n --copilot-command <cmd> Local Copilot CLI command (default: copilot)\n --copilot-args <args> Local Copilot CLI args\n\nExample:\n agents-host start https://borgee.example.com bgr_xxxxxxxx --provider copilot\n";
22
+ export declare const USAGE = "Usage: agents-host start <serverUrl> <apiKey> [options]\n\nOptions:\n --name <name> Display name (default: Assistant)\n --provider <claude|copilot> Runtime provider (default: claude)\n --claude-command <cmd> Local Claude CLI command (default: claude)\n --claude-args <args> Local Claude CLI args (default: --print)\n --copilot-command <cmd> Local Copilot CLI command (default: copilot)\n --copilot-args <args> Ignored by the Copilot ACP prototype\n\nExample:\n agents-host start https://borgee.example.com bgr_xxxxxxxx --provider copilot\n";
package/dist/cli-args.js CHANGED
@@ -58,8 +58,8 @@ Options:
58
58
  --provider <claude|copilot> Runtime provider (default: claude)
59
59
  --claude-command <cmd> Local Claude CLI command (default: claude)
60
60
  --claude-args <args> Local Claude CLI args (default: --print)
61
- --copilot-command <cmd> Local Copilot CLI command (default: copilot)
62
- --copilot-args <args> Local Copilot CLI args
61
+ --copilot-command <cmd> Local Copilot CLI command (default: copilot)
62
+ --copilot-args <args> Ignored by the Copilot ACP prototype
63
63
 
64
64
  Example:
65
65
  agents-host start https://borgee.example.com bgr_xxxxxxxx --provider copilot
package/dist/config.d.ts CHANGED
@@ -3,5 +3,9 @@ import type { AgentsHostConfig } from './types.js';
3
3
  * Loads agents-host configuration from environment variables. This process
4
4
  * hosts exactly one Borgee agent (`BORGEE_AGENT_API_KEY`) — to run more
5
5
  * agents, run more processes with different env vars.
6
+ *
7
+ * The Copilot ACP prototype still parses `COPILOT_ARGS` for CLI/env backward
8
+ * compatibility, but the persistent ACP backend intentionally ignores them and
9
+ * always launches `copilot --acp`.
6
10
  */
7
11
  export declare function loadConfigFromEnv(): AgentsHostConfig;
package/dist/config.js CHANGED
@@ -22,6 +22,10 @@ function resolveProvider() {
22
22
  * Loads agents-host configuration from environment variables. This process
23
23
  * hosts exactly one Borgee agent (`BORGEE_AGENT_API_KEY`) — to run more
24
24
  * agents, run more processes with different env vars.
25
+ *
26
+ * The Copilot ACP prototype still parses `COPILOT_ARGS` for CLI/env backward
27
+ * compatibility, but the persistent ACP backend intentionally ignores them and
28
+ * always launches `copilot --acp`.
25
29
  */
26
30
  export function loadConfigFromEnv() {
27
31
  return {
@@ -5,4 +5,5 @@ export declare class CopilotProviderAdapter implements ProviderAdapter {
5
5
  private readonly cli;
6
6
  constructor(cli: CopilotCliClient);
7
7
  generateReply(input: ProviderInput): Promise<ProviderReply>;
8
+ dispose(): Promise<void>;
8
9
  }
@@ -15,4 +15,7 @@ export class CopilotProviderAdapter {
15
15
  const text = await this.cli.generateReply(input.channelId, prompt);
16
16
  return { text };
17
17
  }
18
+ async dispose() {
19
+ await this.cli.dispose();
20
+ }
18
21
  }
@@ -1,24 +1,58 @@
1
+ import spawn from 'cross-spawn';
2
+ import { PROTOCOL_VERSION, client, methods, ndJsonStream } from '@agentclientprotocol/sdk';
3
+ interface CopilotAcpRuntime {
4
+ spawn: typeof spawn;
5
+ client: typeof client;
6
+ ndJsonStream: typeof ndJsonStream;
7
+ methods: typeof methods;
8
+ protocolVersion: typeof PROTOCOL_VERSION;
9
+ cwd: string;
10
+ shutdownGracePeriodMs: number;
11
+ shutdownForceKillWaitMs: number;
12
+ }
1
13
  /**
2
- * CLI client for the GitHub Copilot CLI (`copilot`) non-interactive mode,
3
- * with native per-channel session continuity.
4
- *
5
- * Unlike the Claude CLI, Copilot's non-interactive mode takes the prompt as
6
- * a `-p/--prompt` argument rather than reading it from stdin, so the prompt
7
- * is appended to the configured base args on every invocation instead of
8
- * being written to the child process's stdin.
14
+ * Persistent ACP-backed client for the GitHub Copilot CLI (`copilot --acp`).
9
15
  *
10
- * Verified CLI behavior (`copilot --help` + empirical check): `--session-id
11
- * <uuid>` both *creates* a new session pinned to that UUID (first call) and
12
- * *resumes* it (subsequent calls with the same UUID) — one flag covers both
13
- * cases, unlike Claude's `--session-id`/`--resume` split. No message history
14
- * is kept on our side — the CLI's own session storage is the single source
15
- * of truth for conversation memory.
16
+ * The legacy one-shot `COPILOT_ARGS` / constructor args are intentionally
17
+ * ignored here: the prototype always launches the child as `copilot --acp`,
18
+ * performs the ACP initialize handshake once, then reuses one ACP session per
19
+ * Borgee channel for the process lifetime.
16
20
  */
17
21
  export declare class CopilotCliClient {
18
22
  private readonly command;
19
- private readonly args;
20
- private readonly sessionIdsByChannel;
21
- constructor(command: string, args: string[]);
23
+ private readonly runtime;
24
+ private readonly channels;
25
+ private readonly closingSessions;
26
+ private readonly pendingSessionStarts;
27
+ private readonly pendingSessionCloses;
28
+ private readonly fatalPromise;
29
+ private rejectFatalPromise;
30
+ private child?;
31
+ private connection?;
32
+ private startPromise?;
33
+ private shutdownPromise?;
34
+ private childExitPromise?;
35
+ private resolveChildExit?;
36
+ private fatalError;
37
+ private disposing;
38
+ private backendClosed;
39
+ constructor(command: string, _ignoredArgs?: string[], runtimeOverrides?: Partial<CopilotAcpRuntime>);
22
40
  generateReply(channelId: string, prompt: string): Promise<string>;
23
- private run;
41
+ dispose(): Promise<void>;
42
+ private ensureStarted;
43
+ private startBackend;
44
+ private getOrCreateChannelState;
45
+ private processChannelQueue;
46
+ private getOrCreateSession;
47
+ private runTurn;
48
+ private raceWithFatal;
49
+ private failAll;
50
+ private invalidateSession;
51
+ private closeSession;
52
+ private rejectQueuedTurnsAfterSessionTaint;
53
+ private shutdownBackend;
54
+ private waitForPendingSessionStarts;
55
+ private waitForPendingSessionCloses;
56
+ private waitForChildExit;
24
57
  }
58
+ export {};
@@ -1,65 +1,455 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { Readable, Writable } from 'node:stream';
2
2
  import spawn from 'cross-spawn';
3
+ import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from '@agentclientprotocol/sdk';
4
+ const SESSION_TAINTED_ERRORS = new WeakSet();
5
+ const DEFAULT_SHUTDOWN_GRACE_PERIOD_MS = 250;
6
+ const DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS = 250;
7
+ 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';
8
+ const DEFAULT_RUNTIME = {
9
+ spawn,
10
+ client,
11
+ ndJsonStream,
12
+ methods,
13
+ protocolVersion: PROTOCOL_VERSION,
14
+ cwd: process.cwd(),
15
+ shutdownGracePeriodMs: DEFAULT_SHUTDOWN_GRACE_PERIOD_MS,
16
+ shutdownForceKillWaitMs: DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS,
17
+ };
18
+ function createDeferredTurn(prompt) {
19
+ let settled = false;
20
+ let resolvePromise;
21
+ let rejectPromise;
22
+ const promise = new Promise((resolve, reject) => {
23
+ resolvePromise = resolve;
24
+ rejectPromise = reject;
25
+ });
26
+ return {
27
+ prompt,
28
+ promise,
29
+ resolve(value) {
30
+ if (settled)
31
+ return;
32
+ settled = true;
33
+ resolvePromise(value);
34
+ },
35
+ reject(error) {
36
+ if (settled)
37
+ return;
38
+ settled = true;
39
+ rejectPromise(normalizeError(error));
40
+ },
41
+ };
42
+ }
43
+ function normalizeError(error) {
44
+ return error instanceof Error ? error : new Error(String(error));
45
+ }
46
+ function markSessionTainted(error) {
47
+ const normalized = normalizeError(error);
48
+ SESSION_TAINTED_ERRORS.add(normalized);
49
+ return normalized;
50
+ }
51
+ function isSessionTainted(error) {
52
+ return error instanceof Error && SESSION_TAINTED_ERRORS.has(error);
53
+ }
54
+ function selectPermissionOption(options) {
55
+ const option = options.find((candidate) => candidate.kind === 'allow_always') ??
56
+ options.find((candidate) => candidate.kind === 'allow_once');
57
+ if (!option) {
58
+ throw new Error('Copilot ACP requested permission but did not offer an allow option');
59
+ }
60
+ return option.optionId;
61
+ }
3
62
  /**
4
- * CLI client for the GitHub Copilot CLI (`copilot`) non-interactive mode,
5
- * with native per-channel session continuity.
6
- *
7
- * Unlike the Claude CLI, Copilot's non-interactive mode takes the prompt as
8
- * a `-p/--prompt` argument rather than reading it from stdin, so the prompt
9
- * is appended to the configured base args on every invocation instead of
10
- * being written to the child process's stdin.
63
+ * Persistent ACP-backed client for the GitHub Copilot CLI (`copilot --acp`).
11
64
  *
12
- * Verified CLI behavior (`copilot --help` + empirical check): `--session-id
13
- * <uuid>` both *creates* a new session pinned to that UUID (first call) and
14
- * *resumes* it (subsequent calls with the same UUID) — one flag covers both
15
- * cases, unlike Claude's `--session-id`/`--resume` split. No message history
16
- * is kept on our side — the CLI's own session storage is the single source
17
- * of truth for conversation memory.
65
+ * The legacy one-shot `COPILOT_ARGS` / constructor args are intentionally
66
+ * ignored here: the prototype always launches the child as `copilot --acp`,
67
+ * performs the ACP initialize handshake once, then reuses one ACP session per
68
+ * Borgee channel for the process lifetime.
18
69
  */
19
70
  export class CopilotCliClient {
20
71
  command;
21
- args;
22
- sessionIdsByChannel = new Map();
23
- constructor(command, args) {
72
+ runtime;
73
+ channels = new Map();
74
+ closingSessions = new WeakSet();
75
+ pendingSessionStarts = new Set();
76
+ pendingSessionCloses = new Set();
77
+ fatalPromise;
78
+ rejectFatalPromise;
79
+ child;
80
+ connection;
81
+ startPromise;
82
+ shutdownPromise;
83
+ childExitPromise;
84
+ resolveChildExit;
85
+ fatalError = null;
86
+ disposing = false;
87
+ backendClosed = false;
88
+ constructor(command, _ignoredArgs = [], runtimeOverrides = {}) {
24
89
  this.command = command;
25
- this.args = args;
90
+ this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
91
+ this.fatalPromise = new Promise((_, reject) => {
92
+ this.rejectFatalPromise = reject;
93
+ });
94
+ void this.fatalPromise.catch(() => { });
26
95
  }
27
96
  async generateReply(channelId, prompt) {
28
- const sessionId = this.sessionIdsByChannel.get(channelId) ?? randomUUID();
29
- const text = await this.run([...this.args, `--session-id=${sessionId}`, '-p', prompt]);
30
- this.sessionIdsByChannel.set(channelId, sessionId);
31
- return text;
32
- }
33
- async run(args) {
34
- return new Promise((resolve, reject) => {
35
- const child = spawn(this.command, args, {
36
- stdio: ['ignore', 'pipe', 'pipe'],
37
- });
38
- let stdout = '';
39
- let stderr = '';
40
- child.stdout.setEncoding('utf8');
41
- child.stderr.setEncoding('utf8');
42
- child.stdout.on('data', (chunk) => {
43
- stdout += chunk;
44
- });
45
- child.stderr.on('data', (chunk) => {
46
- stderr += chunk;
47
- });
48
- child.on('error', (error) => {
49
- reject(error);
97
+ if (this.fatalError) {
98
+ throw this.fatalError;
99
+ }
100
+ const state = this.getOrCreateChannelState(channelId);
101
+ const turn = createDeferredTurn(prompt);
102
+ state.queue.push(turn);
103
+ this.processChannelQueue(channelId, state);
104
+ return turn.promise;
105
+ }
106
+ async dispose() {
107
+ const error = new Error('Copilot ACP backend stopped');
108
+ this.disposing = true;
109
+ const closed = this.connection?.closed ?? Promise.resolve();
110
+ this.failAll(error);
111
+ await Promise.all([closed, this.shutdownPromise ?? Promise.resolve()]);
112
+ }
113
+ async ensureStarted() {
114
+ if (this.fatalError) {
115
+ throw this.fatalError;
116
+ }
117
+ if (!this.startPromise) {
118
+ this.startPromise = this.startBackend();
119
+ }
120
+ await this.startPromise;
121
+ if (this.fatalError) {
122
+ throw this.fatalError;
123
+ }
124
+ if (!this.connection) {
125
+ throw new Error('Copilot ACP connection is not available');
126
+ }
127
+ }
128
+ async startBackend() {
129
+ const child = this.runtime.spawn(this.command, ['--acp'], {
130
+ stdio: ['pipe', 'pipe', 'pipe'],
131
+ });
132
+ this.child = child;
133
+ child.stderr.resume();
134
+ child.once('error', (error) => {
135
+ this.failAll(new Error(`Copilot ACP process error: ${normalizeError(error).message}`));
136
+ });
137
+ child.once('exit', (code, signal) => {
138
+ this.resolveChildExit?.();
139
+ if (this.disposing)
140
+ return;
141
+ const suffix = signal ? `signal ${signal}` : `code ${String(code ?? 'unknown')}`;
142
+ this.failAll(new Error(`Copilot ACP process exited unexpectedly (${suffix})`));
143
+ });
144
+ this.childExitPromise = new Promise((resolve) => {
145
+ this.resolveChildExit = resolve;
146
+ });
147
+ const output = Writable.toWeb(child.stdin);
148
+ const input = Readable.toWeb(child.stdout);
149
+ const stream = this.runtime.ndJsonStream(output, input);
150
+ const app = this.runtime
151
+ .client({ name: 'borgee-agents-host' })
152
+ .onRequest(this.runtime.methods.client.session.requestPermission, ({ params }) => ({
153
+ outcome: {
154
+ outcome: 'selected',
155
+ optionId: selectPermissionOption(params.options),
156
+ },
157
+ }));
158
+ const connection = app.connect(stream);
159
+ this.connection = connection;
160
+ void connection.closed.then(() => {
161
+ if (!this.disposing) {
162
+ this.failAll(new Error('Copilot ACP connection closed unexpectedly'));
163
+ }
164
+ });
165
+ try {
166
+ await connection.agent.request(this.runtime.methods.agent.initialize, {
167
+ protocolVersion: this.runtime.protocolVersion,
168
+ clientCapabilities: {},
169
+ clientInfo: {
170
+ name: '@borgee/agents-host',
171
+ version: '0.1.6',
172
+ },
50
173
  });
51
- child.on('close', (code) => {
52
- if (code !== 0) {
53
- reject(new Error(`Copilot CLI failed with code ${code}: ${stderr.trim()}`));
54
- return;
174
+ }
175
+ catch (error) {
176
+ const normalized = new Error(`Copilot ACP initialize failed: ${normalizeError(error).message}`);
177
+ this.failAll(normalized);
178
+ throw normalized;
179
+ }
180
+ }
181
+ getOrCreateChannelState(channelId) {
182
+ let state = this.channels.get(channelId);
183
+ if (!state) {
184
+ state = {
185
+ processing: false,
186
+ queue: [],
187
+ };
188
+ this.channels.set(channelId, state);
189
+ }
190
+ return state;
191
+ }
192
+ processChannelQueue(channelId, state) {
193
+ if (state.processing) {
194
+ return;
195
+ }
196
+ state.processing = true;
197
+ void (async () => {
198
+ try {
199
+ while (!this.fatalError) {
200
+ const turn = state.queue.shift();
201
+ if (!turn) {
202
+ return;
203
+ }
204
+ state.activeTurn = turn;
205
+ try {
206
+ await this.ensureStarted();
207
+ const session = await this.getOrCreateSession(channelId, state);
208
+ let reply;
209
+ try {
210
+ reply = await this.runTurn(session, turn.prompt);
211
+ }
212
+ catch (error) {
213
+ if (isSessionTainted(error)) {
214
+ this.invalidateSession(state, session);
215
+ this.rejectQueuedTurnsAfterSessionTaint(state, error);
216
+ }
217
+ throw error;
218
+ }
219
+ turn.resolve(reply);
220
+ }
221
+ catch (error) {
222
+ turn.reject(error);
223
+ }
224
+ finally {
225
+ state.activeTurn = undefined;
226
+ }
55
227
  }
56
- const text = stdout.trim();
57
- if (!text) {
58
- reject(new Error('Copilot CLI returned empty output'));
59
- return;
228
+ }
229
+ finally {
230
+ state.processing = false;
231
+ }
232
+ })();
233
+ }
234
+ async getOrCreateSession(channelId, state) {
235
+ if (state.session) {
236
+ return state.session;
237
+ }
238
+ if (state.sessionPromise) {
239
+ return state.sessionPromise;
240
+ }
241
+ if (!this.connection) {
242
+ throw new Error('Copilot ACP connection is not available');
243
+ }
244
+ const sessionPromise = this.connection.agent.buildSession(this.runtime.cwd).start();
245
+ this.pendingSessionStarts.add(sessionPromise);
246
+ state.sessionPromise = sessionPromise;
247
+ let sessionAdopted = false;
248
+ void sessionPromise
249
+ .then((session) => {
250
+ if (!sessionAdopted &&
251
+ (this.fatalError !== null || this.disposing || state.sessionPromise !== sessionPromise)) {
252
+ this.closeSession(session);
253
+ }
254
+ })
255
+ .finally(() => {
256
+ this.pendingSessionStarts.delete(sessionPromise);
257
+ })
258
+ .catch(() => { });
259
+ try {
260
+ const session = await this.raceWithFatal(sessionPromise);
261
+ if (this.fatalError) {
262
+ this.closeSession(session);
263
+ throw this.fatalError;
264
+ }
265
+ sessionAdopted = true;
266
+ state.session = session;
267
+ return session;
268
+ }
269
+ catch (error) {
270
+ state.session = undefined;
271
+ throw error;
272
+ }
273
+ finally {
274
+ if (state.sessionPromise === sessionPromise) {
275
+ state.sessionPromise = undefined;
276
+ }
277
+ if (!state.session && state.queue.length === 0 && !state.activeTurn) {
278
+ this.channels.delete(channelId);
279
+ }
280
+ }
281
+ }
282
+ async runTurn(session, prompt) {
283
+ const promptPromise = this.raceWithFatal(session.prompt(prompt));
284
+ const promptFailure = new Promise((_, reject) => {
285
+ void promptPromise.catch((error) => reject(markSessionTainted(error)));
286
+ });
287
+ let text = '';
288
+ for (;;) {
289
+ let update;
290
+ try {
291
+ update = await this.raceWithFatal(Promise.race([session.nextUpdate(), promptFailure]));
292
+ }
293
+ catch (error) {
294
+ if (this.fatalError && error === this.fatalError) {
295
+ throw error;
60
296
  }
61
- resolve(text);
62
- });
297
+ throw markSessionTainted(error);
298
+ }
299
+ if (update.kind === 'stop') {
300
+ let response;
301
+ try {
302
+ response = await promptPromise;
303
+ }
304
+ catch (error) {
305
+ throw markSessionTainted(error);
306
+ }
307
+ if (response.stopReason !== 'end_turn') {
308
+ throw new Error(`Copilot ACP turn stopped with stopReason "${response.stopReason}"`);
309
+ }
310
+ const output = text.trim();
311
+ if (!output) {
312
+ throw new Error('Copilot ACP returned empty output');
313
+ }
314
+ return output;
315
+ }
316
+ if (update.update.sessionUpdate === 'agent_message_chunk' &&
317
+ update.update.content.type === 'text') {
318
+ text += update.update.content.text;
319
+ }
320
+ }
321
+ }
322
+ async raceWithFatal(promise) {
323
+ if (this.fatalError) {
324
+ throw this.fatalError;
325
+ }
326
+ return Promise.race([promise, this.fatalPromise]);
327
+ }
328
+ failAll(error) {
329
+ if (this.fatalError) {
330
+ return;
331
+ }
332
+ this.fatalError = error;
333
+ this.rejectFatalPromise(error);
334
+ for (const [channelId, state] of this.channels.entries()) {
335
+ state.activeTurn?.reject(error);
336
+ for (const turn of state.queue) {
337
+ turn.reject(error);
338
+ }
339
+ state.queue.length = 0;
340
+ if (state.session) {
341
+ this.closeSession(state.session);
342
+ }
343
+ state.session = undefined;
344
+ state.sessionPromise = undefined;
345
+ state.activeTurn = undefined;
346
+ if (!state.processing) {
347
+ this.channels.delete(channelId);
348
+ }
349
+ }
350
+ this.channels.clear();
351
+ this.shutdownPromise = this.shutdownBackend(error);
352
+ }
353
+ invalidateSession(state, session) {
354
+ if (state.session === session) {
355
+ state.session = undefined;
356
+ }
357
+ this.closeSession(session);
358
+ }
359
+ closeSession(session) {
360
+ if (this.closingSessions.has(session)) {
361
+ return;
362
+ }
363
+ this.closingSessions.add(session);
364
+ const agent = this.connection?.agent;
365
+ const closeMethod = this.runtime.methods.agent.session?.close;
366
+ const closePromise = typeof agent?.closeSession === 'function'
367
+ ? agent.closeSession({ sessionId: session.sessionId })
368
+ : closeMethod
369
+ ? this.connection?.agent.request(closeMethod, { sessionId: session.sessionId })
370
+ : undefined;
371
+ session.dispose();
372
+ if (!closePromise) {
373
+ return;
374
+ }
375
+ let trackedClosePromise;
376
+ trackedClosePromise = Promise.resolve(closePromise)
377
+ .then(() => undefined)
378
+ .catch(() => { })
379
+ .finally(() => {
380
+ this.pendingSessionCloses.delete(trackedClosePromise);
381
+ });
382
+ this.pendingSessionCloses.add(trackedClosePromise);
383
+ }
384
+ rejectQueuedTurnsAfterSessionTaint(state, error) {
385
+ if (state.queue.length === 0) {
386
+ return;
387
+ }
388
+ const rejection = new Error(QUEUED_TURN_DROPPED_MESSAGE, {
389
+ cause: normalizeError(error),
63
390
  });
391
+ for (const turn of state.queue) {
392
+ turn.reject(rejection);
393
+ }
394
+ state.queue.length = 0;
395
+ }
396
+ async shutdownBackend(error) {
397
+ if (this.backendClosed) {
398
+ return;
399
+ }
400
+ this.backendClosed = true;
401
+ const connection = this.connection;
402
+ const child = this.child;
403
+ await this.waitForPendingSessionStarts();
404
+ await this.waitForPendingSessionCloses();
405
+ connection?.close(error);
406
+ this.connection = undefined;
407
+ if (!child) {
408
+ this.child = undefined;
409
+ return;
410
+ }
411
+ const childExitPromise = this.childExitPromise ?? Promise.resolve();
412
+ child.kill('SIGTERM');
413
+ const exitedAfterTerm = await this.waitForChildExit(childExitPromise, this.runtime.shutdownGracePeriodMs);
414
+ if (exitedAfterTerm) {
415
+ this.child = undefined;
416
+ return;
417
+ }
418
+ child.kill('SIGKILL');
419
+ await this.waitForChildExit(childExitPromise, this.runtime.shutdownForceKillWaitMs);
420
+ this.child = undefined;
421
+ }
422
+ async waitForPendingSessionStarts() {
423
+ if (this.pendingSessionStarts.size === 0) {
424
+ return;
425
+ }
426
+ await Promise.race([
427
+ Promise.allSettled([...this.pendingSessionStarts]).then(() => undefined),
428
+ new Promise((resolve) => {
429
+ setTimeout(resolve, this.runtime.shutdownGracePeriodMs);
430
+ }),
431
+ ]);
432
+ }
433
+ async waitForPendingSessionCloses() {
434
+ if (this.pendingSessionCloses.size === 0) {
435
+ return;
436
+ }
437
+ await Promise.race([
438
+ Promise.allSettled([...this.pendingSessionCloses]).then(() => undefined),
439
+ new Promise((resolve) => {
440
+ setTimeout(resolve, this.runtime.shutdownGracePeriodMs);
441
+ }),
442
+ ]);
443
+ }
444
+ async waitForChildExit(childExitPromise, timeoutMs) {
445
+ if (timeoutMs <= 0) {
446
+ return false;
447
+ }
448
+ return Promise.race([
449
+ childExitPromise.then(() => true),
450
+ new Promise((resolve) => {
451
+ setTimeout(() => resolve(false), timeoutMs);
452
+ }),
453
+ ]);
64
454
  }
65
455
  }
@@ -1,4 +1,5 @@
1
1
  import type { ProviderInput, ProviderReply } from '../types.js';
2
2
  export interface ProviderAdapter {
3
3
  generateReply(input: ProviderInput): Promise<ProviderReply>;
4
+ dispose?(): Promise<void>;
4
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@borgee/agents-host",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -21,6 +21,7 @@
21
21
  },
22
22
  "license": "MIT",
23
23
  "dependencies": {
24
+ "@agentclientprotocol/sdk": "^1.2.1",
24
25
  "cross-spawn": "^7.0.6",
25
26
  "@borgee/plugin-sdk": "0.1.1"
26
27
  },