@borgee/agents-host 0.1.6 → 0.1.7

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.
@@ -0,0 +1,313 @@
1
+ import { watch } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { AgentsHost } from './agents-host.js';
4
+ import { loadLocalConfigSnapshot, MANAGED_WRITE_LOCK_DIRNAME } from './local-config.js';
5
+ const RELOAD_DEBOUNCE_MS = 300;
6
+ function sameAgentConfig(left, right) {
7
+ return JSON.stringify(left) === JSON.stringify(right);
8
+ }
9
+ export class AgentsHostSupervisor {
10
+ hostConfigPath;
11
+ createHost;
12
+ loadSnapshot;
13
+ watchPath;
14
+ logger;
15
+ activeHosts = new Map();
16
+ watchers = new Map();
17
+ currentSnapshot = null;
18
+ reloadTimer = null;
19
+ reloadInFlight = false;
20
+ pendingReload = false;
21
+ runningReloadPromise = null;
22
+ shuttingDown = false;
23
+ started = false;
24
+ constructor(hostConfigPath, deps = {}) {
25
+ this.hostConfigPath = resolve(hostConfigPath);
26
+ this.createHost = deps.createHost ?? ((config) => new AgentsHost(config));
27
+ this.loadSnapshot = deps.loadSnapshot ?? ((configPath) => loadLocalConfigSnapshot(configPath));
28
+ this.watchPath =
29
+ deps.watchPath ??
30
+ ((path, onEvent) => watch(path, (eventType, filename) => {
31
+ onEvent({
32
+ eventType,
33
+ filename: filename == null ? undefined : String(filename),
34
+ });
35
+ }));
36
+ this.logger = deps.logger ?? console;
37
+ }
38
+ async start() {
39
+ if (this.started) {
40
+ return;
41
+ }
42
+ this.started = true;
43
+ this.shuttingDown = false;
44
+ try {
45
+ this.installStableWatcher();
46
+ await this.runReloadLoop(true);
47
+ if (this.shuttingDown) {
48
+ await this.cleanUpFailedStart();
49
+ return;
50
+ }
51
+ const snapshot = this.currentSnapshot;
52
+ if (!snapshot) {
53
+ throw new Error('Local-config startup completed without an applied snapshot');
54
+ }
55
+ this.logger.log('[agents-host] local-config supervisor started', {
56
+ configPath: this.hostConfigPath,
57
+ agentsDir: snapshot.agentsDir,
58
+ agentCount: snapshot.agents.length,
59
+ });
60
+ }
61
+ catch (error) {
62
+ await this.cleanUpFailedStart();
63
+ throw error;
64
+ }
65
+ }
66
+ async stop() {
67
+ if (!this.started) {
68
+ return;
69
+ }
70
+ this.started = false;
71
+ this.shuttingDown = true;
72
+ this.clearReloadTimer();
73
+ this.closeAllWatchers();
74
+ await this.runningReloadPromise;
75
+ const activeKeys = [...this.activeHosts.keys()].sort((left, right) => left.localeCompare(right));
76
+ for (const key of activeKeys) {
77
+ const activeHost = this.activeHosts.get(key);
78
+ if (!activeHost) {
79
+ continue;
80
+ }
81
+ await this.stopActiveHost(key, activeHost);
82
+ }
83
+ }
84
+ scheduleReload() {
85
+ if (this.shuttingDown) {
86
+ return;
87
+ }
88
+ this.clearReloadTimer();
89
+ this.reloadTimer = setTimeout(() => {
90
+ this.reloadTimer = null;
91
+ void this.runReloadLoop();
92
+ }, RELOAD_DEBOUNCE_MS);
93
+ }
94
+ clearReloadTimer() {
95
+ if (this.reloadTimer) {
96
+ clearTimeout(this.reloadTimer);
97
+ this.reloadTimer = null;
98
+ }
99
+ }
100
+ async runReloadLoop(failOnStartError = false) {
101
+ if (this.reloadInFlight) {
102
+ this.pendingReload = true;
103
+ return;
104
+ }
105
+ this.reloadInFlight = true;
106
+ this.runningReloadPromise = (async () => {
107
+ try {
108
+ do {
109
+ this.pendingReload = false;
110
+ await this.reloadOnce(failOnStartError);
111
+ } while (this.pendingReload && !this.shuttingDown);
112
+ }
113
+ finally {
114
+ this.reloadInFlight = false;
115
+ this.runningReloadPromise = null;
116
+ }
117
+ })();
118
+ await this.runningReloadPromise;
119
+ }
120
+ async reloadOnce(failOnStartError = false) {
121
+ if (this.shuttingDown) {
122
+ return;
123
+ }
124
+ try {
125
+ const snapshot = await this.loadSnapshot(this.hostConfigPath);
126
+ if (this.shuttingDown) {
127
+ return;
128
+ }
129
+ await this.applySnapshot(snapshot, failOnStartError);
130
+ if (this.shuttingDown) {
131
+ return;
132
+ }
133
+ this.refreshWatchers(snapshot);
134
+ this.currentSnapshot = snapshot;
135
+ this.logger.log('[agents-host] applied local-config reload', {
136
+ configPath: this.hostConfigPath,
137
+ agentsDir: snapshot.agentsDir,
138
+ agentCount: snapshot.agents.length,
139
+ });
140
+ }
141
+ catch (error) {
142
+ this.logger.error('[agents-host] local-config reload failed; keeping last applied snapshot', {
143
+ configPath: this.hostConfigPath,
144
+ error,
145
+ });
146
+ if (failOnStartError) {
147
+ throw error;
148
+ }
149
+ }
150
+ }
151
+ refreshWatchers(snapshot) {
152
+ const watchPaths = this.getWatchPaths(snapshot);
153
+ const nextWatchers = new Map();
154
+ try {
155
+ for (const path of watchPaths) {
156
+ const existing = this.watchers.get(path);
157
+ if (existing) {
158
+ nextWatchers.set(path, existing);
159
+ continue;
160
+ }
161
+ nextWatchers.set(path, this.watchPath(path, (event) => this.handleWatchEvent(path, event)));
162
+ }
163
+ }
164
+ catch (error) {
165
+ for (const [path, handle] of nextWatchers) {
166
+ if (!this.watchers.has(path)) {
167
+ handle.close();
168
+ }
169
+ }
170
+ throw error;
171
+ }
172
+ for (const [path, handle] of this.watchers) {
173
+ if (!nextWatchers.has(path)) {
174
+ handle.close();
175
+ }
176
+ }
177
+ this.watchers.clear();
178
+ for (const [path, handle] of nextWatchers) {
179
+ this.watchers.set(path, handle);
180
+ }
181
+ }
182
+ getWatchPaths(snapshot) {
183
+ const paths = snapshot.managedRootPath
184
+ ? [snapshot.managedRootPath, snapshot.resolvedAgentsDir]
185
+ : [dirname(this.hostConfigPath), snapshot.resolvedHostConfigDir, snapshot.resolvedAgentsDir];
186
+ return [...new Set(paths)].sort((a, b) => a.localeCompare(b));
187
+ }
188
+ handleWatchEvent(path, event) {
189
+ if (event?.filename === MANAGED_WRITE_LOCK_DIRNAME && path === dirname(this.hostConfigPath)) {
190
+ return;
191
+ }
192
+ this.scheduleReload();
193
+ }
194
+ installStableWatcher() {
195
+ const stablePath = dirname(this.hostConfigPath);
196
+ if (!this.watchers.has(stablePath)) {
197
+ this.watchers.set(stablePath, this.watchPath(stablePath, (event) => this.handleWatchEvent(stablePath, event)));
198
+ }
199
+ }
200
+ closeAllWatchers() {
201
+ const watchers = [...this.watchers.entries()];
202
+ this.watchers.clear();
203
+ for (const [path, handle] of watchers) {
204
+ try {
205
+ handle.close();
206
+ }
207
+ catch (error) {
208
+ this.logger.error('[agents-host] failed to close config watcher', { path, error });
209
+ }
210
+ }
211
+ }
212
+ async cleanUpFailedStart() {
213
+ this.shuttingDown = true;
214
+ this.clearReloadTimer();
215
+ this.closeAllWatchers();
216
+ const activeKeys = [...this.activeHosts.keys()].sort((left, right) => left.localeCompare(right));
217
+ for (const key of activeKeys) {
218
+ const activeHost = this.activeHosts.get(key);
219
+ if (activeHost) {
220
+ await this.stopActiveHost(key, activeHost);
221
+ }
222
+ }
223
+ this.currentSnapshot = null;
224
+ this.started = false;
225
+ }
226
+ async applySnapshot(snapshot, failOnStartError = false) {
227
+ const desiredAgents = new Map(snapshot.agents.map((agent) => [agent.key, agent]));
228
+ const activeKeys = [...this.activeHosts.keys()].sort((left, right) => left.localeCompare(right));
229
+ for (const key of activeKeys) {
230
+ if (!desiredAgents.has(key)) {
231
+ const activeHost = this.activeHosts.get(key);
232
+ if (activeHost) {
233
+ await this.stopActiveHost(key, activeHost);
234
+ }
235
+ }
236
+ }
237
+ const desiredKeys = [...desiredAgents.keys()].sort((left, right) => left.localeCompare(right));
238
+ for (const key of desiredKeys) {
239
+ const desiredAgent = desiredAgents.get(key);
240
+ if (!desiredAgent) {
241
+ continue;
242
+ }
243
+ const activeHost = this.activeHosts.get(key);
244
+ if (!activeHost) {
245
+ await this.startDesiredAgent(desiredAgent, failOnStartError);
246
+ continue;
247
+ }
248
+ if (sameAgentConfig(activeHost.config, desiredAgent.config)) {
249
+ continue;
250
+ }
251
+ await this.stopActiveHost(key, activeHost);
252
+ await this.startDesiredAgent(desiredAgent, failOnStartError);
253
+ }
254
+ }
255
+ async startDesiredAgent(agent, failOnStartError = false) {
256
+ if (this.shuttingDown) {
257
+ return;
258
+ }
259
+ const runner = this.createHost(agent.config);
260
+ try {
261
+ await runner.start();
262
+ this.activeHosts.set(agent.key, {
263
+ config: agent.config,
264
+ runner,
265
+ });
266
+ this.logger.log('[agents-host] started managed agent', {
267
+ key: agent.key,
268
+ sourcePath: agent.sourcePath,
269
+ agentName: agent.config.agent.agentName,
270
+ provider: agent.config.agent.provider,
271
+ });
272
+ }
273
+ catch (error) {
274
+ try {
275
+ await runner.stop();
276
+ }
277
+ catch (stopError) {
278
+ this.logger.error('[agents-host] failed to clean up agent after start error', {
279
+ key: agent.key,
280
+ sourcePath: agent.sourcePath,
281
+ error: stopError,
282
+ });
283
+ }
284
+ this.logger.error('[agents-host] failed to start managed agent; leaving it absent until the next reload', {
285
+ key: agent.key,
286
+ sourcePath: agent.sourcePath,
287
+ error,
288
+ });
289
+ if (failOnStartError) {
290
+ throw error;
291
+ }
292
+ }
293
+ }
294
+ async stopActiveHost(key, activeHost) {
295
+ this.activeHosts.delete(key);
296
+ try {
297
+ await activeHost.runner.stop();
298
+ this.logger.log('[agents-host] stopped managed agent', {
299
+ key,
300
+ agentName: activeHost.config.agent.agentName,
301
+ provider: activeHost.config.agent.provider,
302
+ });
303
+ }
304
+ catch (error) {
305
+ this.logger.error('[agents-host] failed to stop managed agent', {
306
+ key,
307
+ agentName: activeHost.config.agent.agentName,
308
+ provider: activeHost.config.agent.provider,
309
+ error,
310
+ });
311
+ }
312
+ }
313
+ }
@@ -10,10 +10,10 @@ import type { AgentsHostConfig } from './types.js';
10
10
  * the cli-client.ts file under each provider's folder. This class does not
