@borgee/agents-host 0.1.4 → 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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Minimal local runtime host: connects a local **Claude Code CLI** (`claude`) or
4
4
  **GitHub Copilot CLI** (`copilot`) process to a single Borgee agent over
5
- [`@borgee/plugin-sdk`](../../sdk/plugin-ts) — the same BPP (`/ws/plugin`)
5
+ [`@borgee/plugin-sdk`](../sdk/plugin-ts) — the same BPP (`/ws/plugin`)
6
6
  protocol the official OpenClaw plugin uses.
7
7
 
8
8
  ## What this is (and isn't)
@@ -19,10 +19,9 @@ Borgee channel message
19
19
  → Borgee channel
20
20
  ```
21
21
 
22
- **In scope:** one agent per process, mention-only gating (with a DM bypass),
23
- per-channel conversation memory via each CLI's own native session resume
24
- (Claude `--resume`, Copilot `--session-id`), and dispatching to a local CLI
25
- provider.
22
+ **In scope:** one agent per process, per-channel conversation memory via each
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,
@@ -51,22 +50,23 @@ pnpm --filter @borgee/agents-host dev
51
50
  | --- | --- | --- | --- |
52
51
  | `BORGEE_BASE_URL` | yes | — | Borgee server base URL (e.g. `https://borgee.example.com`) |
53
52
  | `BORGEE_AGENT_API_KEY` | yes | — | The agent's API key, revealed from the web UI |
