agents-relay 1.0.2 → 1.0.4

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
@@ -17,6 +17,14 @@ npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id job-1
17
17
  npx agents-relay status --repo OWNER/REPO --pr 12 --id job-1
18
18
  ~~~
19
19
 
20
+ Mutable queued/retryable task fields can be changed in place without replacing the durable task identity. `task update` rejects `RUNNING`, `SUCCEEDED`, and `CANCELLED` tasks so active execution and terminal history cannot be rewritten. For example:
21
+
22
+ ~~~sh
23
+ npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id child \
24
+ --adapter chatgpt --provider chatgpt --model gpt-5.6-sol --reasoning high \
25
+ --capabilities model,chatgpt,browser-harness
26
+ ~~~
27
+
20
28
  Jobs default to `fixed`, preserving the original submit-and-run behavior. Use
21
29
  `--mode autonomous` with `init` or `job create` to make reconciliation own
22
30
  planner progress. The durable status JSON and dashboard show the mode. An
@@ -78,24 +86,24 @@ Codex/model-backed tasks require --provider and --model (or equivalent routing m
78
86
 
79
87
  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.
80
88
 
81
- ### ChatGPT workers through MacBridge
89
+ ### ChatGPT workers through the browser-worker agent
82
90
 
83
- Use the chatgpt adapter to make a relay task create a real ChatGPT web conversation through the local MacBridge runtime. The returned ChatGPT conversation_id is stored as the task threadId, so lineage is durable and a retry continues the same conversation.
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.
84
92
 
85
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.
86
94
 
87
- 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 deletes every ChatGPT task conversation through MacBridge. 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.
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.
88
96
 
89
97
  ~~~sh
90
98
  npx agents-relay submit --repo OWNER/REPO --pr 5 --id job-1 \
91
99
  --task-id research-ui --adapter chatgpt \
92
- --capabilities model,chatgpt,macbridge \
100
+ --capabilities model,chatgpt,browser-harness \
93
101
  --provider openai --model gpt-5-6-sol --reasoning high \
94
102
  --chatgpt-project g-p-EXACT_PROJECT_ID \
95
103
  --input "Research the dashboard UX and return implementation guidance."
96
104
  ~~~
97
105
 
98
- The adapter calls MacBridge's loopback-only /experimental/chatgpt/conversation endpoint. Configure it with --macbridge-url and --macbridge-token-file, or AGENTS_RELAY_MACBRIDGE_URL / AGENTS_RELAY_MACBRIDGE_TOKEN_FILE. By default it uses loopback port MAC_DEV_BRIDGE_HTTP_PORT (or 8788) and the MacBridge menu app token file under ~/Library/Application Support/MacDeveloperBridge/http-token. Secrets are read locally and are never stored in PR markers.
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.
99
107
 
100
108
  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.
101
109
 
package/dist/adapters.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { readFile, readdir } from 'node:fs/promises';
2
+ import { access, readFile, readdir } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { randomUUID } from 'node:crypto';
@@ -151,100 +151,145 @@ export class CodexAdapter {
151
151
  return null;
152
152
  }
153
153
  }
