@borgee/agents-host 0.1.6 → 0.1.8

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,772 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { parseDocument, stringify } from 'yaml';
5
+ import { optionalNonEmptyString, optionalStringArray, parseCopilotSessionTtlMinutesValue, requireNonEmptyString, resolveProvider, resolveProviderCommandConfig, } from './config.js';
6
+ const SUPPORTED_CONFIG_EXTENSIONS = new Set(['.json', '.yaml', '.yml']);
7
+ export const DEFAULT_LOCAL_HOST_CONFIG_FILENAME = 'agents-host.yaml';
8
+ export const DEFAULT_LOCAL_AGENTS_DIRNAME = 'agents';
9
+ const DEFAULT_AGENTS_DIR = './agents';
10
+ const MANAGED_GENERATIONS_DIRNAME = '.generations';
11
+ const MANAGED_CURRENT_LINK_NAME = 'current';
12
+ export const MANAGED_WRITE_LOCK_DIRNAME = '.generate-config.lock';
13
+ const MANAGED_READERS_DIRNAME = '.readers';
14
+ const MANAGED_HOLDER_METADATA_FILENAME = '.holder.json';
15
+ const MANAGED_ROOT_MODE = 0o700;
16
+ const MANAGED_CONFIG_MODE = 0o600;
17
+ const MANAGED_WRITE_LOCK_WAIT_MS = 10_000;
18
+ const MANAGED_WRITE_LOCK_RETRY_MS = 50;
19
+ const MANAGED_HOLDER_MAX_AGE_MS = 5 * 60_000;
20
+ const MANAGED_GENERATION_NAME_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
21
+ const nodeFileSystem = {
22
+ async readFile(path) {
23
+ return fs.readFile(path, 'utf8');
24
+ },
25
+ async readDir(path) {
26
+ const entries = await fs.readdir(path, { withFileTypes: true });
27
+ return entries.map((entry) => ({
28
+ name: entry.name,
29
+ isFile: entry.isFile(),
30
+ }));
31
+ },
32
+ async realPath(path) {
33
+ return fs.realpath(path);
34
+ },
35
+ async writeFile(path, content) {
36
+ await fs.writeFile(path, content, { encoding: 'utf8', mode: MANAGED_CONFIG_MODE });
37
+ },
38
+ async mkdir(path, options = {}) {
39
+ await fs.mkdir(path, options);
40
+ },
41
+ async removeFile(path) {
42
+ await fs.unlink(path);
43
+ },
44
+ async removeTree(path) {
45
+ await fs.rm(path, { recursive: true, force: true });
46
+ },
47
+ async rename(from, to) {
48
+ await fs.rename(from, to);
49
+ },
50
+ async symlink(target, path) {
51
+ await fs.symlink(target, path);
52
+ },
53
+ async readLink(path) {
54
+ return fs.readlink(path);
55
+ },
56
+ async lstat(path) {
57
+ const status = await fs.lstat(path);
58
+ return {
59
+ isDirectory: status.isDirectory(),
60
+ isSymbolicLink: status.isSymbolicLink(),
61
+ mtimeMs: status.mtimeMs,
62
+ };
63
+ },
64
+ async chmod(path, mode) {
65
+ await fs.chmod(path, mode);
66
+ },
67
+ };
68
+ function isRecord(value) {
69
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
70
+ }
71
+ export function resolveLocalConfigLayout(rootPath) {
72
+ const root = resolve(rootPath);
73
+ return {
74
+ root,
75
+ hostConfigPath: resolve(root, DEFAULT_LOCAL_HOST_CONFIG_FILENAME),
76
+ agentsDir: resolve(root, DEFAULT_LOCAL_AGENTS_DIRNAME),
77
+ };
78
+ }
79
+ async function loadParsedDocument(fileSystem, filePath, kind) {
80
+ const raw = await fileSystem.readFile(filePath);
81
+ const document = parseDocument(raw);
82
+ if (document.errors.length > 0) {
83
+ throw new Error(`Invalid ${kind} file ${filePath}: ${document.errors[0]?.message ?? 'parse error'}`);
84
+ }
85
+ const parsed = document.toJS();
86
+ if (!isRecord(parsed)) {
87
+ throw new Error(`Invalid ${kind} file ${filePath}: expected a top-level object`);
88
+ }
89
+ return parsed;
90
+ }
91
+ function parseProviderCommandOverrides(value, sourceLabel) {
92
+ if (value === undefined || value === null) {
93
+ return {};
94
+ }
95
+ if (!isRecord(value)) {
96
+ throw new Error(`Invalid provider defaults in ${sourceLabel}: expected an object`);
97
+ }
98
+ const overrides = {};
99
+ const claudeCommand = optionalNonEmptyString(value.claudeCommand, 'claudeCommand', sourceLabel);
100
+ if (claudeCommand !== undefined) {
101
+ overrides.claudeCommand = claudeCommand;
102
+ }
103
+ const claudeArgs = optionalStringArray(value.claudeArgs, 'claudeArgs', sourceLabel);
104
+ if (claudeArgs !== undefined) {
105
+ overrides.claudeArgs = claudeArgs;
106
+ }
107
+ const copilotCommand = optionalNonEmptyString(value.copilotCommand, 'copilotCommand', sourceLabel);
108
+ if (copilotCommand !== undefined) {
109
+ overrides.copilotCommand = copilotCommand;
110
+ }
111
+ const copilotArgs = optionalStringArray(value.copilotArgs, 'copilotArgs', sourceLabel);
112
+ if (copilotArgs !== undefined) {
113
+ overrides.copilotArgs = copilotArgs;
114
+ }
115
+ if (value.copilotSessionTtlMinutes !== undefined && value.copilotSessionTtlMinutes !== null) {
116
+ overrides.copilotSessionTtlMinutes = parseCopilotSessionTtlMinutesValue(value.copilotSessionTtlMinutes, sourceLabel);
117
+ }
118
+ return overrides;
119
+ }
120
+ function toAgentsDir(hostConfigPath, rawAgentsDir) {
121
+ const agentsDir = rawAgentsDir ?? DEFAULT_AGENTS_DIR;
122
+ return isAbsolute(agentsDir) ? agentsDir : resolve(dirname(hostConfigPath), agentsDir);
123
+ }
124
+ function parseHostConfigFile(hostConfigPath, value) {
125
+ return {
126
+ borgeeBaseUrl: requireNonEmptyString(value.borgeeBaseUrl, `Invalid host config ${hostConfigPath}: missing required borgeeBaseUrl`),
127
+ agentsDir: optionalNonEmptyString(value.agentsDir, 'agentsDir', hostConfigPath),
128
+ defaults: parseProviderCommandOverrides(value.defaults, `host config ${hostConfigPath}`),
129
+ };
130
+ }
131
+ function toGeneratedAgentConfigFilename(key) {
132
+ return `${encodeURIComponent(key)}.yaml`;
133
+ }
134
+ function parseGenerateHostConfigFile(sourceLabel, value) {
135
+ if (value.agentsDir !== undefined) {
136
+ throw new Error(`Invalid generate-config host spec in ${sourceLabel}: agentsDir is not supported; generate-config always writes the canonical default layout`);
137
+ }
138
+ return {
139
+ borgeeBaseUrl: requireNonEmptyString(value.borgeeBaseUrl, `Invalid generate-config host spec in ${sourceLabel}: missing required borgeeBaseUrl`),
140
+ defaults: parseProviderCommandOverrides(value.defaults, `generate-config host spec ${sourceLabel}`),
141
+ };
142
+ }
143
+ function parseAgentConfigFile(agentConfigPath, value) {
144
+ const enabled = value.enabled;
145
+ if (enabled !== undefined && typeof enabled !== 'boolean') {
146
+ throw new Error(`Invalid enabled in ${agentConfigPath}: expected a boolean`);
147
+ }
148
+ return {
149
+ key: requireNonEmptyString(value.key, `Invalid agent config ${agentConfigPath}: missing required key`),
150
+ name: requireNonEmptyString(value.name, `Invalid agent config ${agentConfigPath}: missing required name`),
151
+ apiKey: requireNonEmptyString(value.apiKey, `Invalid agent config ${agentConfigPath}: missing required apiKey`),
152
+ provider: resolveProvider(requireNonEmptyString(value.provider, `Invalid agent config ${agentConfigPath}: missing required provider`), `Invalid provider in ${agentConfigPath}`),
153
+ enabled: enabled ?? true,
154
+ ...parseProviderCommandOverrides(value, `agent config ${agentConfigPath}`),
155
+ };
156
+ }
157
+ export function parseGenerateConfigSpec(value, sourceLabel) {
158
+ if (!isRecord(value)) {
159
+ throw new Error(`Invalid generate-config spec in ${sourceLabel}: expected a top-level object`);
160
+ }
161
+ if (!isRecord(value.host)) {
162
+ throw new Error(`Invalid generate-config spec in ${sourceLabel}: missing required host object`);
163
+ }
164
+ if (!Array.isArray(value.agents)) {
165
+ throw new Error(`Invalid generate-config spec in ${sourceLabel}: missing required agents array`);
166
+ }
167
+ const host = parseGenerateHostConfigFile(sourceLabel, value.host);
168
+ const seenKeys = new Map();
169
+ const seenGeneratedFilenames = new Map();
170
+ const agents = value.agents.map((entry, index) => {
171
+ if (!isRecord(entry)) {
172
+ throw new Error(`Invalid generate-config agents[${index}] in ${sourceLabel}: expected an object`);
173
+ }
174
+ const agent = parseAgentConfigFile(`generate-config agents[${index}] in ${sourceLabel}`, entry);
175
+ const existingIndex = seenKeys.get(agent.key);
176
+ if (existingIndex !== undefined) {
177
+ throw new Error(`Duplicate generate-config agent key "${agent.key}" in ${sourceLabel} at indexes ${existingIndex} and ${index}`);
178
+ }
179
+ seenKeys.set(agent.key, index);
180
+ const generatedFilename = toGeneratedAgentConfigFilename(agent.key);
181
+ const generatedFilenameIdentity = generatedFilename.toLowerCase();
182
+ const existingGeneratedFile = seenGeneratedFilenames.get(generatedFilenameIdentity);
183
+ if (existingGeneratedFile) {
184
+ throw new Error(`Conflicting generate-config agent keys "${existingGeneratedFile.key}" and "${agent.key}" in ${sourceLabel}: both map to ${generatedFilename} on a case-insensitive filesystem`);
185
+ }
186
+ seenGeneratedFilenames.set(generatedFilenameIdentity, { key: agent.key, index });
187
+ return agent;
188
+ });
189
+ return { host, agents };
190
+ }
191
+ function toGeneratedAgentConfigPath(agentsDir, key) {
192
+ return resolve(agentsDir, toGeneratedAgentConfigFilename(key));
193
+ }
194
+ function renderHostConfigYaml(host) {
195
+ const config = {
196
+ borgeeBaseUrl: host.borgeeBaseUrl,
197
+ };
198
+ if (host.defaults && Object.keys(host.defaults).length > 0) {
199
+ config.defaults = host.defaults;
200
+ }
201
+ return stringify(config);
202
+ }
203
+ function isNotFoundError(error) {
204
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
205
+ }
206
+ function isAlreadyExistsError(error) {
207
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST';
208
+ }
209
+ function wait(milliseconds) {
210
+ return new Promise((resolveWait) => {
211
+ setTimeout(resolveWait, milliseconds);
212
+ });
213
+ }
214
+ async function pathStatus(fileSystem, path) {
215
+ try {
216
+ return await fileSystem.lstat(path);
217
+ }
218
+ catch (error) {
219
+ if (isNotFoundError(error)) {
220
+ return undefined;
221
+ }
222
+ throw error;
223
+ }
224
+ }
225
+ async function ensureDirectory(fileSystem, path, mode) {
226
+ const status = await pathStatus(fileSystem, path);
227
+ if (status) {
228
+ if (!status.isDirectory || status.isSymbolicLink) {
229
+ throw new Error(`Managed config path must be a non-symlink directory: ${path}`);
230
+ }
231
+ }
232
+ else {
233
+ await fileSystem.mkdir(path, { recursive: true, mode });
234
+ }
235
+ await fileSystem.chmod(path, mode);
236
+ }
237
+ function parseManagedHolderMetadata(value) {
238
+ try {
239
+ const parsed = JSON.parse(value);
240
+ const pid = isRecord(parsed) ? parsed.pid : undefined;
241
+ const createdAt = isRecord(parsed) ? parsed.createdAt : undefined;
242
+ const token = isRecord(parsed) ? parsed.token : undefined;
243
+ if (!Number.isSafeInteger(pid) ||
244
+ typeof pid !== 'number' ||
245
+ pid <= 0 ||
246
+ typeof createdAt !== 'number' ||
247
+ !Number.isFinite(createdAt) ||
248
+ createdAt < 0 ||
249
+ createdAt > Date.now() ||
250
+ typeof token !== 'string' ||
251
+ token.length === 0) {
252
+ return undefined;
253
+ }
254
+ return {
255
+ pid,
256
+ createdAt,
257
+ token,
258
+ };
259
+ }
260
+ catch {
261
+ return undefined;
262
+ }
263
+ }
264
+ function isOlderThanMaximumHolderAge(timestamp) {
265
+ return Date.now() - timestamp >= MANAGED_HOLDER_MAX_AGE_MS;
266
+ }
267
+ function isProcessAlive(pid) {
268
+ try {
269
+ process.kill(pid, 0);
270
+ return true;
271
+ }
272
+ catch (error) {
273
+ if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH') {
274
+ return false;
275
+ }
276
+ return undefined;
277
+ }
278
+ }
279
+ async function readManagedHolderMetadata(fileSystem, holderPath) {
280
+ try {
281
+ return parseManagedHolderMetadata(await fileSystem.readFile(join(holderPath, MANAGED_HOLDER_METADATA_FILENAME)));
282
+ }
283
+ catch (error) {
284
+ if (isNotFoundError(error)) {
285
+ return undefined;
286
+ }
287
+ throw error;
288
+ }
289
+ }
290
+ async function isStaleManagedHolderDirectory(fileSystem, holderPath) {
291
+ const status = await pathStatus(fileSystem, holderPath);
292
+ if (!status?.isDirectory || status.isSymbolicLink) {
293
+ return false;
294
+ }
295
+ const metadata = await readManagedHolderMetadata(fileSystem, holderPath);
296
+ if (!metadata) {
297
+ return isOlderThanMaximumHolderAge(status.mtimeMs);
298
+ }
299
+ const alive = isProcessAlive(metadata.pid);
300
+ return (alive === false || (alive === undefined && isOlderThanMaximumHolderAge(metadata.createdAt)));
301
+ }
302
+ async function createManagedHolderDirectory(fileSystem, holderPath) {
303
+ const metadata = {
304
+ pid: process.pid,
305
+ createdAt: Date.now(),
306
+ token: randomUUID(),
307
+ };
308
+ await fileSystem.mkdir(holderPath, { mode: MANAGED_ROOT_MODE });
309
+ try {
310
+ const metadataPath = join(holderPath, MANAGED_HOLDER_METADATA_FILENAME);
311
+ await fileSystem.writeFile(metadataPath, `${JSON.stringify(metadata)}\n`);
312
+ await fileSystem.chmod(metadataPath, MANAGED_CONFIG_MODE);
313
+ await fileSystem.chmod(holderPath, MANAGED_ROOT_MODE);
314
+ return metadata;
315
+ }
316
+ catch (error) {
317
+ await fileSystem.removeTree(holderPath).catch(() => undefined);
318
+ throw error;
319
+ }
320
+ }
321
+ async function releaseManagedHolderDirectory(fileSystem, holderPath, token) {
322
+ const metadata = await readManagedHolderMetadata(fileSystem, holderPath);
323
+ if (metadata?.token === token) {
324
+ await fileSystem.removeTree(holderPath);
325
+ }
326
+ }
327
+ async function acquireManagedWriteLock(fileSystem, root) {
328
+ const lockPath = join(root, MANAGED_WRITE_LOCK_DIRNAME);
329
+ const deadline = Date.now() + MANAGED_WRITE_LOCK_WAIT_MS;
330
+ while (true) {
331
+ try {
332
+ const metadata = await createManagedHolderDirectory(fileSystem, lockPath);
333
+ return async () => {
334
+ await releaseManagedHolderDirectory(fileSystem, lockPath, metadata.token);
335
+ };
336
+ }
337
+ catch (error) {
338
+ if (!isAlreadyExistsError(error)) {
339
+ throw error;
340
+ }
341
+ const status = await pathStatus(fileSystem, lockPath);
342
+ if (status && (!status.isDirectory || status.isSymbolicLink)) {
343
+ throw new Error(`Managed config write lock has an unexpected value: ${lockPath}`);
344
+ }
345
+ if (status && (await isStaleManagedHolderDirectory(fileSystem, lockPath))) {
346
+ await fileSystem.removeTree(lockPath);
347
+ continue;
348
+ }
349
+ if (Date.now() >= deadline) {
350
+ throw new Error(`Timed out waiting for managed config writer lock: ${lockPath}; another generate-config may still be running`);
351
+ }
352
+ await wait(MANAGED_WRITE_LOCK_RETRY_MS);
353
+ }
354
+ }
355
+ }
356
+ async function ensureManagedLink(fileSystem, path, target) {
357
+ const status = await pathStatus(fileSystem, path);
358
+ if (!status) {
359
+ await fileSystem.symlink(target, path);
360
+ return;
361
+ }
362
+ if (!status.isSymbolicLink || (await fileSystem.readLink(path)) !== target) {
363
+ throw new Error(`Managed config path has an unexpected value: ${path}`);
364
+ }
365
+ }
366
+ function isDirectManagedGeneration(root, target) {
367
+ const generationsDir = resolve(root, MANAGED_GENERATIONS_DIRNAME);
368
+ const resolvedTarget = resolve(root, target);
369
+ return (dirname(resolvedTarget) === generationsDir && relative(generationsDir, resolvedTarget) !== '');
370
+ }
371
+ async function prepareManagedRoot(fileSystem, root) {
372
+ await ensureDirectory(fileSystem, root, MANAGED_ROOT_MODE);
373
+ const generationsDir = join(root, MANAGED_GENERATIONS_DIRNAME);
374
+ await ensureDirectory(fileSystem, generationsDir, MANAGED_ROOT_MODE);
375
+ const currentPath = join(root, MANAGED_CURRENT_LINK_NAME);
376
+ const currentStatus = await pathStatus(fileSystem, currentPath);
377
+ const hostAliasPath = join(root, DEFAULT_LOCAL_HOST_CONFIG_FILENAME);
378
+ const agentsAliasPath = join(root, DEFAULT_LOCAL_AGENTS_DIRNAME);
379
+ if (!currentStatus) {
380
+ await ensureManagedLink(fileSystem, hostAliasPath, `${MANAGED_CURRENT_LINK_NAME}/${DEFAULT_LOCAL_HOST_CONFIG_FILENAME}`);
381
+ await ensureManagedLink(fileSystem, agentsAliasPath, `${MANAGED_CURRENT_LINK_NAME}/${DEFAULT_LOCAL_AGENTS_DIRNAME}`);
382
+ return {};
383
+ }
384
+ if (!currentStatus.isSymbolicLink) {
385
+ throw new Error(`Managed config path must be a symbolic link: ${currentPath}`);
386
+ }
387
+ const currentTarget = await fileSystem.readLink(currentPath);
388
+ if (!isDirectManagedGeneration(root, currentTarget)) {
389
+ throw new Error(`Managed config current generation must be inside ${generationsDir}`);
390
+ }
391
+ const currentGenerationPath = resolve(root, currentTarget);
392
+ const generationStatus = await pathStatus(fileSystem, currentGenerationPath);
393
+ if (!generationStatus?.isDirectory || generationStatus.isSymbolicLink) {
394
+ throw new Error(`Managed config current generation is not a directory: ${currentGenerationPath}`);
395
+ }
396
+ await fileSystem.chmod(currentGenerationPath, MANAGED_ROOT_MODE);
397
+ await ensureManagedLink(fileSystem, hostAliasPath, `${MANAGED_CURRENT_LINK_NAME}/${DEFAULT_LOCAL_HOST_CONFIG_FILENAME}`);
398
+ await ensureManagedLink(fileSystem, agentsAliasPath, `${MANAGED_CURRENT_LINK_NAME}/${DEFAULT_LOCAL_AGENTS_DIRNAME}`);
399
+ return { currentGenerationPath };
400
+ }
401
+ async function pruneSupersededGenerations(fileSystem, generationsDir, retainedGenerationPaths) {
402
+ const entries = await fileSystem.readDir(generationsDir);
403
+ await Promise.all(entries.map(async (entry) => {
404
+ if (!MANAGED_GENERATION_NAME_PATTERN.test(entry.name)) {
405
+ return;
406
+ }
407
+ const generationPath = resolve(generationsDir, entry.name);
408
+ if (dirname(generationPath) !== generationsDir ||
409
+ retainedGenerationPaths.has(generationPath)) {
410
+ return;
411
+ }
412
+ const status = await pathStatus(fileSystem, generationPath);
413
+ if (status?.isDirectory &&
414
+ !status.isSymbolicLink &&
415
+ !(await hasActiveGenerationReaders(fileSystem, generationPath))) {
416
+ await fileSystem.removeTree(generationPath);
417
+ }
418
+ }));
419
+ }
420
+ async function hasActiveGenerationReaders(fileSystem, generationPath) {
421
+ const readersPath = join(generationPath, MANAGED_READERS_DIRNAME);
422
+ const status = await pathStatus(fileSystem, readersPath);
423
+ if (!status) {
424
+ return false;
425
+ }
426
+ if (!status.isDirectory || status.isSymbolicLink) {
427
+ return true;
428
+ }
429
+ return clearStaleGenerationReaders(fileSystem, readersPath);
430
+ }
431
+ async function clearStaleGenerationReaders(fileSystem, readersPath) {
432
+ const entries = await fileSystem.readDir(readersPath);
433
+ let hasActiveReader = false;
434
+ for (const entry of entries) {
435
+ const leasePath = resolve(readersPath, entry.name);
436
+ if (dirname(leasePath) !== readersPath) {
437
+ hasActiveReader = true;
438
+ continue;
439
+ }
440
+ const leaseStatus = await pathStatus(fileSystem, leasePath);
441
+ if (!leaseStatus) {
442
+ continue;
443
+ }
444
+ if (!leaseStatus.isDirectory || leaseStatus.isSymbolicLink) {
445
+ hasActiveReader = true;
446
+ continue;
447
+ }
448
+ if (await isStaleManagedHolderDirectory(fileSystem, leasePath)) {
449
+ await fileSystem.removeTree(leasePath);
450
+ continue;
451
+ }
452
+ hasActiveReader = true;
453
+ }
454
+ return hasActiveReader;
455
+ }
456
+ function supportsManagedGenerationLeases(fileSystem) {
457
+ return ('mkdir' in fileSystem &&
458
+ 'removeTree' in fileSystem &&
459
+ 'lstat' in fileSystem &&
460
+ 'chmod' in fileSystem &&
461
+ 'readLink' in fileSystem);
462
+ }
463
+ async function managedRootForHostConfigPath(fileSystem, hostConfigPath) {
464
+ if (hostConfigPath === join(dirname(hostConfigPath), DEFAULT_LOCAL_HOST_CONFIG_FILENAME)) {
465
+ const generationPath = dirname(hostConfigPath);
466
+ const generationsDir = dirname(generationPath);
467
+ const root = dirname(generationsDir);
468
+ if (generationsDir === join(root, MANAGED_GENERATIONS_DIRNAME) &&
469
+ MANAGED_GENERATION_NAME_PATTERN.test(generationPath.slice(generationsDir.length + 1))) {
470
+ return root;
471
+ }
472
+ }
473
+ if (hostConfigPath !== join(dirname(hostConfigPath), DEFAULT_LOCAL_HOST_CONFIG_FILENAME)) {
474
+ return undefined;
475
+ }
476
+ const root = dirname(hostConfigPath);
477
+ const currentPath = join(root, MANAGED_CURRENT_LINK_NAME);
478
+ const currentStatus = await pathStatus(fileSystem, currentPath);
479
+ if (!currentStatus?.isSymbolicLink) {
480
+ return undefined;
481
+ }
482
+ return isDirectManagedGeneration(root, await fileSystem.readLink(currentPath)) ? root : undefined;
483
+ }
484
+ async function resolveHostConfigWithGenerationLease(fileSystem, absoluteHostConfigPath) {
485
+ const resolveHostConfigPath = async () => resolve(fileSystem.realPath
486
+ ? await fileSystem.realPath(absoluteHostConfigPath)
487
+ : absoluteHostConfigPath);
488
+ const noLease = async () => undefined;
489
+ if (!supportsManagedGenerationLeases(fileSystem)) {
490
+ return { resolvedHostConfigPath: await resolveHostConfigPath(), release: noLease };
491
+ }
492
+ const root = await managedRootForHostConfigPath(fileSystem, absoluteHostConfigPath);
493
+ if (!root) {
494
+ return { resolvedHostConfigPath: await resolveHostConfigPath(), release: noLease };
495
+ }
496
+ const releaseWriteLock = await acquireManagedWriteLock(fileSystem, root);
497
+ let leasePath;
498
+ try {
499
+ const resolvedHostConfigPath = await resolveHostConfigPath();
500
+ const generationPath = dirname(resolvedHostConfigPath);
501
+ const generationsDir = join(root, MANAGED_GENERATIONS_DIRNAME);
502
+ if (dirname(generationPath) !== generationsDir ||
503
+ !MANAGED_GENERATION_NAME_PATTERN.test(generationPath.slice(generationsDir.length + 1))) {
504
+ return { managedRootPath: root, resolvedHostConfigPath, release: noLease };
505
+ }
506
+ const generationStatus = await pathStatus(fileSystem, generationPath);
507
+ if (!generationStatus?.isDirectory || generationStatus.isSymbolicLink) {
508
+ return { managedRootPath: root, resolvedHostConfigPath, release: noLease };
509
+ }
510
+ const readersPath = join(generationPath, MANAGED_READERS_DIRNAME);
511
+ await ensureDirectory(fileSystem, readersPath, MANAGED_ROOT_MODE);
512
+ await clearStaleGenerationReaders(fileSystem, readersPath);
513
+ leasePath = join(readersPath, randomUUID());
514
+ const leaseMetadata = await createManagedHolderDirectory(fileSystem, leasePath);
515
+ return {
516
+ managedRootPath: root,
517
+ resolvedHostConfigPath,
518
+ release: async () => {
519
+ await releaseManagedHolderDirectory(fileSystem, leasePath, leaseMetadata.token);
520
+ },
521
+ };
522
+ }
523
+ catch (error) {
524
+ if (leasePath) {
525
+ await fileSystem.removeTree(leasePath).catch(() => undefined);
526
+ }
527
+ throw error;
528
+ }
529
+ finally {
530
+ await releaseWriteLock();
531
+ }
532
+ }
533
+ async function listSupportedConfigPaths(fileSystem, agentsDir) {
534
+ try {
535
+ const entries = await fileSystem.readDir(agentsDir);
536
+ return entries
537
+ .filter((entry) => entry.isFile)
538
+ .map((entry) => resolve(agentsDir, entry.name))
539
+ .filter((filePath) => SUPPORTED_CONFIG_EXTENSIONS.has(extname(filePath).toLowerCase()))
540
+ .sort((left, right) => left.localeCompare(right));
541
+ }
542
+ catch (error) {
543
+ if (isNotFoundError(error)) {
544
+ return [];
545
+ }
546
+ throw error;
547
+ }
548
+ }
549
+ function renderAgentConfigYaml(agent) {
550
+ const config = {
551
+ key: agent.key,
552
+ name: agent.name,
553
+ apiKey: agent.apiKey,
554
+ provider: agent.provider,
555
+ };
556
+ if (agent.enabled !== undefined) {
557
+ config.enabled = agent.enabled;
558
+ }
559
+ if (agent.claudeCommand !== undefined) {
560
+ config.claudeCommand = agent.claudeCommand;
561
+ }
562
+ if (agent.claudeArgs !== undefined) {
563
+ config.claudeArgs = agent.claudeArgs;
564
+ }
565
+ if (agent.copilotCommand !== undefined) {
566
+ config.copilotCommand = agent.copilotCommand;
567
+ }
568
+ if (agent.copilotArgs !== undefined) {
569
+ config.copilotArgs = agent.copilotArgs;
570
+ }
571
+ if (agent.copilotSessionTtlMinutes !== undefined) {
572
+ config.copilotSessionTtlMinutes = agent.copilotSessionTtlMinutes;
573
+ }
574
+ return stringify(config);
575
+ }
576
+ function buildManagedAgentSnapshot(host, sourcePath, agent) {
577
+ const providerConfig = resolveProviderCommandConfig({
578
+ ...host.defaults,
579
+ ...parseProviderCommandOverrides(agent, `agent config ${sourcePath}`),
580
+ });
581
+ const config = {
582
+ borgeeBaseUrl: host.borgeeBaseUrl,
583
+ ...providerConfig,
584
+ agent: {
585
+ agentApiKey: agent.apiKey,
586
+ agentName: agent.name,
587
+ provider: agent.provider,
588
+ },
589
+ };
590
+ return {
591
+ key: agent.key,
592
+ sourcePath,
593
+ config,
594
+ };
595
+ }
596
+ export async function loadLocalConfigSnapshot(hostConfigPath, deps = {}) {
597
+ const fileSystem = deps.fileSystem ?? nodeFileSystem;
598
+ const absoluteHostConfigPath = resolve(hostConfigPath);
599
+ const managedGeneration = deps.acquireManagedGenerationLease === false
600
+ ? {
601
+ resolvedHostConfigPath: resolve(fileSystem.realPath
602
+ ? await fileSystem.realPath(absoluteHostConfigPath)
603
+ : absoluteHostConfigPath),
604
+ release: async () => undefined,
605
+ }
606
+ : await resolveHostConfigWithGenerationLease(fileSystem, absoluteHostConfigPath);
607
+ try {
608
+ const { resolvedHostConfigPath } = managedGeneration;
609
+ const hostConfigDir = dirname(absoluteHostConfigPath);
610
+ const resolvedHostConfigDir = dirname(resolvedHostConfigPath);
611
+ const hostConfigRecord = await loadParsedDocument(fileSystem, resolvedHostConfigPath, 'host config');
612
+ const hostConfig = parseHostConfigFile(absoluteHostConfigPath, hostConfigRecord);
613
+ const agentsDir = toAgentsDir(absoluteHostConfigPath, hostConfig.agentsDir);
614
+ const resolvedAgentsDir = resolve(fileSystem.realPath
615
+ ? await fileSystem.realPath(toAgentsDir(resolvedHostConfigPath, hostConfig.agentsDir))
616
+ : toAgentsDir(resolvedHostConfigPath, hostConfig.agentsDir));
617
+ const directoryEntries = await fileSystem.readDir(resolvedAgentsDir);
618
+ const agentFiles = directoryEntries
619
+ .filter((entry) => entry.isFile)
620
+ .map((entry) => resolve(resolvedAgentsDir, entry.name))
621
+ .filter((filePath) => filePath !== resolvedHostConfigPath)
622
+ .filter((filePath) => SUPPORTED_CONFIG_EXTENSIONS.has(extname(filePath).toLowerCase()))
623
+ .sort((left, right) => left.localeCompare(right));
624
+ const parsedAgents = [];
625
+ const seenKeys = new Map();
626
+ for (const agentFilePath of agentFiles) {
627
+ const agentRecord = await loadParsedDocument(fileSystem, agentFilePath, 'agent config');
628
+ const agentConfig = parseAgentConfigFile(agentFilePath, agentRecord);
629
+ const existingPath = seenKeys.get(agentConfig.key);
630
+ if (existingPath) {
631
+ throw new Error(`Duplicate agent key "${agentConfig.key}" in ${existingPath} and ${agentFilePath}`);
632
+ }
633
+ seenKeys.set(agentConfig.key, agentFilePath);
634
+ parsedAgents.push({ sourcePath: agentFilePath, config: agentConfig });
635
+ }
636
+ return {
637
+ hostConfigPath: absoluteHostConfigPath,
638
+ hostConfigDir,
639
+ agentsDir,
640
+ managedRootPath: managedGeneration.managedRootPath,
641
+ resolvedHostConfigPath,
642
+ resolvedHostConfigDir,
643
+ resolvedAgentsDir,
644
+ agents: parsedAgents
645
+ .filter((entry) => entry.config.enabled !== false)
646
+ .map((entry) => buildManagedAgentSnapshot(hostConfig, entry.sourcePath, entry.config)),
647
+ };
648
+ }
649
+ finally {
650
+ await managedGeneration.release();
651
+ }
652
+ }
653
+ export async function loadLocalConfigGenerateSpec(hostConfigPath, deps = {}) {
654
+ const fileSystem = deps.fileSystem ?? nodeFileSystem;
655
+ const absoluteHostConfigPath = resolve(hostConfigPath);
656
+ const managedGeneration = deps.acquireManagedGenerationLease === false
657
+ ? {
658
+ resolvedHostConfigPath: resolve(fileSystem.realPath
659
+ ? await fileSystem.realPath(absoluteHostConfigPath)
660
+ : absoluteHostConfigPath),
661
+ release: async () => undefined,
662
+ }
663
+ : await resolveHostConfigWithGenerationLease(fileSystem, absoluteHostConfigPath);
664
+ try {
665
+ const { resolvedHostConfigPath } = managedGeneration;
666
+ const hostConfigRecord = await loadParsedDocument(fileSystem, resolvedHostConfigPath, 'host config');
667
+ const hostConfig = parseHostConfigFile(absoluteHostConfigPath, hostConfigRecord);
668
+ const resolvedAgentsDir = resolve(fileSystem.realPath
669
+ ? await fileSystem.realPath(toAgentsDir(resolvedHostConfigPath, hostConfig.agentsDir))
670
+ : toAgentsDir(resolvedHostConfigPath, hostConfig.agentsDir));
671
+ const parsedAgents = [];
672
+ const seenKeys = new Map();
673
+ for (const agentFilePath of await listSupportedConfigPaths(fileSystem, resolvedAgentsDir)) {
674
+ if (agentFilePath === resolvedHostConfigPath) {
675
+ continue;
676
+ }
677
+ const agentRecord = await loadParsedDocument(fileSystem, agentFilePath, 'agent config');
678
+ const agentConfig = parseAgentConfigFile(agentFilePath, agentRecord);
679
+ const existingPath = seenKeys.get(agentConfig.key);
680
+ if (existingPath) {
681
+ throw new Error(`Duplicate agent key "${agentConfig.key}" in ${existingPath} and ${agentFilePath}`);
682
+ }
683
+ seenKeys.set(agentConfig.key, agentFilePath);
684
+ parsedAgents.push(agentConfig);
685
+ }
686
+ const host = {
687
+ borgeeBaseUrl: hostConfig.borgeeBaseUrl,
688
+ };
689
+ if (hostConfig.defaults && Object.keys(hostConfig.defaults).length > 0) {
690
+ host.defaults = hostConfig.defaults;
691
+ }
692
+ return { host, agents: parsedAgents };
693
+ }
694
+ finally {
695
+ await managedGeneration.release();
696
+ }
697
+ }
698
+ export async function materializeLocalConfig(rootPath, spec, deps = {}) {
699
+ const fileSystem = deps.fileSystem ?? nodeFileSystem;
700
+ const layout = resolveLocalConfigLayout(rootPath);
701
+ const desiredAgentFiles = new Map();
702
+ for (const agent of spec.agents) {
703
+ desiredAgentFiles.set(toGeneratedAgentConfigPath(layout.agentsDir, agent.key), agent);
704
+ }
705
+ const generatedAgents = [...desiredAgentFiles.entries()]
706
+ .map(([path, agent]) => ({ path, agent }))
707
+ .sort((left, right) => left.path.localeCompare(right.path));
708
+ await ensureDirectory(fileSystem, layout.root, MANAGED_ROOT_MODE);
709
+ const releaseWriteLock = await acquireManagedWriteLock(fileSystem, layout.root);
710
+ try {
711
+ const { currentGenerationPath } = await prepareManagedRoot(fileSystem, layout.root);
712
+ const priorAgentConfigPaths = await listSupportedConfigPaths(fileSystem, layout.agentsDir);
713
+ const generationName = randomUUID();
714
+ const generationsDir = join(layout.root, MANAGED_GENERATIONS_DIRNAME);
715
+ const generationPath = join(generationsDir, generationName);
716
+ const generationAgentsDir = join(generationPath, DEFAULT_LOCAL_AGENTS_DIRNAME);
717
+ const generationHostConfigPath = join(generationPath, DEFAULT_LOCAL_HOST_CONFIG_FILENAME);
718
+ const warnings = [];
719
+ let published = false;
720
+ try {
721
+ await fileSystem.mkdir(generationPath, { mode: MANAGED_ROOT_MODE });
722
+ await fileSystem.mkdir(generationAgentsDir, { mode: MANAGED_ROOT_MODE });
723
+ await fileSystem.writeFile(generationHostConfigPath, renderHostConfigYaml(spec.host));
724
+ await fileSystem.chmod(generationHostConfigPath, MANAGED_CONFIG_MODE);
725
+ for (const generated of generatedAgents) {
726
+ const generationAgentPath = join(generationAgentsDir, toGeneratedAgentConfigFilename(generated.agent.key));
727
+ await fileSystem.writeFile(generationAgentPath, renderAgentConfigYaml(generated.agent));
728
+ await fileSystem.chmod(generationAgentPath, MANAGED_CONFIG_MODE);
729
+ }
730
+ await loadLocalConfigSnapshot(generationHostConfigPath, {
731
+ fileSystem,
732
+ acquireManagedGenerationLease: false,
733
+ });
734
+ const nextCurrentPath = join(layout.root, `.${MANAGED_CURRENT_LINK_NAME}-${generationName}`);
735
+ await fileSystem.symlink(join(MANAGED_GENERATIONS_DIRNAME, generationName), nextCurrentPath);
736
+ await fileSystem.rename(nextCurrentPath, join(layout.root, MANAGED_CURRENT_LINK_NAME));
737
+ published = true;
738
+ try {
739
+ await pruneSupersededGenerations(fileSystem, generationsDir, new Set([generationPath, currentGenerationPath].filter((path) => path !== undefined)));
740
+ }
741
+ catch (error) {
742
+ warnings.push({
743
+ code: 'PRUNE_SUPERSEDED_GENERATIONS_FAILED',
744
+ message: error instanceof Error ? error.message : String(error),
745
+ });
746
+ }
747
+ }
748
+ finally {
749
+ if (!published) {
750
+ await fileSystem.removeTree(generationPath).catch(() => undefined);
751
+ }
752
+ }
753
+ const activeAgentPaths = new Set(generatedAgents.map(({ path }) => path));
754
+ const prunedAgentConfigPaths = priorAgentConfigPaths.filter((path) => !activeAgentPaths.has(path));
755
+ return {
756
+ root: layout.root,
757
+ hostConfigPath: layout.hostConfigPath,
758
+ agentsDir: layout.agentsDir,
759
+ generatedAgents: generatedAgents.map(({ path, agent }) => ({
760
+ key: agent.key,
761
+ path,
762
+ provider: agent.provider,
763
+ enabled: agent.enabled ?? true,
764
+ })),
765
+ prunedAgentConfigPaths,
766
+ warnings,
767
+ };
768
+ }
769
+ finally {
770
+ await releaseWriteLock();
771
+ }
772
+ }