54
- | `BORGEE_AGENT_NAME` | no | `Assistant` | Display name used for mention matching and prompts |
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 |
58
- | `RESPOND_ON_MENTION_ONLY` | no | `true` | If `true`, only replies in non-DM channels when @mentioned; DMs always get a reply |
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`. |
59
57
 
60
58
  ## Conversation memory
61
59
 
62
- Each Borgee channel is mapped 1:1 to a native CLI session, keyed by a
63
- 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:
64
62
 
65
63
  - **Claude**: first turn for a channel uses `--session-id <uuid>`; every
66
64
  turn after uses `-r/--resume <uuid>` (Claude treats these as distinct
67
65
  operations).
68
- - **Copilot**: every turn uses `--session-id=<uuid>` — Copilot's CLI treats
69
- 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.
70
70
 
71
71
  There is no separate history/context store on our side; if the runtime host
72
72
  process restarts, each channel starts a fresh CLI session (no session-id
@@ -14,6 +14,10 @@ import type { AgentsHostConfig } from './types.js';
14
14
  * node provisioning, multi-agent discovery, systemd install, and scheduled
15
15
  * (periodic) prompts. Running several agents means running several
16
16
  * processes, each with its own `BORGEE_AGENT_API_KEY`.
17
+ *
18
+ * Message gating (mention-only / DM rules) is NOT done here: the server
19
+ * enforces per-agent + per-channel `require_mention` at BPP fan-out, so this
20
+ * host simply replies to every message it is handed (minus its own).
17
21
  */
18
22
  export declare class AgentsHost {
19
23
  private readonly config;
@@ -28,20 +32,4 @@ export declare class AgentsHost {
28
32
  start(): Promise<void>;
29
33
  stop(): Promise<void>;
30
34
  private handleMessage;
31
- /**
32
- * Whether `msg` mentions this agent: either via the SDK's structured
33
- * mention event kind, or a plain-text substring match on the configured
34
- * agent name.
35
- */
36
- isLikelyMention(message: {
37
- type?: string;
38
- mentions?: string[];
39
- }, content: string): boolean;
40
- /**
41
- * Channel-type check, based solely on the `channel_type` hint the SDK
42
- * derives from the inbound BPP event. There is no REST fallback here (that
43
- * would require the excluded control-plane SDK) — an absent hint is
44
- * treated as "not a DM".
45
- */
46
- private isDirectMessage;
47
35
  }
@@ -13,6 +13,10 @@ import { createProvider } from './providers/create-provider.js';
13
13
  * node provisioning, multi-agent discovery, systemd install, and scheduled
14
14
  * (periodic) prompts. Running several agents means running several
15
15
  * processes, each with its own `BORGEE_AGENT_API_KEY`.
16
+ *
17
+ * Message gating (mention-only / DM rules) is NOT done here: the server
18
+ * enforces per-agent + per-channel `require_mention` at BPP fan-out, so this
19
+ * host simply replies to every message it is handed (minus its own).
16
20
  */
17
21
  export class AgentsHost {
18
22
  config;
@@ -55,6 +59,7 @@ export class AgentsHost {
55
59
  return;
56
60
  this.started = false;
57
61
  await this.borgee.close();
62
+ await this.provider.dispose?.();
58
63
  }
59
64
  async handleMessage(msg) {
60
65
  console.log('[agents-host] received message', {
@@ -70,11 +75,10 @@ export class AgentsHost {
70
75
  const content = String(msg.content ?? msg.body ?? '').trim();
71
76
  if (!content)
72
77
  return;
73
- const isDm = this.isDirectMessage(msg.channel_type);
74
- if (this.config.agent.respondOnMentionOnly && !isDm) {
75
- if (!this.isLikelyMention(msg, content))
76
- return;
77
- }
78
+ // No client-side mention/DM gating here: the server already decides which
79
+ // messages an agent receives (per-agent + per-channel require_mention is
80
+ // enforced at BPP fan-out via AgentReceivesChannelMessage). If a channel
81
+ // message reaches this host at all, the agent is meant to handle it.
78
82
  try {
79
83
  const reply = await this.provider.generateReply({
80
84
  agentName: this.config.agent.agentName,
@@ -93,30 +97,4 @@ export class AgentsHost {
93
97
  });
94
98
  }
95
99
  }
96
- /**
97
- * Whether `msg` mentions this agent: either via the SDK's structured
98
- * mention event kind, or a plain-text substring match on the configured
99
- * agent name.
100
- */
101
- isLikelyMention(message, content) {
102
- if (message.type === 'mention') {
103
- return true;
104
- }
105
- if (Array.isArray(message.mentions) && this.selfAgentId) {
106
- if (message.mentions.some((mention) => mention === this.selfAgentId)) {
107
- return true;
108
- }
109
- }
110
- return this.config.agent.agentName.trim().length > 0
111
- && content.toLowerCase().includes(this.config.agent.agentName.toLowerCase());
112
- }
113
- /**
114
- * Channel-type check, based solely on the `channel_type` hint the SDK
115
- * derives from the inbound BPP event. There is no REST fallback here (that
116
- * would require the excluded control-plane SDK) — an absent hint is
117
- * treated as "not a DM".
118
- */
119
- isDirectMessage(hintedType) {
120
- return hintedType === 'dm';
121
- }
122
100
  }
@@ -65,7 +65,6 @@ export function mapInboundToChannelMessage(event) {
65
65
  sender_id: event.message?.authorId ?? event.reaction?.userId,
66
66
  content: event.message?.body,
67
67
  body: event.message?.body,
68
- mentions: event.message?.mentions,
69
68
  content_type: event.message?.contentType,
70
69
  created_at: event.createdAt,
71
70
  };
@@ -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 --mention-only <true|false> Reply only when @mentioned outside DMs (default: true)\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
@@ -11,7 +11,6 @@ export const CLI_FLAG_TO_ENV = {
11
11
  'claude-args': 'CLAUDE_ARGS',
12
12
  'copilot-command': 'COPILOT_COMMAND',
13
13
  'copilot-args': 'COPILOT_ARGS',
14
- 'mention-only': 'RESPOND_ON_MENTION_ONLY',
15
14
  };
16
15
  export class CliUsageError extends Error {
17
16
  }
@@ -59,9 +58,8 @@ Options:
59
58
  --provider <claude|copilot> Runtime provider (default: claude)
60
59
  --claude-command <cmd> Local Claude CLI command (default: claude)
61
60
  --claude-args <args> Local Claude CLI args (default: --print)
62
- --copilot-command <cmd> Local Copilot CLI command (default: copilot)
63
- --copilot-args <args> Local Copilot CLI args
64
- --mention-only <true|false> Reply only when @mentioned outside DMs (default: true)
61
+ --copilot-command <cmd> Local Copilot CLI command (default: copilot)
62
+ --copilot-args <args> Ignored by the Copilot ACP prototype
65
63
 
66
64
  Example:
67
65
  agents-host start https://borgee.example.com bgr_xxxxxxxx --provider copilot
package/dist/cli.js CHANGED
File without changes
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
@@ -9,12 +9,6 @@ function envOr(name, fallback) {
9
9
  const value = process.env[name];
10
10
  return value && value.trim().length > 0 ? value.trim() : fallback;
11
11
  }
12
- function envBoolOr(name, fallback) {
13
- const value = process.env[name];
14
- if (value === undefined || value.trim().length === 0)
15
- return fallback;
16
- return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
17
- }
18
12
  function parseArgs(value) {
19
13
  return value.trim().length > 0 ? value.trim().split(/\s+/) : [];
20
14
  }
@@ -28,6 +22,10 @@ function resolveProvider() {
28
22
  * Loads agents-host configuration from environment variables. This process
29
23
  * hosts exactly one Borgee agent (`BORGEE_AGENT_API_KEY`) — to run more
30
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`.
31
29
  */
