agents-relay 1.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.
Files changed (62) hide show
  1. package/.github/workflows/publish.yml +91 -0
  2. package/AGENTS.md +16 -0
  3. package/LICENSE +21 -0
  4. package/README.md +102 -0
  5. package/dist/adapters.js +311 -0
  6. package/dist/cli.js +455 -0
  7. package/dist/continuation.js +21 -0
  8. package/dist/dashboard.js +446 -0
  9. package/dist/events.js +36 -0
  10. package/dist/github-auth.js +34 -0
  11. package/dist/github-webhook.js +47 -0
  12. package/dist/markers.js +42 -0
  13. package/dist/planner.js +172 -0
  14. package/dist/pool.js +98 -0
  15. package/dist/reconciler.js +434 -0
  16. package/dist/registry.js +27 -0
  17. package/dist/relayd.js +177 -0
  18. package/dist/scheduler.js +49 -0
  19. package/dist/store.js +586 -0
  20. package/dist/types.js +6 -0
  21. package/dist/usage.js +370 -0
  22. package/dist/workspace.js +76 -0
  23. package/docs/agent-network.md +34 -0
  24. package/docs/architecture.md +120 -0
  25. package/docs/autonomous-objective-jobs.md +121 -0
  26. package/docs/example.md +30 -0
  27. package/docs/github-app-rate-limit.md +124 -0
  28. package/docs/service.md +43 -0
  29. package/pack.json +326 -0
  30. package/package.json +14 -0
  31. package/scripts/npm-version.mjs +11 -0
  32. package/skills/agents-relay/SKILL.md +77 -0
  33. package/skills/agents-relay/agents/planner.agent.md +28 -0
  34. package/src/adapters.ts +231 -0
  35. package/src/cli.ts +324 -0
  36. package/src/continuation.ts +6 -0
  37. package/src/dashboard.ts +421 -0
  38. package/src/events.ts +25 -0
  39. package/src/github-auth.ts +35 -0
  40. package/src/github-webhook.ts +37 -0
  41. package/src/markers.ts +33 -0
  42. package/src/planner.ts +150 -0
  43. package/src/pool.ts +87 -0
  44. package/src/reconciler.ts +235 -0
  45. package/src/registry.ts +35 -0
  46. package/src/relayd.ts +137 -0
  47. package/src/scheduler.ts +27 -0
  48. package/src/store.ts +526 -0
  49. package/src/types.ts +45 -0
  50. package/src/usage.ts +385 -0
  51. package/src/workspace.ts +62 -0
  52. package/test/adapters.test.js +303 -0
  53. package/test/autonomous.test.js +119 -0
  54. package/test/core.test.js +363 -0
  55. package/test/dashboard.test.js +178 -0
  56. package/test/github-auth.test.js +51 -0
  57. package/test/github-webhook.test.js +21 -0
  58. package/test/service.test.js +116 -0
  59. package/test/store.test.js +390 -0
  60. package/test/usage.test.js +88 -0
  61. package/test/workspace.test.js +95 -0
  62. package/tsconfig.json +4 -0
