@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 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, 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.
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 starts `CODEX_LOGIN_COMMAND`,
88
- posts the device login URL/code back to the Hub as an agent message, waits for
89
- login completion, then retries the Codex request once.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leoustc/ai-runner-codex",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "description": "Codex app-server backed Agent Runner Hub CLI connector",
6
6
  "license": "MIT",
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(new Error(message.error.message || JSON.stringify(message.error)));
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?.(new Error(message.params.turn.error?.message || "Codex turn failed"));
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 = new Error(message.params?.message || "Codex app-server 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.send(reply(value, this.config, "message", {
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 (text) => {
121
- this.send(reply(value, this.config, "message", {
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.code) {
81
+ if (!info.url && !info.deviceId) {
82
82
  return;
83
83
  }
84
84
 
85
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"));
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: labelledCode || fallbackCode,
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
+ }