@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 +12 -12
- package/dist/agents-host.d.ts +4 -16
- package/dist/agents-host.js +9 -31
- package/dist/chat/sdk-chat-control-plane.js +0 -1
- package/dist/cli-args.d.ts +1 -1
- package/dist/cli-args.js +2 -4
- package/dist/cli.js +0 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +4 -7
- package/dist/providers/copilot/adapter.d.ts +1 -0
- package/dist/providers/copilot/adapter.js +3 -0
- package/dist/providers/copilot/cli-client.d.ts +51 -17
- package/dist/providers/copilot/cli-client.js +440 -50
- package/dist/providers/provider-adapter.d.ts +1 -0
- package/dist/types.d.ts +0 -2
- package/package.json +26 -5
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`](
|
|
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,
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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` /
|
|
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
|
|
63
|
-
|
|
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**:
|
|
69
|
-
|
|
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
|
package/dist/agents-host.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/agents-host.js
CHANGED
|
@@ -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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
};
|
package/dist/cli-args.d.ts
CHANGED
|
@@ -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>
|
|
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>
|
|
63
|
-
--copilot-args <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
|
};
|
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
20
|
-
private readonly
|
|
21
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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.
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
}
|
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.
|
|
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/
|
|
20
|
+
"directory": "packages/agents-host"
|
|
21
21
|
},
|
|
22
22
|
"license": "MIT",
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@
|
|
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
|
+
}
|