@borgee/agents-host 0.1.0

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 ADDED
@@ -0,0 +1,91 @@
1
+ # @borgee/agents-host
2
+
3
+ Minimal local runtime host: connects a local **Claude Code CLI** (`claude`) or
4
+ **GitHub Copilot CLI** (`copilot`) process to a single Borgee agent over
5
+ [`@borgee/plugin-sdk`](../../sdk/plugin-ts) — the same BPP (`/ws/plugin`)
6
+ protocol the official OpenClaw plugin uses.
7
+
8
+ ## What this is (and isn't)
9
+
10
+ This package is intentionally the *smallest* slice that proves the full loop:
11
+
12
+ ```
13
+ Borgee channel message
14
+ → /ws/plugin (BPP)
15
+ → @borgee/plugin-sdk
16
+ → AgentsHost.handleMessage
17
+ → local `claude`/`copilot` CLI subprocess
18
+ → BPP reply
19
+ → Borgee channel
20
+ ```
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.
26
+
27
+ **Out of scope** (see the larger `feat/multi-agent-runtime-host` work for
28
+ these): remote command execution / shell dispatch, node provisioning,
29
+ multi-agent discovery/sync, systemd service install, and scheduled/periodic
30
+ prompts. To run several agents, run several processes with different
31
+ `BORGEE_AGENT_API_KEY` values.
32
+
33
+ ## Setup
34
+
35
+ 1. In the Borgee web UI, create an Agent and reveal its API key.
36
+ 2. Install the Claude Code CLI (`claude`) or GitHub Copilot CLI (`copilot`)
37
+ locally and make sure it works standalone.
38
+ 3. Run this package with the environment variables below.
39
+
40
+ ```bash
41
+ BORGEE_BASE_URL=https://your-borgee-server \
42
+ BORGEE_AGENT_API_KEY=bgr_xxxxxxxx \
43
+ BORGEE_AGENT_NAME="My Agent" \
44
+ RUNTIME_PROVIDER=claude \
45
+ pnpm --filter @borgee/agents-host dev
46
+ ```
47
+
48
+ ## Environment variables
49
+
50
+ | Variable | Required | Default | Description |
51
+ | --- | --- | --- | --- |
52
+ | `BORGEE_BASE_URL` | yes | — | Borgee server base URL (e.g. `https://borgee.example.com`) |
53
+ | `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 |
55
+ | `RUNTIME_PROVIDER` | no | `claude` | `claude` or `copilot` |
56
+ | `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 |
59
+
60
+ ## Conversation memory
61
+
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:
64
+
65
+ - **Claude**: first turn for a channel uses `--session-id <uuid>`; every
66
+ turn after uses `-r/--resume <uuid>` (Claude treats these as distinct
67
+ operations).
68
+ - **Copilot**: every turn uses `--session-id=<uuid>` — Copilot's CLI treats
69
+ that flag as create-or-resume for the same UUID.
70
+
71
+ There is no separate history/context store on our side; if the runtime host
72
+ process restarts, each channel starts a fresh CLI session (no session-id
73
+ persistence across restarts in this minimal version).
74
+
75
+ ## Running
76
+
77
+ ```bash
78
+ pnpm --filter @borgee/agents-host dev # tsx, no build step
79
+ pnpm --filter @borgee/agents-host build # tsc -> dist/
80
+ pnpm --filter @borgee/agents-host start # node dist/index.js
81
+ ```
82
+
83
+ There is no systemd/service installer here — run it as a plain foreground
84
+ process (or wrap it with your own process manager) and stop it with
85
+ `Ctrl+C`/`SIGTERM`.
86
+
87
+ ## Testing
88
+
89
+ ```bash
90
+ pnpm --filter @borgee/agents-host test
91
+ ```
@@ -0,0 +1,46 @@
1
+ import type { ChatControlPlane } from './chat/chat-control-plane.js';
2
+ import type { ProviderAdapter } from './providers/provider-adapter.js';
3
+ import type { AgentsHostConfig } from './types.js';
4
+ /**
5
+ * Minimal single-agent agents host: connects one local Claude/Copilot CLI
6
+ * to exactly one Borgee agent over `@borgee/plugin-sdk` (BPP / `/ws/plugin`).
7
+ *
8
+ * Conversation memory is handled entirely by each provider's native
9
+ * per-channel CLI session (Claude `--resume`, Copilot `--session-id`) — see
10
+ * the cli-client.ts file under each provider's folder. This class does not
11
+ * keep any message history itself.
12
+ *
13
+ * Out of scope by design (see README): execution/remote-command dispatch,
14
+ * node provisioning, multi-agent discovery, systemd install, and scheduled
15
+ * (periodic) prompts. Running several agents means running several
16
+ * processes, each with its own `BORGEE_AGENT_API_KEY`.
17
+ */
18
+ export declare class AgentsHost {
19
+ private readonly config;
20
+ private readonly provider;
21
+ private readonly borgee;
22
+ private selfAgentId;
23
+ private started;
24
+ constructor(config: AgentsHostConfig, deps?: {
25
+ borgee?: ChatControlPlane;
26
+ provider?: ProviderAdapter;
27
+ });
28
+ start(): Promise<void>;
29
+ stop(): Promise<void>;
30
+ 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
+ }, content: string): boolean;
39
+ /**
40
+ * Channel-type check, based solely on the `channel_type` hint the SDK
41
+ * derives from the inbound BPP event. There is no REST fallback here (that
42
+ * would require the excluded control-plane SDK) — an absent hint is
43
+ * treated as "not a DM".
44
+ */
45
+ private isDirectMessage;
46
+ }
@@ -0,0 +1,117 @@
1
+ import { SdkChatControlPlane } from './chat/sdk-chat-control-plane.js';
2
+ import { createProvider } from './providers/create-provider.js';
3
+ /**
4
+ * Minimal single-agent agents host: connects one local Claude/Copilot CLI
5
+ * to exactly one Borgee agent over `@borgee/plugin-sdk` (BPP / `/ws/plugin`).
6
+ *
7
+ * Conversation memory is handled entirely by each provider's native
8
+ * per-channel CLI session (Claude `--resume`, Copilot `--session-id`) — see
9
+ * the cli-client.ts file under each provider's folder. This class does not
10
+ * keep any message history itself.
11
+ *
12
+ * Out of scope by design (see README): execution/remote-command dispatch,
13
+ * node provisioning, multi-agent discovery, systemd install, and scheduled
14
+ * (periodic) prompts. Running several agents means running several
15
+ * processes, each with its own `BORGEE_AGENT_API_KEY`.
16
+ */
17
+ export class AgentsHost {
18
+ config;
19
+ provider;
20
+ borgee;
21
+ selfAgentId = null;
22
+ started = false;
23
+ constructor(config, deps) {
24
+ this.config = config;
25
+ this.provider = deps?.provider ?? createProvider({
26
+ provider: config.agent.provider,
27
+ claudeCommand: config.claudeCommand,
28
+ claudeArgs: config.claudeArgs,
29
+ copilotCommand: config.copilotCommand,
30
+ copilotArgs: config.copilotArgs,
31
+ });
32
+ this.borgee = deps?.borgee ?? new SdkChatControlPlane(config.borgeeBaseUrl, config.agent.agentApiKey, undefined,
33
+ // Lets the server know which runtime/provider actually connected
34
+ // (internal/bpp ConnectHandler → users.last_connected_plugin_id), so
35
+ // the web UI can show real state instead of a client-side guess.
36
+ `agents-host:${config.agent.provider}`);
37
+ }
38
+ async start() {
39
+ if (this.started)
40
+ return;
41
+ this.started = true;
42
+ await this.borgee.connect((message) => {
43
+ void this.handleMessage(message);
44
+ });
45
+ const me = await this.borgee.getMe();
46
+ this.selfAgentId = me.id;
47
+ console.log('[agents-host] connected', {
48
+ agentId: me.id,
49
+ agentName: this.config.agent.agentName,
50
+ provider: this.config.agent.provider,
51
+ });
52
+ }
53
+ async stop() {
54
+ if (!this.started)
55
+ return;
56
+ this.started = false;
57
+ await this.borgee.close();
58
+ }
59
+ async handleMessage(msg) {
60
+ console.log('[agents-host] received message', {
61
+ eventType: msg.type,
62
+ provider: this.config.agent.provider,
63
+ agentName: this.config.agent.agentName,
64
+ channelId: msg.channel_id,
65
+ });
66
+ const authorId = String(msg.user_id ?? msg.sender_id ?? 'unknown');
67
+ if (this.selfAgentId && authorId === this.selfAgentId) {
68
+ return;
69
+ }
70
+ const content = String(msg.content ?? msg.body ?? '').trim();
71
+ if (!content)
72
+ 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
+ try {
79
+ const reply = await this.provider.generateReply({
80
+ agentName: this.config.agent.agentName,
81
+ provider: this.config.agent.provider,
82
+ channelId: msg.channel_id,
83
+ incomingAuthorId: authorId,
84
+ incomingContent: content,
85
+ });
86
+ await this.borgee.postMessage(msg.channel_id, reply.text);
87
+ }
88
+ catch (error) {
89
+ console.error('[agents-host] failed to generate or send reply:', {
90
+ provider: this.config.agent.provider,
91
+ agentName: this.config.agent.agentName,
92
+ error,
93
+ });
94
+ }
95
+ }
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
+ return this.config.agent.agentName.trim().length > 0
106
+ && content.toLowerCase().includes(this.config.agent.agentName.toLowerCase());
107
+ }
108
+ /**
109
+ * Channel-type check, based solely on the `channel_type` hint the SDK
110
+ * derives from the inbound BPP event. There is no REST fallback here (that
111
+ * would require the excluded control-plane SDK) — an absent hint is
112
+ * treated as "not a DM".
113
+ */
114
+ isDirectMessage(hintedType) {
115
+ return hintedType === 'dm';
116
+ }
117
+ }
@@ -0,0 +1,7 @@
1
+ import type { ChannelMessageEvent, MeResponseUser } from '../types.js';
2
+ export interface ChatControlPlane {
3
+ connect(onMessage: (message: ChannelMessageEvent) => void): Promise<void>;
4
+ close(): Promise<void>;
5
+ postMessage(channelId: string, content: string): Promise<void>;
6
+ getMe(): Promise<MeResponseUser>;
7
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import { type BorgeePluginClient, type BorgeePluginOptions, type InboundMessageEvent } from '@borgee/plugin-sdk';
2
+ import type { ChannelMessageEvent, MeResponseUser } from '../types.js';
3
+ import type { ChatControlPlane } from './chat-control-plane.js';
4
+ type PluginClientLike = Pick<BorgeePluginClient, 'close' | 'connect' | 'getMe' | 'on' | 'sendMessage'>;
5
+ type PluginClientFactory = (options: BorgeePluginOptions) => PluginClientLike;
6
+ /**
7
+ * Thin adapter over `@borgee/plugin-sdk` (the same BPP/`/ws/plugin` SDK used by
8
+ * the OpenClaw plugin) that implements the minimal `ChatControlPlane` surface
9
+ * this runtime host needs: connect, receive messages, and post replies.
10
+ */
11
+ export declare class SdkChatControlPlane implements ChatControlPlane {
12
+ private readonly client;
13
+ private pendingMessages;
14
+ private unsubscribe;
15
+ private connected;
16
+ constructor(baseUrl: string, apiKey: string, createClient?: PluginClientFactory, pluginId?: string);
17
+ connect(onMessage: (message: ChannelMessageEvent) => void): Promise<void>;
18
+ close(): Promise<void>;
19
+ postMessage(channelId: string, content: string): Promise<void>;
20
+ getMe(): Promise<MeResponseUser>;
21
+ }
22
+ export declare function mapInboundToChannelMessage(event: InboundMessageEvent): ChannelMessageEvent;
23
+ export {};
@@ -0,0 +1,71 @@
1
+ import { createBorgeePlugin, } from '@borgee/plugin-sdk';
2
+ /**
3
+ * Thin adapter over `@borgee/plugin-sdk` (the same BPP/`/ws/plugin` SDK used by
4
+ * the OpenClaw plugin) that implements the minimal `ChatControlPlane` surface
5
+ * this runtime host needs: connect, receive messages, and post replies.
6
+ */
7
+ export class SdkChatControlPlane {
8
+ client;
9
+ pendingMessages = [];
10
+ unsubscribe = null;
11
+ connected = false;
12
+ constructor(baseUrl, apiKey, createClient = createBorgeePlugin, pluginId) {
13
+ this.client = createClient({ baseUrl, apiKey, pluginId });
14
+ }
15
+ async connect(onMessage) {
16
+ this.unsubscribe = this.client.on('message', (event) => {
17
+ const message = mapInboundToChannelMessage(event);
18
+ if (!this.connected) {
19
+ this.pendingMessages.push(message);
20
+ return;
21
+ }
22
+ onMessage(message);
23
+ });
24
+ try {
25
+ await this.client.connect();
26
+ this.connected = true;
27
+ for (const message of this.pendingMessages) {
28
+ onMessage(message);
29
+ }
30
+ this.pendingMessages = [];
31
+ }
32
+ catch (error) {
33
+ this.unsubscribe?.();
34
+ this.unsubscribe = null;
35
+ this.pendingMessages = [];
36
+ throw error;
37
+ }
38
+ }
39
+ async close() {
40
+ this.connected = false;
41
+ this.pendingMessages = [];
42
+ this.unsubscribe?.();
43
+ this.unsubscribe = null;
44
+ await this.client.close();
45
+ }
46
+ async postMessage(channelId, content) {
47
+ await this.client.sendMessage({ channelId, body: content });
48
+ }
49
+ async getMe() {
50
+ const me = await this.client.getMe();
51
+ return {
52
+ id: me.id,
53
+ display_name: me.displayName,
54
+ role: me.kind,
55
+ };
56
+ }
57
+ }
58
+ export function mapInboundToChannelMessage(event) {
59
+ return {
60
+ type: event.kind,
61
+ channel_id: event.channelId,
62
+ channel_type: event.channelType,
63
+ message_id: event.message?.id ?? event.messageId,
64
+ user_id: event.message?.authorId ?? event.reaction?.userId,
65
+ sender_id: event.message?.authorId ?? event.reaction?.userId,
66
+ content: event.message?.body,
67
+ body: event.message?.body,
68
+ content_type: event.message?.contentType,
69
+ created_at: event.createdAt,
70
+ };
71
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Maps `agents-host start` CLI flags to the env vars `config.ts` reads.
3
+ * Keeping this as a plain lookup table (rather than duplicating
4
+ * `loadConfigFromEnv`'s parsing logic) means the CLI and the plain
5
+ * env-var entry point (`index.ts`) always agree on defaults/validation.
6
+ */
7
+ export declare const CLI_FLAG_TO_ENV: Record<string, string>;
8
+ export interface ParsedStartArgs {
9
+ serverUrl: string;
10
+ apiKey: string;
11
+ env: Record<string, string>;
12
+ }
13
+ export declare class CliUsageError extends Error {
14
+ }
15
+ /**
16
+ * Parses `start <serverUrl> <apiKey> [--flag value ...]` (the argv slice
17
+ * after the `start` command word). Throws `CliUsageError` with a
18
+ * human-readable message on any usage problem instead of exiting the
19
+ * process, so callers (and tests) can decide how to report it.
20
+ */
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";
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Maps `agents-host start` CLI flags to the env vars `config.ts` reads.
3
+ * Keeping this as a plain lookup table (rather than duplicating
4
+ * `loadConfigFromEnv`'s parsing logic) means the CLI and the plain
5
+ * env-var entry point (`index.ts`) always agree on defaults/validation.
6
+ */
7
+ export const CLI_FLAG_TO_ENV = {
8
+ name: 'BORGEE_AGENT_NAME',
9
+ provider: 'RUNTIME_PROVIDER',
10
+ 'claude-command': 'CLAUDE_COMMAND',
11
+ 'claude-args': 'CLAUDE_ARGS',
12
+ 'copilot-command': 'COPILOT_COMMAND',
13
+ 'copilot-args': 'COPILOT_ARGS',
14
+ 'mention-only': 'RESPOND_ON_MENTION_ONLY',
15
+ };
16
+ export class CliUsageError extends Error {
17
+ }
18
+ /**
19
+ * Parses `start <serverUrl> <apiKey> [--flag value ...]` (the argv slice
20
+ * after the `start` command word). Throws `CliUsageError` with a
21
+ * human-readable message on any usage problem instead of exiting the
22
+ * process, so callers (and tests) can decide how to report it.
23
+ */
24
+ export function parseStartArgs(argv) {
25
+ const positionals = [];
26
+ const env = {};
27
+ for (let i = 0; i < argv.length; i++) {
28
+ const arg = argv[i];
29
+ if (arg.startsWith('--')) {
30
+ const flag = arg.slice(2);
31
+ const value = argv[i + 1];
32
+ if (value === undefined || value.startsWith('--')) {
33
+ throw new CliUsageError(`Missing value for --${flag}`);
34
+ }
35
+ const envKey = CLI_FLAG_TO_ENV[flag];
36
+ if (!envKey) {
37
+ throw new CliUsageError(`Unknown option: --${flag}`);
38
+ }
39
+ env[envKey] = value;
40
+ i++;
41
+ }
42
+ else {
43
+ positionals.push(arg);
44
+ }
45
+ }
46
+ const [serverUrl, apiKey, ...extra] = positionals;
47
+ if (!serverUrl)
48
+ throw new CliUsageError('Missing <serverUrl>');
49
+ if (!apiKey)
50
+ throw new CliUsageError('Missing <apiKey>');
51
+ if (extra.length > 0)
52
+ throw new CliUsageError(`Unexpected argument: ${extra[0]}`);
53
+ return { serverUrl, apiKey, env };
54
+ }
55
+ export const USAGE = `Usage: agents-host start <serverUrl> <apiKey> [options]
56
+
57
+ Options:
58
+ --name <name> Display name (default: Assistant)
59
+ --provider <claude|copilot> Runtime provider (default: claude)
60
+ --claude-command <cmd> Local Claude CLI command (default: claude)
61
+ --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)
65
+
66
+ Example:
67
+ agents-host start https://borgee.example.com bgr_xxxxxxxx --provider copilot
68
+ `;
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ import { CliUsageError, parseStartArgs, USAGE } from './cli-args.js';
3
+ import { runMain } from './run.js';
4
+ function fail(message) {
5
+ if (message)
6
+ console.error(`[agents-host] ${message}`);
7
+ console.error(USAGE);
8
+ process.exit(message ? 1 : 0);
9
+ }
10
+ const [command, ...rest] = process.argv.slice(2);
11
+ if (command === undefined || command === '--help' || command === '-h') {
12
+ fail();
13
+ }
14
+ if (command !== 'start') {
15
+ fail(`Unknown command: ${command}`);
16
+ }
17
+ try {
18
+ const { serverUrl, apiKey, env } = parseStartArgs(rest);
19
+ process.env.BORGEE_BASE_URL = serverUrl;
20
+ process.env.BORGEE_AGENT_API_KEY = apiKey;
21
+ for (const [key, value] of Object.entries(env)) {
22
+ process.env[key] = value;
23
+ }
24
+ }
25
+ catch (error) {
26
+ if (error instanceof CliUsageError) {
27
+ fail(error.message);
28
+ }
29
+ throw error;
30
+ }
31
+ runMain().catch((error) => {
32
+ console.error('[agents-host] fatal error:', error);
33
+ process.exitCode = 1;
34
+ });
@@ -0,0 +1,7 @@
1
+ import type { AgentsHostConfig } from './types.js';
2
+ /**
3
+ * Loads agents-host configuration from environment variables. This process
4
+ * hosts exactly one Borgee agent (`BORGEE_AGENT_API_KEY`) — to run more
5
+ * agents, run more processes with different env vars.
6
+ */
7
+ export declare function loadConfigFromEnv(): AgentsHostConfig;
package/dist/config.js ADDED
@@ -0,0 +1,46 @@
1
+ function requireEnv(name) {
2
+ const value = process.env[name];
3
+ if (!value || value.trim().length === 0) {
4
+ throw new Error(`Missing required environment variable: ${name}`);
5
+ }
6
+ return value.trim();
7
+ }
8
+ function envOr(name, fallback) {
9
+ const value = process.env[name];
10
+ return value && value.trim().length > 0 ? value.trim() : fallback;
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
+ function parseArgs(value) {
19
+ return value.trim().length > 0 ? value.trim().split(/\s+/) : [];
20
+ }
21
+ function resolveProvider() {
22
+ const raw = envOr('RUNTIME_PROVIDER', 'claude').toLowerCase();
23
+ if (raw === 'claude' || raw === 'copilot')
24
+ return raw;
25
+ throw new Error(`Unsupported RUNTIME_PROVIDER: ${raw} (expected "claude" or "copilot")`);
26
+ }
27
+ /**
28
+ * Loads agents-host configuration from environment variables. This process
29
+ * hosts exactly one Borgee agent (`BORGEE_AGENT_API_KEY`) — to run more
30
+ * agents, run more processes with different env vars.
31
+ */
32
+ export function loadConfigFromEnv() {
33
+ return {
34
+ borgeeBaseUrl: requireEnv('BORGEE_BASE_URL'),
35
+ claudeCommand: envOr('CLAUDE_COMMAND', 'claude'),
36
+ claudeArgs: parseArgs(envOr('CLAUDE_ARGS', '--print')),
37
+ copilotCommand: envOr('COPILOT_COMMAND', 'copilot'),
38
+ copilotArgs: parseArgs(envOr('COPILOT_ARGS', '-s --no-color --allow-all-tools --output-format text')),
39
+ agent: {
40
+ agentApiKey: requireEnv('BORGEE_AGENT_API_KEY'),
41
+ agentName: envOr('BORGEE_AGENT_NAME', 'Assistant'),
42
+ respondOnMentionOnly: envBoolOr('RESPOND_ON_MENTION_ONLY', true),
43
+ provider: resolveProvider(),
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,15 @@
1
+ import type { ProviderKind } from '../types.js';
2
+ /**
3
+ * Builds the per-turn prompt. Conversation memory is intentionally NOT
4
+ * assembled here: each provider's CLI client resumes a native per-channel
5
+ * session (Claude `--resume`, Copilot `--session-id`), so the CLI itself
6
+ * already has the prior turns. This only needs to carry the agent's
7
+ * identity/instructions plus the new incoming message.
8
+ */
9
+ export declare function buildPrompt(params: {
10
+ agentName: string;
11
+ provider: ProviderKind;
12
+ channelId: string;
13
+ incomingAuthorId: string;
14
+ incomingContent: string;
15
+ }): string;
@@ -0,0 +1,24 @@
1
+ function providerLabel(provider) {
2
+ return provider === 'copilot' ? 'GitHub Copilot' : 'Claude';
3
+ }
4
+ /**
5
+ * Builds the per-turn prompt. Conversation memory is intentionally NOT
6
+ * assembled here: each provider's CLI client resumes a native per-channel
7
+ * session (Claude `--resume`, Copilot `--session-id`), so the CLI itself
8
+ * already has the prior turns. This only needs to carry the agent's
9
+ * identity/instructions plus the new incoming message.
10
+ */
11
+ export function buildPrompt(params) {
12
+ return [
13
+ `You are ${params.agentName}, a helpful AI teammate inside Borgee.`,
14
+ `Your current backend provider is ${providerLabel(params.provider)} (${params.provider}).`,
15
+ 'You are replying inside a shared collaboration channel.',
16
+ 'Be concise, helpful, and honest about uncertainty.',
17
+ 'Do not claim to have performed actions you did not actually perform.',
18
+ `If the user asks who you are, what powers you, or which backend/provider you use, mention that you are currently running on ${providerLabel(params.provider)}.`,
19
+ `Channel: ${params.channelId}`,
20
+ '',
21
+ `New message from ${params.incomingAuthorId}:`,
22
+ params.incomingContent,
23
+ ].join('\n');
24
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ import { runMain } from './run.js';
2
+ // Plain env-var entry point (`pnpm dev` / `pnpm start`). For the CLI entry
3
+ // point that accepts `agents-host start <serverUrl> <apiKey> ...`, see
4
+ // cli.ts, which sets the equivalent env vars from argv and delegates to the
5
+ // same runMain().
6
+ runMain().catch((error) => {
7
+ console.error('[agents-host] fatal error:', error);
8
+ process.exitCode = 1;
9
+ });
@@ -0,0 +1,8 @@
1
+ import type { ProviderAdapter } from '../provider-adapter.js';
2
+ import type { ProviderInput, ProviderReply } from '../../types.js';
3
+ import { ClaudeCliClient } from './cli-client.js';
4
+ export declare class ClaudeProviderAdapter implements ProviderAdapter {
5
+ private readonly cli;
6
+ constructor(cli: ClaudeCliClient);
7
+ generateReply(input: ProviderInput): Promise<ProviderReply>;
8
+ }
@@ -0,0 +1,18 @@
1
+ import { buildPrompt } from '../../context/prompt.js';
2
+ export class ClaudeProviderAdapter {
3
+ cli;
4
+ constructor(cli) {
5
+ this.cli = cli;
6
+ }
7
+ async generateReply(input) {
8
+ const prompt = buildPrompt({
9
+ agentName: input.agentName,
10
+ provider: input.provider,
11
+ channelId: input.channelId,
12
+ incomingAuthorId: input.incomingAuthorId,
13
+ incomingContent: input.incomingContent,
14
+ });
15
+ const text = await this.cli.generateReply(input.channelId, prompt);
16
+ return { text };
17
+ }
18
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * CLI client for the Claude Code CLI (`claude`) non-interactive mode, with
3
+ * native per-channel session continuity.
4
+ *
5
+ * Verified CLI behavior (`claude --help`):
6
+ * - `--session-id <uuid>` starts a *new* conversation pinned to that UUID.
7
+ * - `-r/--resume <uuid>` resumes an *existing* conversation by session ID.
8
+ * These are documented as distinct operations, so this client tracks which
9
+ * channels have already started a session and switches from `--session-id`
10
+ * (first turn) to `--resume` (every turn after) accordingly. No message
11
+ * history is kept on our side — the CLI's own session storage is the single
12
+ * source of truth for conversation memory.
13
+ */
14
+ export declare class ClaudeCliClient {
15
+ private readonly command;
16
+ private readonly args;
17
+ private readonly sessionIdsByChannel;
18
+ constructor(command: string, args: string[]);
19
+ generateReply(channelId: string, prompt: string): Promise<string>;
20
+ private run;
21
+ }
@@ -0,0 +1,71 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ /**
4
+ * CLI client for the Claude Code CLI (`claude`) non-interactive mode, with
5
+ * native per-channel session continuity.
6
+ *
7
+ * Verified CLI behavior (`claude --help`):
8
+ * - `--session-id <uuid>` starts a *new* conversation pinned to that UUID.
9
+ * - `-r/--resume <uuid>` resumes an *existing* conversation by session ID.
10
+ * These are documented as distinct operations, so this client tracks which
11
+ * channels have already started a session and switches from `--session-id`
12
+ * (first turn) to `--resume` (every turn after) accordingly. No message
13
+ * history is kept on our side — the CLI's own session storage is the single
14
+ * source of truth for conversation memory.
15
+ */
16
+ export class ClaudeCliClient {
17
+ command;
18
+ args;
19
+ sessionIdsByChannel = new Map();
20
+ constructor(command, args) {
21
+ this.command = command;
22
+ this.args = args;
23
+ }
24
+ async generateReply(channelId, prompt) {
25
+ const existingSessionId = this.sessionIdsByChannel.get(channelId);
26
+ const sessionId = existingSessionId ?? randomUUID();
27
+ const sessionArgs = existingSessionId
28
+ ? ['--resume', sessionId]
29
+ : ['--session-id', sessionId];
30
+ const text = await this.run([...this.args, ...sessionArgs], prompt);
31
+ // Only remember the session once the CLI call actually succeeds, so a
32
+ // failed first turn doesn't leave us permanently trying to `--resume`
33
+ // a session that was never created.
34
+ this.sessionIdsByChannel.set(channelId, sessionId);
35
+ return text;
36
+ }
37
+ async run(args, prompt) {
38
+ return new Promise((resolve, reject) => {
39
+ const child = spawn(this.command, args, {
40
+ stdio: ['pipe', 'pipe', 'pipe'],
41
+ });
42
+ let stdout = '';
43
+ let stderr = '';
44
+ child.stdout.setEncoding('utf8');
45
+ child.stderr.setEncoding('utf8');
46
+ child.stdout.on('data', (chunk) => {
47
+ stdout += chunk;
48
+ });
49
+ child.stderr.on('data', (chunk) => {
50
+ stderr += chunk;
51
+ });
52
+ child.on('error', (error) => {
53
+ reject(error);
54
+ });
55
+ child.on('close', (code) => {
56
+ if (code !== 0) {
57
+ reject(new Error(`Claude CLI failed with code ${code}: ${stderr.trim()}`));
58
+ return;
59
+ }
60
+ const text = stdout.trim();
61
+ if (!text) {
62
+ reject(new Error('Claude CLI returned empty output'));
63
+ return;
64
+ }
65
+ resolve(text);
66
+ });
67
+ child.stdin.write(prompt);
68
+ child.stdin.end();
69
+ });
70
+ }
71
+ }
@@ -0,0 +1,8 @@
1
+ import type { ProviderAdapter } from '../provider-adapter.js';
2
+ import type { ProviderInput, ProviderReply } from '../../types.js';
3
+ import { CopilotCliClient } from './cli-client.js';
4
+ export declare class CopilotProviderAdapter implements ProviderAdapter {
5
+ private readonly cli;
6
+ constructor(cli: CopilotCliClient);
7
+ generateReply(input: ProviderInput): Promise<ProviderReply>;
8
+ }
@@ -0,0 +1,18 @@
1
+ import { buildPrompt } from '../../context/prompt.js';
2
+ export class CopilotProviderAdapter {
3
+ cli;
4
+ constructor(cli) {
5
+ this.cli = cli;
6
+ }
7
+ async generateReply(input) {
8
+ const prompt = buildPrompt({
9
+ agentName: input.agentName,
10
+ provider: input.provider,
11
+ channelId: input.channelId,
12
+ incomingAuthorId: input.incomingAuthorId,
13
+ incomingContent: input.incomingContent,
14
+ });
15
+ const text = await this.cli.generateReply(input.channelId, prompt);
16
+ return { text };
17
+ }
18
+ }
@@ -0,0 +1,24 @@
1
+ /**
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.
9
+ *
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
+ */
17
+ export declare class CopilotCliClient {
18
+ private readonly command;
19
+ private readonly args;
20
+ private readonly sessionIdsByChannel;
21
+ constructor(command: string, args: string[]);
22
+ generateReply(channelId: string, prompt: string): Promise<string>;
23
+ private run;
24
+ }
@@ -0,0 +1,65 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ /**
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.
11
+ *
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.
18
+ */
19
+ export class CopilotCliClient {
20
+ command;
21
+ args;
22
+ sessionIdsByChannel = new Map();
23
+ constructor(command, args) {
24
+ this.command = command;
25
+ this.args = args;
26
+ }
27
+ 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);
50
+ });
51
+ child.on('close', (code) => {
52
+ if (code !== 0) {
53
+ reject(new Error(`Copilot CLI failed with code ${code}: ${stderr.trim()}`));
54
+ return;
55
+ }
56
+ const text = stdout.trim();
57
+ if (!text) {
58
+ reject(new Error('Copilot CLI returned empty output'));
59
+ return;
60
+ }
61
+ resolve(text);
62
+ });
63
+ });
64
+ }
65
+ }
@@ -0,0 +1,3 @@
1
+ import type { ProviderAdapter } from './provider-adapter.js';
2
+ import type { ProviderRuntimeConfig } from '../types.js';
3
+ export declare function createProvider(config: ProviderRuntimeConfig): ProviderAdapter;
@@ -0,0 +1,18 @@
1
+ import { ClaudeCliClient } from './claude/cli-client.js';
2
+ import { ClaudeProviderAdapter } from './claude/adapter.js';
3
+ import { CopilotCliClient } from './copilot/cli-client.js';
4
+ import { CopilotProviderAdapter } from './copilot/adapter.js';
5
+ export function createProvider(config) {
6
+ switch (config.provider) {
7
+ case 'claude': {
8
+ const cli = new ClaudeCliClient(config.claudeCommand, config.claudeArgs);
9
+ return new ClaudeProviderAdapter(cli);
10
+ }
11
+ case 'copilot': {
12
+ const cli = new CopilotCliClient(config.copilotCommand, config.copilotArgs);
13
+ return new CopilotProviderAdapter(cli);
14
+ }
15
+ default:
16
+ throw new Error(`Unsupported provider: ${String(config.provider)}`);
17
+ }
18
+ }
@@ -0,0 +1,4 @@
1
+ import type { ProviderInput, ProviderReply } from '../types.js';
2
+ export interface ProviderAdapter {
3
+ generateReply(input: ProviderInput): Promise<ProviderReply>;
4
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/run.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Loads config from the environment, starts an `AgentsHost`, and wires up
3
+ * graceful shutdown on SIGINT/SIGTERM. Shared by the plain env-var entry
4
+ * point (`index.ts`) and the CLI entry point (`cli.ts`), which sets the
5
+ * relevant env vars from argv before delegating here.
6
+ */
7
+ export declare function runMain(): Promise<void>;
package/dist/run.js ADDED
@@ -0,0 +1,22 @@
1
+ import { loadConfigFromEnv } from './config.js';
2
+ import { AgentsHost } from './agents-host.js';
3
+ /**
4
+ * Loads config from the environment, starts an `AgentsHost`, and wires up
5
+ * graceful shutdown on SIGINT/SIGTERM. Shared by the plain env-var entry
6
+ * point (`index.ts`) and the CLI entry point (`cli.ts`), which sets the
7
+ * relevant env vars from argv before delegating here.
8
+ */
9
+ export async function runMain() {
10
+ const config = loadConfigFromEnv();
11
+ const host = new AgentsHost(config);
12
+ const shutdown = (signal) => {
13
+ console.log(`[agents-host] received ${signal}, shutting down`);
14
+ host
15
+ .stop()
16
+ .catch((error) => console.error('[agents-host] error during shutdown:', error))
17
+ .finally(() => process.exit(0));
18
+ };
19
+ process.on('SIGINT', () => shutdown('SIGINT'));
20
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
21
+ await host.start();
22
+ }
@@ -0,0 +1,53 @@
1
+ export type ProviderKind = 'claude' | 'copilot';
2
+ export interface ProviderCommandConfig {
3
+ claudeCommand: string;
4
+ claudeArgs: string[];
5
+ copilotCommand: string;
6
+ copilotArgs: string[];
7
+ }
8
+ export interface ProviderRuntimeConfig extends ProviderCommandConfig {
9
+ provider: ProviderKind;
10
+ }
11
+ /**
12
+ * Single hosted-agent configuration. The minimal agents host supports exactly
13
+ * one Borgee agent per process — running multiple agents means running
14
+ * multiple processes with different env vars (see README).
15
+ */
16
+ export interface HostedAgentConfig {
17
+ agentApiKey: string;
18
+ agentName: string;
19
+ respondOnMentionOnly: boolean;
20
+ provider: ProviderKind;
21
+ }
22
+ export interface AgentsHostConfig extends ProviderCommandConfig {
23
+ borgeeBaseUrl: string;
24
+ agent: HostedAgentConfig;
25
+ }
26
+ export interface ChannelMessageEvent {
27
+ type?: string;
28
+ channel_id: string;
29
+ channel_type?: string;
30
+ message_id?: string;
31
+ user_id?: string;
32
+ sender_id?: string;
33
+ content?: string;
34
+ body?: string;
35
+ content_type?: string;
36
+ created_at?: number;
37
+ [key: string]: unknown;
38
+ }
39
+ export interface MeResponseUser {
40
+ id: string;
41
+ display_name?: string;
42
+ role?: string;
43
+ }
44
+ export interface ProviderInput {
45
+ agentName: string;
46
+ provider: ProviderKind;
47
+ channelId: string;
48
+ incomingAuthorId: string;
49
+ incomingContent: string;
50
+ }
51
+ export interface ProviderReply {
52
+ text: string;
53
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@borgee/agents-host",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "type": "module",
9
+ "description": "Minimal local agents host: connects a local Claude/Copilot CLI to a Borgee agent over @borgee/plugin-sdk",
10
+ "bin": {
11
+ "agents-host": "./dist/cli.js"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "README.md"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/codetreker/borgee.git",
20
+ "directory": "packages/runtimes/agents-host"
21
+ },
22
+ "license": "MIT",
23
+ "dependencies": {
24
+ "@borgee/plugin-sdk": "0.1.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^24.3.0",
28
+ "tsx": "^4.20.5",
29
+ "typescript": "^5.9.3",
30
+ "vitest": "^4.1.5"
31
+ },
32
+ "scripts": {
33
+ "predev": "pnpm --filter @borgee/plugin-sdk build",
34
+ "dev": "tsx src/index.ts",
35
+ "precli": "pnpm --filter @borgee/plugin-sdk build",
36
+ "cli": "tsx src/cli.ts",
37
+ "prestart": "pnpm --filter @borgee/plugin-sdk build",
38
+ "start": "node dist/index.js",
39
+ "prebuild": "pnpm --filter @borgee/plugin-sdk build",
40
+ "build": "tsc",
41
+ "typecheck": "tsc --noEmit",
42
+ "pretest": "pnpm --filter @borgee/plugin-sdk build",
43
+ "test": "vitest run"
44
+ }
45
+ }