32
30
  export function loadConfigFromEnv() {
33
31
  return {
@@ -39,7 +37,6 @@ export function loadConfigFromEnv() {
39
37
  agent: {
40
38
  agentApiKey: requireEnv('BORGEE_AGENT_API_KEY'),
41
39
  agentName: envOr('BORGEE_AGENT_NAME', 'Assistant'),
42
- respondOnMentionOnly: envBoolOr('RESPOND_ON_MENTION_ONLY', true),
43
40
  provider: resolveProvider(),
44
41
  },
45
42
  };
@@ -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/dist/types.d.ts CHANGED
@@ -16,7 +16,6 @@ export interface ProviderRuntimeConfig extends ProviderCommandConfig {
16
16
  export interface HostedAgentConfig {
17
17
  agentApiKey: string;
18
18
  agentName: string;
19
- respondOnMentionOnly: boolean;
20
19
  provider: ProviderKind;
21
20
  }
22
21
  export interface AgentsHostConfig extends ProviderCommandConfig {
@@ -34,7 +33,6 @@ export interface ChannelMessageEvent {
34
33
  body?: string;
35
34
  content_type?: string;
36
35
  created_at?: number;
37
- mentions?: string[];
38
36
  [key: string]: unknown;
39
37
  }
40
38
  export interface MeResponseUser {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@borgee/agents-host",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -17,11 +17,32 @@
17
17
  "repository": {
18
18
  "type": "git",
19
19
  "url": "https://github.com/codetreker/borgee.git",
20
- "directory": "packages/runtimes/agents-host"
20
+ "directory": "packages/agents-host"
21
21
  },
22
22
  "license": "MIT",
23
23
  "dependencies": {
24
- "@borgee/plugin-sdk": "0.1.1",
25
- "cross-spawn": "^7.0.6"
24
+ "@agentclientprotocol/sdk": "^1.2.1",
25
+ "cross-spawn": "^7.0.6",
26
+ "@borgee/plugin-sdk": "0.1.1"
27
+ },
28
+ "devDependencies": {
29
+ "@types/cross-spawn": "^6.0.6",
30
+ "@types/node": "^24.3.0",
31
+ "tsx": "^4.20.5",
32
+ "typescript": "^5.9.3",
33
+ "vitest": "^4.1.5"
34
+ },
35
+ "scripts": {
36
+ "predev": "pnpm --filter @borgee/plugin-sdk build",
37
+ "dev": "tsx src/index.ts",
38
+ "precli": "pnpm --filter @borgee/plugin-sdk build",
39
+ "cli": "tsx src/cli.ts",
40
+ "prestart": "pnpm --filter @borgee/plugin-sdk build",
41
+ "start": "node dist/index.js",
42
+ "prebuild": "pnpm --filter @borgee/plugin-sdk build",
43
+ "build": "tsc",
44
+ "typecheck": "tsc --noEmit",
45
+ "pretest": "pnpm --filter @borgee/plugin-sdk build",
46
+ "test": "vitest run"
26
47
  }
27
- }
48
+ }