@leoustc/ai-runner-codex 0.1.1 → 0.1.3
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/AGENTS.md +11 -4
- package/README.md +6 -3
- package/package.json +1 -1
- package/src/codex.js +31 -3
- package/src/hub.js +27 -8
- package/src/login.js +30 -12
- package/src/protocol.js +49 -0
package/AGENTS.md
CHANGED
|
@@ -97,10 +97,12 @@ Optional variables may also be added for the Codex app-server command,
|
|
|
97
97
|
socket mode; this runner is intended to keep Codex hot through app-server
|
|
98
98
|
WebSocket.
|
|
99
99
|
|
|
100
|
-
If Codex app-server returns an auth/login error
|
|
101
|
-
(`codex login --device-auth` by default), relay
|
|
102
|
-
|
|
103
|
-
|
|
100
|
+
If Codex app-server returns an auth/login error or generic app-server error,
|
|
101
|
+
start `CODEX_LOGIN_COMMAND` (`codex login --device-auth` by default), relay a
|
|
102
|
+
normal agent message with `codexLogin: true`, `status`, and any parsed device
|
|
103
|
+
login `url`, `code`, and `deviceId` back to the Hub, wait for login completion,
|
|
104
|
+
and retry the Codex request once. Spawn the login command as a local child
|
|
105
|
+
process and watch stdout/stderr for device login values.
|
|
104
106
|
|
|
105
107
|
|
|
106
108
|
Do not commit real `.env` secrets. If examples are needed, use `.env.example`.
|
|
@@ -141,6 +143,11 @@ For each valid payload, reply to the Hub as:
|
|
|
141
143
|
{ "requestId": "...", "type": "end", "name": "agent-name" }
|
|
142
144
|
```
|
|
143
145
|
|
|
146
|
+
Large Codex reply text must be split into multiple `message` frames before the
|
|
147
|
+
matching `end`. Each `message.text` chunk should be at most 8 KiB encoded as
|
|
148
|
+
UTF-8. Include `message.chunk` metadata with `index`, `total`, and `maxBytes`
|
|
149
|
+
when a reply has more than one chunk.
|
|
150
|
+
|
|
144
151
|
Errors use:
|
|
145
152
|
|
|
146
153
|
```json
|
package/README.md
CHANGED
|
@@ -84,9 +84,12 @@ WebSocket text frame.
|
|
|
84
84
|
On startup, the runner checks that the `codex` CLI is installed and available
|
|
85
85
|
on `PATH` before starting app-server.
|
|
86
86
|
|
|
87
|
-
If Codex returns an auth/login error, the runner
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
If Codex returns an auth/login error or a generic app-server error, the runner
|
|
88
|
+
starts `CODEX_LOGIN_COMMAND`, posts a normal agent message with
|
|
89
|
+
`codexLogin: true`, `status`, and any parsed device login `url`, `code`, and
|
|
90
|
+
`deviceId` back to the Hub, waits for login completion, then retries the Codex
|
|
91
|
+
request once. The login command is spawned as a local child process and the
|
|
92
|
+
runner watches stdout/stderr for the device login values.
|
|
90
93
|
|
|
91
94
|
Hub WebSocket behavior:
|
|
92
95
|
|
package/package.json
CHANGED
package/src/codex.js
CHANGED
|
@@ -194,7 +194,7 @@ class CodexWsClient {
|
|
|
194
194
|
this.pending.delete(String(message.id));
|
|
195
195
|
|
|
196
196
|
if (message.error) {
|
|
197
|
-
pending.reject(
|
|
197
|
+
pending.reject(codexError(message.error, "Codex app-server request failed"));
|
|
198
198
|
} else {
|
|
199
199
|
pending.resolve(message.result);
|
|
200
200
|
}
|
|
@@ -235,7 +235,7 @@ class CodexWsClient {
|
|
|
235
235
|
this.turns.delete(threadId);
|
|
236
236
|
|
|
237
237
|
if (message.params?.turn?.status === "failed") {
|
|
238
|
-
state.reject?.(
|
|
238
|
+
state.reject?.(codexError(message.params.turn.error, "Codex turn failed"));
|
|
239
239
|
} else {
|
|
240
240
|
state.resolve?.(state.text || textFromTurn(message.params?.turn));
|
|
241
241
|
}
|
|
@@ -243,7 +243,7 @@ class CodexWsClient {
|
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
if (message.method === "error") {
|
|
246
|
-
const error =
|
|
246
|
+
const error = codexError(message.params, "Codex app-server error");
|
|
247
247
|
|
|
248
248
|
for (const state of this.turns.values()) {
|
|
249
249
|
state.reject?.(error);
|
|
@@ -379,6 +379,34 @@ function textFromTurn(turn) {
|
|
|
379
379
|
.trim();
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
+
function codexError(value, fallback) {
|
|
383
|
+
if (!value || typeof value !== "object") {
|
|
384
|
+
return new Error(fallback);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const fields = [];
|
|
388
|
+
|
|
389
|
+
for (const key of ["message", "code", "reason", "type", "status"]) {
|
|
390
|
+
if (typeof value[key] === "string" && value[key]) {
|
|
391
|
+
fields.push(`${key}: ${value[key]}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (value.error && typeof value.error === "object") {
|
|
396
|
+
for (const key of ["message", "code", "type"]) {
|
|
397
|
+
if (typeof value.error[key] === "string" && value.error[key]) {
|
|
398
|
+
fields.push(`error.${key}: ${value.error[key]}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (fields.length === 0) {
|
|
404
|
+
fields.push(JSON.stringify(value));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return new Error(`${fallback}: ${fields.join("; ")}`);
|
|
408
|
+
}
|
|
409
|
+
|
|
382
410
|
function sandboxPolicy(mode, cwd) {
|
|
383
411
|
if (mode === "danger-full-access") {
|
|
384
412
|
return { type: "dangerFullAccess" };
|
package/src/hub.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { codexRequest, codexText, heartbeat, isRoomPayload, registration, reply } from "./protocol.js";
|
|
1
|
+
import { codexRequest, codexText, heartbeat, isRoomPayload, messageReplies, registration, reply } from "./protocol.js";
|
|
2
2
|
import { requestCodex } from "./codex.js";
|
|
3
3
|
import { ensureCodexLogin, isCodexAuthError } from "./login.js";
|
|
4
4
|
|
|
@@ -94,10 +94,10 @@ export class HubClient {
|
|
|
94
94
|
try {
|
|
95
95
|
const response = await this.requestCodexWithLogin(value);
|
|
96
96
|
const text = codexText(response) || "Codex returned no text.";
|
|
97
|
-
this.
|
|
97
|
+
this.sendMessages(value, {
|
|
98
98
|
model: this.config.model,
|
|
99
99
|
text,
|
|
100
|
-
})
|
|
100
|
+
});
|
|
101
101
|
} catch (error) {
|
|
102
102
|
this.send(reply(value, this.config, "error", {
|
|
103
103
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -117,11 +117,8 @@ export class HubClient {
|
|
|
117
117
|
throw error;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
await ensureCodexLogin(this.config, this.log, async (
|
|
121
|
-
this.
|
|
122
|
-
codexLogin: true,
|
|
123
|
-
text,
|
|
124
|
-
}));
|
|
120
|
+
await ensureCodexLogin(this.config, this.log, async (notice) => {
|
|
121
|
+
this.sendMessages(value, loginNoticeMessage(notice));
|
|
125
122
|
});
|
|
126
123
|
|
|
127
124
|
return await requestCodex(this.config, request);
|
|
@@ -210,6 +207,12 @@ export class HubClient {
|
|
|
210
207
|
}
|
|
211
208
|
}
|
|
212
209
|
|
|
210
|
+
sendMessages(payload, message) {
|
|
211
|
+
for (const value of messageReplies(payload, this.config, message)) {
|
|
212
|
+
this.send(value);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
213
216
|
sendNow(value) {
|
|
214
217
|
if (this.socket?.readyState !== WebSocket.OPEN) {
|
|
215
218
|
return false;
|
|
@@ -251,3 +254,19 @@ function parseJson(data) {
|
|
|
251
254
|
return null;
|
|
252
255
|
}
|
|
253
256
|
}
|
|
257
|
+
|
|
258
|
+
function loginNoticeMessage(notice) {
|
|
259
|
+
if (notice && typeof notice === "object") {
|
|
260
|
+
return {
|
|
261
|
+
...notice,
|
|
262
|
+
codexLogin: true,
|
|
263
|
+
text: typeof notice.text === "string" ? notice.text : JSON.stringify(notice, null, 2),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
codexLogin: true,
|
|
269
|
+
status: "notice",
|
|
270
|
+
text: String(notice),
|
|
271
|
+
};
|
|
272
|
+
}
|
package/src/login.js
CHANGED
|
@@ -5,12 +5,12 @@ let activeLogin = null;
|
|
|
5
5
|
export function isCodexAuthError(error) {
|
|
6
6
|
const message = error instanceof Error ? error.message : String(error);
|
|
7
7
|
|
|
8
|
-
return /auth|login|sign[ -]?in|unauthori[sz]ed|forbidden|invalid_grant|refresh token|access token/i.test(message);
|
|
8
|
+
return /auth|login|sign[ -]?in|unauthori[sz]ed|forbidden|invalid_grant|refresh token|access token|codex app-server error/i.test(message);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export async function ensureCodexLogin(config, log, notify) {
|
|
12
12
|
if (activeLogin) {
|
|
13
|
-
await notify("Codex login is already in progress. Waiting for it to complete.");
|
|
13
|
+
await notify(loginStatusMessage("waiting", "Codex login is already in progress. Waiting for it to complete."));
|
|
14
14
|
return await activeLogin;
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -24,7 +24,7 @@ export async function ensureCodexLogin(config, log, notify) {
|
|
|
24
24
|
async function runCodexLogin(config, log, notify) {
|
|
25
25
|
const [program, ...args] = splitCommand(config.codexLoginCommand);
|
|
26
26
|
|
|
27
|
-
await notify("Codex login is required. Starting device login.");
|
|
27
|
+
await notify(loginStatusMessage("starting", "Codex login is required. Starting device login process."));
|
|
28
28
|
log.info(`starting codex login: ${config.codexLoginCommand}`);
|
|
29
29
|
|
|
30
30
|
return await new Promise((resolve, reject) => {
|
|
@@ -62,7 +62,7 @@ async function runCodexLogin(config, log, notify) {
|
|
|
62
62
|
clearTimeout(timer);
|
|
63
63
|
|
|
64
64
|
if (code === 0) {
|
|
65
|
-
void notify("Codex login completed. Retrying the request.");
|
|
65
|
+
void notify(loginStatusMessage("complete", "Codex login completed. Retrying the request."));
|
|
66
66
|
resolve();
|
|
67
67
|
} else {
|
|
68
68
|
reject(new Error(`Codex login exited code=${code ?? "null"} signal=${signal ?? "null"}`));
|
|
@@ -78,27 +78,45 @@ async function sendLoginInfo(state, notify) {
|
|
|
78
78
|
|
|
79
79
|
const info = parseLoginInfo(state.buffer);
|
|
80
80
|
|
|
81
|
-
if (!info.url && !info.
|
|
81
|
+
if (!info.url && !info.deviceId) {
|
|
82
82
|
return;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
state.sentLoginInfo = true;
|
|
86
|
-
await notify(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
info.
|
|
90
|
-
|
|
91
|
-
|
|
86
|
+
await notify({
|
|
87
|
+
codexLogin: true,
|
|
88
|
+
status: "login_required",
|
|
89
|
+
...(info.url ? { url: info.url } : {}),
|
|
90
|
+
...(info.deviceId ? { code: info.deviceId, deviceId: info.deviceId } : {}),
|
|
91
|
+
text: [
|
|
92
|
+
"Codex login required.",
|
|
93
|
+
info.url ? `Open: ${info.url}` : "",
|
|
94
|
+
info.deviceId ? `Code: ${info.deviceId}` : "",
|
|
95
|
+
"After login completes, the runner will retry automatically.",
|
|
96
|
+
].filter(Boolean).join("\n"),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function loginStatusMessage(status, text) {
|
|
101
|
+
return {
|
|
102
|
+
codexLogin: true,
|
|
103
|
+
status,
|
|
104
|
+
text,
|
|
105
|
+
};
|
|
92
106
|
}
|
|
93
107
|
|
|
94
108
|
function parseLoginInfo(text) {
|
|
95
109
|
const url = text.match(/https?:\/\/[^\s"'<>]+/)?.[0]?.replace(/[),.;]+$/, "") ?? "";
|
|
110
|
+
const labelledDeviceId =
|
|
111
|
+
text.match(/(?:device|user|login|verification)?\s*(?:id|code)(?:\s+is)?\s*[:=]?\s*([A-Z0-9][A-Z0-9-]{4,})/i)?.[1] ?? "";
|
|
96
112
|
const labelledCode =
|
|
97
113
|
text.match(/(?:user|device|login|verification)?\s*code(?:\s+is)?\s*[:=]?\s*([A-Z0-9][A-Z0-9-]{4,})/i)?.[1] ?? "";
|
|
98
114
|
const fallbackCode = text.match(/\b[A-Z0-9]{4}-[A-Z0-9]{4}\b/i)?.[0] ?? "";
|
|
115
|
+
const deviceId = labelledDeviceId || labelledCode || fallbackCode;
|
|
99
116
|
|
|
100
117
|
return {
|
|
101
|
-
code:
|
|
118
|
+
code: deviceId,
|
|
119
|
+
deviceId,
|
|
102
120
|
url,
|
|
103
121
|
};
|
|
104
122
|
}
|
package/src/protocol.js
CHANGED
|
@@ -36,6 +36,30 @@ export function reply(payload, config, type, message) {
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
export function messageReplies(payload, config, message, maxBytes = 8 * 1024) {
|
|
40
|
+
if (typeof message?.text !== "string") {
|
|
41
|
+
return [reply(payload, config, "message", message)];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const chunks = splitUtf8Chunks(message.text, maxBytes);
|
|
45
|
+
|
|
46
|
+
return chunks.map((chunk, index) =>
|
|
47
|
+
reply(payload, config, "message", {
|
|
48
|
+
...message,
|
|
49
|
+
text: chunk,
|
|
50
|
+
...(chunks.length > 1
|
|
51
|
+
? {
|
|
52
|
+
chunk: {
|
|
53
|
+
index,
|
|
54
|
+
total: chunks.length,
|
|
55
|
+
maxBytes,
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
: {}),
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
39
63
|
export function codexRequest(payload, config) {
|
|
40
64
|
return {
|
|
41
65
|
requestId: payload.requestId,
|
|
@@ -80,3 +104,28 @@ function parseMessage(message) {
|
|
|
80
104
|
}
|
|
81
105
|
}
|
|
82
106
|
|
|
107
|
+
function splitUtf8Chunks(text, maxBytes) {
|
|
108
|
+
const encoder = new TextEncoder();
|
|
109
|
+
const chunks = [];
|
|
110
|
+
let current = "";
|
|
111
|
+
let currentBytes = 0;
|
|
112
|
+
|
|
113
|
+
for (const character of text) {
|
|
114
|
+
const characterBytes = encoder.encode(character).byteLength;
|
|
115
|
+
|
|
116
|
+
if (current && currentBytes + characterBytes > maxBytes) {
|
|
117
|
+
chunks.push(current);
|
|
118
|
+
current = "";
|
|
119
|
+
currentBytes = 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
current += character;
|
|
123
|
+
currentBytes += characterBytes;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (current || chunks.length === 0) {
|
|
127
|
+
chunks.push(current);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return chunks;
|
|
131
|
+
}
|