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.
- package/.github/workflows/publish.yml +91 -0
- package/AGENTS.md +16 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/adapters.js +311 -0
- package/dist/cli.js +455 -0
- package/dist/continuation.js +21 -0
- package/dist/dashboard.js +446 -0
- package/dist/events.js +36 -0
- package/dist/github-auth.js +34 -0
- package/dist/github-webhook.js +47 -0
- package/dist/markers.js +42 -0
- package/dist/planner.js +172 -0
- package/dist/pool.js +98 -0
- package/dist/reconciler.js +434 -0
- package/dist/registry.js +27 -0
- package/dist/relayd.js +177 -0
- package/dist/scheduler.js +49 -0
- package/dist/store.js +586 -0
- package/dist/types.js +6 -0
- package/dist/usage.js +370 -0
- package/dist/workspace.js +76 -0
- package/docs/agent-network.md +34 -0
- package/docs/architecture.md +120 -0
- package/docs/autonomous-objective-jobs.md +121 -0
- package/docs/example.md +30 -0
- package/docs/github-app-rate-limit.md +124 -0
- package/docs/service.md +43 -0
- package/pack.json +326 -0
- package/package.json +14 -0
- package/scripts/npm-version.mjs +11 -0
- package/skills/agents-relay/SKILL.md +77 -0
- package/skills/agents-relay/agents/planner.agent.md +28 -0
- package/src/adapters.ts +231 -0
- package/src/cli.ts +324 -0
- package/src/continuation.ts +6 -0
- package/src/dashboard.ts +421 -0
- package/src/events.ts +25 -0
- package/src/github-auth.ts +35 -0
- package/src/github-webhook.ts +37 -0
- package/src/markers.ts +33 -0
- package/src/planner.ts +150 -0
- package/src/pool.ts +87 -0
- package/src/reconciler.ts +235 -0
- package/src/registry.ts +35 -0
- package/src/relayd.ts +137 -0
- package/src/scheduler.ts +27 -0
- package/src/store.ts +526 -0
- package/src/types.ts +45 -0
- package/src/usage.ts +385 -0
- package/src/workspace.ts +62 -0
- package/test/adapters.test.js +303 -0
- package/test/autonomous.test.js +119 -0
- package/test/core.test.js +363 -0
- package/test/dashboard.test.js +178 -0
- package/test/github-auth.test.js +51 -0
- package/test/github-webhook.test.js +21 -0
- package/test/service.test.js +116 -0
- package/test/store.test.js +390 -0
- package/test/usage.test.js +88 -0
- package/test/workspace.test.js +95 -0
- 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.
|
package/dist/adapters.js
ADDED
|
@@ -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
|
+
}
|