@capybearista/opencode-agents-loader 1.0.0 → 2.0.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.
@@ -0,0 +1,923 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, open, readdir, readFile, readlink, realpath, rename, symlink, unlink, writeFile, } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { validateAgentContent } from "./agent-source.js";
6
+ const manifestVersion = 1;
7
+ const manifestDirectoryName = ".agents-loader";
8
+ const defaultPollIntervalMs = 500;
9
+ const defaultLockAttempts = 20;
10
+ const defaultLockDelayMs = 25;
11
+ const commandDomain = {
12
+ destination: "commands",
13
+ manifestFileName: "commands-manifest.json",
14
+ lockFileName: "commands.lock",
15
+ sourceDirectories: ["command", "commands"],
16
+ nativeDirectories: ["command", "commands"],
17
+ configNames: ["commands", "command"],
18
+ };
19
+ const agentDomain = {
20
+ destination: "agents",
21
+ manifestFileName: "agents-manifest.json",
22
+ lockFileName: "agents.lock",
23
+ sourceDirectories: ["agent", "agents"],
24
+ nativeDirectories: ["agent", "agents", "mode", "modes"],
25
+ configNames: ["agents", "agent", "mode"],
26
+ validateSource: validateAgentContent,
27
+ };
28
+ class UnsafeScopeError extends Error {
29
+ constructor(message) {
30
+ super(message);
31
+ this.name = "UnsafeScopeError";
32
+ }
33
+ }
34
+ const defaultScheduler = {
35
+ setInterval: (callback, delay) => setInterval(callback, delay),
36
+ clearInterval: (handle) => clearInterval(handle),
37
+ };
38
+ export async function startCommandBridge(context, options = {}) {
39
+ return startNativeBridge({ location: context.location, reload: context.command.reload }, commandDomain, options);
40
+ }
41
+ export async function startAgentBridge(context, options = {}) {
42
+ return startNativeBridge({ location: context.location, reload: context.agent.reload }, agentDomain, options);
43
+ }
44
+ async function startNativeBridge(context, domain, options) {
45
+ const diagnostics = dedupeConsecutiveDiagnostics(options.diagnostics);
46
+ let disposed = false;
47
+ let polling = false;
48
+ let activePoll;
49
+ let lastFingerprint;
50
+ const initial = await syncNativeLinks(context.location, domain, { ...options, diagnostics });
51
+ let lastResult = initial;
52
+ if (initial.safe) {
53
+ lastFingerprint = initial.fingerprint;
54
+ }
55
+ const poll = async () => {
56
+ if (disposed || polling)
57
+ return;
58
+ polling = true;
59
+ const operation = (async () => {
60
+ try {
61
+ const next = await syncNativeLinks(context.location, domain, { ...options, diagnostics });
62
+ lastResult = next;
63
+ if (!next.safe)
64
+ return;
65
+ const changed = lastFingerprint === undefined || lastFingerprint !== next.fingerprint;
66
+ lastFingerprint = next.fingerprint;
67
+ if (changed)
68
+ await context.reload();
69
+ }
70
+ catch (error) {
71
+ diagnostics(`${domain.destination} bridge polling failed: ${formatError(error)}`);
72
+ }
73
+ finally {
74
+ polling = false;
75
+ }
76
+ })();
77
+ activePoll = operation;
78
+ await operation;
79
+ activePoll = undefined;
80
+ };
81
+ const scheduler = options.scheduler ?? defaultScheduler;
82
+ const interval = scheduler.setInterval(() => poll(), normalizePollInterval(options.pollIntervalMs));
83
+ const possibleTimer = interval;
84
+ if (possibleTimer !== null && possibleTimer !== undefined)
85
+ possibleTimer.unref?.();
86
+ return {
87
+ sync: async () => {
88
+ await poll();
89
+ return lastResult;
90
+ },
91
+ dispose: async () => {
92
+ if (disposed)
93
+ return;
94
+ disposed = true;
95
+ scheduler.clearInterval(interval);
96
+ await activePoll;
97
+ },
98
+ };
99
+ }
100
+ export async function syncCommandLinks(location, options = {}) {
101
+ return syncNativeLinks(location, commandDomain, options);
102
+ }
103
+ export async function syncAgentLinks(location, options = {}) {
104
+ return syncNativeLinks(location, agentDomain, options);
105
+ }
106
+ async function syncNativeLinks(location, domain, options) {
107
+ const home = path.resolve(options.home ?? os.homedir());
108
+ const environment = options.environment ?? process.env;
109
+ const diagnostics = dedupeConsecutiveDiagnostics(options.diagnostics);
110
+ const projectRoot = path.resolve(location.project.directory);
111
+ const currentDirectory = path.resolve(location.directory);
112
+ if (!isWithin(projectRoot, currentDirectory)) {
113
+ diagnostics(`project cwd ${currentDirectory} is outside project root ${projectRoot}; ${domain.destination} bridge stopped safely`);
114
+ const scope = `project:${projectRoot}`;
115
+ return {
116
+ safe: false,
117
+ changed: false,
118
+ fingerprint: "unsafe",
119
+ scopes: [{ scope, safe: false, changed: false, fingerprint: "unsafe" }],
120
+ };
121
+ }
122
+ const scopes = projectScopes(projectRoot, currentDirectory);
123
+ const specs = scopes.map((scope) => ({
124
+ id: `project:${scope}`,
125
+ kind: "project",
126
+ scope,
127
+ sourceRoot: path.join(scope, ".agents"),
128
+ destinationParent: path.join(scope, ".opencode"),
129
+ }));
130
+ const globalConfigDirectory = path.resolve(environment.OPENCODE_CONFIG_DIR ?? path.join(home, ".config", "opencode"));
131
+ specs.push({
132
+ id: `global:${globalConfigDirectory}`,
133
+ kind: "global",
134
+ scope: home,
135
+ sourceRoot: path.join(home, ".agents"),
136
+ destinationParent: globalConfigDirectory,
137
+ });
138
+ const results = [];
139
+ for (const spec of specs) {
140
+ results.push(await reconcileScope(spec, {
141
+ domain,
142
+ home,
143
+ environment,
144
+ currentDirectory,
145
+ diagnostics,
146
+ lockAttempts: options.lockAttempts,
147
+ lockDelayMs: options.lockDelayMs,
148
+ }));
149
+ }
150
+ const safe = results.every((result) => result.safe);
151
+ const fingerprint = safe
152
+ ? fingerprintValue(results.map((result) => ({
153
+ scope: result.scope,
154
+ snapshot: snapshotValue(result.snapshot),
155
+ })))
156
+ : "unsafe";
157
+ return {
158
+ safe,
159
+ changed: results.some((result) => result.changed),
160
+ fingerprint,
161
+ scopes: results.map(({ scope, safe: resultSafe, changed, fingerprint: resultFingerprint }) => ({
162
+ scope,
163
+ safe: resultSafe,
164
+ changed,
165
+ fingerprint: resultFingerprint,
166
+ })),
167
+ };
168
+ }
169
+ async function reconcileScope(spec, options) {
170
+ let source;
171
+ try {
172
+ source = await scanSources(spec.sourceRoot, options.domain, options.home, options.diagnostics);
173
+ }
174
+ catch (error) {
175
+ options.diagnostics(`${spec.id}: source census stopped safely: ${formatError(error)}`);
176
+ return emptyUnsafeResult(spec);
177
+ }
178
+ const manifestDirectory = path.join(spec.destinationParent, manifestDirectoryName);
179
+ const destinationRoot = path.join(spec.destinationParent, options.domain.destination);
180
+ let manifestState;
181
+ try {
182
+ manifestState = await readManifest(manifestDirectory, destinationRoot, spec.sourceRoot, options.domain);
183
+ }
184
+ catch (error) {
185
+ options.diagnostics(`${spec.id}: manifest validation stopped safely: ${formatError(error)}`);
186
+ return emptyUnsafeResult(spec);
187
+ }
188
+ if (source.files.size === 0 && manifestState.manifest.links.length === 0) {
189
+ return {
190
+ scope: spec.id,
191
+ safe: true,
192
+ changed: false,
193
+ fingerprint: fingerprintValue({ source: sourceValue(source), native: [], links: [] }),
194
+ snapshot: { source, nativeNames: new Set(), links: [] },
195
+ };
196
+ }
197
+ try {
198
+ await ensureDirectoryPath(destinationRoot);
199
+ await ensureDirectoryPath(manifestDirectory);
200
+ }
201
+ catch (error) {
202
+ options.diagnostics(`${spec.id}: destination path stopped safely: ${formatError(error)}`);
203
+ return emptyUnsafeResult(spec);
204
+ }
205
+ let result;
206
+ try {
207
+ result = await withScopeLock(manifestDirectory, options.domain.lockFileName, options.lockAttempts ?? defaultLockAttempts, options.lockDelayMs ?? defaultLockDelayMs, async () => reconcileLocked(spec, options));
208
+ }
209
+ catch (error) {
210
+ options.diagnostics(`${spec.id}: reconciliation stopped safely: ${formatError(error)}`);
211
+ return emptyUnsafeResult(spec);
212
+ }
213
+ return result;
214
+ }
215
+ async function reconcileLocked(spec, options) {
216
+ const manifestDirectory = path.join(spec.destinationParent, manifestDirectoryName);
217
+ const destinationRoot = path.join(spec.destinationParent, options.domain.destination);
218
+ const source = await scanSources(spec.sourceRoot, options.domain, options.home, options.diagnostics);
219
+ const manifestState = await readManifest(manifestDirectory, destinationRoot, spec.sourceRoot, options.domain);
220
+ const nativeNames = await scanNativeNames(spec, destinationRoot, manifestState.manifest, options);
221
+ const candidates = new Map();
222
+ for (const [name, candidate] of source.files) {
223
+ if (!nativeNames.has(name))
224
+ candidates.set(name, candidate);
225
+ }
226
+ const oldLinks = [...manifestState.manifest.links].toSorted((left, right) => left.destination.localeCompare(right.destination));
227
+ const desiredLinks = new Map();
228
+ for (const candidate of [...candidates.values()].toSorted((left, right) => left.name.localeCompare(right.name))) {
229
+ const destination = `${candidate.name}.md`;
230
+ const destinationPath = path.join(destinationRoot, ...destination.split("/"));
231
+ const target = path.relative(path.dirname(destinationPath), candidate.source);
232
+ if (!isRelativeTarget(target) ||
233
+ path.resolve(path.dirname(destinationPath), target) !== candidate.source) {
234
+ options.diagnostics(`${spec.id}: rejected unsafe source target for ${destination}`);
235
+ continue;
236
+ }
237
+ desiredLinks.set(destination, { destination, source: candidate.source, target });
238
+ }
239
+ await validateDestinationParents(destinationRoot, [...oldLinks, ...desiredLinks.values()]);
240
+ const retained = new Map();
241
+ let changed = false;
242
+ for (const oldLink of oldLinks) {
243
+ const destinationPath = destinationPathFor(destinationRoot, oldLink.destination);
244
+ const actual = await readDestination(destinationPath);
245
+ const desired = desiredLinks.get(oldLink.destination);
246
+ const ownsActual = actual?.kind === "symlink" && (await readlink(destinationPath, "utf8")) === oldLink.target;
247
+ if (desired &&
248
+ desired.source === oldLink.source &&
249
+ desired.target === oldLink.target &&
250
+ ownsActual) {
251
+ retained.set(oldLink.destination, oldLink);
252
+ continue;
253
+ }
254
+ if (ownsActual) {
255
+ try {
256
+ await unlink(destinationPath);
257
+ changed = true;
258
+ }
259
+ catch (error) {
260
+ options.diagnostics(`${spec.id}: could not remove owned stale link ${oldLink.destination}: ${formatError(error)}`);
261
+ }
262
+ }
263
+ else if (actual !== undefined) {
264
+ options.diagnostics(`${spec.id}: preserved user-owned destination ${oldLink.destination}`);
265
+ }
266
+ else {
267
+ changed = true;
268
+ }
269
+ }
270
+ for (const desired of desiredLinks.values()) {
271
+ if (retained.has(desired.destination))
272
+ continue;
273
+ const destinationPath = destinationPathFor(destinationRoot, desired.destination);
274
+ const actual = await readDestination(destinationPath);
275
+ if (actual !== undefined) {
276
+ options.diagnostics(`${spec.id}: preserved existing destination ${desired.destination}`);
277
+ continue;
278
+ }
279
+ await ensureDirectoryPath(path.dirname(destinationPath));
280
+ try {
281
+ await symlink(desired.target, destinationPath);
282
+ }
283
+ catch (error) {
284
+ if (isErrorCode(error, "EEXIST")) {
285
+ options.diagnostics(`${spec.id}: destination appeared during link creation; skipped ${desired.destination}`);
286
+ continue;
287
+ }
288
+ throw error;
289
+ }
290
+ if ((await readDestination(destinationPath))?.kind !== "symlink" ||
291
+ (await readlink(destinationPath, "utf8")) !== desired.target) {
292
+ options.diagnostics(`${spec.id}: link verification failed; ownership was not recorded for ${desired.destination}`);
293
+ continue;
294
+ }
295
+ retained.set(desired.destination, desired);
296
+ changed = true;
297
+ }
298
+ const links = [...retained.values()].toSorted((left, right) => left.destination.localeCompare(right.destination));
299
+ const manifest = {
300
+ version: manifestVersion,
301
+ destination: options.domain.destination,
302
+ links,
303
+ };
304
+ if (!sameLinks(oldLinks, links) || (!manifestState.present && source.files.size > 0)) {
305
+ await writeManifest(manifestDirectory, manifest, options.domain);
306
+ changed = true;
307
+ }
308
+ const snapshot = { source, nativeNames, links };
309
+ return {
310
+ scope: spec.id,
311
+ safe: true,
312
+ changed,
313
+ fingerprint: fingerprintValue(snapshotValue(snapshot)),
314
+ snapshot,
315
+ };
316
+ }
317
+ async function scanSources(sourceRoot, domain, home, diagnostics) {
318
+ const files = new Map();
319
+ const invalid = new Map();
320
+ const rootState = await existingState(sourceRoot);
321
+ if (rootState === "missing")
322
+ return { files, invalid };
323
+ if (rootState !== "directory")
324
+ throw new UnsafeScopeError(`${sourceRoot} is not a regular directory`);
325
+ await assertNoSymlinkParents(sourceRoot);
326
+ for (const sourceDirectory of domain.sourceDirectories) {
327
+ const directory = path.join(sourceRoot, sourceDirectory);
328
+ const state = await existingState(directory);
329
+ if (state === "missing")
330
+ continue;
331
+ if (state !== "directory")
332
+ throw new UnsafeScopeError(`${directory} is not a regular directory`);
333
+ await visitSourceDirectory(directory, directory, files, invalid, domain, home, diagnostics);
334
+ }
335
+ return { files, invalid };
336
+ }
337
+ async function visitSourceDirectory(directory, root, files, invalid, domain, home, diagnostics) {
338
+ let entries;
339
+ try {
340
+ entries = await readdir(directory, { encoding: "utf8", withFileTypes: true });
341
+ }
342
+ catch (error) {
343
+ throw new UnsafeScopeError(`cannot read ${directory}: ${formatError(error)}`);
344
+ }
345
+ for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) {
346
+ const filePath = path.join(directory, entry.name);
347
+ if (entry.isDirectory()) {
348
+ await visitSourceDirectory(filePath, root, files, invalid, domain, home, diagnostics);
349
+ continue;
350
+ }
351
+ if (entry.isSymbolicLink())
352
+ throw new UnsafeScopeError(`source symlink is not allowed: ${filePath}`);
353
+ if (!entry.isFile() || !filePath.endsWith(".md"))
354
+ continue;
355
+ let content;
356
+ try {
357
+ content = await readFile(filePath);
358
+ }
359
+ catch (error) {
360
+ throw new UnsafeScopeError(`cannot read ${filePath}: ${formatError(error)}`);
361
+ }
362
+ const relative = path.relative(root, filePath).replaceAll(path.sep, "/");
363
+ const name = relative.slice(0, -3);
364
+ if (!isSafeRelative(name))
365
+ throw new UnsafeScopeError(`unsafe source name: ${relative}`);
366
+ if (domain.validateSource) {
367
+ let validation;
368
+ try {
369
+ validation = await domain.validateSource(filePath, content, home);
370
+ }
371
+ catch (error) {
372
+ validation = `validation failed: ${formatError(error)}`;
373
+ }
374
+ if (validation !== undefined) {
375
+ diagnostics(`skipped invalid native source ${filePath}: ${validation}`);
376
+ invalid.set(name, {
377
+ name,
378
+ source: path.resolve(filePath),
379
+ fingerprint: createHash("sha256").update(content).digest("hex"),
380
+ });
381
+ continue;
382
+ }
383
+ }
384
+ files.set(name, {
385
+ name,
386
+ source: path.resolve(filePath),
387
+ fingerprint: createHash("sha256").update(content).digest("hex"),
388
+ });
389
+ }
390
+ }
391
+ async function scanNativeNames(spec, destinationRoot, manifest, options) {
392
+ const names = new Set();
393
+ const owned = new Map(manifest.links.map((link) => [link.destination, link]));
394
+ for (const sourceDirectory of options.domain.nativeDirectories) {
395
+ await visitNativeDirectory(path.join(spec.destinationParent, sourceDirectory), sourceDirectory, options.domain, destinationRoot, owned, names, options.diagnostics);
396
+ }
397
+ const configFiles = new Set();
398
+ if (spec.kind === "project") {
399
+ for (const name of ["opencode.json", "opencode.jsonc"]) {
400
+ configFiles.add(path.join(spec.scope, name));
401
+ configFiles.add(path.join(spec.destinationParent, name));
402
+ }
403
+ }
404
+ else {
405
+ for (const name of ["opencode.json", "opencode.jsonc"])
406
+ configFiles.add(path.join(spec.destinationParent, name));
407
+ }
408
+ const explicit = options.environment.OPENCODE_CONFIG;
409
+ if (explicit)
410
+ configFiles.add(path.resolve(options.currentDirectory, explicit));
411
+ for (const file of configFiles)
412
+ await collectConfigNames(file, names, options.domain.configNames, options.diagnostics);
413
+ const content = options.environment.OPENCODE_CONFIG_CONTENT;
414
+ if (content)
415
+ collectConfigNamesFromText(content, "OPENCODE_CONFIG_CONTENT", names, options.domain.configNames, options.diagnostics);
416
+ return names;
417
+ }
418
+ async function visitNativeDirectory(directory, sourceDirectory, domain, destinationRoot, owned, names, diagnostics, visited = new Set()) {
419
+ let state;
420
+ try {
421
+ state = await existingState(directory);
422
+ }
423
+ catch (error) {
424
+ throw new UnsafeScopeError(`cannot inspect ${directory}: ${formatError(error)}`);
425
+ }
426
+ if (state === "missing")
427
+ return;
428
+ if (state === "file")
429
+ throw new UnsafeScopeError(`${directory} is not a directory`);
430
+ let identity;
431
+ try {
432
+ identity = await realpath(directory);
433
+ }
434
+ catch (error) {
435
+ throw new UnsafeScopeError(`cannot resolve native directory ${directory}: ${formatError(error)}`);
436
+ }
437
+ if (visited.has(identity))
438
+ return;
439
+ visited.add(identity);
440
+ let entries;
441
+ try {
442
+ entries = await readdir(directory, { encoding: "utf8", withFileTypes: true });
443
+ }
444
+ catch (error) {
445
+ throw new UnsafeScopeError(`cannot read native directory ${directory}: ${formatError(error)}`);
446
+ }
447
+ for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) {
448
+ const filePath = path.join(directory, entry.name);
449
+ if (entry.isDirectory()) {
450
+ await visitNativeDirectory(filePath, sourceDirectory, domain, destinationRoot, owned, names, diagnostics, visited);
451
+ continue;
452
+ }
453
+ if (!entry.isFile() && !entry.isSymbolicLink())
454
+ continue;
455
+ if (!filePath.endsWith(".md"))
456
+ continue;
457
+ const relative = path
458
+ .relative(path.join(path.dirname(destinationRoot), sourceDirectory), filePath)
459
+ .replaceAll(path.sep, "/");
460
+ const name = relative.slice(0, -3);
461
+ if (!isSafeRelative(name))
462
+ continue;
463
+ if (sourceDirectory === domain.destination &&
464
+ path.resolve(path.dirname(destinationRoot), sourceDirectory, ...relative.split("/")) ===
465
+ path.resolve(destinationRoot, relative)) {
466
+ const ownedEntry = owned.get(relative);
467
+ const actual = await readDestination(filePath);
468
+ if (actual?.kind === "symlink") {
469
+ const target = await readlink(filePath, "utf8");
470
+ if (ownedEntry && target === ownedEntry.target)
471
+ continue;
472
+ diagnostics(`preserved unowned native symlink ${filePath}`);
473
+ }
474
+ }
475
+ names.add(name);
476
+ }
477
+ }
478
+ async function collectConfigNames(file, names, configNames, diagnostics) {
479
+ let content;
480
+ try {
481
+ content = await readFile(file, "utf8");
482
+ }
483
+ catch (error) {
484
+ if (isErrorCode(error, "ENOENT"))
485
+ return;
486
+ diagnostics(`native config census skipped unreadable ${file}: ${formatError(error)}`);
487
+ return;
488
+ }
489
+ collectConfigNamesFromText(content, file, names, configNames, diagnostics);
490
+ }
491
+ function collectConfigNamesFromText(content, source, names, configNames, diagnostics) {
492
+ try {
493
+ const value = parseJsonc(content);
494
+ if (!isRecord(value))
495
+ return;
496
+ for (const key of configNames)
497
+ collectMapNames(value[key], names);
498
+ }
499
+ catch (error) {
500
+ diagnostics(`native config census skipped malformed ${source}: ${formatError(error)}`);
501
+ }
502
+ }
503
+ async function readManifest(manifestDirectory, destinationRoot, sourceRoot, domain) {
504
+ const directoryState = await existingState(manifestDirectory);
505
+ if (directoryState === "missing")
506
+ return { manifest: emptyManifest(domain), present: false };
507
+ if (directoryState !== "directory")
508
+ throw new UnsafeScopeError(`${manifestDirectory} is not a regular directory`);
509
+ await assertNoSymlinkParents(manifestDirectory);
510
+ const manifestPath = path.join(manifestDirectory, domain.manifestFileName);
511
+ let state;
512
+ try {
513
+ state = await existingState(manifestPath);
514
+ }
515
+ catch (error) {
516
+ throw new UnsafeScopeError(`cannot inspect ${manifestPath}: ${formatError(error)}`);
517
+ }
518
+ if (state === "missing")
519
+ return { manifest: emptyManifest(domain), present: false };
520
+ if (state !== "file")
521
+ throw new UnsafeScopeError(`${manifestPath} is not a regular manifest file`);
522
+ let value;
523
+ try {
524
+ value = JSON.parse(await readFile(manifestPath, "utf8"));
525
+ }
526
+ catch (error) {
527
+ throw new UnsafeScopeError(`cannot parse ${manifestPath}: ${formatError(error)}`);
528
+ }
529
+ if (!isRecord(value) ||
530
+ value.version !== manifestVersion ||
531
+ value.destination !== domain.destination ||
532
+ !Array.isArray(value.links)) {
533
+ throw new UnsafeScopeError(`invalid ${manifestPath} shape`);
534
+ }
535
+ const links = [];
536
+ const destinations = new Set();
537
+ for (const raw of value.links) {
538
+ if (!isRecord(raw) ||
539
+ typeof raw.destination !== "string" ||
540
+ typeof raw.source !== "string" ||
541
+ typeof raw.target !== "string") {
542
+ throw new UnsafeScopeError(`invalid link entry in ${manifestPath}`);
543
+ }
544
+ if (!isSafeRelative(raw.destination) ||
545
+ !raw.destination.endsWith(".md") ||
546
+ destinations.has(raw.destination)) {
547
+ throw new UnsafeScopeError(`invalid destination in ${manifestPath}: ${raw.destination}`);
548
+ }
549
+ if (!path.isAbsolute(raw.source) ||
550
+ !isWithin(sourceRoot, raw.source) ||
551
+ !raw.source.endsWith(".md")) {
552
+ throw new UnsafeScopeError(`invalid source in ${manifestPath}: ${raw.source}`);
553
+ }
554
+ if (sourceName(sourceRoot, raw.source, domain.sourceDirectories) !== raw.destination) {
555
+ throw new UnsafeScopeError(`source and destination disagree in ${manifestPath}: ${raw.destination}`);
556
+ }
557
+ const destinationPath = destinationPathFor(destinationRoot, raw.destination);
558
+ if (!isRelativeTarget(raw.target) ||
559
+ path.resolve(path.dirname(destinationPath), raw.target) !== path.resolve(raw.source)) {
560
+ throw new UnsafeScopeError(`invalid target in ${manifestPath}: ${raw.target}`);
561
+ }
562
+ destinations.add(raw.destination);
563
+ links.push({
564
+ destination: raw.destination,
565
+ source: path.resolve(raw.source),
566
+ target: raw.target,
567
+ });
568
+ }
569
+ return {
570
+ manifest: { version: manifestVersion, destination: domain.destination, links },
571
+ present: true,
572
+ };
573
+ }
574
+ async function writeManifest(manifestDirectory, manifest, domain) {
575
+ const manifestPath = path.join(manifestDirectory, domain.manifestFileName);
576
+ const temporaryPath = path.join(manifestDirectory, `.${domain.manifestFileName}.${process.pid}.${randomUUID()}.tmp`);
577
+ try {
578
+ await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, {
579
+ encoding: "utf8",
580
+ flag: "wx",
581
+ mode: 0o600,
582
+ });
583
+ await rename(temporaryPath, manifestPath);
584
+ }
585
+ finally {
586
+ try {
587
+ await unlink(temporaryPath);
588
+ }
589
+ catch {
590
+ // A leftover temp file is never a valid manifest and is ignored on the next run.
591
+ }
592
+ }
593
+ }
594
+ async function withScopeLock(manifestDirectory, lockFileName, attempts, delayMs, callback) {
595
+ const lockPath = path.join(manifestDirectory, lockFileName);
596
+ const token = randomUUID();
597
+ for (let attempt = 0; attempt < Math.max(1, attempts); attempt++) {
598
+ let handle;
599
+ try {
600
+ handle = await open(lockPath, "wx", 0o600);
601
+ await handle.writeFile(token, "utf8");
602
+ try {
603
+ return await callback();
604
+ }
605
+ finally {
606
+ await handle.close();
607
+ await releaseLock(lockPath, token);
608
+ }
609
+ }
610
+ catch (error) {
611
+ if (handle) {
612
+ try {
613
+ await handle.close();
614
+ }
615
+ catch {
616
+ // The handle is already unusable; the ownership check below remains conservative.
617
+ }
618
+ await releaseLock(lockPath, token);
619
+ }
620
+ if (!isErrorCode(error, "EEXIST"))
621
+ throw error;
622
+ if (attempt + 1 >= Math.max(1, attempts))
623
+ throw lockTimeoutError(lockPath);
624
+ await sleep(delayMs);
625
+ }
626
+ }
627
+ throw lockTimeoutError(lockPath);
628
+ }
629
+ function lockTimeoutError(lockPath) {
630
+ return new UnsafeScopeError(`cooperating lock timed out at ${lockPath}; manually remove ${lockPath} only after all affected OpenCode instances are stopped`);
631
+ }
632
+ async function releaseLock(lockPath, token) {
633
+ try {
634
+ if ((await readFile(lockPath, "utf8")) !== token)
635
+ return;
636
+ await unlink(lockPath);
637
+ }
638
+ catch {
639
+ // Never guess at stale ownership. A later instance will fail closed on this lock.
640
+ }
641
+ }
642
+ async function validateDestinationParents(destinationRoot, links) {
643
+ for (const link of links) {
644
+ const destinationPath = destinationPathFor(destinationRoot, link.destination);
645
+ await assertNoSymlinkParents(path.dirname(destinationPath), destinationRoot);
646
+ }
647
+ }
648
+ async function ensureDirectoryPath(directory) {
649
+ const absolute = path.resolve(directory);
650
+ const root = path.parse(absolute).root;
651
+ const relative = path.relative(root, absolute);
652
+ let current = root;
653
+ for (const part of relative ? relative.split(path.sep) : []) {
654
+ current = path.join(current, part);
655
+ let state;
656
+ try {
657
+ state = await existingState(current);
658
+ }
659
+ catch (error) {
660
+ throw new UnsafeScopeError(`cannot inspect directory ${current}: ${formatError(error)}`);
661
+ }
662
+ if (state === "missing") {
663
+ try {
664
+ await mkdir(current);
665
+ }
666
+ catch (error) {
667
+ if (!isErrorCode(error, "EEXIST"))
668
+ throw error;
669
+ }
670
+ state = await existingState(current);
671
+ }
672
+ if (state !== "directory")
673
+ throw new UnsafeScopeError(`directory path escapes through ${current}`);
674
+ }
675
+ }
676
+ async function assertNoSymlinkParents(directory, allowedRootPath) {
677
+ const absolute = path.resolve(directory);
678
+ const allowedRoot = path.resolve(allowedRootPath ?? path.parse(absolute).root);
679
+ if (!isWithin(allowedRoot, absolute))
680
+ throw new UnsafeScopeError(`path escapes allowed root: ${absolute}`);
681
+ const rootState = await existingState(allowedRoot);
682
+ if (rootState === "symlink")
683
+ throw new UnsafeScopeError(`symlink parent rejected: ${allowedRoot}`);
684
+ if (rootState === "file")
685
+ throw new UnsafeScopeError(`file parent rejected: ${allowedRoot}`);
686
+ const relative = path.relative(allowedRoot, absolute);
687
+ let current = allowedRoot;
688
+ for (const part of relative ? relative.split(path.sep) : []) {
689
+ current = path.join(current, part);
690
+ let state;
691
+ try {
692
+ state = await existingState(current);
693
+ }
694
+ catch (error) {
695
+ throw new UnsafeScopeError(`cannot inspect path ${current}: ${formatError(error)}`);
696
+ }
697
+ if (state === "symlink")
698
+ throw new UnsafeScopeError(`symlink parent rejected: ${current}`);
699
+ if (state === "file")
700
+ throw new UnsafeScopeError(`file parent rejected: ${current}`);
701
+ }
702
+ }
703
+ async function readDestination(filePath) {
704
+ let state;
705
+ try {
706
+ state = await existingState(filePath);
707
+ }
708
+ catch (error) {
709
+ throw new UnsafeScopeError(`cannot inspect destination ${filePath}: ${formatError(error)}`);
710
+ }
711
+ if (state === "missing")
712
+ return undefined;
713
+ return { kind: state };
714
+ }
715
+ async function existingState(filePath) {
716
+ try {
717
+ const info = await lstat(filePath);
718
+ if (info.isSymbolicLink())
719
+ return "symlink";
720
+ if (info.isDirectory())
721
+ return "directory";
722
+ if (info.isFile())
723
+ return "file";
724
+ return "file";
725
+ }
726
+ catch (error) {
727
+ if (isErrorCode(error, "ENOENT"))
728
+ return "missing";
729
+ throw error;
730
+ }
731
+ }
732
+ function destinationPathFor(destinationRoot, relative) {
733
+ if (!isSafeRelative(relative))
734
+ throw new UnsafeScopeError(`unsafe destination path: ${relative}`);
735
+ const destination = path.resolve(destinationRoot, ...relative.split("/"));
736
+ if (!isWithin(destinationRoot, destination))
737
+ throw new UnsafeScopeError(`destination escapes root: ${relative}`);
738
+ return destination;
739
+ }
740
+ function projectScopes(projectRoot, currentDirectory) {
741
+ const result = [];
742
+ let current = currentDirectory;
743
+ while (isWithin(projectRoot, current)) {
744
+ result.unshift(current);
745
+ if (current === projectRoot)
746
+ break;
747
+ current = path.dirname(current);
748
+ }
749
+ return result;
750
+ }
751
+ function snapshotValue(snapshot) {
752
+ return {
753
+ source: sourceValue(snapshot.source),
754
+ native: [...snapshot.nativeNames].toSorted(),
755
+ links: snapshot.links,
756
+ };
757
+ }
758
+ function sourceValue(source) {
759
+ return [...source.files.values(), ...source.invalid.values()]
760
+ .toSorted((left, right) => left.name.localeCompare(right.name))
761
+ .map(({ name, source: sourcePath, fingerprint }) => ({
762
+ name,
763
+ source: sourcePath,
764
+ fingerprint,
765
+ }));
766
+ }
767
+ function fingerprintValue(value) {
768
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
769
+ }
770
+ function emptyManifest(domain) {
771
+ return { version: manifestVersion, destination: domain.destination, links: [] };
772
+ }
773
+ function emptyUnsafeResult(spec) {
774
+ const snapshot = {
775
+ source: { files: new Map(), invalid: new Map() },
776
+ nativeNames: new Set(),
777
+ links: [],
778
+ };
779
+ return {
780
+ scope: spec.id,
781
+ safe: false,
782
+ changed: false,
783
+ fingerprint: "unsafe",
784
+ snapshot,
785
+ };
786
+ }
787
+ function sameLinks(left, right) {
788
+ if (left.length !== right.length)
789
+ return false;
790
+ return left.every((entry, index) => entry.destination === right[index]?.destination &&
791
+ entry.source === right[index]?.source &&
792
+ entry.target === right[index]?.target);
793
+ }
794
+ function isSafeRelative(value) {
795
+ if (!value || value.includes("\\") || value.includes("\0") || path.posix.isAbsolute(value))
796
+ return false;
797
+ const normalized = path.posix.normalize(value);
798
+ return (normalized === value &&
799
+ normalized !== "." &&
800
+ !normalized.startsWith("../") &&
801
+ normalized !== "..");
802
+ }
803
+ function sourceName(sourceRoot, source, sourceDirectories) {
804
+ const relative = path
805
+ .relative(path.resolve(sourceRoot), path.resolve(source))
806
+ .replaceAll(path.sep, "/");
807
+ for (const directory of sourceDirectories) {
808
+ const prefix = `${directory}/`;
809
+ if (!relative.startsWith(prefix))
810
+ continue;
811
+ const name = relative.slice(prefix.length);
812
+ if (isSafeRelative(name) && name.endsWith(".md"))
813
+ return name;
814
+ }
815
+ return undefined;
816
+ }
817
+ function isRelativeTarget(value) {
818
+ return Boolean(value) && !path.isAbsolute(value) && !value.includes("\0");
819
+ }
820
+ function isWithin(root, candidate) {
821
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
822
+ return (relative === "" ||
823
+ (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)));
824
+ }
825
+ function isErrorCode(error, code) {
826
+ return isRecord(error) && error.code === code;
827
+ }
828
+ function formatError(error) {
829
+ return error instanceof Error ? error.message : String(error);
830
+ }
831
+ function normalizePollInterval(value) {
832
+ return value === undefined || !Number.isFinite(value)
833
+ ? defaultPollIntervalMs
834
+ : Math.max(1, Math.floor(value));
835
+ }
836
+ function dedupeConsecutiveDiagnostics(diagnostics) {
837
+ const sink = diagnostics ??
838
+ ((message) => console.error(`[capybearista.opencode-agents-loader] ${message}`));
839
+ let previous;
840
+ return (message) => {
841
+ if (message === previous)
842
+ return;
843
+ previous = message;
844
+ sink(message);
845
+ };
846
+ }
847
+ async function sleep(milliseconds) {
848
+ await new Promise((resolve) => setTimeout(resolve, Math.max(0, milliseconds)));
849
+ }
850
+ function parseJsonc(content) {
851
+ let withoutComments = "";
852
+ let string = false;
853
+ let escaped = false;
854
+ let lineComment = false;
855
+ let blockComment = false;
856
+ for (let index = 0; index < content.length; index++) {
857
+ const character = content[index];
858
+ const next = content[index + 1];
859
+ if (lineComment) {
860
+ if (character === "\n") {
861
+ lineComment = false;
862
+ withoutComments += character;
863
+ }
864
+ continue;
865
+ }
866
+ if (blockComment) {
867
+ if (character === "*" && next === "/") {
868
+ blockComment = false;
869
+ index++;
870
+ }
871
+ else if (character === "\n") {
872
+ withoutComments += character;
873
+ }
874
+ continue;
875
+ }
876
+ if (!string && character === "/" && next === "/") {
877
+ lineComment = true;
878
+ index++;
879
+ continue;
880
+ }
881
+ if (!string && character === "/" && next === "*") {
882
+ blockComment = true;
883
+ index++;
884
+ continue;
885
+ }
886
+ withoutComments += character;
887
+ if (character === '"' && !escaped)
888
+ string = !string;
889
+ escaped = character === "\\" && !escaped;
890
+ if (character !== "\\")
891
+ escaped = false;
892
+ }
893
+ let withoutTrailingCommas = "";
894
+ string = false;
895
+ escaped = false;
896
+ for (let index = 0; index < withoutComments.length; index++) {
897
+ const character = withoutComments[index];
898
+ if (character === '"' && !escaped)
899
+ string = !string;
900
+ if (!string && character === ",") {
901
+ let next = index + 1;
902
+ while (/\s/.test(withoutComments[next] ?? ""))
903
+ next++;
904
+ if (withoutComments[next] === "}" || withoutComments[next] === "]")
905
+ continue;
906
+ }
907
+ withoutTrailingCommas += character;
908
+ escaped = character === "\\" && !escaped;
909
+ if (character !== "\\")
910
+ escaped = false;
911
+ }
912
+ return JSON.parse(withoutTrailingCommas);
913
+ }
914
+ function collectMapNames(value, names) {
915
+ if (!isRecord(value))
916
+ return;
917
+ for (const name of Object.keys(value))
918
+ names.add(name);
919
+ }
920
+ function isRecord(value) {
921
+ return typeof value === "object" && value !== null && !Array.isArray(value);
922
+ }
923
+ //# sourceMappingURL=command-bridge.js.map