agents-relay 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.github/workflows/publish.yml +91 -0
  2. package/AGENTS.md +16 -0
  3. package/LICENSE +21 -0
  4. package/README.md +102 -0
  5. package/dist/adapters.js +311 -0
  6. package/dist/cli.js +455 -0
  7. package/dist/continuation.js +21 -0
  8. package/dist/dashboard.js +446 -0
  9. package/dist/events.js +36 -0
  10. package/dist/github-auth.js +34 -0
  11. package/dist/github-webhook.js +47 -0
  12. package/dist/markers.js +42 -0
  13. package/dist/planner.js +172 -0
  14. package/dist/pool.js +98 -0
  15. package/dist/reconciler.js +434 -0
  16. package/dist/registry.js +27 -0
  17. package/dist/relayd.js +177 -0
  18. package/dist/scheduler.js +49 -0
  19. package/dist/store.js +586 -0
  20. package/dist/types.js +6 -0
  21. package/dist/usage.js +370 -0
  22. package/dist/workspace.js +76 -0
  23. package/docs/agent-network.md +34 -0
  24. package/docs/architecture.md +120 -0
  25. package/docs/autonomous-objective-jobs.md +121 -0
  26. package/docs/example.md +30 -0
  27. package/docs/github-app-rate-limit.md +124 -0
  28. package/docs/service.md +43 -0
  29. package/pack.json +326 -0
  30. package/package.json +14 -0
  31. package/scripts/npm-version.mjs +11 -0
  32. package/skills/agents-relay/SKILL.md +77 -0
  33. package/skills/agents-relay/agents/planner.agent.md +28 -0
  34. package/src/adapters.ts +231 -0
  35. package/src/cli.ts +324 -0
  36. package/src/continuation.ts +6 -0
  37. package/src/dashboard.ts +421 -0
  38. package/src/events.ts +25 -0
  39. package/src/github-auth.ts +35 -0
  40. package/src/github-webhook.ts +37 -0
  41. package/src/markers.ts +33 -0
  42. package/src/planner.ts +150 -0
  43. package/src/pool.ts +87 -0
  44. package/src/reconciler.ts +235 -0
  45. package/src/registry.ts +35 -0
  46. package/src/relayd.ts +137 -0
  47. package/src/scheduler.ts +27 -0
  48. package/src/store.ts +526 -0
  49. package/src/types.ts +45 -0
  50. package/src/usage.ts +385 -0
  51. package/src/workspace.ts +62 -0
  52. package/test/adapters.test.js +303 -0
  53. package/test/autonomous.test.js +119 -0
  54. package/test/core.test.js +363 -0
  55. package/test/dashboard.test.js +178 -0
  56. package/test/github-auth.test.js +51 -0
  57. package/test/github-webhook.test.js +21 -0
  58. package/test/service.test.js +116 -0
  59. package/test/store.test.js +390 -0
  60. package/test/usage.test.js +88 -0
  61. package/test/workspace.test.js +95 -0
  62. package/tsconfig.json +4 -0