11
11
  * keep any message history itself.
12
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`.
13
+ * Out of scope for this single-agent runner (see README): execution/remote-command
14
+ * dispatch, node provisioning, systemd install, and scheduled (periodic)
15
+ * prompts. Multi-agent local hosting is handled one level up by the local-config
16
+ * supervisor, which creates one isolated `AgentsHost` per effective agent key.
17
17
  *
18
18
  * Message gating (mention-only / DM rules) is NOT done here: the server
19
19
  * enforces per-agent + per-channel `require_mention` at BPP fan-out, so this
@@ -9,10 +9,10 @@ import { createProvider } from './providers/create-provider.js';
9
9
  * the cli-client.ts file under each provider's folder. This class does not
10
10
  * keep any message history itself.
11
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`.
12
+ * Out of scope for this single-agent runner (see README): execution/remote-command
13
+ * dispatch, node provisioning, systemd install, and scheduled (periodic)
14
+ * prompts. Multi-agent local hosting is handled one level up by the local-config
15
+ * supervisor, which creates one isolated `AgentsHost` per effective agent key.
16
16
  *
17
17
  * Message gating (mention-only / DM rules) is NOT done here: the server
18
18
  * enforces per-agent + per-channel `require_mention` at BPP fan-out, so this
@@ -32,6 +32,7 @@ export class AgentsHost {
32
32
  claudeArgs: config.claudeArgs,
33
33
  copilotCommand: config.copilotCommand,
34
34
  copilotArgs: config.copilotArgs,
35
+ copilotSessionTtlMinutes: config.copilotSessionTtlMinutes,
35
36
  });
