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/cli.ts ADDED
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { realpathSync } from 'node:fs';
4
+ import { once } from 'node:events';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { dashboardManagedJobs, InMemoryStore, DurableStore, GitHubClient, GitHubStore, PullRequest, resolvePullRequest, listManagedGitHubJobs } from './store.js';
8
+ import { githubAuthContext } from './github-auth.js';
9
+ import { EventRecord, Job, Task, AdapterName, RoutingDecision, ExecutionMode } from './types.js';
10
+ import { Reconciler } from './reconciler.js';
11
+ import { ShellAdapter, CodexAdapter, ChatGptAdapter } from './adapters.js';
12
+ import { CodexThreadContinuation, CommandContinuation, WebhookContinuation } from './continuation.js';
13
+ import { NatsEventBus, eventFor, EventBus } from './events.js';
14
+ import { serveDashboard } from './dashboard.js';
15
+ import { codexAndZaiUsageRegistry } from './usage.js';
16
+ import { Server } from 'node:http';
17
+ import { AgentRegistration, Priority } from './types.js';
18
+ import { discoverAgents } from './registry.js';
19
+ import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
20
+ import { extendRetryBudget } from './scheduler.js';
21
+ const usage = 'agents-relay <job|init|submit|record|agent-register|agent-discover|status|reconcile|retry|cancel|serve> [options]';
22
+ const help = {
23
+ root: `Agents Relay coordinates durable asynchronous agent jobs through GitHub pull requests.
24
+
25
+ Usage:
26
+ npx agents-relay <command> [options]
27
+
28
+ Commands:
29
+ job create|adopt|repair Create or repair a durable GitHub-backed job
30
+ init Create a local demo job or initialize a GitHub job
31
+ submit Queue a task for a managed job
32
+ record Backfill a completed task into durable history
33
+ agent-register Register an agent role and its capabilities
34
+ agent-discover Find registered agents matching hard filters
35
+ status Show the durable job state
36
+ reconcile Run reconciliation once
37
+ retry|cancel Change a task state
38
+ serve Run the dashboard and reconciliation service
39
+
40
+ Run 'npx agents-relay <command> --help' for command options and examples.
41
+ Use 'node dist/cli.js' only for explicit local development/demo mode.`,
42
+ job: `Manage a GitHub-backed durable job.
43
+
44
+ Usage:
45
+ npx agents-relay job <create|adopt|repair> [options]
46
+
47
+ Actions:
48
+ create Find or create an open PR, then persist its job marker
49
+ adopt Attach a job marker to an existing PR
50
+ repair Repair duplicate markers for the same job
51
+
52
+ Common options:
53
+ --repo OWNER/REPO GitHub repository (required)
54
+ --id JOB_ID Durable job identifier (required)
55
+ --pr NUMBER Existing PR number (required for adopt/repair)
56
+ --title TEXT Job title
57
+ --objective TEXT Objective for autonomous mode
58
+ --mode fixed|autonomous Execution mode (default: fixed)
59
+
60
+ Example:
61
+ npx agents-relay job create --repo OWNER/REPO --head feat/example --base main --id job-1`,
62
+ 'job create': `Create or reuse an open PR and persist one durable job marker.
63
+
64
+ Required: --repo OWNER/REPO, --id JOB_ID, and --head BRANCH.
65
+ Common: --base BRANCH (default: main), --title TEXT, --body TEXT, --mode fixed|autonomous.
66
+
67
+ Example:
68
+ npx agents-relay job create --repo OWNER/REPO --head feat/example --base main --id job-1 --title "Objective"`,
69
+ 'job adopt': `Adopt an existing PR as a durable job.
70
+
71
+ Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
72
+ Common: --title TEXT, --objective TEXT, --mode fixed|autonomous.
73
+
74
+ Example:
75
+ npx agents-relay job adopt --repo OWNER/REPO --pr 12 --id job-12 --title "Objective"`,
76
+ 'job repair': `Repair duplicate durable markers for an existing job.
77
+
78
+ Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
79
+
80
+ Example:
81
+ npx agents-relay job repair --repo OWNER/REPO --pr 12 --id job-12`,
82
+ submit: `Queue a task for a managed job.
83
+
84
+ Required: --repo OWNER/REPO, --pr NUMBER, --id JOB_ID, and --input TEXT.
85
+ Common: --task-id ID, --adapter shell|codex|chatgpt, --priority P0|P1|P2|P3, --max-attempts N.
86
+
87
+ Example:
88
+ npx agents-relay submit --repo OWNER/REPO --pr 12 --id job-1 --task-id build --input "npm test"`,
89
+ record: `Backfill work completed outside the relay into durable history.
90
+
91
+ Required: --file PATH or --repo/--pr/--id, plus --summary TEXT.
92
+ Common: --task-id ID, --commit SHA, --project NAME.
93
+
94
+ Example:
95
+ npx agents-relay record --file .agents-relay.json --summary "Built release" --commit abc123`,
96
+ status: `Show the durable state for a job.
97
+
98
+ Required: --file PATH for local demo mode, or --repo OWNER/REPO --pr NUMBER --id JOB_ID.
99
+
100
+ Example:
101
+ npx agents-relay status --repo OWNER/REPO --pr 12 --id job-1`,
102
+ reconcile: `Reconcile a job once, launching ready tasks and persisting results.
103
+
104
+ Required: --file PATH for local demo mode, or --repo OWNER/REPO --pr NUMBER --id JOB_ID.
105
+
106
+ Example:
107
+ npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id job-1`,
108
+ init: `Initialize a new job. GitHub mode requires --repo and --pr; local mode requires explicit --file.
109
+
110
+ Common: --id ID, --title TEXT, --objective TEXT, --mode fixed|autonomous, --priority P0|P1|P2|P3.
111
+
112
+ Example:
113
+ npx agents-relay init --repo OWNER/REPO --pr 12 --id job-1 --title "Objective"`,
114
+ serve: `Run the dashboard and reconciliation service for a managed job.
115
+
116
+ Required: --file PATH for local demo mode, or --repo OWNER/REPO --pr NUMBER --id JOB_ID.
117
+ Common: --port PORT, --interval MS, --events nats.
118
+
119
+ Example:
120
+ npx agents-relay serve --repo OWNER/REPO --pr 12 --id job-1`,
121
+ 'agent-register': `Register an agent role and its execution endpoint.
122
+
123
+ Required: --file PATH or managed GitHub options, plus --name NAME --responsibility TEXT --role ROLE --endpoint TARGET.
124
+ Common: --capabilities a,b, --runtime shell|codex|chatgpt, --provider NAME, --model NAME.
125
+
126
+ Example:
127
+ npx agents-relay agent-register --file .agents-relay.json --name builder --responsibility "Build code" --role engineer --endpoint shell`,
128
+ 'agent-discover': `Find registered agents using hard filters.
129
+
130
+ Required: --file PATH or managed GitHub options.
131
+ Common: --capabilities a,b, --runtime NAME, --availability available,busy, --task-kind NAME, --explore.
132
+
133
+ Example:
134
+ npx agents-relay agent-discover --file .agents-relay.json --capabilities shell`,
135
+ retry: `Retry a task by setting it READY.
136
+
137
+ Required: --task-id ID and the normal job storage options.
138
+
139
+ Example:
140
+ npx agents-relay retry --repo OWNER/REPO --pr 12 --id job-1 --task-id build`,
141
+ cancel: `Cancel a task.
142
+
143
+ Required: --task-id ID and the normal job storage options.
144
+
145
+ Example:
146
+ npx agents-relay cancel --repo OWNER/REPO --pr 12 --id job-1 --task-id build`
147
+ } as const;
148
+
149
+ export function helpText(command?: string, action?: string): string {
150
+ if (command === 'job') return action ? help[`job ${action}` as keyof typeof help] ?? help.job : help.job;
151
+ return command ? help[command as keyof typeof help] ?? help.root : help.root;
152
+ }
153
+ export const SERVICE_DEFAULTS = { port: 8787, watchdogMs: 300000, webhookWatchdogMs: 1800000, dashboardRefreshMs: 300000 } as const;
154
+ class ServiceEventBus implements EventBus {
155
+ private readonly unsubscribers = new Set<() => Promise<void>>();
156
+ constructor(private readonly upstream: EventBus) {}
157
+ async publish(event: EventRecord): Promise<void> { await this.upstream.publish(event); }
158
+ async subscribe(jobId: string, wake: (event: EventRecord) => Promise<void>): Promise<() => Promise<void>> {
159
+ const unsubscribe = await this.upstream.subscribe(jobId, wake);
160
+ this.unsubscribers.add(unsubscribe);
161
+ return async (): Promise<void> => { this.unsubscribers.delete(unsubscribe); await unsubscribe(); };
162
+ }
163
+ async subscribeAll(wake: (event: EventRecord) => Promise<void>): Promise<() => Promise<void>> {
164
+ const unsubscribe = await this.upstream.subscribeAll(wake);
165
+ this.unsubscribers.add(unsubscribe);
166
+ return async (): Promise<void> => { this.unsubscribers.delete(unsubscribe); await unsubscribe(); };
167
+ }
168
+ async close(): Promise<void> {
169
+ const pending = [...this.unsubscribers].map(unsubscribe => unsubscribe());
170
+ this.unsubscribers.clear(); await Promise.all(pending);
171
+ }
172
+ }
173
+ function arg(args: string[], name: string, fallback = ''): string { const index = args.indexOf(name); return index >= 0 ? args[index + 1] ?? fallback : fallback; }
174
+ function repository(args: string[]): string { const value = arg(args, '--repo'); if (!value) throw new Error('--repo is required for GitHub-backed operation'); return value; }
175
+ function pullRequest(args: string[]): number { const value = Number(arg(args, '--pr')); if (!Number.isInteger(value) || value <= 0) throw new Error('--pr is required for GitHub-backed operation'); return value; }
176
+ async function localStore(file: string): Promise<{ store: InMemoryStore; job: Job }> { const parsed = JSON.parse(await readFile(file, 'utf8')) as Job; const store = new InMemoryStore(parsed); return { store, job: await store.load(parsed.id) }; }
177
+ async function githubStore(args: string[]): Promise<GitHubStore> { const auth = await githubAuthContext(args); return new GitHubStore(auth.client, repository(args), pullRequest(args), auth.trustedAuthors); }
178
+ async function storeFor(args: string[]): Promise<{ store: DurableStore; job: Job; localFile: string | null }> { const repo = arg(args, '--repo'); const pr = arg(args, '--pr'); if (repo && pr) { const store = await githubStore(args); const jobId = arg(args, '--id'); if (!jobId) throw new Error('--id is required for GitHub-backed operation'); return { store, job: await store.load(jobId), localFile: null }; } const file = arg(args, '--file'); if (!file) throw new Error('Local mode is demo/test only; pass --file explicitly'); const loaded = await localStore(file); return { store: loaded.store, job: loaded.job, localFile: file }; }
179
+ async function saveLocal(file: string | null, job: Job): Promise<void> { if (file) await writeFile(file, JSON.stringify(job, null, 2)); }
180
+ export function eventBus(args: string[]): EventBus | undefined { if (arg(args, '--events') !== 'nats') return undefined; return new NatsEventBus(arg(args, '--nats-url', 'nats://127.0.0.1:4222'), arg(args, '--subject-prefix', 'agents-relay.events.job')); }
181
+ async function publishWake(bus: EventBus | undefined, job: Job, taskId: string, message: string): Promise<void> { if (!bus) return; try { await bus.publish(eventFor(job.id, taskId, null, 'job.wake', 'queued', message, 'orchestrator', { repository: job.repository, prNumber: job.prNumber })); } catch (error) { process.stderr.write(`warning: event wake unavailable: ${error instanceof Error ? error.message : String(error)}\n`); } }
182
+ function priority(args: string[], fallback: Priority = 'P2'): Priority { const value = arg(args, '--priority', fallback); if (!['P0','P1','P2','P3'].includes(value)) throw new Error('--priority must be P0, P1, P2, or P3'); return value as Priority; }
183
+ export function executionMode(args: string[], fallback: ExecutionMode = 'fixed'): ExecutionMode { const value = arg(args, '--mode', arg(args, '--execution-mode', fallback)); if (value !== 'fixed' && value !== 'autonomous') throw new Error('--mode must be fixed or autonomous'); return value; }
184
+ function requestedExecutionMode(args: string[]): ExecutionMode | undefined { const value = arg(args, '--mode', arg(args, '--execution-mode')); return value ? executionMode(args) : undefined; }
185
+ function continuation(args: string[]): Job['continuation'] { const kind = arg(args, '--continue-kind'); const target = arg(args, '--continue-target'); return kind && target ? { kind: kind as NonNullable<Job['continuation']>['kind'], target, input: arg(args, '--continue-input') || undefined } : null; }
186
+
187
+ export async function ensureManagedGitHubJob(
188
+ client: GitHubClient,
189
+ repositoryName: string,
190
+ trustedAuthors: ReadonlySet<string>,
191
+ options: { id: string; title?: string; objective?: string; priority?: Priority; executionMode?: ExecutionMode; continuation?: Job['continuation']; prNumber?: number; head?: string; base?: string; body?: string }
192
+ ): Promise<{ job: Job; pr: PullRequest }> {
193
+ if (!options.id) throw new Error('--id is required for managed GitHub jobs');
194
+ const pr = await resolvePullRequest(client, repositoryName, { prNumber: options.prNumber, head: options.head, base: options.base, title: options.title, body: options.body });
195
+ const store = new GitHubStore(client, repositoryName, pr.number, trustedAuthors);
196
+ let job: Job;
197
+ try {
198
+ job = await store.load(options.id);
199
+ } catch (error) {
200
+ if (!(error instanceof Error) || !error.message.includes('has no durable')) throw error;
201
+ const now = new Date().toISOString();
202
+ job = { id: options.id, title: options.title || pr.title || 'Agents Relay job', objective: options.objective || pr.body || undefined, description: pr.body || undefined, priority: options.priority ?? 'P2', executionMode: options.executionMode ?? 'fixed', prNumber: pr.number, repository: repositoryName, state: 'OPEN', continuation: options.continuation ?? null, createdAt: now, updatedAt: now, tasks: [] };
203
+ }
204
+ if (!job.description && pr.body) job.description = pr.body;
205
+ if (options.objective) job.objective = options.objective;
206
+ if (options.executionMode) job.executionMode = options.executionMode;
207
+ if (options.priority) job.priority = options.priority;
208
+ else if (!job.priority) job.priority = 'P2';
209
+ if (!job.title && pr.title) job.title = pr.title;
210
+ await store.saveJob(job);
211
+ return { job: await store.load(options.id), pr };
212
+ }
213
+
214
+ async function runJobCommand(action: string, args: string[]): Promise<void> {
215
+ if (!['create', 'adopt', 'repair'].includes(action)) throw new Error(usage);
216
+ const repo = repository(args);
217
+ const id = arg(args, '--id');
218
+ if (!id) throw new Error('--id is required for managed GitHub jobs');
219
+ const auth = await githubAuthContext(args);
220
+ const client = auth.client;
221
+ const trustedAuthors = auth.trustedAuthors;
222
+ const prValue = arg(args, '--pr');
223
+ const prNumber = prValue ? Number(prValue) : undefined;
224
+ if (prValue && (!Number.isInteger(prNumber) || (prNumber ?? 0) <= 0)) throw new Error('--pr must be a positive integer');
225
+ if ((action === 'adopt' || action === 'repair') && !prNumber) throw new Error(`job ${action} requires --pr`);
226
+ const result = await ensureManagedGitHubJob(client, repo, trustedAuthors, {
227
+ id,
228
+ title: arg(args, '--title') || undefined,
229
+ objective: arg(args, '--objective') || undefined,
230
+ priority: priority(args),
231
+ executionMode: requestedExecutionMode(args),
232
+ continuation: continuation(args),
233
+ prNumber,
234
+ head: arg(args, '--head') || undefined,
235
+ base: arg(args, '--base', 'main'),
236
+ body: arg(args, '--body') || undefined
237
+ });
238
+ console.log(JSON.stringify({ repository: repo, pr: result.pr.number, job: result.job.id, executionMode: result.job.executionMode, state: result.job.state }, null, 2));
239
+ }
240
+
241
+ export function runtime(store: DurableStore, args: string[], bus: EventBus | undefined): Reconciler { const emit = (event: ReturnType<typeof eventFor>): void => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const plannerCommand = arg(args, '--planner-command'); const modelRuntime = arg(args, '--codex', 'codex'); const planner = plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); return new Reconciler(store, { owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new ShellAdapter(), new CodexAdapter(modelRuntime), new ChatGptAdapter({ endpoint: arg(args, '--macbridge-url') || undefined, tokenFile: arg(args, '--macbridge-token-file') || undefined })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, eventBus: bus, emit }); }
242
+ export interface Service { store: DurableStore; job: Job; reconciler: Reconciler; dashboardServer: Server; watchdogCleared: boolean; stop(): Promise<void>; }
243
+ export async function createService(args: string[]): Promise<Service> {
244
+ const loaded = await storeFor(args); const upstream = eventBus(args); const bus = upstream ? new ServiceEventBus(upstream) : undefined; const reconciler = runtime(loaded.store, args, bus);
245
+ const requestedUsageDays = Number(arg(args, '--usage-window-days', '1'));
246
+ const usageWindowDays = Number.isInteger(requestedUsageDays) && requestedUsageDays > 0 ? requestedUsageDays : 1;
247
+ const usageRegistry = codexAndZaiUsageRegistry(undefined, usageWindowDays);
248
+ const repo = arg(args, '--repo');
249
+ let overviewProvider: (() => Promise<Awaited<ReturnType<typeof listManagedGitHubJobs>>>) | undefined;
250
+ if (repo) {
251
+ const overviewAuth = await githubAuthContext(args);
252
+ overviewProvider = async () => dashboardManagedJobs(await listManagedGitHubJobs(overviewAuth.client, repo, overviewAuth.trustedAuthors));
253
+ }
254
+ const webhookSecret = arg(args, '--github-webhook-secret') || process.env.AGENTS_RELAY_GITHUB_WEBHOOK_SECRET || '';
255
+ const webhook = repo && webhookSecret ? { secret: webhookSecret, repositories: new Set([repo]), pullRequest: loaded.job.prNumber, wake: (event: import('./github-webhook.js').GitHubWebhookWake): void => { const wakeEvent = eventFor(loaded.job.id, 'github', null, 'github.webhook', 'queued', 'GitHub webhook ' + event.event + (event.action ? ':' + event.action : ''), 'orchestrator', { deliveryId: event.deliveryId, event: event.event, action: event.action, repository: event.repository, prNumber: event.pullRequest }); if (bus) void bus.publish(wakeEvent); else void reconciler.reconcile(loaded.job.id); } } : undefined;
256
+ const dashboardServer = serveDashboard(loaded.store, Number(arg(args, '--port', String(SERVICE_DEFAULTS.port))), bus, Number(arg(args, '--refresh-ms', String(SERVICE_DEFAULTS.dashboardRefreshMs))), loaded.job.id, usageRegistry, overviewProvider, webhook);
257
+ await once(dashboardServer, 'listening');
258
+ const stopWatcher = await reconciler.watch(loaded.job.id);
259
+ const watchdog = setInterval(() => void reconciler.reconcile(loaded.job.id), Number(arg(args, '--interval', String(SERVICE_DEFAULTS.watchdogMs)))); watchdog.unref();
260
+ let stopping: Promise<void> | null = null;
261
+ const service: Service = {
262
+ store: loaded.store,
263
+ job: loaded.job,
264
+ reconciler,
265
+ dashboardServer,
266
+ watchdogCleared: false,
267
+ stop(): Promise<void> {
268
+ if (stopping) return stopping;
269
+ clearInterval(watchdog); service.watchdogCleared = true;
270
+ const serverClosed = new Promise<void>((resolve, reject) => { dashboardServer.close(error => error ? reject(error) : resolve()); });
271
+ dashboardServer.closeAllConnections();
272
+ const watcherClosed = bus ? bus.close() : stopWatcher();
273
+ stopping = Promise.all([watcherClosed, serverClosed]).then(() => undefined, error => { throw error; });
274
+ return stopping;
275
+ }
276
+ };
277
+ return service;
278
+ }
279
+ export async function startService(args: string[]): Promise<Service> {
280
+ const service = await createService(args);
281
+ const stop = (): void => { void service.stop().then(() => process.exit(0), error => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); }); };
282
+ process.once('SIGINT', stop); process.once('SIGTERM', stop);
283
+ console.log('Dashboard listening');
284
+ try {
285
+ await service.reconciler.reconcile(service.job.id);
286
+ } catch (error) {
287
+ console.error(`Initial reconciliation failed; dashboard remains available: ${error instanceof Error ? error.message : String(error)}`);
288
+ }
289
+ return service;
290
+ }
291
+ export function isEntrypoint(moduleUrl: string): boolean {
292
+ const invoked = process.argv[1]; if (!invoked) return false;
293
+ try { return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(invoked); } catch { return moduleUrl === `file://${invoked}`; }
294
+ }
295
+ async function main(): Promise<void> {
296
+ const [command, ...args] = process.argv.slice(2);
297
+ if (!command || command === '--help' || command === '-h') { console.log(helpText()); return; }
298
+ if (args.includes('--help') || args.includes('-h')) { console.log(helpText(command, command === 'job' ? args[0] : undefined)); return; }
299
+ if (command === 'job') { const [action, ...jobArgs] = args; await runJobCommand(action ?? '', jobArgs); return; }
300
+ if (command === 'init') { const now = new Date().toISOString(); const job: Job = { id: arg(args, '--id', randomUUID()), title: arg(args, '--title', 'Agents Relay job'), objective: arg(args, '--objective') || undefined, priority: priority(args), executionMode: executionMode(args), prNumber: Number(arg(args, '--pr', '0')), repository: arg(args, '--repo'), state: 'OPEN', continuation: continuation(args), createdAt: now, updatedAt: now, tasks: [] }; let store: DurableStore; let file: string | null = null; if (arg(args, '--repo') && arg(args, '--pr')) store = await githubStore(args); else { file = arg(args, '--file'); if (!file) throw new Error('init requires --repo/--pr or explicit --file demo mode'); store = new InMemoryStore(job); } await store.saveJob(job); await saveLocal(file, job); console.log(`Initialized ${job.id}`); return; }
301
+ const loaded = await storeFor(args); const { store, job, localFile } = loaded; const bus = eventBus(args);
302
+ if (command === 'agent-register') {
303
+ const now = new Date().toISOString();
304
+ const agent: AgentRegistration = { id: arg(args, '--agent-id', randomUUID()), name: arg(args, '--name'), responsibility: arg(args, '--responsibility'), role: arg(args, '--role'), capabilities: arg(args, '--capabilities').split(',').filter(Boolean), boundaries: arg(args, '--boundaries').split('|').filter(Boolean), endpoint: { kind: arg(args, '--endpoint-kind', 'adapter') as 'adapter' | 'command' | 'http', target: arg(args, '--endpoint') }, runtime: { adapter: arg(args, '--runtime', arg(args, '--adapter', 'shell')), provider: arg(args, '--provider') || undefined, model: arg(args, '--model') || undefined }, availability: arg(args, '--availability', 'available') as AgentRegistration['availability'], routing: { labels: arg(args, '--labels').split(',').filter(Boolean).reduce<Record<string, string>>((out, item) => { const [key, value] = item.split('=', 2); if (key && value) out[key] = value; return out; }, {}), trustDomains: arg(args, '--trust-domains').split(',').filter(Boolean), regions: arg(args, '--regions').split(',').filter(Boolean) }, evidence: { evaluations: [], outcomes: { succeeded: 0, failed: 0, timedOut: 0 }, lastObservedAt: null }, registeredAt: now, updatedAt: now };
305
+ if (!agent.name || !agent.responsibility || !agent.role || !agent.endpoint.target) throw new Error('--name, --responsibility, --role, and --endpoint are required');
306
+ await store.saveAgent(agent); console.log(`Registered ${agent.id}`); return;
307
+ }
308
+ if (command === 'agent-discover') { const candidates = discoverAgents(await store.listAgents(), { capabilities: arg(args, '--capabilities').split(',').filter(Boolean), runtime: arg(args, '--runtime') || undefined, availability: arg(args, '--availability').split(',').filter(Boolean) as AgentRegistration['availability'][], trustDomain: arg(args, '--trust-domain') || undefined, taskKind: arg(args, '--task-kind') || undefined, explore: args.includes('--explore') }); console.log(JSON.stringify(candidates, null, 2)); return; }
309
+ if (command === 'record') {
310
+ const now = new Date().toISOString(); const id = arg(args, '--task-id', randomUUID()); const summary = arg(args, '--summary');
311
+ if (!summary) throw new Error('record requires --summary');
312
+ const commit = arg(args, '--commit');
313
+ const task: Task = { jobId: job.id, id, projectName: arg(args, '--project') || undefined, agentName: arg(args, '--agent', 'orchestrator'), parentTaskId: arg(args, '--parent') || null, dependencies: arg(args, '--deps').split(',').filter(Boolean), capabilities: [], adapter: 'orchestrator', input: arg(args, '--input', summary), continuation: null, continuationDeliveredAt: null, state: 'SUCCEEDED', attempt: 1, maxAttempts: 1, leaseOwner: null, leaseExpiresAt: null, executionId: commit || null, threadId: null, result: { summary, data: commit ? { commit } : undefined }, error: null, timeoutMs: 0, createdAt: now, updatedAt: now };
314
+ await store.appendTask(task); job.tasks.push(task); await saveLocal(localFile, job); console.log(`Recorded ${id}`); return;
315
+ }
316
+ if (command === 'submit') { const now = new Date().toISOString(); const model = arg(args, '--model'); const routing = model ? { provider: arg(args, '--provider', 'openai'), model, profile: arg(args, '--profile') || undefined, reasoning: arg(args, '--reasoning') || undefined, cwd: arg(args, '--cwd') || undefined, projectId: arg(args, '--chatgpt-project') || undefined, decidedBy: 'cli', decidedAt: now } satisfies RoutingDecision : undefined; const task: Task = { jobId: job.id, id: arg(args, '--task-id', randomUUID()), priority: priority(args, job.priority ?? 'P2'), projectName: arg(args, '--project') || undefined, agentName: arg(args, '--agent') || undefined, parentTaskId: arg(args, '--parent') || null, dependencies: arg(args, '--deps').split(',').filter(Boolean), capabilities: arg(args, '--capabilities').split(',').filter(Boolean), adapter: arg(args, '--adapter', 'shell') as AdapterName, input: arg(args, '--input', 'true'), routing, continuation: continuation(args), continuationDeliveredAt: null, state: 'QUEUED', attempt: 0, maxAttempts: Number(arg(args, '--max-attempts', '3')), leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, error: null, timeoutMs: Number(arg(args, '--timeout', '300000')), createdAt: now, updatedAt: now }; await store.appendTask(task); job.tasks.push(task); if (job.state === 'COMPLETED') { job.state = 'OPEN'; job.updatedAt = now; await store.saveJob(job); } await saveLocal(localFile, job); await publishWake(bus, job, task.id, `Task ${task.id} submitted`); console.log(`Submitted ${task.id}`); return; }
317
+ if (command === 'status') { console.log(JSON.stringify(await store.load(job.id), null, 2)); return; }
318
+ if (command === 'retry' || command === 'cancel') { const id = arg(args, '--task-id'); const task = job.tasks.find(item => item.id === id); if (!task) throw new Error(`Task ${id} not found`); if (command === 'retry') task.maxAttempts = extendRetryBudget(task); task.state = command === 'retry' ? 'READY' : 'CANCELLED'; task.error = command === 'retry' ? null : 'Cancelled by user'; task.updatedAt = new Date().toISOString(); await store.saveTask(task); await saveLocal(localFile, job); await publishWake(bus, job, task.id, `${task.id} ${command} requested`); console.log(`${id}: ${task.state}`); return; }
319
+ const reconciler = runtime(store, args, bus);
320
+ if (command === 'reconcile') { await reconciler.reconcile(job.id); await reconciler.idle(); const finalState = await store.load(job.id); await saveLocal(localFile, finalState); console.log(JSON.stringify(finalState, null, 2)); return; }
321
+ if (command === 'serve') { await startService(args); return; }
322
+ throw new Error(usage);
323
+ }
324
+ if (isEntrypoint(import.meta.url)) main().catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
@@ -0,0 +1,6 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { Continuation } from './types.js';
3
+ export interface ContinuationAdapter { readonly kind: Continuation['kind']; continue(input: string, continuation: Continuation): Promise<void>; }
4
+ export class CodexThreadContinuation implements ContinuationAdapter { readonly kind = 'codex-thread' as const; constructor(private readonly command = 'codex') {} async continue(input: string, continuation: Continuation): Promise<void> { if (continuation.kind !== this.kind) throw new Error('Continuation is not a Codex thread'); await new Promise<void>((resolve, reject) => { const child = spawn(this.command, ['exec', 'resume', continuation.target, input]); child.on('error', reject); child.on('close', code => code === 0 ? resolve() : reject(new Error(`Codex continuation exited ${code}`))); }); } }
5
+ export class CommandContinuation implements ContinuationAdapter { readonly kind = 'command' as const; async continue(input: string, continuation: Continuation): Promise<void> { if (continuation.kind !== this.kind) throw new Error('Continuation is not a command'); await new Promise<void>((resolve, reject) => { const child = spawn(continuation.target, [input]); child.on('error', reject); child.on('close', code => code === 0 ? resolve() : reject(new Error(`Continuation exited ${code}`))); }); } }
6
+ export class WebhookContinuation implements ContinuationAdapter { readonly kind = 'webhook' as const; async continue(input: string, continuation: Continuation): Promise<void> { if (continuation.kind !== this.kind) throw new Error('Continuation is not a webhook'); const response = await fetch(continuation.target, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ input }) }); if (!response.ok) throw new Error(`Continuation webhook returned ${response.status}`); } }