154
- function chatGptThinkingEffort(reasoning) {
155
- if (reasoning === 'minimal' || reasoning === 'low' || reasoning === 'standard' || reasoning === 'high' || reasoning === 'max')
156
- return reasoning;
157
- if (reasoning === 'medium')
158
- return 'standard';
159
- if (reasoning === 'xhigh' || reasoning === 'extra-high')
160
- return 'max';
161
- return 'standard';
154
+ function browserWorkerCandidates(explicit) {
155
+ 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')]
157
+ .filter((value) => Boolean(value))
158
+ .map(value => value.endsWith('browser-worker.agent.md') ? value : join(value, 'chatgpt-browser-worker', 'agents', 'browser-worker.agent.md'));
162
159
  }
163
- function chatGptEndpoint(base) {
164
- const configured = (base ?? process.env.AGENTS_RELAY_MACBRIDGE_URL ?? `http://127.0.0.1:${process.env.MAC_DEV_BRIDGE_HTTP_PORT ?? '8788'}`).replace(/\/+$/, '');
165
- const url = new URL(configured);
166
- if (url.pathname === '/experimental/chatgpt/conversation')
167
- return url.toString().replace(/\/$/, '');
168
- // --macbridge-url is a service origin. Do not inherit an unrelated API path
169
- // such as /v1/responses from a model-router endpoint.
170
- url.pathname = '/experimental/chatgpt/conversation';
171
- url.search = '';
172
- url.hash = '';
173
- return url.toString();
160
+ async function loadBrowserWorkerDefinition(explicit) {
161
+ for (const candidate of browserWorkerCandidates(explicit)) {
162
+ try {
163
+ await access(candidate);
164
+ return readFile(candidate, 'utf8');
165
+ }
166
+ catch { /* try the next installed skill root */ }
167
+ }
168
+ throw new Error('chatgpt-browser-worker agent definition not found; set AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT');
174
169
  }
175
- function chatGptTokenFile(file) {
176
- return file ?? process.env.AGENTS_RELAY_MACBRIDGE_TOKEN_FILE ?? process.env.MAC_DEV_BRIDGE_HTTP_TOKEN_FILE ?? join(homedir(), 'Library', 'Application Support', 'MacDeveloperBridge', 'http-token');
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';
177
176
  }
178
- function chatGptConversationId(text) {
179
- const match = /\"conversation_id\"\s*:\s*\"((?:\\.|[^\"\\])*)\"/.exec(text);
180
- if (!match)
181
- return undefined;
177
+ function parseBrowserWorkerResponse(output) {
178
+ const text = output.trim().replace(/^```(?:json)?\s*|\s*```$/g, '').trim();
179
+ let value;
182
180
  try {
183
- const value = JSON.parse(`\"${match[1]}\"`);
184
- return typeof value === 'string' ? value : undefined;
181
+ value = JSON.parse(text);
185
182
  }
186
183
  catch {
187
- return undefined;
184
+ throw new Error('browser-worker returned non-JSON output');
188
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 || !response.status)
190
+ throw new Error('browser-worker response omitted operation or status');
191
+ return response;
189
192
  }
190
- async function readChatGptResponse(response, onConversationId) {
191
- if (!response.body) {
192
- const text = await response.text();
193
- const conversationId = chatGptConversationId(text);
194
- if (conversationId)
195
- onConversationId(conversationId);
196
- return text;
197
- }
198
- const reader = response.body.getReader();
199
- const decoder = new TextDecoder();
200
- let text = '';
201
- while (true) {
202
- const { done, value } = await reader.read();
203
- if (done)
204
- break;
205
- text += decoder.decode(value, { stream: true });
206
- const conversationId = chatGptConversationId(text);
207
- if (conversationId)
208
- onConversationId(conversationId);
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'] });
199
+ let stdout = '';
200
+ let stderr = '';
201
+ child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
202
+ child.stderr?.on('data', (chunk) => { stderr = appendTail(stderr, chunk.toString()); });
203
+ const promise = new Promise((resolve, reject) => {
204
+ 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}`)));
206
+ if (signal.aborted)
207
+ child.kill('SIGTERM');
208
+ else
209
+ signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
210
+ });
211
+ return { promise, cancel: () => { child.kill('SIGTERM'); } };
212
+ };
213
+ }
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 */ }
209
227
  }
210
- text += decoder.decode();
211
- const conversationId = chatGptConversationId(text);
212
- if (conversationId)
213
- onConversationId(conversationId);
214
- return text;
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);
215
233
  }
216
234
  export class ChatGptAdapter {
217
235
  name = 'chatgpt';
218
- id = 'chatgpt/macbridge';
219
- capabilities = ['model', 'chatgpt', 'macbridge'];
220
- endpoint;
221
- tokenFile;
222
- fetchImpl;
223
- constructor(options = {}) {
224
- this.endpoint = chatGptEndpoint(options.endpoint);
225
- this.tokenFile = chatGptTokenFile(options.tokenFile);
226
- this.fetchImpl = options.fetch ?? fetch;
227
- }
228
- async deleteThread(threadId) {
229
- const token = (await readFile(this.tokenFile, 'utf8')).trim();
230
- if (!token)
231
- throw new Error(`MacBridge token file is empty: ${this.tokenFile}`);
232
- const response = await this.fetchImpl(this.endpoint, {
233
- method: 'DELETE',
234
- headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
235
- body: JSON.stringify({ conversation_id: threadId }),
236
+ id = 'chatgpt/browser-worker';
237
+ capabilities = ['model', 'chatgpt', 'browser-harness'];
238
+ agentPath;
239
+ harness;
240
+ 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); });
236
257
  });
237
- if (response.status === 404)
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))
238
281
  return;
239
- if (!response.ok) {
240
- let detail = '';
241
- try {
242
- const payload = await response.json();
243
- detail = typeof payload.error === 'string' ? payload.error : '';
244
- }
245
- catch { }
246
- throw new Error(detail || `MacBridge ChatGPT conversation delete failed (${response.status})`);
247
- }
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);
248
293
  }
249
294
  launch(task, signal) {
250
295
  if (!task.routing)
@@ -257,54 +302,45 @@ export class ChatGptAdapter {
257
302
  else
258
303
  signal.addEventListener('abort', abort, { once: true });
259
304
  const execution = { id: randomUUID(), promise: Promise.resolve({ summary: '' }), cancel: abort };
305
+ const timeout = setTimeout(abort, Math.max(1, task.timeoutMs));
260
306
  execution.promise = (async () => {
261
- const token = (await readFile(this.tokenFile, 'utf8')).trim();
262
- if (!token)
263
- throw new Error(`MacBridge token file is empty: ${this.tokenFile}`);
264
- const body = {
265
- prompt: task.input,
266
- model: route.model,
267
- thinking_effort: chatGptThinkingEffort(route.reasoning),
268
- max_runtime_seconds: Math.max(30, Math.min(3600, Math.ceil(task.timeoutMs / 1000))),
269
- };
270
- if (route.projectId)
271
- body.project_id = route.projectId;
272
- if (task.threadId)
273
- body.conversation_id = task.threadId;
274
- const response = await this.fetchImpl(this.endpoint, {
275
- method: 'POST',
276
- headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
277
- body: JSON.stringify(body),
278
- signal: controller.signal,
279
- });
280
- let exposedConversationId;
281
- const exposeConversationId = (conversationId) => {
282
- if (exposedConversationId === conversationId)
283
- return;
284
- exposedConversationId = conversationId;
285
- execution.threadId = conversationId;
286
- execution.onThreadStarted?.(conversationId);
287
- };
288
- const text = await readChatGptResponse(response, exposeConversationId);
289
- let payload;
290
307
  try {
291
- payload = JSON.parse(text);
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,
314
+ };
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 } };
292
340
  }
293
- catch {
294
- throw new Error(`MacBridge returned unreadable ChatGPT response (${response.status})`);
341
+ finally {
342
+ clearTimeout(timeout);
295
343
  }
296
- const conversationId = typeof payload.conversation_id === 'string' ? payload.conversation_id : exposedConversationId;
297
- if (conversationId)
298
- exposeConversationId(conversationId);
299
- if (!response.ok)
300
- throw new Error(typeof payload.error === 'string' ? payload.error : `MacBridge ChatGPT request failed (${response.status})`);
301
- if (payload.complete !== true)
302
- throw new Error('MacBridge ChatGPT conversation did not complete');
303
- const assistantText = typeof payload.assistant_text === 'string' ? payload.assistant_text.trim() : '';
304
- return {
305
- summary: assistantText || 'ChatGPT conversation completed',
306
- data: { conversationId, provider: route.provider, model: route.model },
307
- };
308
344
  })();
309
345
  return execution;
310
346
  }
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@ import { codexAndZaiUsageRegistry } from './usage.js';
15
15
  import { discoverAgents } from './registry.js';
16
16
  import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
17
17
  import { extendRetryBudget } from './scheduler.js';
18
- const usage = 'agents-relay <job|init|submit|record|agent-register|agent-discover|status|reconcile|retry|cancel|serve> [options]';
18
+ const usage = 'agents-relay <job|task|init|submit|record|agent-register|agent-discover|status|reconcile|retry|cancel|serve> [options]';
19
19
  const help = {
20
20
  root: `Agents Relay coordinates durable asynchronous agent jobs through GitHub pull requests.
21
21
 
@@ -24,6 +24,7 @@ Usage:
24
24
 
25
25
  Commands:
26
26
  job create|adopt|repair Create or repair a durable GitHub-backed job
27
+ task update Update mutable durable task fields in place
27
28
  init Create a local demo job or initialize a GitHub job
28
29
  submit Queue a task for a managed job
29
30
  record Backfill a completed task into durable history
@@ -76,6 +77,21 @@ Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
76
77
 
77
78
  Example:
78
79
  npx agents-relay job repair --repo OWNER/REPO --pr 12 --id job-12`,
80
+ task: `Manage a durable task.
81
+
82
+ Usage:
83
+ npx agents-relay task update [options]
84
+
85
+ Actions:
86
+ update Update mutable task fields without changing task identity/history`,
87
+ 'task update': `Update mutable durable task fields in place.
88
+
89
+ Required: --task-id ID and the normal job storage options.
90
+ Mutable while not RUNNING/SUCCEEDED/CANCELLED: --adapter, --priority, --input, --project, --agent, --parent, --deps, --capabilities, --timeout, --max-attempts, --provider, --model, --profile, --reasoning, --cwd, --chatgpt-project.
91
+ Use --clear-routing to remove routing metadata. A model update requires --model; provider defaults to the existing provider or openai.
92
+
93
+ Example:
94
+ npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id build --adapter chatgpt --provider chatgpt --model gpt-5.6-sol --reasoning high`,
79
95
  submit: `Queue a task for a managed job.
80
96
 
81
97
  Required: --repo OWNER/REPO, --pr NUMBER, --id JOB_ID, and --input TEXT.
@@ -145,6 +161,8 @@ Example:
145
161
  export function helpText(command, action) {
146
162
  if (command === 'job')
147
163
  return action ? help[`job ${action}`] ?? help.job : help.job;
164
+ if (command === 'task')
165
+ return action ? help[`task ${action}`] ?? help.task : help.task;
148
166
  return command ? help[command] ?? help.root : help.root;
149
167
  }
150
168
  export const SERVICE_DEFAULTS = { port: 8787, watchdogMs: 300000, webhookWatchdogMs: 1800000, dashboardRefreshMs: 300000 };
@@ -172,6 +190,10 @@ class ServiceEventBus {
172
190
  }
173
191
  }
174
192
  function arg(args, name, fallback = '') { const index = args.indexOf(name); return index >= 0 ? args[index + 1] ?? fallback : fallback; }
193
+ function hasArg(args, name) { return args.includes(name); }
194
+ function integerArg(args, name, fallback) { if (!hasArg(args, name))
195
+ return fallback; const value = Number(arg(args, name)); if (!Number.isInteger(value) || value < 0)
196
+ throw new Error(`${name} must be a non-negative integer`); return value; }
175
197
  function repository(args) { const value = arg(args, '--repo'); if (!value)
176
198
  throw new Error('--repo is required for GitHub-backed operation'); return value; }
177
199
  function pullRequest(args) { const value = Number(arg(args, '--pr')); if (!Number.isInteger(value) || value <= 0)
@@ -233,6 +255,66 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
233
255
  await store.saveJob(job);
234
256
  return { job: await store.load(options.id), pr };
235
257
  }
258
+ async function runTaskCommand(action, args) {
259
+ if (action !== 'update')
260
+ throw new Error(usage);
261
+ const { store, job, localFile } = await storeFor(args);
262
+ const id = arg(args, '--task-id');
263
+ if (!id)
264
+ throw new Error('task update requires --task-id');
265
+ const task = job.tasks.find(item => item.id === id);
266
+ if (!task)
267
+ throw new Error(`Task ${id} not found`);
268
+ if (['RUNNING', 'SUCCEEDED', 'CANCELLED'].includes(task.state))
269
+ throw new Error(`Task ${id} is ${task.state}; durable execution fields are immutable in this state`);
270
+ if (hasArg(args, '--adapter')) {
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');
274
+ task.adapter = adapter;
275
+ }
276
+ if (hasArg(args, '--priority'))
277
+ task.priority = priority(args, task.priority ?? job.priority ?? 'P2');
278
+ if (hasArg(args, '--input'))
279
+ task.input = arg(args, '--input');
280
+ if (hasArg(args, '--project'))
281
+ task.projectName = arg(args, '--project') || undefined;
282
+ if (hasArg(args, '--agent'))
283
+ task.agentName = arg(args, '--agent') || undefined;
284
+ if (hasArg(args, '--parent'))
285
+ task.parentTaskId = arg(args, '--parent') || null;
286
+ if (hasArg(args, '--deps'))
287
+ task.dependencies = arg(args, '--deps').split(',').filter(Boolean);
288
+ if (hasArg(args, '--capabilities'))
289
+ task.capabilities = arg(args, '--capabilities').split(',').filter(Boolean);
290
+ task.timeoutMs = integerArg(args, '--timeout', task.timeoutMs);
291
+ task.maxAttempts = integerArg(args, '--max-attempts', task.maxAttempts);
292
+ if (hasArg(args, '--max-attempts') && task.maxAttempts < task.attempt)
293
+ throw new Error('--max-attempts cannot be less than the current attempt count');
294
+ const routingFlags = ['--provider', '--model', '--profile', '--reasoning', '--cwd', '--chatgpt-project'];
295
+ if (args.includes('--clear-routing'))
296
+ task.routing = undefined;
297
+ else if (routingFlags.some(flag => hasArg(args, flag))) {
298
+ const current = task.routing;
299
+ const model = hasArg(args, '--model') ? arg(args, '--model') : current?.model;
300
+ if (!model)
301
+ throw new Error('task routing update requires --model when no existing routing is present');
302
+ task.routing = {
303
+ provider: hasArg(args, '--provider') ? arg(args, '--provider') : current?.provider ?? 'openai',
304
+ model,
305
+ profile: hasArg(args, '--profile') ? arg(args, '--profile') || undefined : current?.profile,
306
+ reasoning: hasArg(args, '--reasoning') ? arg(args, '--reasoning') || undefined : current?.reasoning,
307
+ cwd: hasArg(args, '--cwd') ? arg(args, '--cwd') || undefined : current?.cwd,
308
+ projectId: hasArg(args, '--chatgpt-project') ? arg(args, '--chatgpt-project') || undefined : current?.projectId,
309
+ decidedBy: 'cli:update',
310
+ decidedAt: new Date().toISOString()
311
+ };
312
+ }
313
+ task.updatedAt = new Date().toISOString();
314
+ await store.saveTask(task);
315
+ await saveLocal(localFile, job);
316
+ console.log(JSON.stringify(task, null, 2));
317
+ }
236
318
  async function runJobCommand(action, args) {
237
319
  if (!['create', 'adopt', 'repair'].includes(action))
238
320
  throw new Error(usage);
@@ -264,7 +346,7 @@ async function runJobCommand(action, args) {
264
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));
265
347
  }
266
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); }
267
- 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({ 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 }); }
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 }); }
268
350
  export async function createService(args) {
269
351
  const loaded = await storeFor(args);
270
352
  const upstream = eventBus(args);
@@ -342,7 +424,7 @@ async function main() {
342
424
  return;
343
425
  }
344
426
  if (args.includes('--help') || args.includes('-h')) {
345
- console.log(helpText(command, command === 'job' ? args[0] : undefined));
427
+ console.log(helpText(command, command === 'job' || command === 'task' ? args[0] : undefined));
346
428
  return;
347
429
  }
348
430
  if (command === 'job') {
@@ -350,6 +432,11 @@ async function main() {
350
432
  await runJobCommand(action ?? '', jobArgs);
351
433
  return;
352
434
  }
435
+ if (command === 'task') {
436
+ const [action, ...taskArgs] = args;
437
+ await runTaskCommand(action ?? '', taskArgs);
438
+ return;
439
+ }
353
440
  if (command === 'init') {
354
441
  const now = new Date().toISOString();
355
442
  const 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: [] };
@@ -359,7 +359,7 @@ export class Reconciler {
359
359
  return;
360
360
  for (const task of job.tasks.filter(item => item.adapter === 'chatgpt' && item.threadId && !item.threadDeletedAt)) {
361
361
  try {
362
- await adapter.deleteThread(task.threadId);
362
+ await adapter.deleteThread(task.threadId, task);
363
363
  task.threadDeletedAt = new Date().toISOString();
364
364
  task.threadCleanupError = null;
365
365
  await this.store.saveTask(task);
package/dist/relayd.js CHANGED
@@ -64,7 +64,7 @@ export async function runDaemon(argv) {
64
64
  const trusted = auth.trustedAuthors;
65
65
  const concurrency = Number(value(argv, '--concurrency', '4'));
66
66
  const bus = value(argv, '--events') === 'nats' ? new NatsEventBus(value(argv, '--nats-url', 'nats://127.0.0.1:4222'), value(argv, '--subject-prefix', 'agents-relay.events.job')) : undefined;
67
- const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new ShellAdapter(), new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter({ endpoint: value(argv, '--macbridge-url') || undefined, tokenFile: value(argv, '--macbridge-token-file') || undefined })], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), eventBus: bus });
67
+ const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new ShellAdapter(), new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), eventBus: bus });
68
68
  const repositories = normalized.repository
