@leoustc/ai-runner-codex 0.1.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/.env.example +24 -0
- package/AGENTS.md +193 -0
- package/README.md +101 -0
- package/bin/ai-runner-codex.js +8 -0
- package/package.json +27 -0
- package/src/codex.js +443 -0
- package/src/config.js +133 -0
- package/src/hub.js +158 -0
- package/src/login.js +115 -0
- package/src/protocol.js +82 -0
- package/src/runner.js +45 -0
package/.env.example
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
ROOM_WSS=wss://agentrunner-hub.leoustc.com/room/cm9vbV9NQ1BfREVNT19VU0VS
|
|
2
|
+
ROOM_ID=cm9vbV9NQ1BfREVNT19VU0VS
|
|
3
|
+
ROOM_TOKEN=rt_replace_me
|
|
4
|
+
ROOM_AGENT_NAME=lisa
|
|
5
|
+
ROOM_AGENT_ID=lisa_0000
|
|
6
|
+
|
|
7
|
+
# Runner-owned agent metadata. These defaults can be changed locally.
|
|
8
|
+
ROOM_AGENT_ROLE=Answer clearly and concisely as Lisa, a Codex-backed AI runner.
|
|
9
|
+
ROOM_AGENT_MODEL=codex
|
|
10
|
+
|
|
11
|
+
CODEX_LISTEN=ws://127.0.0.1:17655
|
|
12
|
+
CODEX_COMMAND=codex app-server --listen {listen}
|
|
13
|
+
CODEX_LOGIN_COMMAND=codex login --device-auth
|
|
14
|
+
CODEX_LOGIN_TIMEOUT_MS=600000
|
|
15
|
+
CODEX_CWD=.
|
|
16
|
+
CODEX_HOME=
|
|
17
|
+
CODEX_MODEL=
|
|
18
|
+
CODEX_SANDBOX=read-only
|
|
19
|
+
CODEX_APPROVAL_POLICY=never
|
|
20
|
+
CODEX_REQUEST_TIMEOUT_MS=120000
|
|
21
|
+
HEARTBEAT_INTERVAL_MS=60000
|
|
22
|
+
RECONNECT_MIN_MS=2000
|
|
23
|
+
RECONNECT_MAX_MS=60000
|
|
24
|
+
LOG_LEVEL=info
|
package/AGENTS.md
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# AI Runner Codex Instructions
|
|
2
|
+
|
|
3
|
+
This folder is the Node/npm Codex runner package. Its package name is
|
|
4
|
+
`@leoustc/ai-runner-codex`.
|
|
5
|
+
|
|
6
|
+
The runner is separate from the Cloudflare Worker projects:
|
|
7
|
+
|
|
8
|
+
- `../agentrunner-hub/` owns the public Hub, MCP endpoint, dashboard, room
|
|
9
|
+
Durable Object, OAuth/session auth, room registry, logs, and room WebSocket.
|
|
10
|
+
- `../agentrunner-ai/` is the older standalone Cloudflare Workers AI connector.
|
|
11
|
+
- `runner/` is the npm Codex runner that connects to the public Hub and relays work
|
|
12
|
+
to a hot local Codex app-server over WebSocket.
|
|
13
|
+
|
|
14
|
+
Do not move Hub dashboard, MCP, OAuth, room registry, or Cloudflare Worker logic
|
|
15
|
+
into this folder.
|
|
16
|
+
|
|
17
|
+
## Runtime Model
|
|
18
|
+
|
|
19
|
+
The npm runner loads configuration from `.env` by default, connects to the Hub
|
|
20
|
+
WebSocket, and relays messages between the Hub and a hot local Codex app-server
|
|
21
|
+
WebSocket.
|
|
22
|
+
|
|
23
|
+
The CLI command is:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
ai-runner-codex -f config
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`-f` selects the config/env file path. If `-f` is omitted, the runner defaults
|
|
30
|
+
to `.env` in the current working directory.
|
|
31
|
+
|
|
32
|
+
The intended flow is:
|
|
33
|
+
|
|
34
|
+
1. Load the config file selected by `-f`, defaulting to `.env`.
|
|
35
|
+
2. Spawn a hot Codex app-server listening on local `CODEX_LISTEN`
|
|
36
|
+
(`ws://127.0.0.1:PORT`).
|
|
37
|
+
3. Connect to the Hub WebSocket:
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
{ROOM_WSS}?connector={ROOM_AGENT_ID}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
4. Authenticate to the Hub with the room token using WebSocket subprotocols:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
["room-auth", "room-token.${ROOM_TOKEN}"]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
5. On WebSocket open, register the agent with the Hub:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{ "type": "agent_register", "name": "agent-name", "role": "agent role prompt", "model": "codex" }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
6. For each valid Hub room payload, send `start` to the Hub, relay the payload
|
|
56
|
+
to Codex, then send Codex output back to the Hub as `message` and finish
|
|
57
|
+
with `end`.
|
|
58
|
+
|
|
59
|
+
7. On errors, send an `error` reply for the matching `requestId`, then send
|
|
60
|
+
`end` when appropriate.
|
|
61
|
+
|
|
62
|
+
The runner is a relay. The Hub remains authoritative for routing, target-agent
|
|
63
|
+
selection, room auth, token revocation, and room audit events.
|
|
64
|
+
|
|
65
|
+
## Environment
|
|
66
|
+
|
|
67
|
+
Local configuration lives in `runner/.env`.
|
|
68
|
+
|
|
69
|
+
Run with the default config:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
ai-runner-codex
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Run with an explicit config file:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
ai-runner-codex -f .env
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Expected variables:
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
ROOM_WSS=wss://agentrunner-hub.leoustc.com/room/...
|
|
85
|
+
ROOM_ID=...
|
|
86
|
+
ROOM_TOKEN=rt_...
|
|
87
|
+
ROOM_AGENT_NAME=...
|
|
88
|
+
ROOM_AGENT_ID=...
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`ROOM_AGENT_ROLE` and `ROOM_AGENT_MODEL` are runner-owned optional settings.
|
|
92
|
+
The runner supplies defaults and uses these values when registering with the
|
|
93
|
+
Hub. They are not issued by the Hub and can be changed per runner.
|
|
94
|
+
|
|
95
|
+
Optional variables may also be added for the Codex app-server command,
|
|
96
|
+
`CODEX_LISTEN`, reconnect behavior, and timeouts. Do not add exec mode or Unix
|
|
97
|
+
socket mode; this runner is intended to keep Codex hot through app-server
|
|
98
|
+
WebSocket.
|
|
99
|
+
|
|
100
|
+
If Codex app-server returns an auth/login error, start `CODEX_LOGIN_COMMAND`
|
|
101
|
+
(`codex login --device-auth` by default), relay the device login URL/code back
|
|
102
|
+
to the Hub as an agent message, wait for login completion, and retry the Codex
|
|
103
|
+
request once.
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
Do not commit real `.env` secrets. If examples are needed, use `.env.example`.
|
|
107
|
+
|
|
108
|
+
## Connector Contract
|
|
109
|
+
|
|
110
|
+
The runner receives strict Hub room payloads shaped like:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"requestId": "...",
|
|
115
|
+
"room": "Room of user",
|
|
116
|
+
"userId": "dashboard",
|
|
117
|
+
"tool": "ask",
|
|
118
|
+
"systemInstruction": "You are handling the ask tool...",
|
|
119
|
+
"message": "{\"message\":\"hello\"}"
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Agent-to-agent messages use `tool: "agent_message"` and include an
|
|
124
|
+
agent-to-agent system instruction from the Hub. Treat them the same as other
|
|
125
|
+
room payloads: relay to Codex and return the reply to the Hub.
|
|
126
|
+
|
|
127
|
+
Runner-side Codex instructions must distinguish user-facing action types:
|
|
128
|
+
|
|
129
|
+
- `ask` and compatibility alias `chat`: answer from workspace knowledge, do not
|
|
130
|
+
edit files or modify the workspace, and be explicit when the workspace does
|
|
131
|
+
not provide enough evidence.
|
|
132
|
+
- `task`: focus on doing the requested work. Inspect files, run appropriate
|
|
133
|
+
commands, and edit code when needed. Return a concise step-by-step account of
|
|
134
|
+
what was done, followed by a short outcome summary and any tests/checks run.
|
|
135
|
+
|
|
136
|
+
For each valid payload, reply to the Hub as:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{ "requestId": "...", "type": "start", "name": "agent-name" }
|
|
140
|
+
{ "requestId": "...", "type": "message", "name": "agent-name", "message": { "text": "..." } }
|
|
141
|
+
{ "requestId": "...", "type": "end", "name": "agent-name" }
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Errors use:
|
|
145
|
+
|
|
146
|
+
```json
|
|
147
|
+
{ "requestId": "...", "type": "error", "name": "agent-name", "message": { "error": "..." } }
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The runner should send periodic heartbeats while connected:
|
|
151
|
+
|
|
152
|
+
```json
|
|
153
|
+
{ "type": "agent_heartbeat", "name": "agent-name", "agentId": "agent_id", "model": "codex" }
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Reconnect Behavior
|
|
157
|
+
|
|
158
|
+
The Hub enforces single-active-use for generated `rt_...` room tokens. If the
|
|
159
|
+
same token is already connected, a second connection is rejected. The runner
|
|
160
|
+
should reconnect only after the previous socket closes, using bounded backoff
|
|
161
|
+
and jitter.
|
|
162
|
+
|
|
163
|
+
Deleting an agent token from the Hub dashboard revokes that token and forces the
|
|
164
|
+
agent offline. Muting an agent does not revoke its token; it only prevents Hub
|
|
165
|
+
delivery to that agent.
|
|
166
|
+
|
|
167
|
+
## Development
|
|
168
|
+
|
|
169
|
+
This folder is a normal npm package with a `package.json` named
|
|
170
|
+
`@leoustc/ai-runner-codex`.
|
|
171
|
+
|
|
172
|
+
On startup, check that the `codex` CLI is installed and available on `PATH`
|
|
173
|
+
before starting Codex app-server. Fail with a clear install/PATH error if it is
|
|
174
|
+
missing.
|
|
175
|
+
|
|
176
|
+
Prefer small modules:
|
|
177
|
+
|
|
178
|
+
- `.env` loading and validation
|
|
179
|
+
- Hub WebSocket connection and reconnect loop
|
|
180
|
+
- Codex app-server process lifecycle
|
|
181
|
+
- Codex app-server WebSocket client
|
|
182
|
+
- Hub payload parsing and reply formatting
|
|
183
|
+
|
|
184
|
+
Run package commands from `runner/`:
|
|
185
|
+
|
|
186
|
+
```sh
|
|
187
|
+
make build
|
|
188
|
+
make local
|
|
189
|
+
make publish
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`make local` runs the CLI with `-f .env`. `make publish` must run the build
|
|
193
|
+
check before `npm publish --access public`.
|
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# @leoustc/ai-runner-codex
|
|
2
|
+
|
|
3
|
+
Local npm CLI connector for Agent Runner Hub. It loads a config file, connects
|
|
4
|
+
to the public Hub WebSocket room, starts a local Codex app-server over
|
|
5
|
+
WebSocket, and relays Hub messages to Codex.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
Create a config from the example:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
cp .env.example .env
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Run with the default `.env`:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
ai-runner-codex
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Run with an explicit config file:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
ai-runner-codex -f .env
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Make Targets
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
make build
|
|
31
|
+
make local
|
|
32
|
+
make publish
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`make local` runs `ai-runner-codex -f .env`. `make publish` runs the build check
|
|
36
|
+
first, then publishes the package to npm with public access.
|
|
37
|
+
|
|
38
|
+
## Config
|
|
39
|
+
|
|
40
|
+
Required:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
ROOM_WSS=wss://agentrunner-hub.leoustc.com/room/...
|
|
44
|
+
ROOM_ID=...
|
|
45
|
+
ROOM_TOKEN=rt_...
|
|
46
|
+
ROOM_AGENT_NAME=lisa
|
|
47
|
+
ROOM_AGENT_ID=lisa_0000
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Optional:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
# Runner-owned agent metadata. Defaults are generated by ai-runner-codex.
|
|
54
|
+
ROOM_AGENT_ROLE=Answer clearly and concisely.
|
|
55
|
+
ROOM_AGENT_MODEL=codex
|
|
56
|
+
|
|
57
|
+
CODEX_LISTEN=ws://127.0.0.1:17655
|
|
58
|
+
CODEX_COMMAND=codex app-server --listen {listen}
|
|
59
|
+
CODEX_LOGIN_COMMAND=codex login --device-auth
|
|
60
|
+
CODEX_LOGIN_TIMEOUT_MS=600000
|
|
61
|
+
CODEX_CWD=.
|
|
62
|
+
CODEX_HOME=
|
|
63
|
+
CODEX_MODEL=
|
|
64
|
+
CODEX_SANDBOX=read-only
|
|
65
|
+
CODEX_APPROVAL_POLICY=never
|
|
66
|
+
CODEX_REQUEST_TIMEOUT_MS=120000
|
|
67
|
+
HEARTBEAT_INTERVAL_MS=60000
|
|
68
|
+
RECONNECT_MIN_MS=2000
|
|
69
|
+
RECONNECT_MAX_MS=60000
|
|
70
|
+
LOG_LEVEL=info
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`ROOM_AGENT_ROLE` and `ROOM_AGENT_MODEL` are not issued by the Hub. They are
|
|
74
|
+
local runner settings used when registering the agent and can be changed per
|
|
75
|
+
runner.
|
|
76
|
+
|
|
77
|
+
The runner keeps a hot Codex app-server process. `CODEX_LISTEN` must be a local
|
|
78
|
+
`ws://127.0.0.1:PORT` listener. The runner uses one JSON-RPC message per
|
|
79
|
+
WebSocket text frame.
|
|
80
|
+
|
|
81
|
+
On startup, the runner checks that the `codex` CLI is installed and available
|
|
82
|
+
on `PATH` before starting app-server.
|
|
83
|
+
|
|
84
|
+
If Codex returns an auth/login error, the runner starts `CODEX_LOGIN_COMMAND`,
|
|
85
|
+
posts the device login URL/code back to the Hub as an agent message, waits for
|
|
86
|
+
login completion, then retries the Codex request once.
|
|
87
|
+
|
|
88
|
+
Prompt behavior:
|
|
89
|
+
|
|
90
|
+
- `ask`/`chat`: answer from workspace knowledge without editing files.
|
|
91
|
+
- `task`: do the requested work, then return concise steps, outcome, and checks.
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
The runner authenticates with the Hub using WebSocket subprotocols:
|
|
95
|
+
|
|
96
|
+
```text
|
|
97
|
+
room-auth
|
|
98
|
+
room-token.${ROOM_TOKEN}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Do not commit real `.env` files.
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@leoustc/ai-runner-codex",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Codex app-server backed Agent Runner Hub CLI connector",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"ai-runner-codex": "bin/ai-runner-codex.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"src",
|
|
14
|
+
"README.md",
|
|
15
|
+
"AGENTS.md",
|
|
16
|
+
".env.example"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "npm run check",
|
|
20
|
+
"check": "node --check ./bin/ai-runner-codex.js && node --check ./src/config.js && node --check ./src/codex.js && node --check ./src/hub.js && node --check ./src/login.js && node --check ./src/protocol.js && node --check ./src/runner.js",
|
|
21
|
+
"local": "node ./bin/ai-runner-codex.js -f .env",
|
|
22
|
+
"start": "node ./bin/ai-runner-codex.js"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/codex.js
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export class CodexServer {
|
|
4
|
+
constructor(config, log) {
|
|
5
|
+
this.config = config;
|
|
6
|
+
this.log = log;
|
|
7
|
+
this.process = null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async start() {
|
|
11
|
+
assertWsEndpoint(this.config.codexListen);
|
|
12
|
+
await ensureCodexCli();
|
|
13
|
+
const command = interpolateCommand(this.config.codexCommand, {
|
|
14
|
+
listen: this.config.codexListen,
|
|
15
|
+
});
|
|
16
|
+
const [program, ...args] = splitCommand(command);
|
|
17
|
+
|
|
18
|
+
this.log.info(`starting codex app-server: ${command}`);
|
|
19
|
+
this.process = spawn(program, args, {
|
|
20
|
+
env: {
|
|
21
|
+
...process.env,
|
|
22
|
+
...(this.config.codexHome ? { CODEX_HOME: this.config.codexHome } : {}),
|
|
23
|
+
},
|
|
24
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
this.process.stdout.on("data", (data) => this.log.debug(`[codex stdout] ${String(data).trimEnd()}`));
|
|
28
|
+
this.process.stderr.on("data", (data) => this.log.info(`[codex stderr] ${String(data).trimEnd()}`));
|
|
29
|
+
this.process.on("exit", (code, signal) => {
|
|
30
|
+
this.log.info(`codex app-server exited code=${code ?? "null"} signal=${signal ?? "null"}`);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await waitForReady(this.config.codexListen, 15_000);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async stop() {
|
|
37
|
+
this.process?.kill("SIGTERM");
|
|
38
|
+
this.process = null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function requestCodex(config, request) {
|
|
43
|
+
const client = new CodexWsClient(config);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await client.connect();
|
|
47
|
+
return await client.runTurn(request);
|
|
48
|
+
} finally {
|
|
49
|
+
client.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class CodexWsClient {
|
|
54
|
+
constructor(config) {
|
|
55
|
+
this.config = config;
|
|
56
|
+
this.nextRequestId = 1;
|
|
57
|
+
this.pending = new Map();
|
|
58
|
+
this.turns = new Map();
|
|
59
|
+
this.ws = null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async connect() {
|
|
63
|
+
assertWsEndpoint(this.config.codexListen);
|
|
64
|
+
this.ws = new WebSocket(this.config.codexListen);
|
|
65
|
+
await new Promise((resolve, reject) => {
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
cleanup();
|
|
68
|
+
reject(new Error("Timed out waiting for Codex app-server WebSocket open"));
|
|
69
|
+
}, this.config.codexRequestTimeoutMs);
|
|
70
|
+
const cleanup = () => {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
this.ws.removeEventListener("open", onOpen);
|
|
73
|
+
this.ws.removeEventListener("error", onError);
|
|
74
|
+
};
|
|
75
|
+
const onOpen = () => {
|
|
76
|
+
cleanup();
|
|
77
|
+
resolve();
|
|
78
|
+
};
|
|
79
|
+
const onError = () => {
|
|
80
|
+
cleanup();
|
|
81
|
+
reject(new Error("Codex app-server WebSocket failed to open"));
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
this.ws.addEventListener("open", onOpen);
|
|
85
|
+
this.ws.addEventListener("error", onError);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
this.ws.addEventListener("message", (event) => {
|
|
89
|
+
const text = typeof event.data === "string" ? event.data : String(event.data);
|
|
90
|
+
this.handleJson(text);
|
|
91
|
+
});
|
|
92
|
+
this.ws.addEventListener("close", () => {
|
|
93
|
+
this.rejectAll(new Error("Codex app-server closed the WebSocket"));
|
|
94
|
+
});
|
|
95
|
+
this.ws.addEventListener("error", () => {
|
|
96
|
+
this.rejectAll(new Error("Codex app-server WebSocket error"));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await this.request("initialize", {
|
|
100
|
+
clientInfo: {
|
|
101
|
+
name: "ai-runner-codex",
|
|
102
|
+
title: "AI Runner Codex",
|
|
103
|
+
version: "0.1.0",
|
|
104
|
+
},
|
|
105
|
+
capabilities: {
|
|
106
|
+
experimentalApi: true,
|
|
107
|
+
requestAttestation: false,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async runTurn(request) {
|
|
113
|
+
const thread = await this.request("thread/start", {
|
|
114
|
+
approvalPolicy: this.config.codexApprovalPolicy,
|
|
115
|
+
cwd: this.config.codexCwd,
|
|
116
|
+
developerInstructions: [
|
|
117
|
+
this.config.role,
|
|
118
|
+
actionInstruction(request.tool),
|
|
119
|
+
request.systemInstruction,
|
|
120
|
+
].filter(Boolean).join("\n\n"),
|
|
121
|
+
ephemeral: true,
|
|
122
|
+
historyMode: "paginated",
|
|
123
|
+
model: this.config.codexModel || null,
|
|
124
|
+
sandbox: this.config.codexSandbox,
|
|
125
|
+
});
|
|
126
|
+
const threadId = thread?.thread?.id;
|
|
127
|
+
|
|
128
|
+
if (!threadId) {
|
|
129
|
+
throw new Error("Codex app-server did not return a thread id");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const state = {
|
|
133
|
+
reject: null,
|
|
134
|
+
resolve: null,
|
|
135
|
+
text: "",
|
|
136
|
+
};
|
|
137
|
+
const done = new Promise((resolve, reject) => {
|
|
138
|
+
state.resolve = resolve;
|
|
139
|
+
state.reject = reject;
|
|
140
|
+
});
|
|
141
|
+
this.turns.set(threadId, state);
|
|
142
|
+
|
|
143
|
+
const turn = await this.request("turn/start", {
|
|
144
|
+
approvalPolicy: this.config.codexApprovalPolicy,
|
|
145
|
+
cwd: this.config.codexCwd,
|
|
146
|
+
input: [
|
|
147
|
+
{
|
|
148
|
+
type: "text",
|
|
149
|
+
text: formatPrompt(request),
|
|
150
|
+
text_elements: [],
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
model: this.config.codexModel || null,
|
|
154
|
+
sandboxPolicy: sandboxPolicy(this.config.codexSandbox, this.config.codexCwd),
|
|
155
|
+
threadId,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (turn?.turn?.status === "completed") {
|
|
159
|
+
this.turns.delete(threadId);
|
|
160
|
+
return { text: state.text || textFromTurn(turn.turn) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const text = await withTimeout(done, this.config.codexRequestTimeoutMs, "Timed out waiting for Codex turn completion");
|
|
164
|
+
return { text };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async request(method, params) {
|
|
168
|
+
const id = String(this.nextRequestId);
|
|
169
|
+
this.nextRequestId += 1;
|
|
170
|
+
const response = new Promise((resolve, reject) => {
|
|
171
|
+
this.pending.set(id, { resolve, reject });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
this.sendJson({ id, method, params });
|
|
175
|
+
return await withTimeout(response, this.config.codexRequestTimeoutMs, `Timed out waiting for Codex ${method}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
handleJson(text) {
|
|
179
|
+
let message;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
message = JSON.parse(text);
|
|
183
|
+
} catch {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (message.id !== undefined) {
|
|
188
|
+
const pending = this.pending.get(String(message.id));
|
|
189
|
+
|
|
190
|
+
if (!pending) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
this.pending.delete(String(message.id));
|
|
195
|
+
|
|
196
|
+
if (message.error) {
|
|
197
|
+
pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
|
|
198
|
+
} else {
|
|
199
|
+
pending.resolve(message.result);
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
this.handleNotification(message);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
handleNotification(message) {
|
|
208
|
+
if (message.method === "item/agentMessage/delta") {
|
|
209
|
+
const state = this.turns.get(message.params?.threadId);
|
|
210
|
+
|
|
211
|
+
if (state && typeof message.params?.delta === "string") {
|
|
212
|
+
state.text += message.params.delta;
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (message.method === "item/completed") {
|
|
218
|
+
const state = this.turns.get(message.params?.threadId);
|
|
219
|
+
const item = message.params?.item;
|
|
220
|
+
|
|
221
|
+
if (state && item?.type === "agentMessage" && typeof item.text === "string") {
|
|
222
|
+
state.text = item.text;
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (message.method === "turn/completed") {
|
|
228
|
+
const threadId = message.params?.threadId;
|
|
229
|
+
const state = this.turns.get(threadId);
|
|
230
|
+
|
|
231
|
+
if (!state) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
this.turns.delete(threadId);
|
|
236
|
+
|
|
237
|
+
if (message.params?.turn?.status === "failed") {
|
|
238
|
+
state.reject?.(new Error(message.params.turn.error?.message || "Codex turn failed"));
|
|
239
|
+
} else {
|
|
240
|
+
state.resolve?.(state.text || textFromTurn(message.params?.turn));
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (message.method === "error") {
|
|
246
|
+
const error = new Error(message.params?.message || "Codex app-server error");
|
|
247
|
+
|
|
248
|
+
for (const state of this.turns.values()) {
|
|
249
|
+
state.reject?.(error);
|
|
250
|
+
}
|
|
251
|
+
this.turns.clear();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
sendJson(value) {
|
|
256
|
+
this.ws.send(JSON.stringify(value));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
close() {
|
|
260
|
+
this.ws?.close();
|
|
261
|
+
this.ws = null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
rejectAll(error) {
|
|
265
|
+
for (const pending of this.pending.values()) {
|
|
266
|
+
pending.reject(error);
|
|
267
|
+
}
|
|
268
|
+
this.pending.clear();
|
|
269
|
+
|
|
270
|
+
for (const state of this.turns.values()) {
|
|
271
|
+
state.reject?.(error);
|
|
272
|
+
}
|
|
273
|
+
this.turns.clear();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function waitForReady(listen, timeoutMs) {
|
|
278
|
+
const deadline = Date.now() + timeoutMs;
|
|
279
|
+
let lastError;
|
|
280
|
+
|
|
281
|
+
while (Date.now() < deadline) {
|
|
282
|
+
try {
|
|
283
|
+
const readyUrl = new URL("/readyz", listen);
|
|
284
|
+
readyUrl.protocol = readyUrl.protocol === "wss:" ? "https:" : "http:";
|
|
285
|
+
const response = await fetch(readyUrl);
|
|
286
|
+
if (response.ok) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
lastError = new Error(`readyz returned ${response.status}`);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
lastError = error;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
await sleep(250);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
throw new Error(`Codex app-server did not open ${listen}: ${lastError?.message || "timeout"}`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function assertWsEndpoint(listen) {
|
|
301
|
+
if (!listen.startsWith("ws://") && !listen.startsWith("wss://")) {
|
|
302
|
+
throw new Error(`CODEX_LISTEN must be ws:// or wss://, got: ${listen}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function ensureCodexCli() {
|
|
307
|
+
return new Promise((resolve, reject) => {
|
|
308
|
+
const child = spawn("codex", ["--version"], {
|
|
309
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
310
|
+
});
|
|
311
|
+
let stderr = "";
|
|
312
|
+
|
|
313
|
+
child.stderr.on("data", (data) => {
|
|
314
|
+
stderr += String(data);
|
|
315
|
+
});
|
|
316
|
+
child.on("error", (error) => {
|
|
317
|
+
if (error?.code === "ENOENT") {
|
|
318
|
+
reject(new Error("Codex CLI is not installed or not on PATH. Install it first, then rerun ai-runner-codex."));
|
|
319
|
+
} else {
|
|
320
|
+
reject(error);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
child.on("exit", (code) => {
|
|
324
|
+
if (code === 0) {
|
|
325
|
+
resolve();
|
|
326
|
+
} else {
|
|
327
|
+
reject(new Error(stderr.trim() || `codex --version exited with code ${code}`));
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function formatPrompt(request) {
|
|
334
|
+
return [
|
|
335
|
+
`Room: ${request.room ?? ""}`,
|
|
336
|
+
`User: ${request.userId ?? ""}`,
|
|
337
|
+
`Tool: ${request.tool ?? ""}`,
|
|
338
|
+
"",
|
|
339
|
+
"Message:",
|
|
340
|
+
typeof request.message === "string" ? request.message : JSON.stringify(request.message, null, 2),
|
|
341
|
+
].join("\n");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function actionInstruction(tool) {
|
|
345
|
+
const normalized = String(tool || "").toLowerCase();
|
|
346
|
+
|
|
347
|
+
if (normalized === "ask" || normalized === "chat") {
|
|
348
|
+
return [
|
|
349
|
+
"This is an ask/chat action.",
|
|
350
|
+
"Answer the user's question using the workspace knowledge available to you.",
|
|
351
|
+
"Do not edit files, run code-changing commands, or modify the workspace.",
|
|
352
|
+
"Ground the answer in facts from the workspace when possible. If the workspace does not contain enough evidence, say what is unknown instead of guessing.",
|
|
353
|
+
"Keep the answer direct, solid, and useful.",
|
|
354
|
+
].join(" ");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (normalized === "task") {
|
|
358
|
+
return [
|
|
359
|
+
"This is a task action.",
|
|
360
|
+
"Focus on completing the requested task in the workspace.",
|
|
361
|
+
"You may inspect files, run appropriate commands, and edit code when needed.",
|
|
362
|
+
"When finished, return a concise step-by-step account of what you did, then a short summary of the task outcome.",
|
|
363
|
+
"Mention any tests or checks you ran, and clearly state if something could not be completed.",
|
|
364
|
+
].join(" ");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return "";
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function textFromTurn(turn) {
|
|
371
|
+
if (!turn?.items) {
|
|
372
|
+
return "";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return turn.items
|
|
376
|
+
.filter((item) => item?.type === "agentMessage" && typeof item.text === "string")
|
|
377
|
+
.map((item) => item.text)
|
|
378
|
+
.join("\n\n")
|
|
379
|
+
.trim();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function sandboxPolicy(mode, cwd) {
|
|
383
|
+
if (mode === "danger-full-access") {
|
|
384
|
+
return { type: "dangerFullAccess" };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (mode === "workspace-write") {
|
|
388
|
+
return {
|
|
389
|
+
type: "workspaceWrite",
|
|
390
|
+
writableRoots: [cwd],
|
|
391
|
+
networkAccess: false,
|
|
392
|
+
excludeTmpdirEnvVar: false,
|
|
393
|
+
excludeSlashTmp: false,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
type: "readOnly",
|
|
399
|
+
networkAccess: false,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function withTimeout(promise, timeoutMs, message) {
|
|
404
|
+
return new Promise((resolve, reject) => {
|
|
405
|
+
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
406
|
+
|
|
407
|
+
promise.then(
|
|
408
|
+
(value) => {
|
|
409
|
+
clearTimeout(timer);
|
|
410
|
+
resolve(value);
|
|
411
|
+
},
|
|
412
|
+
(error) => {
|
|
413
|
+
clearTimeout(timer);
|
|
414
|
+
reject(error);
|
|
415
|
+
},
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function splitCommand(command) {
|
|
421
|
+
const matches = command.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
|
422
|
+
return matches.map((part) => {
|
|
423
|
+
if ((part.startsWith('"') && part.endsWith('"')) || (part.startsWith("'") && part.endsWith("'"))) {
|
|
424
|
+
return part.slice(1, -1);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return part;
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function interpolateCommand(command, values) {
|
|
432
|
+
let next = command;
|
|
433
|
+
|
|
434
|
+
for (const [key, value] of Object.entries(values)) {
|
|
435
|
+
next = next.replaceAll(`{${key}}`, String(value));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return next;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function sleep(ms) {
|
|
442
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
443
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_CONFIG = ".env";
|
|
5
|
+
const DEFAULT_CODEX_LISTEN = "ws://127.0.0.1:17655";
|
|
6
|
+
const DEFAULT_CODEX_COMMAND = "codex app-server --listen {listen}";
|
|
7
|
+
const DEFAULT_CODEX_LOGIN_COMMAND = "codex login --device-auth";
|
|
8
|
+
|
|
9
|
+
export function parseArgs(argv) {
|
|
10
|
+
const args = {
|
|
11
|
+
configPath: DEFAULT_CONFIG,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
15
|
+
const value = argv[index];
|
|
16
|
+
|
|
17
|
+
if (value === "-f" || value === "--file" || value === "--config") {
|
|
18
|
+
const next = argv[index + 1];
|
|
19
|
+
|
|
20
|
+
if (!next) {
|
|
21
|
+
throw new Error(`${value} requires a config path`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
args.configPath = next;
|
|
25
|
+
index += 1;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (value === "-h" || value === "--help") {
|
|
30
|
+
args.help = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
throw new Error(`Unknown argument: ${value}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return args;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function usage() {
|
|
41
|
+
return [
|
|
42
|
+
"Usage: ai-runner-codex [-f config]",
|
|
43
|
+
"",
|
|
44
|
+
"Options:",
|
|
45
|
+
" -f, --file, --config <path> Config/env file path. Defaults to .env.",
|
|
46
|
+
" -h, --help Show this help.",
|
|
47
|
+
].join("\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function loadConfig(configPath) {
|
|
51
|
+
const absolutePath = resolve(configPath || DEFAULT_CONFIG);
|
|
52
|
+
const env = parseEnv(await readFile(absolutePath, "utf8"));
|
|
53
|
+
const config = {
|
|
54
|
+
agentId: required(env, "ROOM_AGENT_ID"),
|
|
55
|
+
agentName: required(env, "ROOM_AGENT_NAME"),
|
|
56
|
+
codexCommand: env.CODEX_COMMAND || DEFAULT_CODEX_COMMAND,
|
|
57
|
+
codexCwd: resolve(env.CODEX_CWD || process.cwd()),
|
|
58
|
+
codexHome: env.CODEX_HOME || "",
|
|
59
|
+
codexListen: env.CODEX_LISTEN || DEFAULT_CODEX_LISTEN,
|
|
60
|
+
codexLoginCommand: env.CODEX_LOGIN_COMMAND || DEFAULT_CODEX_LOGIN_COMMAND,
|
|
61
|
+
codexLoginTimeoutMs: numberValue(env.CODEX_LOGIN_TIMEOUT_MS, 10 * 60_000),
|
|
62
|
+
codexModel: env.CODEX_MODEL || "",
|
|
63
|
+
codexRequestTimeoutMs: numberValue(env.CODEX_REQUEST_TIMEOUT_MS, 120_000),
|
|
64
|
+
codexSandbox: env.CODEX_SANDBOX || "read-only",
|
|
65
|
+
codexApprovalPolicy: env.CODEX_APPROVAL_POLICY || "never",
|
|
66
|
+
heartbeatIntervalMs: numberValue(env.HEARTBEAT_INTERVAL_MS, 60_000),
|
|
67
|
+
logLevel: env.LOG_LEVEL || "info",
|
|
68
|
+
model: env.ROOM_AGENT_MODEL || "codex",
|
|
69
|
+
reconnectMaxMs: numberValue(env.RECONNECT_MAX_MS, 60_000),
|
|
70
|
+
reconnectMinMs: numberValue(env.RECONNECT_MIN_MS, 2_000),
|
|
71
|
+
role:
|
|
72
|
+
env.ROOM_AGENT_ROLE ||
|
|
73
|
+
`You are ${env.ROOM_AGENT_NAME || "an AI runner"}, a Codex-backed room agent. Answer clearly and concisely.`,
|
|
74
|
+
roomId: required(env, "ROOM_ID"),
|
|
75
|
+
roomToken: required(env, "ROOM_TOKEN"),
|
|
76
|
+
roomWss: required(env, "ROOM_WSS"),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
...config,
|
|
81
|
+
configPath: absolutePath,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseEnv(text) {
|
|
86
|
+
const env = {};
|
|
87
|
+
|
|
88
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
89
|
+
const line = rawLine.trim();
|
|
90
|
+
|
|
91
|
+
if (!line || line.startsWith("#")) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const equals = line.indexOf("=");
|
|
96
|
+
|
|
97
|
+
if (equals === -1) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const key = line.slice(0, equals).trim();
|
|
102
|
+
const value = line.slice(equals + 1).trim();
|
|
103
|
+
env[key] = unquote(value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return env;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function unquote(value) {
|
|
110
|
+
if (
|
|
111
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
112
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
113
|
+
) {
|
|
114
|
+
return value.slice(1, -1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function required(env, key) {
|
|
121
|
+
const value = env[key]?.trim();
|
|
122
|
+
|
|
123
|
+
if (!value) {
|
|
124
|
+
throw new Error(`${key} is required in the runner config`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function numberValue(value, fallback) {
|
|
131
|
+
const numeric = Number(value);
|
|
132
|
+
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : fallback;
|
|
133
|
+
}
|
package/src/hub.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { codexRequest, codexText, heartbeat, isRoomPayload, registration, reply } from "./protocol.js";
|
|
2
|
+
import { requestCodex } from "./codex.js";
|
|
3
|
+
import { ensureCodexLogin, isCodexAuthError } from "./login.js";
|
|
4
|
+
|
|
5
|
+
export class HubClient {
|
|
6
|
+
constructor(config, log) {
|
|
7
|
+
this.config = config;
|
|
8
|
+
this.log = log;
|
|
9
|
+
this.heartbeatTimer = null;
|
|
10
|
+
this.reconnectAttempt = 0;
|
|
11
|
+
this.stopped = false;
|
|
12
|
+
this.socket = null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
start() {
|
|
16
|
+
this.connect();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
stop() {
|
|
20
|
+
this.stopped = true;
|
|
21
|
+
this.stopHeartbeat();
|
|
22
|
+
this.socket?.close();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
connect() {
|
|
26
|
+
const url = new URL(this.config.roomWss);
|
|
27
|
+
url.protocol = url.protocol === "https:" ? "wss:" : url.protocol;
|
|
28
|
+
url.searchParams.set("connector", this.config.agentId);
|
|
29
|
+
|
|
30
|
+
this.log.info(`connecting to hub ${url.toString()}`);
|
|
31
|
+
const socket = new WebSocket(url.toString(), [
|
|
32
|
+
"room-auth",
|
|
33
|
+
`room-token.${this.config.roomToken}`,
|
|
34
|
+
]);
|
|
35
|
+
this.socket = socket;
|
|
36
|
+
|
|
37
|
+
socket.addEventListener("open", () => {
|
|
38
|
+
this.reconnectAttempt = 0;
|
|
39
|
+
this.log.info("hub websocket connected");
|
|
40
|
+
this.send(registration(this.config));
|
|
41
|
+
this.startHeartbeat();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
socket.addEventListener("message", (event) => {
|
|
45
|
+
void this.handleMessage(event.data);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
socket.addEventListener("close", (event) => {
|
|
49
|
+
this.log.info(`hub websocket closed code=${event.code} reason=${event.reason || ""}`);
|
|
50
|
+
this.stopHeartbeat();
|
|
51
|
+
this.scheduleReconnect();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
socket.addEventListener("error", () => {
|
|
55
|
+
this.log.info("hub websocket error");
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async handleMessage(data) {
|
|
60
|
+
const value = parseJson(data);
|
|
61
|
+
|
|
62
|
+
if (!isRoomPayload(value)) {
|
|
63
|
+
this.log.debug(`ignoring non-room payload: ${String(data).slice(0, 200)}`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.send(reply(value, this.config, "start"));
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const response = await this.requestCodexWithLogin(value);
|
|
71
|
+
const text = codexText(response) || "Codex returned no text.";
|
|
72
|
+
this.send(reply(value, this.config, "message", {
|
|
73
|
+
model: this.config.model,
|
|
74
|
+
text,
|
|
75
|
+
}));
|
|
76
|
+
} catch (error) {
|
|
77
|
+
this.send(reply(value, this.config, "error", {
|
|
78
|
+
error: error instanceof Error ? error.message : String(error),
|
|
79
|
+
}));
|
|
80
|
+
} finally {
|
|
81
|
+
this.send(reply(value, this.config, "end"));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async requestCodexWithLogin(value) {
|
|
86
|
+
const request = codexRequest(value, this.config);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
return await requestCodex(this.config, request);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (!isCodexAuthError(error)) {
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await ensureCodexLogin(this.config, this.log, async (text) => {
|
|
96
|
+
this.send(reply(value, this.config, "message", {
|
|
97
|
+
codexLogin: true,
|
|
98
|
+
text,
|
|
99
|
+
}));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return await requestCodex(this.config, request);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
startHeartbeat() {
|
|
107
|
+
this.stopHeartbeat();
|
|
108
|
+
const send = () => this.send(heartbeat(this.config));
|
|
109
|
+
send();
|
|
110
|
+
this.heartbeatTimer = setInterval(send, this.config.heartbeatIntervalMs);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
stopHeartbeat() {
|
|
114
|
+
if (this.heartbeatTimer) {
|
|
115
|
+
clearInterval(this.heartbeatTimer);
|
|
116
|
+
this.heartbeatTimer = null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
scheduleReconnect() {
|
|
121
|
+
if (this.stopped) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
this.reconnectAttempt += 1;
|
|
126
|
+
const base = Math.min(
|
|
127
|
+
this.config.reconnectMaxMs,
|
|
128
|
+
this.config.reconnectMinMs * 2 ** Math.min(8, this.reconnectAttempt - 1),
|
|
129
|
+
);
|
|
130
|
+
const jitter = Math.floor(Math.random() * Math.min(1_000, base));
|
|
131
|
+
const delay = base + jitter;
|
|
132
|
+
|
|
133
|
+
this.log.info(`reconnecting in ${delay}ms`);
|
|
134
|
+
setTimeout(() => {
|
|
135
|
+
if (!this.stopped) {
|
|
136
|
+
this.connect();
|
|
137
|
+
}
|
|
138
|
+
}, delay);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
send(value) {
|
|
142
|
+
if (this.socket?.readyState !== WebSocket.OPEN) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this.socket.send(JSON.stringify(value));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseJson(data) {
|
|
151
|
+
const text = typeof data === "string" ? data : String(data);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
return JSON.parse(text);
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
package/src/login.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
let activeLogin = null;
|
|
4
|
+
|
|
5
|
+
export function isCodexAuthError(error) {
|
|
6
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
7
|
+
|
|
8
|
+
return /auth|login|sign[ -]?in|unauthori[sz]ed|forbidden|invalid_grant|refresh token|access token/i.test(message);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function ensureCodexLogin(config, log, notify) {
|
|
12
|
+
if (activeLogin) {
|
|
13
|
+
await notify("Codex login is already in progress. Waiting for it to complete.");
|
|
14
|
+
return await activeLogin;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
activeLogin = runCodexLogin(config, log, notify).finally(() => {
|
|
18
|
+
activeLogin = null;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
return await activeLogin;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function runCodexLogin(config, log, notify) {
|
|
25
|
+
const [program, ...args] = splitCommand(config.codexLoginCommand);
|
|
26
|
+
|
|
27
|
+
await notify("Codex login is required. Starting device login.");
|
|
28
|
+
log.info(`starting codex login: ${config.codexLoginCommand}`);
|
|
29
|
+
|
|
30
|
+
return await new Promise((resolve, reject) => {
|
|
31
|
+
const child = spawn(program, args, {
|
|
32
|
+
cwd: config.codexCwd,
|
|
33
|
+
env: {
|
|
34
|
+
...process.env,
|
|
35
|
+
...(config.codexHome ? { CODEX_HOME: config.codexHome } : {}),
|
|
36
|
+
},
|
|
37
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
38
|
+
});
|
|
39
|
+
const state = {
|
|
40
|
+
buffer: "",
|
|
41
|
+
sentLoginInfo: false,
|
|
42
|
+
};
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
child.kill("SIGTERM");
|
|
45
|
+
reject(new Error(`Codex login timed out after ${config.codexLoginTimeoutMs}ms`));
|
|
46
|
+
}, config.codexLoginTimeoutMs);
|
|
47
|
+
|
|
48
|
+
const onData = (data) => {
|
|
49
|
+
const text = String(data);
|
|
50
|
+
log.debug(`[codex login] ${text.trimEnd()}`);
|
|
51
|
+
state.buffer = `${state.buffer}\n${text}`.slice(-8000);
|
|
52
|
+
void sendLoginInfo(state, notify);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
child.stdout.on("data", onData);
|
|
56
|
+
child.stderr.on("data", onData);
|
|
57
|
+
child.on("error", (error) => {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
reject(error);
|
|
60
|
+
});
|
|
61
|
+
child.on("exit", (code, signal) => {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
|
|
64
|
+
if (code === 0) {
|
|
65
|
+
void notify("Codex login completed. Retrying the request.");
|
|
66
|
+
resolve();
|
|
67
|
+
} else {
|
|
68
|
+
reject(new Error(`Codex login exited code=${code ?? "null"} signal=${signal ?? "null"}`));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function sendLoginInfo(state, notify) {
|
|
75
|
+
if (state.sentLoginInfo) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const info = parseLoginInfo(state.buffer);
|
|
80
|
+
|
|
81
|
+
if (!info.url && !info.code) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
state.sentLoginInfo = true;
|
|
86
|
+
await notify([
|
|
87
|
+
"Codex login required.",
|
|
88
|
+
info.url ? `Open: ${info.url}` : "",
|
|
89
|
+
info.code ? `Code: ${info.code}` : "",
|
|
90
|
+
"After login completes, the runner will retry automatically.",
|
|
91
|
+
].filter(Boolean).join("\n"));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseLoginInfo(text) {
|
|
95
|
+
const url = text.match(/https?:\/\/[^\s"'<>]+/)?.[0]?.replace(/[),.;]+$/, "") ?? "";
|
|
96
|
+
const labelledCode =
|
|
97
|
+
text.match(/(?:user|device|login|verification)?\s*code(?:\s+is)?\s*[:=]?\s*([A-Z0-9][A-Z0-9-]{4,})/i)?.[1] ?? "";
|
|
98
|
+
const fallbackCode = text.match(/\b[A-Z0-9]{4}-[A-Z0-9]{4}\b/i)?.[0] ?? "";
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
code: labelledCode || fallbackCode,
|
|
102
|
+
url,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function splitCommand(command) {
|
|
107
|
+
const matches = command.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
|
108
|
+
return matches.map((part) => {
|
|
109
|
+
if ((part.startsWith('"') && part.endsWith('"')) || (part.startsWith("'") && part.endsWith("'"))) {
|
|
110
|
+
return part.slice(1, -1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return part;
|
|
114
|
+
});
|
|
115
|
+
}
|
package/src/protocol.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export function isRoomPayload(value) {
|
|
2
|
+
return Boolean(
|
|
3
|
+
value &&
|
|
4
|
+
typeof value === "object" &&
|
|
5
|
+
typeof value.requestId === "string" &&
|
|
6
|
+
typeof value.tool === "string" &&
|
|
7
|
+
typeof value.systemInstruction === "string" &&
|
|
8
|
+
typeof value.message === "string",
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function registration(config) {
|
|
13
|
+
return {
|
|
14
|
+
type: "agent_register",
|
|
15
|
+
name: config.agentName,
|
|
16
|
+
role: config.role,
|
|
17
|
+
model: config.model,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function heartbeat(config) {
|
|
22
|
+
return {
|
|
23
|
+
type: "agent_heartbeat",
|
|
24
|
+
name: config.agentName,
|
|
25
|
+
agentId: config.agentId,
|
|
26
|
+
model: config.model,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function reply(payload, config, type, message) {
|
|
31
|
+
return {
|
|
32
|
+
requestId: payload.requestId,
|
|
33
|
+
type,
|
|
34
|
+
name: config.agentName,
|
|
35
|
+
...(message === undefined ? {} : { message }),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function codexRequest(payload, config) {
|
|
40
|
+
return {
|
|
41
|
+
requestId: payload.requestId,
|
|
42
|
+
agent: {
|
|
43
|
+
id: config.agentId,
|
|
44
|
+
name: config.agentName,
|
|
45
|
+
role: config.role,
|
|
46
|
+
model: config.model,
|
|
47
|
+
},
|
|
48
|
+
room: payload.room,
|
|
49
|
+
userId: payload.userId,
|
|
50
|
+
tool: payload.tool,
|
|
51
|
+
systemInstruction: payload.systemInstruction,
|
|
52
|
+
message: parseMessage(payload.message),
|
|
53
|
+
rawMessage: payload.message,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function codexText(response) {
|
|
58
|
+
if (typeof response === "string") {
|
|
59
|
+
return response;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!response || typeof response !== "object") {
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const key of ["text", "message", "output", "response"]) {
|
|
67
|
+
if (typeof response[key] === "string" && response[key].trim()) {
|
|
68
|
+
return response[key].trim();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return JSON.stringify(response);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseMessage(message) {
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(message);
|
|
78
|
+
} catch {
|
|
79
|
+
return message;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
package/src/runner.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { CodexServer } from "./codex.js";
|
|
2
|
+
import { loadConfig, parseArgs, usage } from "./config.js";
|
|
3
|
+
import { HubClient } from "./hub.js";
|
|
4
|
+
|
|
5
|
+
export async function run(argv) {
|
|
6
|
+
const args = parseArgs(argv);
|
|
7
|
+
|
|
8
|
+
if (args.help) {
|
|
9
|
+
console.log(usage());
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const config = await loadConfig(args.configPath);
|
|
14
|
+
const log = logger(config.logLevel);
|
|
15
|
+
const codex = new CodexServer(config, log);
|
|
16
|
+
const hub = new HubClient(config, log);
|
|
17
|
+
|
|
18
|
+
await codex.start();
|
|
19
|
+
hub.start();
|
|
20
|
+
|
|
21
|
+
const shutdown = async () => {
|
|
22
|
+
log.info("shutting down");
|
|
23
|
+
hub.stop();
|
|
24
|
+
await codex.stop();
|
|
25
|
+
process.exit(0);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
process.once("SIGINT", () => void shutdown());
|
|
29
|
+
process.once("SIGTERM", () => void shutdown());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function logger(level) {
|
|
33
|
+
const debugEnabled = level === "debug";
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
debug(message) {
|
|
37
|
+
if (debugEnabled) {
|
|
38
|
+
console.error(`[debug] ${message}`);
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
info(message) {
|
|
42
|
+
console.error(`[ai-runner-codex] ${message}`);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|