package/src/usage.ts ADDED
@@ -0,0 +1,385 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { readdir, stat } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { createInterface } from 'node:readline';
6
+ import { spawn } from 'node:child_process';
7
+
8
+ export type AccountUsageWindow = {
9
+ usedPercent: number;
10
+ windowMinutes: number;
11
+ resetsAt: number | null;
12
+ };
13
+
14
+ export type AccountUsageSnapshot = {
15
+ available: boolean;
16
+ generatedAt: string;
17
+ fiveHour: AccountUsageWindow | null;
18
+ weekly: AccountUsageWindow | null;
19
+ reason: string | null;
20
+ };
21
+
22
+ export type AccountUsageProvider = {
23
+ id: 'openai' | 'zai';
24
+ label: string;
25
+ usage: AccountUsageSnapshot;
26
+ };
27
+
28
+ export type CreditBalance = { currency: string; total: number | null; used: number | null; remaining: number | null };
29
+
30
+ export type CreditSnapshot = {
31
+ available: boolean;
32
+ generatedAt: string;
33
+ balances: CreditBalance[];
34
+ reason: string | null;
35
+ };
36
+
37
+ export type CreditProvider = { id: 'deepseek' | 'openrouter'; label: string; credits: CreditSnapshot };
38
+
39
+ export type AccountUsageOverview = {
40
+ generatedAt: string;
41
+ providers: AccountUsageProvider[];
42
+ credits: CreditProvider[];
43
+ };
44
+
45
+ export interface UsageReader {
46
+ read(): Promise<AccountUsageSnapshot>;
47
+ }
48
+
49
+ export interface CreditReader {
50
+ read(): Promise<CreditSnapshot>;
51
+ }
52
+
53
+ function finiteNumber(value: unknown): number | null {
54
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
55
+ if (typeof value === 'string' && value.trim() !== '') {
56
+ const parsed = Number(value);
57
+ return Number.isFinite(parsed) ? parsed : null;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ function finitePercent(value: unknown): number | null {
63
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
64
+ }
65
+
66
+ function positiveMinutes(value: unknown): number | null {
67
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
68
+ }
69
+
70
+ function epochSeconds(value: unknown): number | null {
71
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
72
+ }
73
+
74
+ function parseWindow(value: unknown): AccountUsageWindow | null {
75
+ if (!value || typeof value !== 'object') return null;
76
+ const raw = value as Record<string, unknown>;
77
+ const usedPercent = finitePercent(raw.used_percent);
78
+ const windowMinutes = positiveMinutes(raw.window_minutes);
79
+ if (usedPercent === null || windowMinutes === null) return null;
80
+ return { usedPercent, windowMinutes, resetsAt: epochSeconds(raw.resets_at) };
81
+ }
82
+
83
+ function rateLimitsFromLine(line: string): { timestamp: string; windows: AccountUsageWindow[] } | null {
84
+ try {
85
+ const parsed = JSON.parse(line) as unknown;
86
+ if (!parsed || typeof parsed !== 'object') return null;
87
+ const event = parsed as Record<string, unknown>;
88
+ if (event.type !== 'event_msg') return null;
89
+ const payload = event.payload;
90
+ if (!payload || typeof payload !== 'object') return null;
91
+ const message = payload as Record<string, unknown>;
92
+ if (message.type !== 'token_count') return null;
93
+ const rateLimits = message.rate_limits;
94
+ if (!rateLimits || typeof rateLimits !== 'object') return null;
95
+ const limits = rateLimits as Record<string, unknown>;
96
+ const windows = [parseWindow(limits.primary), parseWindow(limits.secondary)].filter((window): window is AccountUsageWindow => window !== null);
97
+ const timestamp = typeof event.timestamp === 'string' && !Number.isNaN(Date.parse(event.timestamp))
98
+ ? event.timestamp
99
+ : new Date(0).toISOString();
100
+ return { timestamp, windows };
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ export class CodexApiUsageReader implements UsageReader {
107
+ constructor(private readonly now: () => Date = () => new Date()) {}
108
+ async read(): Promise<AccountUsageSnapshot> {
109
+ const generatedAt = this.now().toISOString();
110
+ return await new Promise(resolve => {
111
+ const child = spawn('codex', ['app-server', '--stdio'], { stdio: ['pipe', 'pipe', 'ignore'] });
112
+ const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
113
+ const finish = (snapshot: AccountUsageSnapshot) => { lines.close(); child.kill(); resolve(snapshot); };
114
+ const timer = setTimeout(() => finish({ available: false, generatedAt, fiveHour: null, weekly: null, reason: 'Codex app-server rate-limit request timed out.' }), 10_000);
115
+ lines.on('line', line => {
116
+ try {
117
+ const message = JSON.parse(line) as { id?: number; result?: { rateLimits?: { primary?: { usedPercent?: number; windowDurationMins?: number; resetsAt?: number }; secondary?: { usedPercent?: number; windowDurationMins?: number; resetsAt?: number } } } };
118
+ if (message.id !== 2) return;
119
+ clearTimeout(timer);
120
+ const parse = (window: { usedPercent?: number; windowDurationMins?: number; resetsAt?: number } | undefined): AccountUsageWindow | null => {
121
+ if (!window) return null; const usedPercent = finitePercent(window.usedPercent); const windowMinutes = positiveMinutes(window.windowDurationMins);
122
+ return usedPercent === null || windowMinutes === null ? null : { usedPercent, windowMinutes, resetsAt: epochSeconds(window.resetsAt) };
123
+ };
124
+ const limits = message.result?.rateLimits; const primary = parse(limits?.primary); const secondary = parse(limits?.secondary);
125
+ const windows = [primary, secondary].filter((window): window is AccountUsageWindow => window !== null);
126
+ const fiveHour = windows.find(window => window.windowMinutes === 300) ?? null; const weekly = windows.find(window => window.windowMinutes === 10_080) ?? null;
127
+ const available = fiveHour !== null && weekly !== null; finish({ available, generatedAt, fiveHour, weekly, reason: available ? null : 'Codex app-server did not return both account quota windows.' });
128
+ } catch { return; }
129
+ });
130
+ child.once('error', () => { clearTimeout(timer); finish({ available: false, generatedAt, fiveHour: null, weekly: null, reason: 'Codex app-server could not be started.' }); });
131
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientInfo: { name: 'agents-relay', version: '1' }, capabilities: {} } }) + '\n');
132
+ setTimeout(() => child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'account/rateLimits/read', params: {} }) + '\n'), 100);
133
+ });
134
+ }
135
+ }
136
+
137
+ export class CodexSessionUsageReader implements UsageReader {
138
+ private cache: { cachedAt: number; snapshot: AccountUsageSnapshot } | null = null;
139
+
140
+ constructor(
141
+ private readonly codexHome = join(homedir(), '.codex'),
142
+ private readonly lookbackDays = 8,
143
+ private readonly cacheTtlMs = 10_000,
144
+ private readonly now: () => Date = () => new Date(),
145
+ ) {}
146
+
147
+ private async usageFiles(cutoff: Date): Promise<Array<{ path: string; modifiedAt: Date }>> {
148
+ const files: Array<{ path: string; modifiedAt: Date }> = [];
149
+ for (const root of [join(this.codexHome, 'sessions'), join(this.codexHome, 'archived_sessions')]) {
150
+ await this.collectFiles(root, cutoff, files, 0);
151
+ }
152
+ return files.sort((left, right) => right.modifiedAt.getTime() - left.modifiedAt.getTime());
153
+ }
154
+
155
+ private async collectFiles(
156
+ directory: string,
157
+ cutoff: Date,
158
+ files: Array<{ path: string; modifiedAt: Date }>,
159
+ depth: number,
160
+ ): Promise<void> {
161
+ if (depth > 5) return;
162
+ let entries;
163
+ try {
164
+ entries = await readdir(directory, { withFileTypes: true });
165
+ } catch {
166
+ return;
167
+ }
168
+ for (const entry of entries) {
169
+ const path = join(directory, entry.name);
170
+ if (entry.isDirectory()) {
171
+ await this.collectFiles(path, cutoff, files, depth + 1);
172
+ continue;
173
+ }
174
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
175
+ try {
176
+ const metadata = await stat(path);
177
+ if (metadata.mtime >= cutoff) files.push({ path, modifiedAt: metadata.mtime });
178
+ } catch {
179
+ continue;
180
+ }
181
+ }
182
+ }
183
+
184
+ async read(): Promise<AccountUsageSnapshot> {
185
+ const now = this.now();
186
+ if (this.cache && now.getTime() - this.cache.cachedAt < this.cacheTtlMs) return this.cache.snapshot;
187
+ const cutoff = new Date(now.getTime() - this.lookbackDays * 86_400_000);
188
+ const files = await this.usageFiles(cutoff);
189
+ let latestTimestamp = '';
190
+ let latestWindows: AccountUsageWindow[] = [];
191
+
192
+ for (const file of files) {
193
+ const stream = createReadStream(file.path, { encoding: 'utf8' });
194
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
195
+ try {
196
+ for await (const line of lines) {
197
+ if (!line.includes('"rate_limits"')) continue;
198
+ const telemetry = rateLimitsFromLine(line);
199
+ if (telemetry && telemetry.timestamp > latestTimestamp) {
200
+ latestTimestamp = telemetry.timestamp;
201
+ latestWindows = telemetry.windows;
202
+ }
203
+ }
204
+ } finally {
205
+ lines.close();
206
+ stream.destroy();
207
+ }
208
+ }
209
+
210
+ const fiveHour = latestWindows.find(window => window.windowMinutes === 300) ?? null;
211
+ const weekly = latestWindows.find(window => window.windowMinutes === 10_080) ?? null;
212
+ const telemetryAgeMs = latestTimestamp ? now.getTime() - Date.parse(latestTimestamp) : Number.POSITIVE_INFINITY;
213
+ const fresh = telemetryAgeMs >= 0 && telemetryAgeMs <= 15 * 60_000;
214
+ const available = fresh && fiveHour !== null && weekly !== null;
215
+ const snapshot: AccountUsageSnapshot = {
216
+ available,
217
+ generatedAt: now.toISOString(),
218
+ fiveHour,
219
+ weekly,
220
+ reason: available
221
+ ? null
222
+ : latestTimestamp && !fresh
223
+ ? 'Latest Codex account rate-limit telemetry is stale.'
224
+ : files.length
225
+ ? 'Latest Codex account telemetry does not expose current 5-hour and weekly rate-limit windows.'
226
+ : 'No recent Codex account rate-limit telemetry is available.',
227
+ };
228
+ this.cache = { cachedAt: now.getTime(), snapshot };
229
+ return snapshot;
230
+ }
231
+ }
232
+
233
+
234
+ export class ZaiUsageReader implements UsageReader {
235
+ constructor(
236
+ private readonly apiKey = process.env.ZAI_API_KEY ?? '',
237
+ private readonly endpoint = 'https://api.z.ai/api/monitor/usage/quota/limit',
238
+ private readonly fetchImpl: typeof fetch = fetch,
239
+ private readonly now: () => Date = () => new Date(),
240
+ ) {}
241
+
242
+ async read(): Promise<AccountUsageSnapshot> {
243
+ const generatedAt = this.now().toISOString();
244
+ if (!this.apiKey) {
245
+ return { available: false, generatedAt, fiveHour: null, weekly: null, reason: 'ZAI_API_KEY is not configured.' };
246
+ }
247
+ try {
248
+ const response = await this.fetchImpl(this.endpoint, {
249
+ headers: { Authorization: `Bearer ${this.apiKey}`, Accept: 'application/json' },
250
+ });
251
+ if (!response.ok) {
252
+ return { available: false, generatedAt, fiveHour: null, weekly: null, reason: `Z.ai usage request failed with HTTP ${response.status}.` };
253
+ }
254
+ const payload = await response.json() as { data?: { limits?: Array<Record<string, unknown>> } };
255
+ const limits = Array.isArray(payload?.data?.limits) ? payload.data.limits : [];
256
+ const readLimit = (unit: number, windowMinutes: number): AccountUsageWindow | null => {
257
+ const raw = limits.find(limit => (limit.type === 'TOKENS_LIMIT' || limit.type === 'CREDIT_LIMIT') && limit.unit === unit);
258
+ if (!raw) return null;
259
+ const usedPercent = finitePercent(raw.percentage);
260
+ if (usedPercent === null) return null;
261
+ const resetMs = typeof raw.nextResetTime === 'number' && Number.isFinite(raw.nextResetTime) ? raw.nextResetTime : null;
262
+ return { usedPercent, windowMinutes, resetsAt: resetMs === null ? null : Math.floor(resetMs / 1000) };
263
+ };
264
+ const fiveHour = readLimit(3, 300);
265
+ const weekly = readLimit(6, 10_080);
266
+ const available = fiveHour !== null && weekly !== null;
267
+ return {
268
+ available,
269
+ generatedAt,
270
+ fiveHour,
271
+ weekly,
272
+ reason: available ? null : 'Z.ai returned no usable 5-hour and weekly Coding Plan quota windows.',
273
+ };
274
+ } catch {
275
+ return { available: false, generatedAt, fiveHour: null, weekly: null, reason: 'Z.ai account usage request could not be completed.' };
276
+ }
277
+ }
278
+ }
279
+
280
+
281
+ export class DeepSeekCreditReader implements CreditReader {
282
+ constructor(
283
+ private readonly apiKey = process.env.DEEPSEEK_API_KEY ?? '',
284
+ private readonly endpoint = 'https://api.deepseek.com/user/balance',
285
+ private readonly fetchImpl: typeof fetch = fetch,
286
+ private readonly now: () => Date = () => new Date(),
287
+ ) {}
288
+
289
+ async read(): Promise<CreditSnapshot> {
290
+ const generatedAt = this.now().toISOString();
291
+ if (!this.apiKey) return { available: false, generatedAt, balances: [], reason: 'DEEPSEEK_API_KEY is not configured.' };
292
+ try {
293
+ const response = await this.fetchImpl(this.endpoint, { headers: { Authorization: `Bearer ${this.apiKey}`, Accept: 'application/json' } });
294
+ if (!response.ok) return { available: false, generatedAt, balances: [], reason: `DeepSeek balance request failed with HTTP ${response.status}.` };
295
+ const payload = await response.json() as { is_available?: boolean; balance_infos?: Array<Record<string, unknown>> };
296
+ const balances = (Array.isArray(payload.balance_infos) ? payload.balance_infos : []).map(info => ({
297
+ currency: typeof info.currency === 'string' ? info.currency : 'credit',
298
+ total: finiteNumber(info.total_balance),
299
+ used: null,
300
+ remaining: finiteNumber(info.total_balance),
301
+ })).filter(balance => balance.remaining !== null);
302
+ return { available: payload.is_available === true && balances.length > 0, generatedAt, balances, reason: balances.length ? null : 'DeepSeek returned no usable balance information.' };
303
+ } catch {
304
+ return { available: false, generatedAt, balances: [], reason: 'DeepSeek balance request could not be completed.' };
305
+ }
306
+ }
307
+ }
308
+
309
+ export class OpenRouterCreditReader implements CreditReader {
310
+ constructor(
311
+ private readonly apiKey = process.env.OPENROUTER_API_KEY ?? '',
312
+ private readonly endpoint = 'https://openrouter.ai/api/v1/credits',
313
+ private readonly fetchImpl: typeof fetch = fetch,
314
+ private readonly now: () => Date = () => new Date(),
315
+ ) {}
316
+
317
+ async read(): Promise<CreditSnapshot> {
318
+ const generatedAt = this.now().toISOString();
319
+ if (!this.apiKey) return { available: false, generatedAt, balances: [], reason: 'OPENROUTER_API_KEY is not configured.' };
320
+ try {
321
+ const response = await this.fetchImpl(this.endpoint, { headers: { Authorization: `Bearer ${this.apiKey}`, Accept: 'application/json' } });
322
+ if (!response.ok) return { available: false, generatedAt, balances: [], reason: `OpenRouter credits request failed with HTTP ${response.status}; this endpoint requires a management key.` };
323
+ const payload = await response.json() as { data?: { total_credits?: unknown; total_usage?: unknown } };
324
+ const total = finiteNumber(payload.data?.total_credits);
325
+ const used = finiteNumber(payload.data?.total_usage);
326
+ const remaining = total !== null && used !== null ? total - used : null;
327
+ const balances = total === null && used === null ? [] : [{ currency: 'USD', total, used, remaining }];
328
+ return { available: balances.length > 0, generatedAt, balances, reason: balances.length ? null : 'OpenRouter returned no usable credit information.' };
329
+ } catch {
330
+ return { available: false, generatedAt, balances: [], reason: 'OpenRouter credits request could not be completed.' };
331
+ }
332
+ }
333
+ }
334
+
335
+ export class UsageRegistry {
336
+ constructor(
337
+ private readonly openaiReader: UsageReader,
338
+ private readonly zaiReader: UsageReader = new ZaiUsageReader(),
339
+ private readonly deepSeekReader: CreditReader | null = null,
340
+ private readonly openRouterReader: CreditReader | null = null,
341
+ ) {}
342
+
343
+ async snapshot(): Promise<AccountUsageOverview> {
344
+ const [openai, zai, deepseek, openrouter] = await Promise.all([
345
+ this.openaiReader.read(),
346
+ this.zaiReader.read(),
347
+ this.deepSeekReader?.read() ?? Promise.resolve(null),
348
+ this.openRouterReader?.read() ?? Promise.resolve(null),
349
+ ]);
350
+ const credits: CreditProvider[] = [];
351
+ if (deepseek) credits.push({ id: 'deepseek', label: 'DeepSeek', credits: deepseek });
352
+ if (openrouter) credits.push({ id: 'openrouter', label: 'OpenRouter', credits: openrouter });
353
+ return {
354
+ generatedAt: new Date().toISOString(),
355
+ providers: [
356
+ { id: 'openai', label: 'OpenAI', usage: openai },
357
+ { id: 'zai', label: 'Z.ai', usage: zai },
358
+ ],
359
+ credits,
360
+ };
361
+ }
362
+ }
363
+
364
+ export class UnavailableUsageReader implements UsageReader {
365
+ constructor(private readonly reason: string, private readonly now: () => Date = () => new Date()) {}
366
+
367
+ async read(): Promise<AccountUsageSnapshot> {
368
+ return {
369
+ available: false,
370
+ generatedAt: this.now().toISOString(),
371
+ fiveHour: null,
372
+ weekly: null,
373
+ reason: this.reason,
374
+ };
375
+ }
376
+ }
377
+
378
+ export function codexAndZaiUsageRegistry(codexHome?: string, _legacyWindowDays?: number): UsageRegistry {
379
+ return new UsageRegistry(
380
+ new CodexApiUsageReader(),
381
+ new ZaiUsageReader(),
382
+ process.env.DEEPSEEK_API_KEY ? new DeepSeekCreditReader() : null,
383
+ process.env.OPENROUTER_API_KEY ? new OpenRouterCreditReader() : null,
384
+ );
385
+ }
@@ -0,0 +1,62 @@
1
+ import { readdir, stat } from 'node:fs/promises';
2
+ import { join, relative, resolve } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ export type DiscoveredRepository = { path: string; repository: string };
10
+
11
+ export function defaultWorkspaceRoot(): string {
12
+ return join(homedir(), 'Workspace');
13
+ }
14
+
15
+ export function githubRepositoryFromRemote(remote: string): string | null {
16
+ const value = remote.trim().replace(/\.git$/, '');
17
+ const match = value.match(/github\.com[/:]([^/]+)\/([^/]+)$/i);
18
+ return match ? `${match[1]}/${match[2]}` : null;
19
+ }
20
+
21
+ async function hasGitDirectory(path: string): Promise<boolean> {
22
+ try { await stat(join(path, '.git')); return true; } catch { return false; }
23
+ }
24
+
25
+ export async function discoverGitRepositories(root: string): Promise<string[]> {
26
+ const found: string[] = [];
27
+ const visit = async (directory: string): Promise<void> => {
28
+ if (await hasGitDirectory(directory)) { found.push(directory); return; }
29
+ let entries;
30
+ try { entries = await readdir(directory, { withFileTypes: true }); } catch { return; }
31
+ await Promise.all(entries.filter(entry => entry.isDirectory() && !['node_modules', '.git', '.worktrees', '.backup'].includes(entry.name)).map(entry => visit(join(directory, entry.name))));
32
+ };
33
+ await visit(resolve(root));
34
+ return found.sort((a, b) => relative(root, a).localeCompare(relative(root, b)));
35
+ }
36
+
37
+ async function origin(path: string): Promise<string | null> {
38
+ try { return (await execFileAsync('git', ['-C', path, 'remote', 'get-url', 'origin'])).stdout.trim() || null; } catch { return null; }
39
+ }
40
+
41
+ export async function discoverWorkspaceRepositories(root: string): Promise<DiscoveredRepository[]> {
42
+ const paths = await discoverGitRepositories(root);
43
+ const discovered = await Promise.all(paths.map(async path => {
44
+ const repository = githubRepositoryFromRemote(await origin(path) ?? '');
45
+ return repository ? { path, repository } : null;
46
+ }));
47
+ const unique = new Map<string, DiscoveredRepository>();
48
+ for (const item of discovered) {
49
+ if (!item) continue;
50
+ const current = unique.get(item.repository);
51
+ if (!current || repositoryPathRank(item.path, root) < repositoryPathRank(current.path, root)) unique.set(item.repository, item);
52
+ }
53
+ return [...unique.values()].sort((a, b) => a.repository.localeCompare(b.repository));
54
+ }
55
+
56
+ function repositoryPathRank(path: string, root: string): number {
57
+ const relativePath = relative(root, path);
58
+ if (!relativePath.includes('/')) return 0;
59
+ if (relativePath.startsWith('.worktrees/')) return 2;
60
+ if (relativePath.startsWith('.backup/')) return 3;
61
+ return 1;
62
+ }