69
69
  ? [normalized.repository]
70
70
  : (await discoverWorkspaceRepositories(normalized.workspaceRoot ?? defaultWorkspaceRoot())).map(item => item.repository);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,7 @@ description: Create and operate durable asynchronous agent jobs through GitHub P
10
10
  This skill is the installable instruction/agent bundle. Do not require a local source checkout to execute Agents Relay. Use `npx agents-relay ...` for the executable CLI/runtime. Supporting agent definitions live under this skill directory and ship with the npm package; the default autonomous planner uses `agents/planner.agent.md`.
11
11
 
12
12
 
13
- Use Agents Relay for asynchronous work that must survive the current agent process. Create one durable top-level job per objective and submit child tasks with unique IDs, explicit parentTaskId, dependencies, capabilities, adapter, timeout, and retry policy. A model-backed task must carry a recorded routing decision (provider, model, optional profile/reasoning/cwd/projectId) before it can launch. Use adapter codex for local Codex-compatible workers and adapter chatgpt for a ChatGPT web worker created through MacBridge.
13
+ Use Agents Relay for asynchronous work that must survive the current agent process. Create one durable top-level job per objective and submit child tasks with unique parentTaskId, dependencies, capabilities, adapter, timeout, and retry policy. A model-backed task must carry a recorded routing decision (provider, model, optional profile/reasoning/cwd/projectId) before it can launch. Use adapter codex for local Codex-compatible workers and adapter chatgpt for the installed `chatgpt-browser-worker` agent through a local model harness.
14
14
 
