agents-relay 1.0.5 → 1.0.6

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Agents Relay
2
2
 
3
- Agents Relay is a generic TypeScript runtime for durable asynchronous agent jobs coordinated through GitHub pull requests. GitHub PR comments are the source of truth; events only wake the reconciler and update the dashboard.
3
+ Agents Relay is a generic TypeScript runtime for durable asynchronous agent jobs coordinated through GitHub pull requests. GitHub PR comments store durable workflow state; correlated terminal events are authoritative for model-backed task lifecycle transitions.
4
4
 
5
5
  ## Quick start
6
6
 
@@ -11,7 +11,8 @@ npx agents-relay job create --repo OWNER/REPO --head feat/example --base main \
11
11
  --id job-1 --title "Objective" --body "PR description" --mode fixed
12
12
 
13
13
  npx agents-relay submit --repo OWNER/REPO --pr 12 --id job-1 \
14
- --task-id child --input "echo hello" --adapter shell
14
+ --task-id child --adapter codex --provider openai --model MODEL --output task-pr \
15
+ --input "Implement the described child task and publish a terminal event."
15
16
 
16
17
  npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id job-1
17
18
  npx agents-relay status --repo OWNER/REPO --pr 12 --id job-1
@@ -67,14 +68,14 @@ For explicit local demo/test mode:
67
68
  npm install
68
69
  npm run build
69
70
  node dist/cli.js init --file .agents-relay.json --title "Dogfood" --id demo
70
- node dist/cli.js submit --file .agents-relay.json --task-id hello --input "echo hello" --adapter shell
71
+ node dist/cli.js submit --file .agents-relay.json --task-id hello --adapter codex --provider openai --model MODEL --output task-pr --input "Complete the described task"
71
72
  node dist/cli.js reconcile --file .agents-relay.json
72
73
  npx agents-relay serve --file .agents-relay.json
73
74
  ~~~
74
75
 
75
76
  Open the dashboard on localhost port 8787. The marker format is intentionally public and append-friendly; the CLI uses GitHubStore with the authenticated gh client in operational mode.
76
77
 
77
- reconcile leases and launches ready tasks without waiting inside the runtime scheduler; the CLI waits only long enough for its launched workers to persist results. agents-relayd (or the equivalent npx agents-relay serve) is the watchdog/dashboard mode and can use --events nats --subject-prefix PREFIX for optional wakeups. See [docs/service.md](docs/service.md). --file PATH is explicit local demo/test mode only.
78
+ reconcile leases and launches ready tasks without waiting inside the runtime scheduler; the CLI waits only long enough for its launched workers to persist results. agents-relayd (or the equivalent npx agents-relay serve) is the watchdog/dashboard mode. Model-backed workers require --events nats; the event transport carries authoritative terminal worker events as well as wake/progress traffic. See [docs/service.md](docs/service.md). --file PATH is explicit local demo/test mode only.
78
79
 
79
80
  `agents-relayd` without `--pr`/`--id` runs in workspace mode by default: it discovers nested Git repositories under `~/Workspace`, keeps only GitHub `origin` remotes, and aggregates their managed PR jobs into one dashboard and worker pool. Pass `--workspace PATH` to use another root. `--repo OWNER/REPO` remains the single-repository pool mode.
80
81
 