@@ -0,0 +1,77 @@
1
+ ---
2
+ name: agents-relay
3
+ description: Create and operate durable asynchronous agent jobs through GitHub PR state, worker adapters, continuations, retries, and live progress events. Use when an orchestrator needs work to survive the current thread/process or delegate recoverable child tasks.
4
+ ---
5
+
6
+ # Agents Relay
7
+
8
+ ## Runtime contract
9
+
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
+
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.
14
+
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
+
17
+ Jobs are `fixed` by default. Autonomous jobs are explicitly created with
18
+ `--mode autonomous`; reconciliation creates durable planner tasks only after
19
+ the current parent/subtask work is settled. A planner must return typed
20
+ `objective_status`, `assessment`, and `next_tasks`; the runtime owns leases,
21
+ restart recovery, stable-ID deduplication, and completion gates. Planner
22
+ satisfaction does not override failed or blocked work.
23
+
24
+ ## Managed GitHub bootstrap
25
+
26
+ For new GitHub-backed work, do not create the PR or job marker with raw gh commands. Use the first-class CLI:
27
+
28
+ ~~~sh
29
+ npx agents-relay job create --repo OWNER/REPO --head feat/example --base main \
30
+ --id JOB_ID --title "Objective" --body "PR description"
31
+ ~~~
32
+
33
+ job create resolves an existing open PR with the same head/base before creating one, then persists exactly one trusted agents-relay:job:v1 marker. Re-running the command is idempotent.
34
+
35
+ To bring an existing unmanaged PR under Agents Relay, even when it has zero comments:
36
+
37
+ ~~~sh
38
+ npx agents-relay job adopt --repo OWNER/REPO --pr 6 --id JOB_ID --title "Objective"
39
+ ~~~
40
+
41
+ To repair duplicate markers for that same job:
42
+
43
+ ~~~sh
44
+ npx agents-relay job repair --repo OWNER/REPO --pr 6 --id JOB_ID
45
+ ~~~
46
+
47
+ A PR carrying a marker for a different job is rejected rather than silently reassigned. Managed task commands must not run until the durable job marker exists; submit/status/reconcile/retry/cancel/serve fail clearly when it is absent.
48
+
49
+ The legacy init flow remains supported for compatibility, but orchestrators should prefer job create/adopt/repair for GitHub-backed work.
50
+
51
+ Typical managed flow:
52
+
53
+ ~~~sh
54
+ npx agents-relay job create --repo OWNER/REPO --head feat/example --id JOB_ID --title "Objective"
55
+ npx agents-relay submit --repo OWNER/REPO --pr 12 --id JOB_ID --task-id child --input "echo work" --adapter shell
56
+ npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id JOB_ID
57
+ npx agents-relay status --repo OWNER/REPO --pr 12 --id JOB_ID
58
+ npx agents-relay serve --repo OWNER/REPO --pr 12 --id JOB_ID
59
+ ~~~
60
+
61
+ Use --file PATH only for explicit local demo/test mode. Optional --events nats enables wake/progress events with --subject-prefix; the PR remains authoritative.
62
+
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
+
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.
66
+
67
+ 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
+
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.
70
+
71
+ 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
+
73
+ Registered agents are result-owning roles with responsibility boundaries; skills are callable capabilities and adapters are execution runtimes. Register agents with `npx agents-relay agent-register`, discover them with hard filters using `npx agents-relay agent-discover`, and treat claimed capabilities separately from observed evaluations/outcomes. Discovery returns explainable evidence and permits exploration; it does not produce a universal trust score. The future constrained remote surface should authenticate `POST /jobs` and `GET /jobs/:id` only, with public/intention-only jobs separated from private-data or credential work.
74
+
75
+ Treat GitHub PR state as authoritative at reconciliation boundaries. `MERGED` forces the job to `COMPLETED`; closed-unmerged forces `CANCELLED` and prevents launches. Use the repository-wide dashboard job list to compare GitHub and durable states; repair any mismatch rather than trusting a stale marker.
76
+
77
+ If already-completed orchestrator work is missing from a managed job, backfill it with `npx agents-relay record` and the real commit SHA rather than inventing a worker execution. Treat this as a repair path only; new work should be submitted before it starts. Managed job description comes from the GitHub PR body and should remain visible with the job title in the dashboard.
@@ -0,0 +1,28 @@
1
+ # Planner Agent
2
+
3
+ You are the default objective planner for Agents Relay autonomous jobs.
4
+
5
+ Your job is to inspect the durable objective and current durable task tree, then decide whether the objective is satisfied or which concrete tasks should run next.
6
+
7
+ ## Rules
8
+
9
+ - Treat the supplied durable job/task state as authoritative.
10
+ - Never invent completed work or evidence.
11
+ - Do not bypass failed, blocked, review, merge, or other normal completion gates.
12
+ - Prefer small, independently verifiable next tasks.
13
+ - Preserve parent/subtask causality and stable task IDs.
14
+ - Reuse existing tasks instead of duplicating equivalent work.
15
+ - Select an appropriate agent identity and execution adapter for each new task when known.
16
+ - Do not call an execution agent "codex"; agent identity describes responsibility, while adapters describe runtime.
17
+ - Return no prose outside the JSON result.
18
+
19
+ ## Output
20
+
21
+ Return exactly one JSON object with:
22
+ - objective_status: "in_progress" or "satisfied"
23
+ - assessment: a short evidence-based assessment
24
+ - next_tasks: an array of typed task objects
25
+
26
+ Each next task may contain: id, input, priority, projectName, agentName, dependencies, capabilities, adapter, maxAttempts, and timeoutMs.
27
+
28
+ When the objective is satisfied, next_tasks must be empty.
@@ -0,0 +1,231 @@
1
+ import { spawn, ChildProcess } from 'node:child_process';
2
+ import { readFile, readdir } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { Task, AdapterName, Result, RoutingDecision } from './types.js';
7
+
8
+ export type Execution = { id: string; promise: Promise<Result>; cancel: () => void; threadId?: string; onThreadStarted?: (threadId: string) => void };
9
+ export interface WorkerAdapter { readonly name: AdapterName; readonly id: string; readonly capabilities: readonly string[]; launch(task: Task, signal: AbortSignal): Execution; recover?(task: Task): Promise<Result | null>; deleteThread?(threadId: string): Promise<void>; }
10
+ function commandExecution(child: ChildProcess, output: () => string, signal: AbortSignal, id: string): Execution { const promise = new Promise<Result>((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'); } }; }
11
+ export function buildCodexArgs(route: Pick<RoutingDecision, 'provider' | 'model'> & Partial<Pick<RoutingDecision, 'profile' | 'reasoning' | 'cwd'>>, input: string): readonly string[] {
12
+ const args: string[] = ['exec', '--json'];
13
+ if (route.profile) args.push('-p', route.profile);
14
+ if (route.model) args.push('-m', route.model);
15
+ if (route.provider) args.push('-c', `model_provider=${route.provider}`);
16
+ if (route.reasoning) args.push('-c', `model_reasoning_effort=${route.reasoning}`);
17
+ if (route.cwd) args.push('-C', route.cwd);
18
+ args.push('--', input);
19
+ return args;
20
+ }
21
+ function parseThreadStarted(line: string): string | undefined { try { const value = JSON.parse(line) as Record<string, unknown>; return value.type === 'thread.started' && typeof value.thread_id === 'string' ? value.thread_id : undefined; } catch { return undefined; } }
22
+ function parseCodexAssistantMessage(line: string): string | undefined {
23
+ try {
24
+ const value = JSON.parse(line) as Record<string, unknown>;
25
+ const payload = value.payload as Record<string, unknown> | undefined;
26
+ if (value.type === 'event_msg' && payload?.type === 'task_complete' && typeof payload.last_agent_message === 'string') return payload.last_agent_message.trim() || undefined;
27
+ const item = value.item as Record<string, unknown> | undefined;
28
+ if (value.type === 'item.completed' && item?.type === 'agent_message' && typeof item.text === 'string') return item.text.trim() || undefined;
29
+ } catch { /* non-JSON diagnostic output is ignored on successful Codex runs */ }
30
+ return undefined;
31
+ }
32
+ function appendTail(current: string, chunk: string, maxBytes = 16384): string {
33
+ const combined = current + chunk;
34
+ return Buffer.byteLength(combined) <= maxBytes ? combined : Buffer.from(combined).subarray(-maxBytes).toString('utf8');
35
+ }
36
+ export class ShellAdapter implements WorkerAdapter {
37
+ readonly name = 'shell' as const; readonly id = 'shell'; readonly capabilities = ['shell', 'command'] as const;
38
+ constructor(private readonly shell = false) {}
39
+ launch(task: Task, signal: AbortSignal): Execution { 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: Buffer) => { output += x.toString(); }); child.stderr?.on('data', (x: Buffer) => { output += x.toString(); }); return commandExecution(child, () => output, signal, randomUUID()); }
40
+ }
41
+ export class CodexAdapter implements WorkerAdapter {
42
+ readonly name = 'codex' as const; readonly id = 'codex'; readonly capabilities = ['model', 'codex'] as const;
43
+ constructor(private readonly command = 'codex', private readonly sessionsRoot = join(homedir(), '.codex', 'sessions')) {}
44
+ launch(task: Task, signal: AbortSignal): Execution {
45
+ if (!task.routing) throw new Error(`Task ${task.id} requires routing metadata before Codex launch`);
46
+ const route = task.routing;
47
+ const args = buildCodexArgs(route, task.input);
48
+ const child = spawn(this.command, args, { shell: false, cwd: route.cwd, stdio: ['ignore', 'pipe', 'pipe'] });
49
+ let stdoutBuffer = '';
50
+ let finalAssistantMessage = '';
51
+ let stderrTail = '';
52
+ let bufferedThreadId: string | undefined;
53
+ let execution: Execution;
54
+ const consumeLine = (line: string): void => {
55
+ const threadId = parseThreadStarted(line);
56
+ if (threadId) {
57
+ bufferedThreadId = threadId;
58
+ if (execution) { execution.threadId = threadId; execution.onThreadStarted?.(threadId); }
59
+ }
60
+ const assistantMessage = parseCodexAssistantMessage(line);
61
+ if (assistantMessage) finalAssistantMessage = assistantMessage;
62
+ };
63
+ child.stdout?.on('data', (x: Buffer) => {
64
+ stdoutBuffer += x.toString();
65
+ const lines = stdoutBuffer.split(/\r?\n/);
66
+ stdoutBuffer = lines.pop() ?? '';
67
+ for (const line of lines) consumeLine(line);
68
+ });
69
+ child.stderr?.on('data', (x: Buffer) => { stderrTail = appendTail(stderrTail, x.toString()); });
70
+ const id = randomUUID();
71
+ const promise = new Promise<Result>((resolve, reject) => {
72
+ child.on('error', reject);
73
+ child.on('close', code => {
74
+ if (stdoutBuffer) consumeLine(stdoutBuffer);
75
+ if (code === 0) resolve({ summary: finalAssistantMessage || 'Codex task completed' });
76
+ else reject(new Error(stderrTail.trim() || `Codex exited ${code}`));
77
+ });
78
+ signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
79
+ });
80
+ execution = { id, promise, cancel: () => { child.kill('SIGTERM'); } };
81
+ execution.threadId = bufferedThreadId;
82
+ return execution;
83
+ }
84
+ async recover(task: Task): Promise<Result | null> {
85
+ if (!task.threadId) return null;
86
+ let entries: string[];
87
+ try { entries = await readdir(this.sessionsRoot, { recursive: true, encoding: 'utf8' }); } catch { return null; }
88
+ const filename = entries.find(entry => entry.endsWith(`${task.threadId}.jsonl`));
89
+ if (!filename) return null;
90
+ let lines: string[];
91
+ try { lines = (await readFile(join(this.sessionsRoot, filename), 'utf8')).split(/\r?\n/); } catch { return null; }
92
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
93
+ const line = lines[index]; if (!line) continue;
94
+ try {
95
+ const value = JSON.parse(line) as Record<string, unknown>;
96
+ if (value.type !== 'event_msg') continue;
97
+ const payload = value.payload as Record<string, unknown> | undefined;
98
+ if (payload?.type !== 'task_complete') continue;
99
+ const summary = typeof payload.last_agent_message === 'string' ? payload.last_agent_message.trim() : '';
100
+ return { summary: summary || 'Codex task completed', data: { recoveredFromThread: task.threadId } };
101
+ } catch { /* ignore malformed session rows */ }
102
+ }
103
+ return null;
104
+ }
105
+ }
106
+
107
+
108
+ type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
109
+ export type ChatGptAdapterOptions = { endpoint?: string; tokenFile?: string; fetch?: FetchLike };
110
+ function chatGptThinkingEffort(reasoning?: string): 'minimal' | 'low' | 'standard' | 'high' | 'max' {
111
+ if (reasoning === 'minimal' || reasoning === 'low' || reasoning === 'standard' || reasoning === 'high' || reasoning === 'max') return reasoning;
112
+ if (reasoning === 'medium') return 'standard';
113
+ if (reasoning === 'xhigh' || reasoning === 'extra-high') return 'max';
114
+ return 'standard';
115
+ }
116
+ function chatGptEndpoint(base?: string): string {
117
+ const configured = (base ?? process.env.AGENTS_RELAY_MACBRIDGE_URL ?? `http://127.0.0.1:${process.env.MAC_DEV_BRIDGE_HTTP_PORT ?? '8788'}`).replace(/\/+$/, '');
118
+ const url = new URL(configured);
119
+ if (url.pathname === '/experimental/chatgpt/conversation') return url.toString().replace(/\/$/, '');
120
+ // --macbridge-url is a service origin. Do not inherit an unrelated API path
121
+ // such as /v1/responses from a model-router endpoint.
122
+ url.pathname = '/experimental/chatgpt/conversation';
123
+ url.search = '';
124
+ url.hash = '';
125
+ return url.toString();
126
+ }
127
+ function chatGptTokenFile(file?: string): string {
128
+ 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');
129
+ }
130
+ function chatGptConversationId(text: string): string | undefined {
131
+ const match = /\"conversation_id\"\s*:\s*\"((?:\\.|[^\"\\])*)\"/.exec(text);
132
+ if (!match) return undefined;
133
+ try { const value = JSON.parse(`\"${match[1]}\"`) as unknown; return typeof value === 'string' ? value : undefined; } catch { return undefined; }
134
+ }
135
+ async function readChatGptResponse(response: Response, onConversationId: (conversationId: string) => void): Promise<string> {
136
+ if (!response.body) {
137
+ const text = await response.text();
138
+ const conversationId = chatGptConversationId(text);
139
+ if (conversationId) onConversationId(conversationId);
140
+ return text;
141
+ }
142
+ const reader = response.body.getReader();
143
+ const decoder = new TextDecoder();
144
+ let text = '';
145
+ while (true) {
146
+ const { done, value } = await reader.read();
147
+ if (done) break;
148
+ text += decoder.decode(value, { stream: true });
149
+ const conversationId = chatGptConversationId(text);
150
+ if (conversationId) onConversationId(conversationId);
151
+ }
152
+ text += decoder.decode();
153
+ const conversationId = chatGptConversationId(text);
154
+ if (conversationId) onConversationId(conversationId);
155
+ return text;
156
+ }
157
+
158
+ export class ChatGptAdapter implements WorkerAdapter {
159
+ readonly name = 'chatgpt' as const;
160
+ readonly id = 'chatgpt/macbridge';
161
+ readonly capabilities = ['model', 'chatgpt', 'macbridge'] as const;
162
+ private readonly endpoint: string;
163
+ private readonly tokenFile: string;
164
+ private readonly fetchImpl: FetchLike;
165
+ constructor(options: ChatGptAdapterOptions = {}) {
166
+ this.endpoint = chatGptEndpoint(options.endpoint);
167
+ this.tokenFile = chatGptTokenFile(options.tokenFile);
168
+ this.fetchImpl = options.fetch ?? fetch;
169
+ }
170
+ async deleteThread(threadId: string): Promise<void> {
171
+ const token = (await readFile(this.tokenFile, 'utf8')).trim();
172
+ if (!token) throw new Error(`MacBridge token file is empty: ${this.tokenFile}`);
173
+ const response = await this.fetchImpl(this.endpoint, {
174
+ method: 'DELETE',
175
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
176
+ body: JSON.stringify({ conversation_id: threadId }),
177
+ });
178
+ if (response.status === 404) return;
179
+ if (!response.ok) {
180
+ let detail = '';
181
+ try { const payload = await response.json() as Record<string, unknown>; detail = typeof payload.error === 'string' ? payload.error : ''; } catch {}
182
+ throw new Error(detail || `MacBridge ChatGPT conversation delete failed (${response.status})`);
183
+ }
184
+ }
185
+ launch(task: Task, signal: AbortSignal): Execution {
186
+ if (!task.routing) throw new Error(`Task ${task.id} requires routing metadata before ChatGPT launch`);
187
+ const route = task.routing;
188
+ const controller = new AbortController();
189
+ const abort = (): void => controller.abort();
190
+ if (signal.aborted) abort(); else signal.addEventListener('abort', abort, { once: true });
191
+ const execution: Execution = { id: randomUUID(), promise: Promise.resolve({ summary: '' }), cancel: abort };
192
+ execution.promise = (async (): Promise<Result> => {
193
+ const token = (await readFile(this.tokenFile, 'utf8')).trim();
194
+ if (!token) throw new Error(`MacBridge token file is empty: ${this.tokenFile}`);
195
+ const body: Record<string, unknown> = {
196
+ prompt: task.input,
197
+ model: route.model,
198
+ thinking_effort: chatGptThinkingEffort(route.reasoning),
199
+ max_runtime_seconds: Math.max(30, Math.min(3600, Math.ceil(task.timeoutMs / 1000))),
200
+ };
201
+ if (route.projectId) body.project_id = route.projectId;
202
+ if (task.threadId) body.conversation_id = task.threadId;
203
+ const response = await this.fetchImpl(this.endpoint, {
204
+ method: 'POST',
205
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
206
+ body: JSON.stringify(body),
207
+ signal: controller.signal,
208
+ });
209
+ let exposedConversationId: string | undefined;
210
+ const exposeConversationId = (conversationId: string): void => {
211
+ if (exposedConversationId === conversationId) return;
212
+ exposedConversationId = conversationId;
213
+ execution.threadId = conversationId;
214
+ execution.onThreadStarted?.(conversationId);
215
+ };
216
+ const text = await readChatGptResponse(response, exposeConversationId);
217
+ let payload: Record<string, unknown>;
218
+ try { payload = JSON.parse(text) as Record<string, unknown>; } catch { throw new Error(`MacBridge returned unreadable ChatGPT response (${response.status})`); }
219
+ const conversationId = typeof payload.conversation_id === 'string' ? payload.conversation_id : exposedConversationId;
220
+ if (conversationId) exposeConversationId(conversationId);
221
+ if (!response.ok) throw new Error(typeof payload.error === 'string' ? payload.error : `MacBridge ChatGPT request failed (${response.status})`);
222
+ if (payload.complete !== true) throw new Error('MacBridge ChatGPT conversation did not complete');
223
+ const assistantText = typeof payload.assistant_text === 'string' ? payload.assistant_text.trim() : '';
224
+ return {
225
+ summary: assistantText || 'ChatGPT conversation completed',
226
+ data: { conversationId, provider: route.provider, model: route.model },
227
+ };
228
+ })();
229
+ return execution;
230
+ }
231
+ }