agents-relay 1.0.4 → 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 `chatgpt-browser-worker/agents/browser-worker.agent.md` from `AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT` first, then `AGENTS_RELAY_SKILL_ROOTS`, `~/.codex/skills`, `~/.agents/skills`, and the local `skills` directory. It runs the definition and one typed JSON request through the configured local harness (`AGENTS_RELAY_AGENT_HARNESS`, default `codex`). 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,9 +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
- import { join } from 'node:path';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
5
6
  import { randomUUID } from 'node:crypto';
6
- 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'); } }; }
7
7
  export function buildCodexArgs(route, input) {
8
8
  const args = ['exec', '--json'];
9
9
  if (route.profile)
@@ -19,277 +19,189 @@ export function buildCodexArgs(route, input) {
19
19
  args.push('--', input);
20
20
  return args;
21
21
  }
22
- function parseThreadStarted(line) { try {
23
- const value = JSON.parse(line);
24
- return value.type === 'thread.started' && typeof value.thread_id === 'string' ? value.thread_id : undefined;
25
- }
26
- catch {
27
- return undefined;
28
- } }
29
- function parseCodexAssistantMessage(line) {
22
+ function parseThreadStarted(line) {
30
23
  try {
31
24
  const value = JSON.parse(line);
32
- const payload = value.payload;
33
- if (value.type === 'event_msg' && payload?.type === 'task_complete' && typeof payload.last_agent_message === 'string')
34
- return payload.last_agent_message.trim() || undefined;
35
- const item = value.item;
36
- if (value.type === 'item.completed' && item?.type === 'agent_message' && typeof item.text === 'string')
37
- 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;
38
29
  }
39
- catch { /* non-JSON diagnostic output is ignored on successful Codex runs */ }
40
- return undefined;
41
30
  }
42
31
  function appendTail(current, chunk, maxBytes = 16384) {
43
32
  const combined = current + chunk;
44
- return Buffer.byteLength(combined) <= maxBytes ? combined : Buffer.from(combined).subarray(-maxBytes).toString('utf8');
45
- }
46
- export class ShellAdapter {
47
- shell;
48
- name = 'shell';
49
- id = 'shell';
50
- capabilities = ['shell', 'command'];
51
- constructor(shell = false) {
52
- this.shell = shell;
53
- }
54
- 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');
55
36
  }
56
37
  export class CodexAdapter {
57
38
  command;
58
- sessionsRoot;
59
39
  name = 'codex';
60
40
  id = 'codex';
61
41
  capabilities = ['model', 'codex'];
62
- constructor(command = 'codex', sessionsRoot = join(homedir(), '.codex', 'sessions')) {
42
+ constructor(command = 'codex') {
63
43
  this.command = command;
64
- this.sessionsRoot = sessionsRoot;
65
44
  }
66
45
  launch(task, signal) {
67
46
  if (!task.routing)
68
47
  throw new Error(`Task ${task.id} requires routing metadata before Codex launch`);
69
- const route = task.routing;
70
- const args = buildCodexArgs(route, task.input);
71
- 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
+ });
72
53
  let stdoutBuffer = '';
73
- let finalAssistantMessage = '';
74
54
  let stderrTail = '';
75
55
  let bufferedThreadId;
76
56
  let execution;
77
57
  const consumeLine = (line) => {
78
58
  const threadId = parseThreadStarted(line);
79
- if (threadId) {
80
- bufferedThreadId = threadId;
81
- if (execution) {
82
- execution.threadId = threadId;
83
- execution.onThreadStarted?.(threadId);
84
- }
59
+ if (!threadId)
60
+ return;
61
+ bufferedThreadId = threadId;
62
+ if (execution) {
63
+ execution.threadId = threadId;
64
+ execution.onThreadStarted?.(threadId);
85
65
  }
86
- const assistantMessage = parseCodexAssistantMessage(line);
87
- if (assistantMessage)
88
- finalAssistantMessage = assistantMessage;
89
66
  };
90
- child.stdout?.on('data', (x) => {
91
- stdoutBuffer += x.toString();
67
+ child.stdout?.on('data', (chunk) => {
68
+ stdoutBuffer += chunk.toString();
92
69
  const lines = stdoutBuffer.split(/\r?\n/);
93
70
  stdoutBuffer = lines.pop() ?? '';
94
71
  for (const line of lines)
95
72
  consumeLine(line);
96
73
  });
97
- child.stderr?.on('data', (x) => { stderrTail = appendTail(stderrTail, x.toString()); });
98
- const id = randomUUID();
74
+ child.stderr?.on('data', (chunk) => {
75
+ stderrTail = appendTail(stderrTail, chunk.toString());
76
+ });
99
77
  const promise = new Promise((resolve, reject) => {
100
78
  child.on('error', reject);
101
79
  child.on('close', code => {
102
80
  if (stdoutBuffer)
103
81
  consumeLine(stdoutBuffer);
104
- if (code === 0)
105
- resolve({ summary: finalAssistantMessage || 'Codex task completed' });
106
- else
82
+ if (code === 0) {
83
+ resolve({ summary: 'Codex runtime exited after task execution; terminal task state is event-owned' });
84
+ }
85
+ else {
107
86
  reject(new Error(stderrTail.trim() || `Codex exited ${code}`));
87
+ }
108
88
  });
109
89
  signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
110
90
  });
111
- execution = { id, promise, cancel: () => { child.kill('SIGTERM'); } };
91
+ execution = { id: randomUUID(), promise, cancel: () => child.kill('SIGTERM') };
112
92
  execution.threadId = bufferedThreadId;
113
93
  return execution;
114
94
  }
115
- async recover(task) {
116
- if (!task.threadId)
117
- return null;
118
- let entries;
119
- try {
120
- entries = await readdir(this.sessionsRoot, { recursive: true, encoding: 'utf8' });
121
- }
122
- catch {
123
- return null;
124
- }
125
- const filename = entries.find(entry => entry.endsWith(`${task.threadId}.jsonl`));
126
- if (!filename)
127
- return null;
128
- let lines;
129
- try {
130
- lines = (await readFile(join(this.sessionsRoot, filename), 'utf8')).split(/\r?\n/);
131
- }
132
- catch {
133
- return null;
134
- }
135
- for (let index = lines.length - 1; index >= 0; index -= 1) {
136
- const line = lines[index];
137
- if (!line)
138
- continue;
139
- try {
140
- const value = JSON.parse(line);
141
- if (value.type !== 'event_msg')
142
- continue;
143
- const payload = value.payload;
144
- if (payload?.type !== 'task_complete')
145
- continue;
146
- const summary = typeof payload.last_agent_message === 'string' ? payload.last_agent_message.trim() : '';
147
- return { summary: summary || 'Codex task completed', data: { recoveredFromThread: task.threadId } };
148
- }
149
- catch { /* ignore malformed session rows */ }
150
- }
151
- return null;
152
- }
95
+ }
96
+ export class LaunchFailure extends Error {
153
97
  }
154
98
  function browserWorkerCandidates(explicit) {
155
99
  const roots = (process.env.AGENTS_RELAY_SKILL_ROOTS ?? '').split(':').filter(Boolean);
156
- return [explicit, ...roots, join(homedir(), '.codex', 'skills'), join(homedir(), '.agents', 'skills'), join(process.cwd(), 'skills')]
100
+ const packageSkills = join(dirname(fileURLToPath(import.meta.url)), '..', '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
+ ]
157
109
  .filter((value) => Boolean(value))
158
- .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'));
159
113
  }
160
- async function loadBrowserWorkerDefinition(explicit) {
114
+ async function loadBrowserWorkerRuntime(explicit) {
161
115
  for (const candidate of browserWorkerCandidates(explicit)) {
162
116
  try {
163
117
  await access(candidate);
164
- return readFile(candidate, 'utf8');
118
+ return { definition: await readFile(candidate, 'utf8'), root: dirname(dirname(candidate)) };
119
+ }
120
+ catch {
121
+ // Try the next installed skill root.
165
122
  }
166
- catch { /* try the next installed skill root */ }
167
123
  }
168
124
  throw new Error('chatgpt-browser-worker agent definition not found; set AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT');
169
125
  }
170
- function thinkingLevel(reasoning) {
171
- if (reasoning === 'low' || reasoning === 'medium' || reasoning === 'high')
172
- return reasoning;
173
- if (reasoning === 'minimal')
174
- return 'low';
175
- return 'default';
176
- }
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');
126
+ function parseDriverJson(output) {
127
+ const lines = output.trim().split(/\r?\n/).filter(Boolean);
128
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
129
+ try {
130
+ const value = JSON.parse(lines[index]);
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.
137
+ }
185
138
  }
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 || !response.status)
190
- throw new Error('browser-worker response omitted operation or status');
191
- return response;
139
+ throw new Error('browser-worker driver returned no submitted receipt');
192
140
  }
193
- function agentPrompt(definition, request) {
194
- return `${definition}\n\nReturn only the machine-readable JSON response required by the agent contract.\nREQUEST_JSON:\n${JSON.stringify(request)}`;
195
- }
196
- function defaultChatGptAgentRunner(harness) {
197
- return (definition, request, route, signal) => {
198
- const child = spawn(harness, buildCodexArgs(route, agentPrompt(definition, request)), { shell: false, cwd: route.cwd, stdio: ['ignore', 'pipe', 'pipe'] });
141
+ function directBrowserWorkerRunner(root) {
142
+ return (_definition, request, _route, signal) => {
143
+ const args = [join(root, 'scripts', 'temporary_bh.py'), '--prompt', request.prompt];
144
+ for (const file of request.files ?? [])
145
+ args.push('--file', file);
146
+ const child = spawn('python3', args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
199
147
  let stdout = '';
200
148
  let stderr = '';
201
- child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
202
- 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
+ });
203
155
  const promise = new Promise((resolve, reject) => {
204
156
  child.on('error', reject);
205
- child.on('close', code => code === 0 ? resolve(parseCodexAgentMessage(stdout) ?? stdout.trim()) : reject(new Error(stderr.trim() || `browser-worker harness exited ${code}`)));
157
+ child.on('close', code => {
158
+ if (code !== 0) {
159
+ reject(new Error(stderr.trim() || stdout.trim() || `browser-worker driver exited ${code}`));
160
+ return;
161
+ }
162
+ try {
163
+ resolve(JSON.stringify(parseDriverJson(stdout)));
164
+ }
165
+ catch (error) {
166
+ reject(error);
167
+ }
168
+ });
206
169
  if (signal.aborted)
207
170
  child.kill('SIGTERM');
208
171
  else
209
172
  signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
210
173
  });
211
- return { promise, cancel: () => { child.kill('SIGTERM'); } };
174
+ return { promise, cancel: () => child.kill('SIGTERM') };
212
175
  };
213
176
  }
214
- function parseCodexAgentMessage(output) {
215
- let final;
216
- for (const line of output.split(/\r?\n/)) {
217
- try {
218
- const value = JSON.parse(line);
219
- const payload = value.payload;
220
- const item = value.item;
221
- if (value.type === 'event_msg' && payload?.type === 'task_complete' && typeof payload.last_agent_message === 'string')
222
- final = payload.last_agent_message;
223
- if (value.type === 'item.completed' && item?.type === 'agent_message' && typeof item.text === 'string')
224
- final = item.text;
225
- }
226
- catch { /* harness diagnostics are not the agent response */ }
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);
227
182
  }
228
- return final;
229
- }
230
- function browserWorkerError(response) {
231
- const detail = response.error?.message || `browser-worker ${response.status}`;
232
- return new Error(response.error?.code ? `${response.error.code}: ${detail}` : detail);
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;
233
195
  }
234
196
  export class ChatGptAdapter {
235
197
  name = 'chatgpt';
236
198
  id = 'chatgpt/browser-worker';
237
199
  capabilities = ['model', 'chatgpt', 'browser-harness'];
238
200
  agentPath;
239
- harness;
240
201
  runner;
241
- deleted = new Set();
242
- routes = new Map();
243
- constructor(options = {}) { this.agentPath = options.agentPath ?? process.env.AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT; this.harness = options.harness ?? process.env.AGENTS_RELAY_AGENT_HARNESS ?? 'codex'; this.runner = options.agentRunner; }
244
- async run(request, route, signal) {
245
- const definition = await loadBrowserWorkerDefinition(this.agentPath);
246
- if (signal.aborted)
247
- throw new Error('browser-worker execution aborted');
248
- const run = (this.runner ?? defaultChatGptAgentRunner(this.harness))(definition, request, route, signal);
249
- const output = await new Promise((resolve, reject) => {
250
- const abort = () => { run.cancel(); reject(new Error('browser-worker execution aborted')); };
251
- if (signal.aborted) {
252
- abort();
253
- return;
254
- }
255
- signal.addEventListener('abort', abort, { once: true });
256
- run.promise.then(value => { signal.removeEventListener('abort', abort); resolve(value); }, error => { signal.removeEventListener('abort', abort); reject(error); });
257
- });
258
- return parseBrowserWorkerResponse(output);
259
- }
260
- project(task, route) {
261
- return { name: task?.projectName ?? route.projectId ?? 'default', id: route.projectId };
262
- }
263
- exposeThread(execution, expected, response) {
264
- const threadId = typeof response.thread_id === 'string' && response.thread_id.trim() ? response.thread_id : undefined;
265
- if (!threadId)
266
- throw new Error('browser-worker response omitted thread_id');
267
- if (expected && threadId !== expected)
268
- throw new Error('browser-worker returned a different thread_id');
269
- execution.threadId = threadId;
270
- execution.onThreadStarted?.(threadId);
271
- return threadId;
272
- }
273
- validateOperation(response, operation) {
274
- if (response.operation !== operation)
275
- throw new Error(`browser-worker response operation mismatch: expected ${operation}, got ${response.operation}`);
276
- if (response.status === 'blocked' || response.status === 'failed')
277
- throw browserWorkerError(response);
278
- }
279
- async deleteThread(threadId, task) {
280
- if (this.deleted.has(threadId))
281
- return;
282
- const route = task?.routing ?? this.routes.get(threadId);
283
- if (!route)
284
- throw new Error(`Task routing metadata is required to delete browser-worker thread ${threadId}`);
285
- const response = await this.run({ operation: 'delete', thread_id: threadId, project: this.project(task, route), state: null }, route, new AbortController().signal);
286
- if (response.operation !== 'delete')
287
- throw new Error(`browser-worker response operation mismatch: expected delete, got ${response.operation}`);
288
- if (response.thread_id && response.thread_id !== threadId)
289
- throw new Error('browser-worker returned a different thread_id');
290
- if (response.status !== 'deleted' && response.status !== 'not_found')
291
- throw browserWorkerError(response);
292
- 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;
293
205
  }
294
206
  launch(task, signal) {
295
207
  if (!task.routing)
@@ -301,45 +213,32 @@ export class ChatGptAdapter {
301
213
  abort();
302
214
  else
303
215
  signal.addEventListener('abort', abort, { once: true });
304
- const execution = { id: randomUUID(), promise: Promise.resolve({ summary: '' }), cancel: abort };
305
- const timeout = setTimeout(abort, Math.max(1, task.timeoutMs));
216
+ const execution = {
217
+ id: randomUUID(),
218
+ promise: Promise.resolve({ summary: '' }),
219
+ cancel: abort,
220
+ };
306
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);
307
226
  try {
308
- const expectedThreadId = task.threadId;
309
- const hasPrompt = Boolean(task.input.trim());
310
- const operation = expectedThreadId ? (hasPrompt ? 'continue' : 'resume') : 'create';
311
- const request = {
312
- operation, thread_id: expectedThreadId, project: this.project(task, route), prompt: hasPrompt ? task.input : undefined,
313
- 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
+ },
314
236
  };
315
- const response = await this.run(request, route, controller.signal);
316
- const threadId = this.exposeThread(execution, expectedThreadId, response);
317
- this.routes.set(threadId, route);
318
- this.validateOperation(response, operation);
319
- let status = response.status;
320
- while (status !== 'awaiting_result' && status !== 'completed') {
321
- if (controller.signal.aborted)
322
- throw new Error('browser-worker execution aborted');
323
- await new Promise((resolve, reject) => {
324
- const timer = setTimeout(resolve, 25);
325
- controller.signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('browser-worker execution aborted')); }, { once: true });
326
- });
327
- const statusResponse = await this.run({ operation: 'status', thread_id: threadId, project: this.project(task, route), state: null }, route, controller.signal);
328
- if (statusResponse.thread_id && statusResponse.thread_id !== threadId)
329
- throw new Error('browser-worker returned a different thread_id');
330
- this.validateOperation(statusResponse, 'status');
331
- status = statusResponse.status;
332
- }
333
- const resultResponse = await this.run({ operation: 'result', thread_id: threadId, project: this.project(task, route), state: null }, route, controller.signal);
334
- if (resultResponse.thread_id && resultResponse.thread_id !== threadId)
335
- throw new Error('browser-worker returned a different thread_id');
336
- this.validateOperation(resultResponse, 'result');
337
- if (resultResponse.status !== 'completed' || resultResponse.result?.verified !== true || !resultResponse.result.message_id || !resultResponse.result.text?.trim())
338
- throw browserWorkerError(resultResponse);
339
- return { summary: resultResponse.result.text.trim(), data: { threadId, messageId: resultResponse.result.message_id, verified: true, provider: route.provider, model: route.model } };
340
237
  }
341
- finally {
342
- clearTimeout(timeout);
238
+ catch (error) {
239
+ if (controller.signal.aborted)
240
+ throw error;
241
+ throw new LaunchFailure(error instanceof Error ? error.message : String(error));
343
242
  }
344
243
  })();
345
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') {