36
37
  this.borgee = deps?.borgee ?? new SdkChatControlPlane(config.borgeeBaseUrl, config.agent.agentApiKey, undefined,
37
38
  // Lets the server know which runtime/provider actually connected
@@ -1,22 +1,45 @@
1
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.
2
+ * Maps single-agent `agents-host start` CLI flags to the env vars `config.ts`
3
+ * reads. Keeping this as a plain lookup table (rather than duplicating
4
+ * `loadConfigFromEnv`'s parsing logic) means the CLI and the env-var entry
5
+ * point (`index.ts`) always agree on defaults/validation.
6
6
  */
7
7
  export declare const CLI_FLAG_TO_ENV: Record<string, string>;
8
- export interface ParsedStartArgs {
8
+ export type ParsedStartArgs = ParsedSingleAgentStartArgs | ParsedLocalConfigStartArgs;
9
+ export interface ParsedSingleAgentStartArgs {
10
+ mode: 'single-agent';
9
11
  serverUrl: string;
10
12
  apiKey: string;
11
13
  env: Record<string, string>;
12
14
  }
15
+ export interface ParsedLocalConfigStartArgs {
16
+ mode: 'local-config';
17
+ configPath: string;
18
+ }
19
+ export interface ParsedValidateArgs {
20
+ configPath: string;
21
+ }
22
+ export interface ParsedDescribeArgs {
23
+ configPath: string;
24
+ }
25
+ export interface ParsedPrintLayoutArgs {
26
+ rootPath: string;
27
+ }
28
+ export interface ParsedGenerateConfigFromArgvArgs {
29
+ rootPath: string;
30
+ input: 'argv';
31
+ specJson: string;
32
+ }
33
+ export interface ParsedGenerateConfigFromStdinArgs {
34
+ rootPath: string;
35
+ input: 'stdin';
36
+ }
37
+ export type ParsedGenerateConfigArgs = ParsedGenerateConfigFromArgvArgs | ParsedGenerateConfigFromStdinArgs;
13
38
  export declare class CliUsageError extends Error {
14
39
  }
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
40
  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> Ignored by the Copilot ACP prototype\n\nExample:\n agents-host start https://borgee.example.com bgr_xxxxxxxx --provider copilot\n";
41
+ export declare function parseValidateArgs(argv: string[]): ParsedValidateArgs;
42
+ export declare function parseDescribeArgs(argv: string[]): ParsedDescribeArgs;
43
+ export declare function parsePrintLayoutArgs(argv: string[]): ParsedPrintLayoutArgs;
44
+ export declare function parseGenerateConfigArgs(argv: string[]): ParsedGenerateConfigArgs;
45
+ export declare const USAGE = "Usage:\n agents-host start <serverUrl> <apiKey> [options]\n agents-host start --config <path-to-host-config>\n agents-host validate --config <path-to-host-config>\n agents-host describe --config <path-to-host-config>\n agents-host print-layout --root <dir>\n agents-host generate-config --root <dir> --stdin\n agents-host generate-config --root <dir> --spec-json <json>\n\nSingle-agent options:\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 --copilot-session-ttl-minutes <minutes>\n Idle session TTL for Copilot ACP sessions (default: 2880)\n\nLocal-config mode:\n start --config <path> Start agents + supervisor + watchers from a host config file\n validate --config <path> Validate local-config files without starting agents or watchers\n describe --config <path> Print the current managed full-set spec as JSON\n print-layout --root <dir> Print the canonical default local-config layout as JSON\n generate-config --root <dir> --stdin Materialize canonical local-config files from stdin\n generate-config --root <dir> --spec-json <json>\n Compatibility input; JSON is exposed in process arguments\n\nExamples:\n agents-host start https://borgee.example.com bgr_xxxxxxxx --provider copilot\n agents-host start --config ./agents-host.yaml\n agents-host validate --config ./agents-host.yaml\n agents-host describe --config ./agents-host.yaml\n agents-host print-layout --root ./runtime-root\n printf '%s' '{\"host\":{\"borgeeBaseUrl\":\"https://borgee.example.com\"},\"agents\":[{\"key\":\"cp1\",\"name\":\"Copilot\",\"apiKey\":\"bgr_xxx\",\"provider\":\"copilot\"}]}' | agents-host generate-config --root ./runtime-root --stdin\n";