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,91 @@
1
+ name: Build, test, and publish
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+
7
+ permissions:
8
+ contents: write
9
+
10
+ concurrency:
11
+ group: npm-publish-main
12
+ cancel-in-progress: false
13
+
14
+ jobs:
15
+ publish:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: actions/setup-node@v4
20
+ with:
21
+ node-version: 22
22
+ registry-url: https://registry.npmjs.org
23
+ - run: npm ci
24
+ - run: npm test
25
+ - run: chmod +x dist/cli.js dist/relayd.js
26
+ - name: Select next package version
27
+ id: version
28
+ shell: bash
29
+ run: |
30
+ error_file="$(mktemp)"
31
+ trap 'rm -f "$error_file"' EXIT
32
+ if current="$(npm view agents-relay version --registry=https://registry.npmjs.org 2>"$error_file")"; then
33
+ :
34
+ elif grep -q 'E404' "$error_file"; then
35
+ current=""
36
+ else
37
+ cat "$error_file" >&2
38
+ exit 1
39
+ fi
40
+ for attempt in 1 2 3 4 5; do
41
+ next="$(node scripts/npm-version.mjs "$current")"
42
+ if npm view "agents-relay@$next" version --registry=https://registry.npmjs.org >/dev/null 2>&1; then
43
+ current="$next"
44
+ continue
45
+ fi
46
+ break
47
+ done
48
+ if npm view "agents-relay@$next" version --registry=https://registry.npmjs.org >/dev/null 2>&1; then
49
+ echo 'Unable to find an unused npm patch version after five attempts' >&2
50
+ exit 1
51
+ fi
52
+ package_version="$(node -p "require('./package.json').version")"
53
+ if [ "$package_version" != "$next" ]; then
54
+ npm version "$next" --no-git-tag-version --ignore-scripts
55
+ else
56
+ echo "package.json is already $next; publishing the initial version without rewriting it."
57
+ fi
58
+ echo "version=$next" >> "$GITHUB_OUTPUT"
59
+ - name: Verify package contents
60
+ run: |
61
+ test -x dist/cli.js
62
+ test -x dist/relayd.js
63
+ npm pack --dry-run --json > pack.json
64
+ node -e 'const pack = require("./pack.json")[0]; const bins = new Map(pack.files.map(file => [file.path, file.mode])); for (const file of ["dist/cli.js", "dist/relayd.js"]) { if (bins.get(file) !== 0o755) throw new Error(`${file} is not executable in the package`); }'
65
+ - name: Publish package
66
+ shell: bash
67
+ run: |
68
+ set -o pipefail
69
+ publish_error="$(mktemp)"
70
+ trap 'rm -f "$publish_error"' EXIT
71
+ if npm publish --access public 2> >(tee "$publish_error" >&2); then
72
+ exit 0
73
+ fi
74
+ if grep -Eq 'EPUBLISH|previously published|cannot publish over' "$publish_error" \
75
+ && [ "$(npm view agents-relay version --registry=https://registry.npmjs.org 2>/dev/null || true)" = "${{ steps.version.outputs.version }}" ]; then
76
+ echo "agents-relay@${{ steps.version.outputs.version }} is already published; treating this run as idempotent."
77
+ exit 0
78
+ fi
79
+ exit 1
80
+ env:
81
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
82
+ - name: Tag published version
83
+ shell: bash
84
+ run: |
85
+ tag="v${{ steps.version.outputs.version }}"
86
+ if git ls-remote --exit-code --refs origin "refs/tags/$tag" >/dev/null 2>&1; then
87
+ echo "$tag already exists; leaving the existing release tag in place."
88
+ exit 0
89
+ fi
90
+ git tag "$tag"
91
+ git push origin "refs/tags/$tag"
package/AGENTS.md ADDED
@@ -0,0 +1,16 @@
1
+ # Working on Agents Relay
2
+
3
+ Agents Relay is a generic, shareable async-agent runtime and skill.
4
+
5
+ ## Principles
6
+
7
+ - GitHub PR state is durable truth. Events are wake-up/observability signals, never durable truth.
8
+ - Reconcile desired durable state into ephemeral worker executions.
9
+ - One top-level objective maps to one PR; child tasks live inside that PR.
10
+ - Agents may submit child tasks; task lineage is explicit.
11
+ - Workers are replaceable and recoverable. Results and state must survive worker or daemon death.
12
+ - Keep provider/model routing separate from roles and capabilities.
13
+ - Prefer standard library and small dependencies; do not build a second database when GitHub already holds the durable state.
14
+ - TypeScript must use explicit types and avoid `any`.
15
+ - UI must remain usable without a frontend framework unless a framework becomes necessary.
16
+ - Tests must cover state transitions, parser/serialization, reconciliation, and recovery-sensitive logic.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cheng Li
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # Agents Relay
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.
4
+
5
+ ## Quick start
6
+
7
+ For GitHub-backed work, create or adopt the PR through Agents Relay itself. This guarantees the durable job marker exists before any managed task is submitted.
8
+
9
+ ~~~sh
10
+ npx agents-relay job create --repo OWNER/REPO --head feat/example --base main \
11
+ --id job-1 --title "Objective" --body "PR description" --mode fixed
12
+
13
+ npx agents-relay submit --repo OWNER/REPO --pr 12 --id job-1 \
14
+ --task-id child --input "echo hello" --adapter shell
15
+
16
+ npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id job-1
17
+ npx agents-relay status --repo OWNER/REPO --pr 12 --id job-1
18
+ ~~~
19
+
20
+ Jobs default to `fixed`, preserving the original submit-and-run behavior. Use
21
+ `--mode autonomous` with `init` or `job create` to make reconciliation own
22
+ planner progress. The durable status JSON and dashboard show the mode. An
23
+ autonomous runtime needs `--planner-command`; it receives the objective/task
24
+ snapshot as JSON on stdin and must return `{ "objective_status": "in_progress"|"satisfied", "assessment": "...", "next_tasks": [...] }`.
25
+ Planner-created tasks use stable IDs and the existing parent/subtask tree, so
26
+ retries and daemon restarts do not create duplicate work.
27
+
28
+ job create first resolves an existing open PR with the same head/base, then creates it only when needed. Re-running it is idempotent: it reuses the PR and maintains exactly one trusted agents-relay:job:v1 marker.
29
+
30
+ To adopt an existing unmanaged PR, including a PR with no comments:
31
+
32
+ ~~~sh
33
+ npx agents-relay job adopt --repo OWNER/REPO --pr 6 --id job-6 --title "Existing objective"
34
+ ~~~
35
+
36
+ Use job repair with the same arguments to repair duplicate same-job markers. A conflicting marker for another job is rejected rather than overwritten.
37
+
38
+ ~~~sh
39
+ npx agents-relay job repair --repo OWNER/REPO --pr 6 --id job-6
40
+ ~~~
41
+
42
+ Managed GitHub submit/status/reconcile/retry/cancel/serve commands require the durable job marker. If it is missing, the CLI fails with a message directing the caller to job adopt. Orchestrators do not need raw gh PR/comment commands: Agents Relay uses GhApiClient and GitHubStore internally.
43
+
44
+ The original init command remains supported for backward compatibility:
45
+
46
+ ~~~sh
47
+ npx agents-relay init --repo OWNER/REPO --pr 1 --id job-1 --title "Objective"
48
+ ~~~
49
+
50
+ ## npm publishing
51
+
52
+ Pushes to `main` run `.github/workflows/publish.yml`: install, test, choose the next npm version, pack-check, publish, and create the matching `vX.Y.Z` tag. Feature branches do not edit `package.json` versions. If `agents-relay` is not published yet the first release is `1.0.0`; later releases increment only the patch version from the npm registry. The repository must provide an `NPM_TOKEN` Actions secret with permission to publish the public `agents-relay` package.
53
+
54
+ Normal consumers should invoke the CLI with `npx agents-relay ...` (or `npx agents-relayd ...`).
55
+
56
+ For explicit local demo/test mode:
57
+
58
+ ~~~sh
59
+ npm install
60
+ npm run build
61
+ node dist/cli.js init --file .agents-relay.json --title "Dogfood" --id demo
62
+ node dist/cli.js submit --file .agents-relay.json --task-id hello --input "echo hello" --adapter shell
63
+ node dist/cli.js reconcile --file .agents-relay.json
64
+ npx agents-relay serve --file .agents-relay.json
65
+ ~~~
66
+
67
+ 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.
68
+
69
+ 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.
70
+
71
+ `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.
72
+
73
+ For automation, Agents Relay can use GitHub App installation authentication via `AGENTS_RELAY_GITHUB_APP_ID`, `AGENTS_RELAY_GITHUB_APP_INSTALLATION_ID`, and `AGENTS_RELAY_GITHUB_APP_PRIVATE_KEY_FILE` (or matching CLI flags). Webhook mode is cache-first: targeted webhook reconciliation is primary, dashboard auto-refresh reads cached job state, and the broad watchdog drops to a 30-minute fallback. See [docs/github-app-rate-limit.md](docs/github-app-rate-limit.md).
74
+
75
+ V1 assumes one active runner/daemon per job. Use a distributed atomic lease before running multiple reconcilers. Do not put secrets, credentials, private prompts, or large private payloads in PR marker comments; store only summaries and artifact references.
76
+
77
+ Codex/model-backed tasks require --provider and --model (or equivalent routing metadata) before launch. See [docs/architecture.md](docs/architecture.md) and [skills/agents-relay/SKILL.md](skills/agents-relay/SKILL.md).
78
+
79
+ 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
+
81
+ ### ChatGPT workers through MacBridge
82
+
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.
84
+
85
+ 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
+
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.
88
+
89
+ ~~~sh
90
+ npx agents-relay submit --repo OWNER/REPO --pr 5 --id job-1 \
91
+ --task-id research-ui --adapter chatgpt \
92
+ --capabilities model,chatgpt,macbridge \
93
+ --provider openai --model gpt-5-6-sol --reasoning high \
94
+ --chatgpt-project g-p-EXACT_PROJECT_ID \
95
+ --input "Research the dashboard UX and return implementation guidance."
96
+ ~~~
97
+
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.
99
+
100
+ 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
+
102
+ 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.
@@ -0,0 +1,311 @@
1
+ import { spawn } 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
+ 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
+ export function buildCodexArgs(route, input) {
8
+ const args = ['exec', '--json'];
9
+ if (route.profile)
10
+ args.push('-p', route.profile);
11
+ if (route.model)
12
+ args.push('-m', route.model);
13
+ if (route.provider)
14
+ args.push('-c', `model_provider=${route.provider}`);
15
+ if (route.reasoning)
16
+ args.push('-c', `model_reasoning_effort=${route.reasoning}`);
17
+ if (route.cwd)
18
+ args.push('-C', route.cwd);
19
+ args.push('--', input);
20
+ return args;
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) {
30
+ try {
31
+ 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;
38
+ }
39
+ catch { /* non-JSON diagnostic output is ignored on successful Codex runs */ }
40
+ return undefined;
41
+ }
42
+ function appendTail(current, chunk, maxBytes = 16384) {
43
+ 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()); }
55
+ }
56
+ export class CodexAdapter {
57
+ command;
58
+ sessionsRoot;
59
+ name = 'codex';
60
+ id = 'codex';
61
+ capabilities = ['model', 'codex'];
62
+ constructor(command = 'codex', sessionsRoot = join(homedir(), '.codex', 'sessions')) {
63
+ this.command = command;
64
+ this.sessionsRoot = sessionsRoot;
65
+ }
66
+ launch(task, signal) {
67
+ if (!task.routing)
68
+ 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'] });
72
+ let stdoutBuffer = '';
73
+ let finalAssistantMessage = '';
74
+ let stderrTail = '';
75
+ let bufferedThreadId;
76
+ let execution;
77
+ const consumeLine = (line) => {
78
+ const threadId = parseThreadStarted(line);
79
+ if (threadId) {
80
+ bufferedThreadId = threadId;
81
+ if (execution) {
82
+ execution.threadId = threadId;
83
+ execution.onThreadStarted?.(threadId);
84
+ }
85
+ }
86
+ const assistantMessage = parseCodexAssistantMessage(line);
87
+ if (assistantMessage)
88
+ finalAssistantMessage = assistantMessage;
89
+ };
90
+ child.stdout?.on('data', (x) => {
91
+ stdoutBuffer += x.toString();
92
+ const lines = stdoutBuffer.split(/\r?\n/);
93
+ stdoutBuffer = lines.pop() ?? '';
94
+ for (const line of lines)
95
+ consumeLine(line);
96
+ });
97
+ child.stderr?.on('data', (x) => { stderrTail = appendTail(stderrTail, x.toString()); });
98
+ const id = randomUUID();
99
+ const promise = new Promise((resolve, reject) => {
100
+ child.on('error', reject);
101
+ child.on('close', code => {
102
+ if (stdoutBuffer)
103
+ consumeLine(stdoutBuffer);
104
+ if (code === 0)
105
+ resolve({ summary: finalAssistantMessage || 'Codex task completed' });
106
+ else
107
+ reject(new Error(stderrTail.trim() || `Codex exited ${code}`));
108
+ });
109
+ signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
110
+ });
111
+ execution = { id, promise, cancel: () => { child.kill('SIGTERM'); } };
112
+ execution.threadId = bufferedThreadId;
113
+ return execution;
114
+ }
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
+ }
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';
162
+ }
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();
174
+ }
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');
177
+ }
178
+ function chatGptConversationId(text) {
179
+ const match = /\"conversation_id\"\s*:\s*\"((?:\\.|[^\"\\])*)\"/.exec(text);
180
+ if (!match)
181
+ return undefined;
182
+ try {
183
+ const value = JSON.parse(`\"${match[1]}\"`);
184
+ return typeof value === 'string' ? value : undefined;
185
+ }
186
+ catch {
187
+ return undefined;
188
+ }
189
+ }
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);
209
+ }
210
+ text += decoder.decode();
211
+ const conversationId = chatGptConversationId(text);
212
+ if (conversationId)
213
+ onConversationId(conversationId);
214
+ return text;
215
+ }
216
+ export class ChatGptAdapter {
217
+ 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
+ });
237
+ if (response.status === 404)
238
+ 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
+ }
248
+ }
249
+ launch(task, signal) {
250
+ if (!task.routing)
251
+ throw new Error(`Task ${task.id} requires routing metadata before ChatGPT launch`);
252
+ const route = task.routing;
253
+ const controller = new AbortController();
254
+ const abort = () => controller.abort();
255
+ if (signal.aborted)
256
+ abort();
257
+ else
258
+ signal.addEventListener('abort', abort, { once: true });
259
+ const execution = { id: randomUUID(), promise: Promise.resolve({ summary: '' }), cancel: abort };
260
+ 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
+ try {
291
+ payload = JSON.parse(text);
292
+ }
293
+ catch {
294
+ throw new Error(`MacBridge returned unreadable ChatGPT response (${response.status})`);
295
+ }
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
+ })();
309
+ return execution;
310
+ }
311
+ }