@@ -86,25 +87,25 @@ Codex/model-backed tasks require --provider and --model (or equivalent routing m
86
87
 
87
88
  The minimal agent-network surface is machine-driven registration and discovery. `agent-register` persists an agent identity, responsibility boundary, claimed capabilities, endpoint/runtime, availability, and routing metadata in a trusted PR marker; `agent-discover` applies hard filters and returns evidence-backed candidates. Adapters remain runtimes, not agent identities. See [docs/architecture.md](docs/architecture.md) for the constrained future remote submission contract.
88
89
 
89
- ### ChatGPT workers through the browser-worker agent
90
+ ### Agent worker lifecycle
90
91
 
91
- Use the chatgpt adapter to launch the installed `chatgpt-browser-worker` agent through a local model harness. The observed ChatGPT `thread_id` is stored as the task `threadId`, so lineage is durable and a retry continues the same conversation.
92
+ Managed tasks are descriptive work for agents, not shell commands. Use `codex` or `chatgpt` for worker tasks. Commands such as tests, build tools, Markad Vision, ffmpeg, and other CLIs are invoked by the orchestrator or by an executing agent; they are not worker adapters.
92
93
 
93
- Managed Codex and ChatGPT worker prompts automatically receive the durable PR URL plus job/task/parent/project context before the original task input. The stored task input is not rewritten.
94
+ Every model-backed task declares a durable output with `--output task-pr` or `--output file --output-path PATH`. Agents Relay enriches the prompt with the PR URL, job/task identity, output contract, and mandatory terminal-event contract.
94
95
 
95
- Each ChatGPT task owns its own conversation. Retries reuse that task's conversation; sibling tasks never share a conversation merely because they use the same adapter. When the job reaches COMPLETED, or GitHub reports the PR merged, Agents Relay asks the browser-worker agent to delete every ChatGPT task conversation. The durable threadId remains in the task marker with threadDeletedAt for audit history. Failed deletion records threadCleanupError and is retried on later reconciliation without reopening the terminal job.
96
+ For Codex and ChatGPT, **events are authoritative task state**. A process exit or browser submission receipt does not complete a task. The executing agent must publish exactly one correlated `task.completed`, `task.failed`, or `task.blocked` event. Relay persists the matching durable state only from that terminal event. Model-backed tasks require an event bus and time out explicitly if no terminal event arrives.
97
+
98
+ The ChatGPT adapter uses the packaged `chatgpt-browser-worker` as a one-shot submitter. It opens a fresh Temporary Chat tab, uses the account defaults, submits the prompt, verifies acceptance, closes the owned tab, and returns. It does not poll for the assistant response, resume/reopen a thread, or use conversation text as the result channel. Any observed ChatGPT thread ID is diagnostic only.
96
99
 
97
100
  ~~~sh
98
101
  npx agents-relay submit --repo OWNER/REPO --pr 5 --id job-1 \
99
102
  --task-id research-ui --adapter chatgpt \
100
103
  --capabilities model,chatgpt,browser-harness \
101
- --provider openai --model gpt-5-6-sol --reasoning high \
102
- --chatgpt-project g-p-EXACT_PROJECT_ID \
103
- --input "Research the dashboard UX and return implementation guidance."
104
+ --provider openai --model MODEL \
105
+ --output file --output-path runs/research-ui.md \
106
+ --input "Research the dashboard UX and write the final report to the declared output file."
104
107
  ~~~
105
108
 
106
- The adapter discovers the packaged `chatgpt-browser-worker` skill from `AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT` first, then `AGENTS_RELAY_SKILL_ROOTS`, the package `skills` directory, `~/.codex/skills`, `~/.agents/skills`, and the local `skills` directory. It invokes that skill's Browser Harness drivers directly; it does not launch Codex, MacDeveloperBridge, or a ChatGPT API transport. Normal consumers do not need a Neo source checkout.
107
-
108
109
  GitHub PR state is authoritative for terminal lifecycle: a merged PR reconciles its managed job to `COMPLETED`; a closed, unmerged PR reconciles to `CANCELLED` and cannot launch queued work. The dashboard exposes `/api/jobs` and shows repository-wide managed PR state beside each durable Agents Relay job state so stale markers are visible instead of being mistaken for current truth.
109
110
 
110
111
  For work that was completed outside the relay but must be represented truthfully in durable history, use `npx agents-relay record --task-id <id> --summary <summary> --commit <sha>`. Recorded tasks use the `orchestrator` adapter and terminal `SUCCEEDED` state; they do not pretend a shell/model worker executed the work. This is a repair/backfill mechanism—normal work should be submitted before execution. Managed jobs also retain the GitHub PR body as the job description for dashboard display.
package/dist/adapters.js CHANGED
@@ -1,10 +1,9 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { access, readFile, readdir } from 'node:fs/promises';
2
+ import { access, readFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { randomUUID } from 'node:crypto';
7
- function commandExecution(child, output, signal, id) { const promise = new Promise((resolve, reject) => { child.on('error', reject); child.on('close', code => code === 0 ? resolve({ summary: output().trim() || 'Command completed' }) : reject(new Error(output().trim() || `Command exited ${code}`))); signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true }); }); return { id, promise, cancel: () => { child.kill('SIGTERM'); } }; }
8
7
  export function buildCodexArgs(route, input) {
9
8
  const args = ['exec', '--json'];
10
9
  if (route.profile)
@@ -20,146 +19,97 @@ export function buildCodexArgs(route, input) {
20
19
  args.push('--', input);
21
20
  return args;
22
21
  }
23
- function parseThreadStarted(line) { try {
24
- const value = JSON.parse(line);
25
- return value.type === 'thread.started' && typeof value.thread_id === 'string' ? value.thread_id : undefined;
26
- }
27
- catch {
28
- return undefined;
29
- } }
30
- function parseCodexAssistantMessage(line) {
22
+ function parseThreadStarted(line) {
31
23
  try {
32
24
  const value = JSON.parse(line);
33
- const payload = value.payload;
34
- if (value.type === 'event_msg' && payload?.type === 'task_complete' && typeof payload.last_agent_message === 'string')
35
- return payload.last_agent_message.trim() || undefined;
36
- const item = value.item;
37
- if (value.type === 'item.completed' && item?.type === 'agent_message' && typeof item.text === 'string')
38
- return item.text.trim() || undefined;
25
+ return value.type === 'thread.started' && typeof value.thread_id === 'string' ? value.thread_id : undefined;
26
+ }
27
+ catch {
28
+ return undefined;
39
29
  }
40
- catch { /* non-JSON diagnostic output is ignored on successful Codex runs */ }
41
- return undefined;
42
30
  }
43
31
  function appendTail(current, chunk, maxBytes = 16384) {
44
32
  const combined = current + chunk;
45
- return Buffer.byteLength(combined) <= maxBytes ? combined : Buffer.from(combined).subarray(-maxBytes).toString('utf8');
46
- }
47
- export class ShellAdapter {
48
- shell;
49
- name = 'shell';
50
- id = 'shell';
51
- capabilities = ['shell', 'command'];
52
- constructor(shell = false) {
53
- this.shell = shell;
54
- }
55
- launch(task, signal) { const parts = task.input.split(' ').filter(Boolean); const child = this.shell ? spawn(task.input, { shell: true }) : spawn(parts[0] ?? 'true', parts.slice(1)); let output = ''; child.stdout?.on('data', (x) => { output += x.toString(); }); child.stderr?.on('data', (x) => { output += x.toString(); }); return commandExecution(child, () => output, signal, randomUUID()); }
33
+ return Buffer.byteLength(combined) <= maxBytes
34
+ ? combined
35
+ : Buffer.from(combined).subarray(-maxBytes).toString('utf8');
56
36
  }
57
37
  export class CodexAdapter {
58
38
  command;
59
- sessionsRoot;
60
39
  name = 'codex';
61
40
  id = 'codex';
62
41
  capabilities = ['model', 'codex'];
63
- constructor(command = 'codex', sessionsRoot = join(homedir(), '.codex', 'sessions')) {
42
+ constructor(command = 'codex') {
64
43
  this.command = command;
65
- this.sessionsRoot = sessionsRoot;
66
44
  }
67
45
  launch(task, signal) {
68
46
  if (!task.routing)
69
47
  throw new Error(`Task ${task.id} requires routing metadata before Codex launch`);
70
- const route = task.routing;
71
- const args = buildCodexArgs(route, task.input);
72
- const child = spawn(this.command, args, { shell: false, cwd: route.cwd, stdio: ['ignore', 'pipe', 'pipe'] });
48
+ const child = spawn(this.command, buildCodexArgs(task.routing, task.input), {
49
+ shell: false,
50
+ cwd: task.routing.cwd,
51
+ stdio: ['ignore', 'pipe', 'pipe'],
52
+ });
73
53
  let stdoutBuffer = '';
74
- let finalAssistantMessage = '';
75
54
  let stderrTail = '';
76
55
  let bufferedThreadId;
77
56
  let execution;
78
57
  const consumeLine = (line) => {
79
58
  const threadId = parseThreadStarted(line);
80
- if (threadId) {
81
- bufferedThreadId = threadId;
82
- if (execution) {
83
- execution.threadId = threadId;
84
- execution.onThreadStarted?.(threadId);
85
- }
59
+ if (!threadId)
60
+ return;
61
+ bufferedThreadId = threadId;
62
+ if (execution) {
63
+ execution.threadId = threadId;
64
+ execution.onThreadStarted?.(threadId);
86
65
  }
87
- const assistantMessage = parseCodexAssistantMessage(line);
88
- if (assistantMessage)
89
- finalAssistantMessage = assistantMessage;
90
66
  };
91
- child.stdout?.on('data', (x) => {
92
- stdoutBuffer += x.toString();
67
+ child.stdout?.on('data', (chunk) => {
68
+ stdoutBuffer += chunk.toString();
93
69
  const lines = stdoutBuffer.split(/\r?\n/);
94
70
  stdoutBuffer = lines.pop() ?? '';
95
71
  for (const line of lines)
96
72
  consumeLine(line);
97
73
  });
98
- child.stderr?.on('data', (x) => { stderrTail = appendTail(stderrTail, x.toString()); });
99
- const id = randomUUID();
74
+ child.stderr?.on('data', (chunk) => {
75
+ stderrTail = appendTail(stderrTail, chunk.toString());
76
+ });
100
77
  const promise = new Promise((resolve, reject) => {
101
78
  child.on('error', reject);
102
79
  child.on('close', code => {
103
80
  if (stdoutBuffer)
104
81
  consumeLine(stdoutBuffer);
105
- if (code === 0)
106
- resolve({ summary: finalAssistantMessage || 'Codex task completed' });
107
- else
82
+ if (code === 0) {
83
+ resolve({ summary: 'Codex runtime exited after task execution; terminal task state is event-owned' });
84
+ }
85
+ else {
108
86
  reject(new Error(stderrTail.trim() || `Codex exited ${code}`));
87
+ }
109
88
  });
110
89
  signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
111
90
  });
112
- execution = { id, promise, cancel: () => { child.kill('SIGTERM'); } };
91
+ execution = { id: randomUUID(), promise, cancel: () => child.kill('SIGTERM') };
113
92
  execution.threadId = bufferedThreadId;
114
93
  return execution;
115
94
  }
116
- async recover(task) {
117
- if (!task.threadId)
118
- return null;
119
- let entries;
120
- try {
121
- entries = await readdir(this.sessionsRoot, { recursive: true, encoding: 'utf8' });
122
- }
123
- catch {
124
- return null;
125
- }
126
- const filename = entries.find(entry => entry.endsWith(`${task.threadId}.jsonl`));
127
- if (!filename)
128
- return null;
129
- let lines;
130
- try {
131
- lines = (await readFile(join(this.sessionsRoot, filename), 'utf8')).split(/\r?\n/);
132
- }
133
- catch {
134
- return null;
135
- }
136
- for (let index = lines.length - 1; index >= 0; index -= 1) {
137
- const line = lines[index];
138
- if (!line)
139
- continue;
140
- try {
141
- const value = JSON.parse(line);
142
- if (value.type !== 'event_msg')
143
- continue;
144
- const payload = value.payload;
145
- if (payload?.type !== 'task_complete')
146
- continue;
147
- const summary = typeof payload.last_agent_message === 'string' ? payload.last_agent_message.trim() : '';
148
- return { summary: summary || 'Codex task completed', data: { recoveredFromThread: task.threadId } };
149
- }
150
- catch { /* ignore malformed session rows */ }
151
- }
152
- return null;
153
- }
154
95
  }
155
96
  export class LaunchFailure extends Error {
156
97
  }
157
98
  function browserWorkerCandidates(explicit) {
158
99
  const roots = (process.env.AGENTS_RELAY_SKILL_ROOTS ?? '').split(':').filter(Boolean);
159
100
  const packageSkills = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
160
- return [explicit, ...roots, packageSkills, join(homedir(), '.codex', 'skills'), join(homedir(), '.agents', 'skills'), join(process.cwd(), 'skills')]
101
+ return [
102
+ explicit,
103
+ ...roots,
104
+ packageSkills,
105
+ join(homedir(), '.codex', 'skills'),
106
+ join(homedir(), '.agents', 'skills'),
107
+ join(process.cwd(), 'skills'),
108
+ ]
161
109
  .filter((value) => Boolean(value))
162
- .map(value => value.endsWith('browser-worker.agent.md') ? value : join(value, 'chatgpt-browser-worker', 'agents', 'browser-worker.agent.md'));
110
+ .map(value => value.endsWith('browser-worker.agent.md')
111
+ ? value
112
+ : join(value, 'chatgpt-browser-worker', 'agents', 'browser-worker.agent.md'));
163
113
  }
164
114
  async function loadBrowserWorkerRuntime(explicit) {
165
115
  for (const candidate of browserWorkerCandidates(explicit)) {
@@ -167,58 +117,41 @@ async function loadBrowserWorkerRuntime(explicit) {
167
117
  await access(candidate);
168
118
  return { definition: await readFile(candidate, 'utf8'), root: dirname(dirname(candidate)) };
169
119
  }
170
- catch { /* try the next installed skill root */ }
120
+ catch {
121
+ // Try the next installed skill root.
122
+ }
171
123
  }
172
124
  throw new Error('chatgpt-browser-worker agent definition not found; set AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT');
173
125
  }
174
- function thinkingLevel(reasoning) {
175
- if (reasoning === 'low' || reasoning === 'medium' || reasoning === 'high')
176
- return reasoning;
177
- if (reasoning === 'minimal')
178
- return 'low';
179
- return 'default';
180
- }
181
- function parseBrowserWorkerResponse(output) {
182
- const text = output.trim().replace(/^```(?:json)?\s*|\s*```$/g, '').trim();
183
- let value;
184
- try {
185
- value = JSON.parse(text);
186
- }
187
- catch {
188
- throw new Error('browser-worker returned non-JSON output');
189
- }
190
- if (!value || typeof value !== 'object')
191
- throw new Error('browser-worker returned an invalid JSON response');
192
- const response = value;
193
- if (!response.operation || !response.status)
194
- throw new Error('browser-worker response omitted operation or status');
195
- return response;
196
- }
197
126
  function parseDriverJson(output) {
198
127
  const lines = output.trim().split(/\r?\n/).filter(Boolean);
199
128
  for (let index = lines.length - 1; index >= 0; index -= 1) {
200
129
  try {
201
130
  const value = JSON.parse(lines[index]);
202
- if (value && typeof value === 'object' && !Array.isArray(value))
203
- return value;
131
+ if (value.operation !== 'submit' || value.status !== 'submitted')
132
+ continue;
133
+ return value;
134
+ }
135
+ catch {
136
+ // Browser-harness diagnostics may precede the final JSON observation.
204
137
  }
205
- catch { /* browser-harness diagnostics may precede the final JSON observation */ }
206
138
  }
207
- throw new Error('browser-worker driver returned no JSON observation');
139
+ throw new Error('browser-worker driver returned no submitted receipt');
208
140
  }
209
141
  function directBrowserWorkerRunner(root) {
210
142
  return (_definition, request, _route, signal) => {
211
- const scripts = join(root, 'scripts');
212
- const project = request.project.name;
213
- const args = request.operation === 'create'
214
- ? [join(scripts, 'create_bh.py'), '--project', project, '--prompt', request.prompt ?? '', '--thinking-level', request.thinking_level ?? 'default']
215
- : [join(scripts, 'operate_bh.py'), request.operation, '--thread-id', request.thread_id ?? '', '--project', project,
216
- ...(request.operation === 'continue' ? ['--prompt', request.prompt ?? ''] : [])];
143
+ const args = [join(root, 'scripts', 'temporary_bh.py'), '--prompt', request.prompt];
144
+ for (const file of request.files ?? [])
145
+ args.push('--file', file);
217
146
  const child = spawn('python3', args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
218
147
  let stdout = '';
219
148
  let stderr = '';
220
- child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
221
- child.stderr?.on('data', (chunk) => { stderr = appendTail(stderr, chunk.toString()); });
149
+ child.stdout?.on('data', (chunk) => {
150
+ stdout += chunk.toString();
151
+ });
152
+ child.stderr?.on('data', (chunk) => {
153
+ stderr = appendTail(stderr, chunk.toString());
154
+ });
222
155
  const promise = new Promise((resolve, reject) => {
223
156
  child.on('error', reject);
224
157
  child.on('close', code => {
@@ -227,33 +160,7 @@ function directBrowserWorkerRunner(root) {
227
160
  return;
228
161
  }
229
162
  try {
230
- const observed = parseDriverJson(stdout);
231
- const threadId = typeof observed.thread_id === 'string' ? observed.thread_id : request.thread_id;
232
- const response = { operation: request.operation, status: 'running', thread_id: threadId ?? null };
233
- if (request.operation === 'create')
234
- response.status = 'running';
235
- else if (request.operation === 'continue')
236
- response.status = 'running';
237
- else if (request.operation === 'resume')
238
- response.status = typeof observed.status === 'string' ? observed.status : 'awaiting_result';
239
- else if (request.operation === 'status')
240
- response.status = typeof observed.status === 'string' ? observed.status : 'awaiting_result';
241
- else if (request.operation === 'result') {
242
- response.status = typeof observed.status === 'string' ? observed.status : 'completed';
243
- response.result = {
244
- message_id: typeof observed.message_id === 'string' ? observed.message_id : undefined,
245
- text: typeof observed.text === 'string' ? observed.text : undefined,
246
- verified: Boolean(observed.message_id && observed.text),
247
- observed_at: new Date().toISOString()
248
- };
249
- }
250
- else if (request.operation === 'delete') {
251
- const outcome = typeof observed.outcome === 'string' ? observed.outcome : '';
252
- response.status = outcome === 'deleted' || outcome === 'not_found' ? outcome : 'failed';
253
- if (response.status === 'failed')
254
- response.error = { code: 'cleanup_not_verified', message: 'browser-worker did not verify thread cleanup', retryable: true };
255
- }
256
- resolve(JSON.stringify(response));
163
+ resolve(JSON.stringify(parseDriverJson(stdout)));
257
164
  }
258
165
  catch (error) {
259
166
  reject(error);
@@ -264,12 +171,27 @@ function directBrowserWorkerRunner(root) {
264
171
  else
265
172
  signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
266
173
  });
267
- return { promise, cancel: () => { child.kill('SIGTERM'); } };
174
+ return { promise, cancel: () => child.kill('SIGTERM') };
268
175
  };
269
176
  }
270
- function browserWorkerError(response) {
271
- const detail = response.error?.message || `browser-worker ${response.status}`;
272
- return new Error(response.error?.code ? `${response.error.code}: ${detail}` : detail);
177
+ function parseBrowserWorkerResponse(output) {
178
+ const text = output.trim().replace(/^\`\`\`(?:json)?\s*|\s*\`\`\`$/g, '').trim();
179
+ let value;
180
+ try {
181
+ value = JSON.parse(text);
182
+ }
183
+ catch {
184
+ throw new Error('browser-worker returned non-JSON output');
185
+ }
186
+ if (!value || typeof value !== 'object')
187
+ throw new Error('browser-worker returned an invalid JSON response');
188
+ const response = value;
189
+ if (response.operation !== 'submit' || response.status !== 'submitted') {
190
+ throw new Error('browser-worker did not return a submitted receipt');
191
+ }
192
+ if (response.verified !== true)
193
+ throw new Error('browser-worker submission was not verified');
194
+ return response;
273
195
  }
274
196
  export class ChatGptAdapter {
275
197
  name = 'chatgpt';
@@ -277,58 +199,9 @@ export class ChatGptAdapter {
277
199
  capabilities = ['model', 'chatgpt', 'browser-harness'];
278
200
  agentPath;
279
201
  runner;
280
- deleted = new Set();
281
- routes = new Map();
282
- constructor(options = {}) { this.agentPath = options.agentPath ?? process.env.AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT; this.runner = options.agentRunner; }
283
- async run(request, route, signal) {
284
- const runtime = await loadBrowserWorkerRuntime(this.agentPath);
285
- if (signal.aborted)
286
- throw new Error('browser-worker execution aborted');
287
- const run = (this.runner ?? directBrowserWorkerRunner(runtime.root))(runtime.definition, request, route, signal);
288
- const output = await new Promise((resolve, reject) => {
289
- const abort = () => { run.cancel(); reject(new Error('browser-worker execution aborted')); };
290
- if (signal.aborted) {
291
- abort();
292
- return;
293
- }
294
- signal.addEventListener('abort', abort, { once: true });
295
- run.promise.then(value => { signal.removeEventListener('abort', abort); resolve(value); }, error => { signal.removeEventListener('abort', abort); reject(error); });
296
- });
297
- return parseBrowserWorkerResponse(output);
298
- }
299
- project(task, route) {
300
- return { name: task?.projectName ?? route.projectId ?? 'default', id: route.projectId };
301
- }
302
- exposeThread(execution, expected, response) {
303
- const threadId = typeof response.thread_id === 'string' && response.thread_id.trim() ? response.thread_id : undefined;
304
- if (!threadId)
305
- throw new Error('browser-worker response omitted thread_id');
306
- if (expected && threadId !== expected)
307
- throw new Error('browser-worker returned a different thread_id');
308
- execution.threadId = threadId;
309
- execution.onThreadStarted?.(threadId);
310
- return threadId;
311
- }
312
- validateOperation(response, operation) {
313
- if (response.operation !== operation)
314
- throw new Error(`browser-worker response operation mismatch: expected ${operation}, got ${response.operation}`);
315
- if (response.status === 'blocked' || response.status === 'failed')
316
- throw browserWorkerError(response);
317
- }
318
- async deleteThread(threadId, task) {
319
- if (this.deleted.has(threadId))
320
- return;
321
- const route = task?.routing ?? this.routes.get(threadId);
322
- if (!route)
323
- throw new Error(`Task routing metadata is required to delete browser-worker thread ${threadId}`);
324
- const response = await this.run({ operation: 'delete', thread_id: threadId, project: this.project(task, route), state: null }, route, new AbortController().signal);
325
- if (response.operation !== 'delete')
326
- throw new Error(`browser-worker response operation mismatch: expected delete, got ${response.operation}`);
327
- if (response.thread_id && response.thread_id !== threadId)
328
- throw new Error('browser-worker returned a different thread_id');
329
- if (response.status !== 'deleted' && response.status !== 'not_found')
330
- throw browserWorkerError(response);
331
- this.deleted.add(threadId);
202
+ constructor(options = {}) {
203
+ this.agentPath = options.agentPath ?? process.env.AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT;
204
+ this.runner = options.agentRunner;
332
205
  }
333
206
  launch(task, signal) {
334
207
  if (!task.routing)
@@ -340,55 +213,32 @@ export class ChatGptAdapter {
340
213
  abort();
341
214
  else
342
215
  signal.addEventListener('abort', abort, { once: true });
343
- const execution = { id: randomUUID(), promise: Promise.resolve({ summary: '' }), cancel: abort };
344
- const timeout = setTimeout(abort, Math.max(1, task.timeoutMs));
216
+ const execution = {
217
+ id: randomUUID(),
218
+ promise: Promise.resolve({ summary: '' }),
219
+ cancel: abort,
220
+ };
345
221
  execution.promise = (async () => {
222
+ const runtime = await loadBrowserWorkerRuntime(this.agentPath);
223
+ if (controller.signal.aborted)
224
+ throw new Error('browser-worker execution aborted');
225
+ const run = (this.runner ?? directBrowserWorkerRunner(runtime.root))(runtime.definition, { prompt: task.input }, route, controller.signal);
346
226
  try {
347
- const expectedThreadId = task.threadId;
348
- const hasPrompt = Boolean(task.input.trim());
349
- const operation = expectedThreadId ? (hasPrompt ? 'continue' : 'resume') : 'create';
350
- const request = {
351
- operation, thread_id: expectedThreadId, project: this.project(task, route), prompt: hasPrompt ? task.input : undefined,
352
- thinking_level: thinkingLevel(route.reasoning), state: null,
227
+ const output = await run.promise;
228
+ const response = parseBrowserWorkerResponse(output);
229
+ return {
230
+ summary: 'ChatGPT task submitted; terminal task state is event-owned',
231
+ data: {
232
+ submitted: true,
233
+ diagnosticThreadId: response.diagnostic_thread_id ?? null,
234
+ userMessageId: response.user_message_id ?? null,
235
+ },
353
236
  };
354
- let response;
355
- try {
356
- response = await this.run(request, route, controller.signal);
357
- }
358
- catch (error) {
359
- if (controller.signal.aborted)
360
- throw error;
361
- if (operation === 'create')
362
- throw new LaunchFailure(error instanceof Error ? error.message : String(error));
363
- throw error;
364
- }
365
- const threadId = this.exposeThread(execution, expectedThreadId, response);
366
- this.routes.set(threadId, route);
367
- this.validateOperation(response, operation);
368
- let status = response.status;
369
- while (status !== 'awaiting_result' && status !== 'completed') {
370
- if (controller.signal.aborted)
371
- throw new Error('browser-worker execution aborted');
372
- await new Promise((resolve, reject) => {
373
- const timer = setTimeout(resolve, 25);
374
- controller.signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('browser-worker execution aborted')); }, { once: true });
375
- });
376
- const statusResponse = await this.run({ operation: 'status', thread_id: threadId, project: this.project(task, route), state: null }, route, controller.signal);
377
- if (statusResponse.thread_id && statusResponse.thread_id !== threadId)
378
- throw new Error('browser-worker returned a different thread_id');
379
- this.validateOperation(statusResponse, 'status');
380
- status = statusResponse.status;
381
- }
382
- const resultResponse = await this.run({ operation: 'result', thread_id: threadId, project: this.project(task, route), state: null }, route, controller.signal);
383
- if (resultResponse.thread_id && resultResponse.thread_id !== threadId)
384
- throw new Error('browser-worker returned a different thread_id');
385
- this.validateOperation(resultResponse, 'result');
386
- if (resultResponse.status !== 'completed' || resultResponse.result?.verified !== true || !resultResponse.result.message_id || !resultResponse.result.text?.trim())
387
- throw browserWorkerError(resultResponse);
388
- return { summary: resultResponse.result.text.trim(), data: { threadId, messageId: resultResponse.result.message_id, verified: true, provider: route.provider, model: route.model } };
389
237
  }
390
- finally {
391
- clearTimeout(timeout);
238
+ catch (error) {
239
+ if (controller.signal.aborted)
240
+ throw error;
241
+ throw new LaunchFailure(error instanceof Error ? error.message : String(error));
392
242
  }
393
243
  })();
394
244
  return execution;
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto';
7
7
  import { dashboardManagedJobs, InMemoryStore, GitHubStore, resolvePullRequest, listManagedGitHubJobs } from './store.js';
8
8
  import { githubAuthContext } from './github-auth.js';
9
9
  import { Reconciler } from './reconciler.js';
10
- import { ShellAdapter, CodexAdapter, ChatGptAdapter } from './adapters.js';
10
+ import { CodexAdapter, ChatGptAdapter } from './adapters.js';
11
11
  import { CodexThreadContinuation, CommandContinuation, WebhookContinuation } from './continuation.js';
12
12
  import { NatsEventBus, eventFor } from './events.js';
13
13
  import { serveDashboard } from './dashboard.js';
@@ -95,10 +95,10 @@ Example:
95
95
  submit: `Queue a task for a managed job.
96
96
 
97
97
  Required: --repo OWNER/REPO, --pr NUMBER, --id JOB_ID, and --input TEXT.
98
- Common: --task-id ID, --adapter shell|codex|chatgpt, --priority P0|P1|P2|P3, --max-attempts N.
98
+ Required for worker tasks: --adapter codex|chatgpt, --model MODEL, and --output task-pr|file. File output also requires --output-path PATH.\nCommon: --task-id ID, --priority P0|P1|P2|P3, --max-attempts N. Model-backed execution requires --events nats in the running reconciler/daemon.
99
99
 
100
100
  Example:
101
- npx agents-relay submit --repo OWNER/REPO --pr 12 --id job-1 --task-id build --input "npm test"`,
101
+ npx agents-relay submit --repo OWNER/REPO --pr 12 --id job-1 --task-id build --adapter codex --provider openai --model gpt-5-6 --output task-pr --input "Run the required tests and report the result through events."`,
102
102
  record: `Backfill work completed outside the relay into durable history.
103
103
 
104
104
  Required: --file PATH or --repo/--pr/--id, plus --summary TEXT.
@@ -269,8 +269,8 @@ async function runTaskCommand(action, args) {
269
269
  throw new Error(`Task ${id} is ${task.state}; durable execution fields are immutable in this state`);
270
270
  if (hasArg(args, '--adapter')) {
271
271
  const adapter = arg(args, '--adapter');
272
- if (!['shell', 'codex', 'chatgpt', 'orchestrator'].includes(adapter))
273
- throw new Error('--adapter must be shell, codex, chatgpt, or orchestrator');
272
+ if (!['codex', 'chatgpt', 'orchestrator'].includes(adapter))
273
+ throw new Error('--adapter must be codex, chatgpt, or orchestrator');
274
274
  task.adapter = adapter;
275
275
  }
276
276
  if (hasArg(args, '--priority'))
@@ -346,7 +346,7 @@ async function runJobCommand(action, args) {
346
346
  console.log(JSON.stringify({ repository: repo, pr: result.pr.number, job: result.job.id, executionMode: result.job.executionMode, state: result.job.state }, null, 2));
347
347
  }
348
348
  export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
349
- export function runtime(store, args, bus) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const planner = runtimePlanner(args); 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()], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, eventBus: bus, emit }); }
349
+ export function runtime(store, args, bus) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const planner = runtimePlanner(args); 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 CodexAdapter(modelRuntime), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, eventBus: bus, emit }); }
350
350
  export async function createService(args) {
351
351
  const loaded = await storeFor(args);
352
352
  const upstream = eventBus(args);
@@ -490,8 +490,19 @@ async function main() {
490
490
  if (command === 'submit') {
491
491
  const now = new Date().toISOString();
492
492
  const model = arg(args, '--model');
493
+ const adapter = arg(args, '--adapter', 'codex');
494
+ const outputKind = arg(args, '--output');
495
+ const outputPath = arg(args, '--output-path');
496
+ if (!['codex', 'chatgpt'].includes(adapter))
497
+ throw new Error('submit adapter must be codex or chatgpt');
498
+ if (!model)
499
+ throw new Error('submit requires --model for codex/chatgpt tasks');
500
+ if (outputKind !== 'task-pr' && outputKind !== 'file')
501
+ throw new Error('submit requires --output task-pr|file');
502
+ if (outputKind === 'file' && !outputPath)
503
+ throw new Error('--output file requires --output-path');
493
504
  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 } : undefined;
494
- const 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'), 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 };
505
+ const 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, input: arg(args, '--input', 'true'), output: outputKind === 'file' ? { kind: 'file', path: outputPath } : { kind: 'task_pr' }, 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 };
495
506
  await store.appendTask(task);
496
507
  job.tasks.push(task);
497
508
  if (job.state === 'COMPLETED') {