15
15
  GitHub PR comments are durable truth in operational mode. Reload the PR after every event or wake-up and reconcile desired durable state into worker executions; events and NATS are only low-latency notifications and must never be treated as completion.
16
16
 
@@ -62,11 +62,13 @@ Use --file PATH only for explicit local demo/test mode. Optional --events nats e
62
62
 
63
63
  Use task-level continuation when the sender needs to resume, otherwise the job continuation. Continuation delivery is deduplicated by the durable delivery timestamp. Treat BLOCKED as an approval/manual-release state until an explicit retry/release changes it. Cancellation, timeout, and lease expiry are durable state transitions; do not claim success from a worker process exit alone.
64
64
 
65
- For a ChatGPT worker, use the chatgpt adapter with capabilities model,chatgpt,macbridge. The runtime calls MacBridge's loopback-only ChatGPT conversation endpoint, stores the returned conversation_id as the durable task threadId, and reuses that ID on retry. Pass --chatgpt-project when the worker must run inside one exact ChatGPT Project. MacBridge URL/token overrides are --macbridge-url and --macbridge-token-file; never place the bearer token in job/task markers.
65
+ Use `npx agents-relay task update` to change safe durable fields of a `QUEUED`, `READY`, `WAITING`, `BLOCKED`, or `FAILED` task without changing its task ID or prior attempt/error history. This is the preferred way to reroute future execution to a different adapter/provider/model. The CLI rejects `RUNNING`, `SUCCEEDED`, and `CANCELLED` task updates so active execution semantics and terminal audit history are not rewritten.
66
+
67
+ For a ChatGPT worker, use the chatgpt adapter with capabilities model,chatgpt,browser-harness. The runtime launches the installed `chatgpt-browser-worker` agent, stores its observed `thread_id` as the durable task threadId, and reuses that ID on retry. Pass --chatgpt-project when the worker must run inside one exact ChatGPT Project. Set `AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT` to an explicit agent definition path when needed; otherwise standard skill roots are searched. Never place credentials or private prompts in job/task markers.
66
68
 
67
69
  Managed Codex and ChatGPT worker prompts are enriched at launch with the PR URL and durable job/task/parent/project identity; do not duplicate that context manually in task input. Keep one ChatGPT conversation per logical task. Retries reuse the same task conversation, but sibling tasks use separate conversations even when their adapter is the same.
68
70
 
69
- When a job becomes COMPLETED or its GitHub PR is merged, delete all ChatGPT task conversations. Preserve threadId in durable markers, record threadDeletedAt on success, and record/retry threadCleanupError on deletion failure without reopening the job.
71
+ When a job becomes COMPLETED or its GitHub PR is merged, ask the browser-worker agent to delete all ChatGPT task conversations. Preserve threadId in durable markers, record threadDeletedAt on success, and record/retry threadCleanupError on deletion failure without reopening the job.
70
72
 
71
73
  V1 runs with one active runner per job; do not start multiple reconcilers without adding an atomic distributed lease. Keep secrets, credentials, private prompts, and large private payloads out of PR markers—store summaries and artifact references only. Event transport failures are degraded observability, not durable task failures.
72
74