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.
- package/.github/workflows/publish.yml +91 -0
- package/AGENTS.md +16 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/adapters.js +311 -0
- package/dist/cli.js +455 -0
- package/dist/continuation.js +21 -0
- package/dist/dashboard.js +446 -0
- package/dist/events.js +36 -0
- package/dist/github-auth.js +34 -0
- package/dist/github-webhook.js +47 -0
- package/dist/markers.js +42 -0
- package/dist/planner.js +172 -0
- package/dist/pool.js +98 -0
- package/dist/reconciler.js +434 -0
- package/dist/registry.js +27 -0
- package/dist/relayd.js +177 -0
- package/dist/scheduler.js +49 -0
- package/dist/store.js +586 -0
- package/dist/types.js +6 -0
- package/dist/usage.js +370 -0
- package/dist/workspace.js +76 -0
- package/docs/agent-network.md +34 -0
- package/docs/architecture.md +120 -0
- package/docs/autonomous-objective-jobs.md +121 -0
- package/docs/example.md +30 -0
- package/docs/github-app-rate-limit.md +124 -0
- package/docs/service.md +43 -0
- package/pack.json +326 -0
- package/package.json +14 -0
- package/scripts/npm-version.mjs +11 -0
- package/skills/agents-relay/SKILL.md +77 -0
- package/skills/agents-relay/agents/planner.agent.md +28 -0
- package/src/adapters.ts +231 -0
- package/src/cli.ts +324 -0
- package/src/continuation.ts +6 -0
- package/src/dashboard.ts +421 -0
- package/src/events.ts +25 -0
- package/src/github-auth.ts +35 -0
- package/src/github-webhook.ts +37 -0
- package/src/markers.ts +33 -0
- package/src/planner.ts +150 -0
- package/src/pool.ts +87 -0
- package/src/reconciler.ts +235 -0
- package/src/registry.ts +35 -0
- package/src/relayd.ts +137 -0
- package/src/scheduler.ts +27 -0
- package/src/store.ts +526 -0
- package/src/types.ts +45 -0
- package/src/usage.ts +385 -0
- package/src/workspace.ts +62 -0
- package/test/adapters.test.js +303 -0
- package/test/autonomous.test.js +119 -0
- package/test/core.test.js +363 -0
- package/test/dashboard.test.js +178 -0
- package/test/github-auth.test.js +51 -0
- package/test/github-webhook.test.js +21 -0
- package/test/service.test.js +116 -0
- package/test/store.test.js +390 -0
- package/test/usage.test.js +88 -0
- package/test/workspace.test.js +95 -0
- package/tsconfig.json +4 -0
package/dist/usage.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
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
|
+
function finiteNumber(value) {
|
|
8
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
9
|
+
return value;
|
|
10
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
11
|
+
const parsed = Number(value);
|
|
12
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
function finitePercent(value) {
|
|
17
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
|
|
18
|
+
}
|
|
19
|
+
function positiveMinutes(value) {
|
|
20
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
|
|
21
|
+
}
|
|
22
|
+
function epochSeconds(value) {
|
|
23
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
|
|
24
|
+
}
|
|
25
|
+
function parseWindow(value) {
|
|
26
|
+
if (!value || typeof value !== 'object')
|
|
27
|
+
return null;
|
|
28
|
+
const raw = value;
|
|
29
|
+
const usedPercent = finitePercent(raw.used_percent);
|
|
30
|
+
const windowMinutes = positiveMinutes(raw.window_minutes);
|
|
31
|
+
if (usedPercent === null || windowMinutes === null)
|
|
32
|
+
return null;
|
|
33
|
+
return { usedPercent, windowMinutes, resetsAt: epochSeconds(raw.resets_at) };
|
|
34
|
+
}
|
|
35
|
+
function rateLimitsFromLine(line) {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(line);
|
|
38
|
+
if (!parsed || typeof parsed !== 'object')
|
|
39
|
+
return null;
|
|
40
|
+
const event = parsed;
|
|
41
|
+
if (event.type !== 'event_msg')
|
|
42
|
+
return null;
|
|
43
|
+
const payload = event.payload;
|
|
44
|
+
if (!payload || typeof payload !== 'object')
|
|
45
|
+
return null;
|
|
46
|
+
const message = payload;
|
|
47
|
+
if (message.type !== 'token_count')
|
|
48
|
+
return null;
|
|
49
|
+
const rateLimits = message.rate_limits;
|
|
50
|
+
if (!rateLimits || typeof rateLimits !== 'object')
|
|
51
|
+
return null;
|
|
52
|
+
const limits = rateLimits;
|
|
53
|
+
const windows = [parseWindow(limits.primary), parseWindow(limits.secondary)].filter((window) => window !== null);
|
|
54
|
+
const timestamp = typeof event.timestamp === 'string' && !Number.isNaN(Date.parse(event.timestamp))
|
|
55
|
+
? event.timestamp
|
|
56
|
+
: new Date(0).toISOString();
|
|
57
|
+
return { timestamp, windows };
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export class CodexApiUsageReader {
|
|
64
|
+
now;
|
|
65
|
+
constructor(now = () => new Date()) {
|
|
66
|
+
this.now = now;
|
|
67
|
+
}
|
|
68
|
+
async read() {
|
|
69
|
+
const generatedAt = this.now().toISOString();
|
|
70
|
+
return await new Promise(resolve => {
|
|
71
|
+
const child = spawn('codex', ['app-server', '--stdio'], { stdio: ['pipe', 'pipe', 'ignore'] });
|
|
72
|
+
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
73
|
+
const finish = (snapshot) => { lines.close(); child.kill(); resolve(snapshot); };
|
|
74
|
+
const timer = setTimeout(() => finish({ available: false, generatedAt, fiveHour: null, weekly: null, reason: 'Codex app-server rate-limit request timed out.' }), 10_000);
|
|
75
|
+
lines.on('line', line => {
|
|
76
|
+
try {
|
|
77
|
+
const message = JSON.parse(line);
|
|
78
|
+
if (message.id !== 2)
|
|
79
|
+
return;
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
const parse = (window) => {
|
|
82
|
+
if (!window)
|
|
83
|
+
return null;
|
|
84
|
+
const usedPercent = finitePercent(window.usedPercent);
|
|
85
|
+
const windowMinutes = positiveMinutes(window.windowDurationMins);
|
|
86
|
+
return usedPercent === null || windowMinutes === null ? null : { usedPercent, windowMinutes, resetsAt: epochSeconds(window.resetsAt) };
|
|
87
|
+
};
|
|
88
|
+
const limits = message.result?.rateLimits;
|
|
89
|
+
const primary = parse(limits?.primary);
|
|
90
|
+
const secondary = parse(limits?.secondary);
|
|
91
|
+
const windows = [primary, secondary].filter((window) => window !== null);
|
|
92
|
+
const fiveHour = windows.find(window => window.windowMinutes === 300) ?? null;
|
|
93
|
+
const weekly = windows.find(window => window.windowMinutes === 10_080) ?? null;
|
|
94
|
+
const available = fiveHour !== null && weekly !== null;
|
|
95
|
+
finish({ available, generatedAt, fiveHour, weekly, reason: available ? null : 'Codex app-server did not return both account quota windows.' });
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
child.once('error', () => { clearTimeout(timer); finish({ available: false, generatedAt, fiveHour: null, weekly: null, reason: 'Codex app-server could not be started.' }); });
|
|
102
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientInfo: { name: 'agents-relay', version: '1' }, capabilities: {} } }) + '\n');
|
|
103
|
+
setTimeout(() => child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'account/rateLimits/read', params: {} }) + '\n'), 100);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export class CodexSessionUsageReader {
|
|
108
|
+
codexHome;
|
|
109
|
+
lookbackDays;
|
|
110
|
+
cacheTtlMs;
|
|
111
|
+
now;
|
|
112
|
+
cache = null;
|
|
113
|
+
constructor(codexHome = join(homedir(), '.codex'), lookbackDays = 8, cacheTtlMs = 10_000, now = () => new Date()) {
|
|
114
|
+
this.codexHome = codexHome;
|
|
115
|
+
this.lookbackDays = lookbackDays;
|
|
116
|
+
this.cacheTtlMs = cacheTtlMs;
|
|
117
|
+
this.now = now;
|
|
118
|
+
}
|
|
119
|
+
async usageFiles(cutoff) {
|
|
120
|
+
const files = [];
|
|
121
|
+
for (const root of [join(this.codexHome, 'sessions'), join(this.codexHome, 'archived_sessions')]) {
|
|
122
|
+
await this.collectFiles(root, cutoff, files, 0);
|
|
123
|
+
}
|
|
124
|
+
return files.sort((left, right) => right.modifiedAt.getTime() - left.modifiedAt.getTime());
|
|
125
|
+
}
|
|
126
|
+
async collectFiles(directory, cutoff, files, depth) {
|
|
127
|
+
if (depth > 5)
|
|
128
|
+
return;
|
|
129
|
+
let entries;
|
|
130
|
+
try {
|
|
131
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
for (const entry of entries) {
|
|
137
|
+
const path = join(directory, entry.name);
|
|
138
|
+
if (entry.isDirectory()) {
|
|
139
|
+
await this.collectFiles(path, cutoff, files, depth + 1);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
|
|
143
|
+
continue;
|
|
144
|
+
try {
|
|
145
|
+
const metadata = await stat(path);
|
|
146
|
+
if (metadata.mtime >= cutoff)
|
|
147
|
+
files.push({ path, modifiedAt: metadata.mtime });
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async read() {
|
|
155
|
+
const now = this.now();
|
|
156
|
+
if (this.cache && now.getTime() - this.cache.cachedAt < this.cacheTtlMs)
|
|
157
|
+
return this.cache.snapshot;
|
|
158
|
+
const cutoff = new Date(now.getTime() - this.lookbackDays * 86_400_000);
|
|
159
|
+
const files = await this.usageFiles(cutoff);
|
|
160
|
+
let latestTimestamp = '';
|
|
161
|
+
let latestWindows = [];
|
|
162
|
+
for (const file of files) {
|
|
163
|
+
const stream = createReadStream(file.path, { encoding: 'utf8' });
|
|
164
|
+
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
165
|
+
try {
|
|
166
|
+
for await (const line of lines) {
|
|
167
|
+
if (!line.includes('"rate_limits"'))
|
|
168
|
+
continue;
|
|
169
|
+
const telemetry = rateLimitsFromLine(line);
|
|
170
|
+
if (telemetry && telemetry.timestamp > latestTimestamp) {
|
|
171
|
+
latestTimestamp = telemetry.timestamp;
|
|
172
|
+
latestWindows = telemetry.windows;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
lines.close();
|
|
178
|
+
stream.destroy();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const fiveHour = latestWindows.find(window => window.windowMinutes === 300) ?? null;
|
|
182
|
+
const weekly = latestWindows.find(window => window.windowMinutes === 10_080) ?? null;
|
|
183
|
+
const telemetryAgeMs = latestTimestamp ? now.getTime() - Date.parse(latestTimestamp) : Number.POSITIVE_INFINITY;
|
|
184
|
+
const fresh = telemetryAgeMs >= 0 && telemetryAgeMs <= 15 * 60_000;
|
|
185
|
+
const available = fresh && fiveHour !== null && weekly !== null;
|
|
186
|
+
const snapshot = {
|
|
187
|
+
available,
|
|
188
|
+
generatedAt: now.toISOString(),
|
|
189
|
+
fiveHour,
|
|
190
|
+
weekly,
|
|
191
|
+
reason: available
|
|
192
|
+
? null
|
|
193
|
+
: latestTimestamp && !fresh
|
|
194
|
+
? 'Latest Codex account rate-limit telemetry is stale.'
|
|
195
|
+
: files.length
|
|
196
|
+
? 'Latest Codex account telemetry does not expose current 5-hour and weekly rate-limit windows.'
|
|
197
|
+
: 'No recent Codex account rate-limit telemetry is available.',
|
|
198
|
+
};
|
|
199
|
+
this.cache = { cachedAt: now.getTime(), snapshot };
|
|
200
|
+
return snapshot;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
export class ZaiUsageReader {
|
|
204
|
+
apiKey;
|
|
205
|
+
endpoint;
|
|
206
|
+
fetchImpl;
|
|
207
|
+
now;
|
|
208
|
+
constructor(apiKey = process.env.ZAI_API_KEY ?? '', endpoint = 'https://api.z.ai/api/monitor/usage/quota/limit', fetchImpl = fetch, now = () => new Date()) {
|
|
209
|
+
this.apiKey = apiKey;
|
|
210
|
+
this.endpoint = endpoint;
|
|
211
|
+
this.fetchImpl = fetchImpl;
|
|
212
|
+
this.now = now;
|
|
213
|
+
}
|
|
214
|
+
async read() {
|
|
215
|
+
const generatedAt = this.now().toISOString();
|
|
216
|
+
if (!this.apiKey) {
|
|
217
|
+
return { available: false, generatedAt, fiveHour: null, weekly: null, reason: 'ZAI_API_KEY is not configured.' };
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
const response = await this.fetchImpl(this.endpoint, {
|
|
221
|
+
headers: { Authorization: `Bearer ${this.apiKey}`, Accept: 'application/json' },
|
|
222
|
+
});
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
return { available: false, generatedAt, fiveHour: null, weekly: null, reason: `Z.ai usage request failed with HTTP ${response.status}.` };
|
|
225
|
+
}
|
|
226
|
+
const payload = await response.json();
|
|
227
|
+
const limits = Array.isArray(payload?.data?.limits) ? payload.data.limits : [];
|
|
228
|
+
const readLimit = (unit, windowMinutes) => {
|
|
229
|
+
const raw = limits.find(limit => (limit.type === 'TOKENS_LIMIT' || limit.type === 'CREDIT_LIMIT') && limit.unit === unit);
|
|
230
|
+
if (!raw)
|
|
231
|
+
return null;
|
|
232
|
+
const usedPercent = finitePercent(raw.percentage);
|
|
233
|
+
if (usedPercent === null)
|
|
234
|
+
return null;
|
|
235
|
+
const resetMs = typeof raw.nextResetTime === 'number' && Number.isFinite(raw.nextResetTime) ? raw.nextResetTime : null;
|
|
236
|
+
return { usedPercent, windowMinutes, resetsAt: resetMs === null ? null : Math.floor(resetMs / 1000) };
|
|
237
|
+
};
|
|
238
|
+
const fiveHour = readLimit(3, 300);
|
|
239
|
+
const weekly = readLimit(6, 10_080);
|
|
240
|
+
const available = fiveHour !== null && weekly !== null;
|
|
241
|
+
return {
|
|
242
|
+
available,
|
|
243
|
+
generatedAt,
|
|
244
|
+
fiveHour,
|
|
245
|
+
weekly,
|
|
246
|
+
reason: available ? null : 'Z.ai returned no usable 5-hour and weekly Coding Plan quota windows.',
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return { available: false, generatedAt, fiveHour: null, weekly: null, reason: 'Z.ai account usage request could not be completed.' };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
export class DeepSeekCreditReader {
|
|
255
|
+
apiKey;
|
|
256
|
+
endpoint;
|
|
257
|
+
fetchImpl;
|
|
258
|
+
now;
|
|
259
|
+
constructor(apiKey = process.env.DEEPSEEK_API_KEY ?? '', endpoint = 'https://api.deepseek.com/user/balance', fetchImpl = fetch, now = () => new Date()) {
|
|
260
|
+
this.apiKey = apiKey;
|
|
261
|
+
this.endpoint = endpoint;
|
|
262
|
+
this.fetchImpl = fetchImpl;
|
|
263
|
+
this.now = now;
|
|
264
|
+
}
|
|
265
|
+
async read() {
|
|
266
|
+
const generatedAt = this.now().toISOString();
|
|
267
|
+
if (!this.apiKey)
|
|
268
|
+
return { available: false, generatedAt, balances: [], reason: 'DEEPSEEK_API_KEY is not configured.' };
|
|
269
|
+
try {
|
|
270
|
+
const response = await this.fetchImpl(this.endpoint, { headers: { Authorization: `Bearer ${this.apiKey}`, Accept: 'application/json' } });
|
|
271
|
+
if (!response.ok)
|
|
272
|
+
return { available: false, generatedAt, balances: [], reason: `DeepSeek balance request failed with HTTP ${response.status}.` };
|
|
273
|
+
const payload = await response.json();
|
|
274
|
+
const balances = (Array.isArray(payload.balance_infos) ? payload.balance_infos : []).map(info => ({
|
|
275
|
+
currency: typeof info.currency === 'string' ? info.currency : 'credit',
|
|
276
|
+
total: finiteNumber(info.total_balance),
|
|
277
|
+
used: null,
|
|
278
|
+
remaining: finiteNumber(info.total_balance),
|
|
279
|
+
})).filter(balance => balance.remaining !== null);
|
|
280
|
+
return { available: payload.is_available === true && balances.length > 0, generatedAt, balances, reason: balances.length ? null : 'DeepSeek returned no usable balance information.' };
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return { available: false, generatedAt, balances: [], reason: 'DeepSeek balance request could not be completed.' };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
export class OpenRouterCreditReader {
|
|
288
|
+
apiKey;
|
|
289
|
+
endpoint;
|
|
290
|
+
fetchImpl;
|
|
291
|
+
now;
|
|
292
|
+
constructor(apiKey = process.env.OPENROUTER_API_KEY ?? '', endpoint = 'https://openrouter.ai/api/v1/credits', fetchImpl = fetch, now = () => new Date()) {
|
|
293
|
+
this.apiKey = apiKey;
|
|
294
|
+
this.endpoint = endpoint;
|
|
295
|
+
this.fetchImpl = fetchImpl;
|
|
296
|
+
this.now = now;
|
|
297
|
+
}
|
|
298
|
+
async read() {
|
|
299
|
+
const generatedAt = this.now().toISOString();
|
|
300
|
+
if (!this.apiKey)
|
|
301
|
+
return { available: false, generatedAt, balances: [], reason: 'OPENROUTER_API_KEY is not configured.' };
|
|
302
|
+
try {
|
|
303
|
+
const response = await this.fetchImpl(this.endpoint, { headers: { Authorization: `Bearer ${this.apiKey}`, Accept: 'application/json' } });
|
|
304
|
+
if (!response.ok)
|
|
305
|
+
return { available: false, generatedAt, balances: [], reason: `OpenRouter credits request failed with HTTP ${response.status}; this endpoint requires a management key.` };
|
|
306
|
+
const payload = await response.json();
|
|
307
|
+
const total = finiteNumber(payload.data?.total_credits);
|
|
308
|
+
const used = finiteNumber(payload.data?.total_usage);
|
|
309
|
+
const remaining = total !== null && used !== null ? total - used : null;
|
|
310
|
+
const balances = total === null && used === null ? [] : [{ currency: 'USD', total, used, remaining }];
|
|
311
|
+
return { available: balances.length > 0, generatedAt, balances, reason: balances.length ? null : 'OpenRouter returned no usable credit information.' };
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
return { available: false, generatedAt, balances: [], reason: 'OpenRouter credits request could not be completed.' };
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
export class UsageRegistry {
|
|
319
|
+
openaiReader;
|
|
320
|
+
zaiReader;
|
|
321
|
+
deepSeekReader;
|
|
322
|
+
openRouterReader;
|
|
323
|
+
constructor(openaiReader, zaiReader = new ZaiUsageReader(), deepSeekReader = null, openRouterReader = null) {
|
|
324
|
+
this.openaiReader = openaiReader;
|
|
325
|
+
this.zaiReader = zaiReader;
|
|
326
|
+
this.deepSeekReader = deepSeekReader;
|
|
327
|
+
this.openRouterReader = openRouterReader;
|
|
328
|
+
}
|
|
329
|
+
async snapshot() {
|
|
330
|
+
const [openai, zai, deepseek, openrouter] = await Promise.all([
|
|
331
|
+
this.openaiReader.read(),
|
|
332
|
+
this.zaiReader.read(),
|
|
333
|
+
this.deepSeekReader?.read() ?? Promise.resolve(null),
|
|
334
|
+
this.openRouterReader?.read() ?? Promise.resolve(null),
|
|
335
|
+
]);
|
|
336
|
+
const credits = [];
|
|
337
|
+
if (deepseek)
|
|
338
|
+
credits.push({ id: 'deepseek', label: 'DeepSeek', credits: deepseek });
|
|
339
|
+
if (openrouter)
|
|
340
|
+
credits.push({ id: 'openrouter', label: 'OpenRouter', credits: openrouter });
|
|
341
|
+
return {
|
|
342
|
+
generatedAt: new Date().toISOString(),
|
|
343
|
+
providers: [
|
|
344
|
+
{ id: 'openai', label: 'OpenAI', usage: openai },
|
|
345
|
+
{ id: 'zai', label: 'Z.ai', usage: zai },
|
|
346
|
+
],
|
|
347
|
+
credits,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
export class UnavailableUsageReader {
|
|
352
|
+
reason;
|
|
353
|
+
now;
|
|
354
|
+
constructor(reason, now = () => new Date()) {
|
|
355
|
+
this.reason = reason;
|
|
356
|
+
this.now = now;
|
|
357
|
+
}
|
|
358
|
+
async read() {
|
|
359
|
+
return {
|
|
360
|
+
available: false,
|
|
361
|
+
generatedAt: this.now().toISOString(),
|
|
362
|
+
fiveHour: null,
|
|
363
|
+
weekly: null,
|
|
364
|
+
reason: this.reason,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
export function codexAndZaiUsageRegistry(codexHome, _legacyWindowDays) {
|
|
369
|
+
return new UsageRegistry(new CodexApiUsageReader(), new ZaiUsageReader(), process.env.DEEPSEEK_API_KEY ? new DeepSeekCreditReader() : null, process.env.OPENROUTER_API_KEY ? new OpenRouterCreditReader() : null);
|
|
370
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
export function defaultWorkspaceRoot() {
|
|
8
|
+
return join(homedir(), 'Workspace');
|
|
9
|
+
}
|
|
10
|
+
export function githubRepositoryFromRemote(remote) {
|
|
11
|
+
const value = remote.trim().replace(/\.git$/, '');
|
|
12
|
+
const match = value.match(/github\.com[/:]([^/]+)\/([^/]+)$/i);
|
|
13
|
+
return match ? `${match[1]}/${match[2]}` : null;
|
|
14
|
+
}
|
|
15
|
+
async function hasGitDirectory(path) {
|
|
16
|
+
try {
|
|
17
|
+
await stat(join(path, '.git'));
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function discoverGitRepositories(root) {
|
|
25
|
+
const found = [];
|
|
26
|
+
const visit = async (directory) => {
|
|
27
|
+
if (await hasGitDirectory(directory)) {
|
|
28
|
+
found.push(directory);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
let entries;
|
|
32
|
+
try {
|
|
33
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
await Promise.all(entries.filter(entry => entry.isDirectory() && !['node_modules', '.git', '.worktrees', '.backup'].includes(entry.name)).map(entry => visit(join(directory, entry.name))));
|
|
39
|
+
};
|
|
40
|
+
await visit(resolve(root));
|
|
41
|
+
return found.sort((a, b) => relative(root, a).localeCompare(relative(root, b)));
|
|
42
|
+
}
|
|
43
|
+
async function origin(path) {
|
|
44
|
+
try {
|
|
45
|
+
return (await execFileAsync('git', ['-C', path, 'remote', 'get-url', 'origin'])).stdout.trim() || null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export async function discoverWorkspaceRepositories(root) {
|
|
52
|
+
const paths = await discoverGitRepositories(root);
|
|
53
|
+
const discovered = await Promise.all(paths.map(async (path) => {
|
|
54
|
+
const repository = githubRepositoryFromRemote(await origin(path) ?? '');
|
|
55
|
+
return repository ? { path, repository } : null;
|
|
56
|
+
}));
|
|
57
|
+
const unique = new Map();
|
|
58
|
+
for (const item of discovered) {
|
|
59
|
+
if (!item)
|
|
60
|
+
continue;
|
|
61
|
+
const current = unique.get(item.repository);
|
|
62
|
+
if (!current || repositoryPathRank(item.path, root) < repositoryPathRank(current.path, root))
|
|
63
|
+
unique.set(item.repository, item);
|
|
64
|
+
}
|
|
65
|
+
return [...unique.values()].sort((a, b) => a.repository.localeCompare(b.repository));
|
|
66
|
+
}
|
|
67
|
+
function repositoryPathRank(path, root) {
|
|
68
|
+
const relativePath = relative(root, path);
|
|
69
|
+
if (!relativePath.includes('/'))
|
|
70
|
+
return 0;
|
|
71
|
+
if (relativePath.startsWith('.worktrees/'))
|
|
72
|
+
return 2;
|
|
73
|
+
if (relativePath.startsWith('.backup/'))
|
|
74
|
+
return 3;
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Agent Network enhancement
|
|
2
|
+
|
|
3
|
+
## Objective
|
|
4
|
+
|
|
5
|
+
Evolve Agents Relay from task adapters into a minimal network of independently registered, result-owning agents while retaining GitHub PR state as durable truth and preserving shell/Codex/ChatGPT worker support.
|
|
6
|
+
|
|
7
|
+
## Scope shipped
|
|
8
|
+
|
|
9
|
+
- Typed agent registration distinguishes identity, role/responsibility, boundaries, claimed capabilities, endpoint/runtime, availability, and routing metadata.
|
|
10
|
+
- Trusted `agents-relay:agent:v1` markers persist registrations in the existing GitHub PR comment store; no second database is introduced.
|
|
11
|
+
- `agent-register` and `agent-discover` are machine-driven CLI/core surfaces.
|
|
12
|
+
- Discovery applies hard requirements first, then returns explainable evidence and a small policy ranking with optional exploration of unobserved agents.
|
|
13
|
+
- Evidence models representative evaluations, historical outcomes, reliability inputs, latency, and cost without fabricating a universal score.
|
|
14
|
+
- Documentation defines the single-orchestrator UX, durable task/lease/retry/reconciliation model, and a narrow future authenticated remote job/status contract.
|
|
15
|
+
|
|
16
|
+
## Non-goals
|
|
17
|
+
|
|
18
|
+
This PR does not build a public marketplace, broad multi-owner control plane, unauthenticated HTTP endpoint, private-data/credential routing, isolation/billing/trust enforcement, or automatic final agent selection. Adapters remain execution runtimes; events remain live observability and never durable truth.
|
|
19
|
+
|
|
20
|
+
## Acceptance criteria
|
|
21
|
+
|
|
22
|
+
- Existing task, scheduler, lease/recovery, continuation, dashboard, and ChatGPT adapter behavior remains green.
|
|
23
|
+
- Agents can be registered and discovered from the same durable PR-backed store.
|
|
24
|
+
- Discovery rejects unmet capabilities/runtime/trust/availability requirements and exposes evidence rather than an opaque score.
|
|
25
|
+
- Registration and discovery work without a human web form.
|
|
26
|
+
- The future remote surface is explicitly limited to authenticated create/submit and status operations, with intention-only/public jobs separated from private-data jobs.
|
|
27
|
+
|
|
28
|
+
## Validation
|
|
29
|
+
|
|
30
|
+
Run `npm test`, `npm run build`, and `git diff --check`. The implementation adds registry/discovery tests and store round-trip/forgery tests while retaining the existing test suite.
|
|
31
|
+
|
|
32
|
+
## Future direction
|
|
33
|
+
|
|
34
|
+
Add authenticated remote submission only when the host has a concrete identity and trust boundary. Then add durable remote task leases/heartbeats and recovery integration, richer evidence aggregation, policy-pluggable matching, and security/isolation gates for private jobs. Keep GitHub PR markers authoritative throughout.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Agents Relay v1 architecture
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Provide a generic, shareable async agent job system where agents can submit durable child tasks, specialized workers execute them asynchronously, results are persisted, and the parent agent can continue when dependencies become ready.
|
|
6
|
+
|
|
7
|
+
## Core model
|
|
8
|
+
|
|
9
|
+
- **GitHub pull request** — durable top-level job container and audit surface.
|
|
10
|
+
- **Job marker comment** — durable job metadata and continuation state.
|
|
11
|
+
- **Task marker comments** — append-friendly durable child-task records.
|
|
12
|
+
- **Agents Relay daemon** — reconciler, scheduler, dispatcher, lease/recovery manager, continuation engine, and dashboard server.
|
|
13
|
+
- **Agent adapters** — pluggable execution backends such as Codex and shell commands.
|
|
14
|
+
- **Event adapter** — optional low-latency notification/wake channel; events never replace durable PR state.
|
|
15
|
+
- **Periodic watchdog** — low-frequency recovery sweep that re-reads durable PR/job state to repair missed wakeups and recover stale execution state (30 minutes with webhook mode by default; 5 minutes without webhooks for compatibility).
|
|
16
|
+
- **Artifact references** — large/private outputs stay outside GitHub comments; task records store references.
|
|
17
|
+
|
|
18
|
+
## Agent network slice
|
|
19
|
+
|
|
20
|
+
An agent is a registered, result-owning worker/social role with a responsibility boundary. It is not the same thing as a skill (a callable capability) or an execution adapter (shell, Codex, or ChatGPT). Registration is machine-driven through the CLI/core API and is stored as a trusted `agents-relay:agent:v1` marker comment on the same PR.
|
|
21
|
+
|
|
22
|
+
Registration records identity/name, role and responsibility, claimed capabilities, explicit boundaries/non-goals, endpoint/runtime, availability, and routing metadata. `agent-register` and `agent-discover` provide the smallest useful registry surface. Discovery applies hard filters first and returns observed evidence and reasons rather than a universal score. Evidence can include representative evaluations, outcomes, reliability, latency, and cost. `--explore` allows new/unobserved agents to be considered first; the orchestrator still owns the final choice.
|
|
23
|
+
|
|
24
|
+
The registry does not yet execute remote agents or expose a public marketplace. A constrained ChatGPT/voice/mobile runtime should eventually use a small authenticated service surface with only `POST /jobs` (intention-only/public jobs) and `GET /jobs/:id`; credentials and private payloads must stay out of PR markers and events. Private-data/credential jobs, multi-owner trust, isolation, billing, and public discovery are future security-gated work.
|
|
25
|
+
|
|
26
|
+
## Execution modes
|
|
27
|
+
|
|
28
|
+
Jobs persist `executionMode` as `fixed` or `autonomous`; missing mode in legacy
|
|
29
|
+
markers is read as `fixed`. Fixed is the default and keeps the v1 submit flow
|
|
30
|
+
unchanged. Autonomous jobs use the existing task records and parent hierarchy:
|
|
31
|
+
when all currently runnable work is settled and no planner task is active,
|
|
32
|
+
reconciliation appends one durable `kind: "planner"` task. The planner returns
|
|
33
|
+
typed `objective_status`, `assessment`, and `next_tasks` values. Each
|
|
34
|
+
`next_tasks` item becomes a normal child task of that planner task.
|
|
35
|
+
|
|
36
|
+
Stable task IDs and append-if-same-definition behavior make planner retries
|
|
37
|
+
idempotent. Leases, owner recovery, and completion decisions remain in the
|
|
38
|
+
reconciler; planner output never directly marks a job complete.
|
|
39
|
+
|
|
40
|
+
## Reconciliation rule
|
|
41
|
+
|
|
42
|
+
Every wake-up means only: "the durable state may have changed." The daemon reloads the PR and task records, derives desired actions, and reconciles actual execution state. It never advances a job solely because an event claimed something happened. Event-driven and periodic reconciles are single-flight within one process; a periodic sweep may create the next autonomous planner task only from durable settled state.
|
|
43
|
+
|
|
44
|
+
## State
|
|
45
|
+
|
|
46
|
+
Job states:
|
|
47
|
+
|
|
48
|
+
OPEN -> RUNNING -> WAITING|BLOCKED -> COMPLETED|FAILED|CANCELLED
|
|
49
|
+
|
|
50
|
+
Task states:
|
|
51
|
+
|
|
52
|
+
QUEUED -> READY -> RUNNING -> WAITING|BLOCKED -> SUCCEEDED|FAILED|CANCELLED
|
|
53
|
+
|
|
54
|
+
A task becomes READY when all declared dependencies have succeeded and any approval gate is satisfied. Autonomous completion additionally requires a durable planner result of `satisfied`, every planner/work task to be `SUCCEEDED`, and no unresolved failure or blocked task.
|
|
55
|
+
|
|
56
|
+
## Task record
|
|
57
|
+
|
|
58
|
+
Each task is one GitHub PR comment containing a human-readable summary plus a hidden `agents-relay:task:v1` JSON marker. Minimum durable fields:
|
|
59
|
+
|
|
60
|
+
- task id and parent task id
|
|
61
|
+
- dependencies
|
|
62
|
+
- capabilities
|
|
63
|
+
- adapter + execution input
|
|
64
|
+
- routing decision for model-backed workers
|
|
65
|
+
- state + attempt
|
|
66
|
+
- lease owner and expiry
|
|
67
|
+
- execution id/thread id
|
|
68
|
+
- result summary and artifact references
|
|
69
|
+
- timestamps
|
|
70
|
+
|
|
71
|
+
Task submission is append-only at creation time, which avoids a single giant mutable manifest and makes agent-to-agent delegation natural.
|
|
72
|
+
|
|
73
|
+
## Execution
|
|
74
|
+
|
|
75
|
+
1. An agent submits a task to the PR.
|
|
76
|
+
2. A task comment is created durably.
|
|
77
|
+
3. The relay wakes and reloads the PR.
|
|
78
|
+
4. Scheduler marks dependency-satisfied tasks ready.
|
|
79
|
+
5. Dispatcher validates routing/capabilities and claims a lease.
|
|
80
|
+
6. Adapter launches the worker.
|
|
81
|
+
7. Worker reports progress over the optional event channel.
|
|
82
|
+
8. Relay persists terminal task state/result back into the task comment.
|
|
83
|
+
9. Relay reconciles dependents and/or resumes the parent continuation.
|
|
84
|
+
10. Periodic watchdog reconciliation only repairs missed wake-ups and expired leases; webhook mode uses targeted reconciliation for the normal path and cached dashboard reads do not trigger broad discovery.
|
|
85
|
+
|
|
86
|
+
## Continuations
|
|
87
|
+
|
|
88
|
+
Continuations are adapters, separate from worker adapters. v1 supports Codex-thread continuation plus a generic command/webhook contract. Hosted web chat continuations may rely on their host integration; their durable state remains recoverable from the PR.
|
|
89
|
+
|
|
90
|
+
## v1 acceptance criteria
|
|
91
|
+
|
|
92
|
+
1. A reusable TypeScript package/CLI named `agents-relay`.
|
|
93
|
+
2. GitHub PR/comment durable store with job/task marker parsing and updates.
|
|
94
|
+
3. CLI to initialize a PR job, submit child tasks, inspect status, reconcile, retry/cancel tasks, and run the daemon.
|
|
95
|
+
4. Dependency scheduling and nested parent/child lineage.
|
|
96
|
+
5. Worker lease, expiry detection, recovery, retry, timeout, and cancellation semantics.
|
|
97
|
+
6. At least Codex and shell worker adapters behind one adapter interface.
|
|
98
|
+
7. Routing metadata is mandatory before a model-backed worker can launch.
|
|
99
|
+
8. Optional event integration for wake-ups/progress with durable-state reconciliation.
|
|
100
|
+
9. Parent continuation interface with Codex-thread continuation implemented.
|
|
101
|
+
10. Dashboard web page showing jobs, task tree/state, worker/model, dependencies, results, leases, and live events/status.
|
|
102
|
+
11. SSE or equivalent local live dashboard updates.
|
|
103
|
+
12. Tests for marker parsing, state transitions, scheduling, reconciliation/recovery, and API/dashboard data.
|
|
104
|
+
13. `skills/agents-relay/SKILL.md` plus supporting agent files describing how an agent submits async work, propagates lineage, consumes results, and treats events vs durable state.
|
|
105
|
+
14. Documentation and a runnable local dogfood example.
|
|
106
|
+
15. Browser-level verification of the dashboard before merge.
|
|
107
|
+
|
|
108
|
+
## Non-goals for v1
|
|
109
|
+
|
|
110
|
+
- Reimplement GitHub as a database.
|
|
111
|
+
- Build a general workflow-language/DAG editor.
|
|
112
|
+
- Require Redis, Postgres, Temporal, or Kubernetes.
|
|
113
|
+
- Make NATS mandatory.
|
|
114
|
+
- Implement a hosted multi-tenant control plane.
|
|
115
|
+
- Encode domain-specific agent roles in the core runtime.
|
|
116
|
+
- Build a broad public agent marketplace or unauthenticated remote HTTP API.
|
|
117
|
+
|
|
118
|
+
## Dogfood
|
|
119
|
+
|
|
120
|
+
This PR is the first top-level Agents Relay job. Development, review, fixes, and verification remain on this PR until the v1 acceptance criteria are met.
|