@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 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.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "../src/runner.js";
3
+
4
+ run(process.argv.slice(2)).catch((error) => {
5
+ console.error(error instanceof Error ? error.stack || error.message : String(error));
6
+ process.exitCode = 1;
7
+ });
8
+
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
+ }
@@ -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
+ }