@leoustc/ai-runner-codex 0.1.0 → 0.1.2

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 CHANGED
@@ -19,6 +19,9 @@ CODEX_SANDBOX=read-only
19
19
  CODEX_APPROVAL_POLICY=never
20
20
  CODEX_REQUEST_TIMEOUT_MS=120000
21
21
  HEARTBEAT_INTERVAL_MS=60000
22
+ HUB_STALE_TIMEOUT_MS=180000
23
+ HUB_WATCHDOG_INTERVAL_MS=30000
24
+ OUTBOUND_QUEUE_MAX=100
22
25
  RECONNECT_MIN_MS=2000
23
26
  RECONNECT_MAX_MS=60000
24
27
  LOG_LEVEL=info
package/AGENTS.md CHANGED
@@ -141,6 +141,11 @@ For each valid payload, reply to the Hub as:
141
141
  { "requestId": "...", "type": "end", "name": "agent-name" }
142
142
  ```
143
143
 
144
+ Large Codex reply text must be split into multiple `message` frames before the
145
+ matching `end`. Each `message.text` chunk should be at most 8 KiB encoded as
146
+ UTF-8. Include `message.chunk` metadata with `index`, `total`, and `maxBytes`
147
+ when a reply has more than one chunk.
148
+
144
149
  Errors use:
145
150
 
146
151
  ```json
package/README.md CHANGED
@@ -65,6 +65,9 @@ CODEX_SANDBOX=read-only
65
65
  CODEX_APPROVAL_POLICY=never
66
66
  CODEX_REQUEST_TIMEOUT_MS=120000
67
67
  HEARTBEAT_INTERVAL_MS=60000
68
+ HUB_STALE_TIMEOUT_MS=180000
69
+ HUB_WATCHDOG_INTERVAL_MS=30000
70
+ OUTBOUND_QUEUE_MAX=100
68
71
  RECONNECT_MIN_MS=2000
69
72
  RECONNECT_MAX_MS=60000
70
73
  LOG_LEVEL=info
@@ -85,6 +88,15 @@ If Codex returns an auth/login error, the runner starts `CODEX_LOGIN_COMMAND`,
85
88
  posts the device login URL/code back to the Hub as an agent message, waits for
86
89
  login completion, then retries the Codex request once.
87
90
 
91
+ Hub WebSocket behavior:
92
+
93
+ - `HEARTBEAT_INTERVAL_MS` defaults to `60000`, so the runner sends a heartbeat
94
+ every 60 seconds while connected.
95
+ - If the socket closes or is detected stale, the runner reconnects with bounded
96
+ exponential backoff between `RECONNECT_MIN_MS` and `RECONNECT_MAX_MS`.
97
+ - Agent replies produced while the Hub socket is briefly down are queued up to
98
+ `OUTBOUND_QUEUE_MAX` and flushed after reconnect.
99
+
88
100
  Prompt behavior:
89
101
 
90
102
  - `ask`/`chat`: answer from workspace knowledge without editing files.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leoustc/ai-runner-codex",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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
@@ -100,7 +100,7 @@ class CodexWsClient {
100
100
  clientInfo: {
101
101
  name: "ai-runner-codex",
102
102
  title: "AI Runner Codex",
103
- version: "0.1.0",
103
+ version: "0.1.1",
104
104
  },
105
105
  capabilities: {
106
106
  experimentalApi: true,
package/src/config.js CHANGED
@@ -64,8 +64,11 @@ export async function loadConfig(configPath) {
64
64
  codexSandbox: env.CODEX_SANDBOX || "read-only",
65
65
  codexApprovalPolicy: env.CODEX_APPROVAL_POLICY || "never",
66
66
  heartbeatIntervalMs: numberValue(env.HEARTBEAT_INTERVAL_MS, 60_000),
67
+ hubStaleTimeoutMs: numberValue(env.HUB_STALE_TIMEOUT_MS, 180_000),
68
+ hubWatchdogIntervalMs: numberValue(env.HUB_WATCHDOG_INTERVAL_MS, 30_000),
67
69
  logLevel: env.LOG_LEVEL || "info",
68
70
  model: env.ROOM_AGENT_MODEL || "codex",
71
+ outboundQueueMax: numberValue(env.OUTBOUND_QUEUE_MAX, 100),
69
72
  reconnectMaxMs: numberValue(env.RECONNECT_MAX_MS, 60_000),
70
73
  reconnectMinMs: numberValue(env.RECONNECT_MIN_MS, 2_000),
71
74
  role:
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
 
@@ -7,9 +7,13 @@ export class HubClient {
7
7
  this.config = config;
8
8
  this.log = log;
9
9
  this.heartbeatTimer = null;
10
+ this.lastSocketActivity = 0;
11
+ this.outboundQueue = [];
10
12
  this.reconnectAttempt = 0;
13
+ this.reconnectTimer = null;
11
14
  this.stopped = false;
12
15
  this.socket = null;
16
+ this.watchdogTimer = null;
13
17
  }
14
18
 
15
19
  start() {
@@ -19,10 +23,13 @@ export class HubClient {
19
23
  stop() {
20
24
  this.stopped = true;
21
25
  this.stopHeartbeat();
26
+ this.stopWatchdog();
27
+ this.clearReconnectTimer();
22
28
  this.socket?.close();
23
29
  }
24
30
 
25
31
  connect() {
32
+ this.clearReconnectTimer();
26
33
  const url = new URL(this.config.roomWss);
27
34
  url.protocol = url.protocol === "https:" ? "wss:" : url.protocol;
28
35
  url.searchParams.set("connector", this.config.agentId);
@@ -35,23 +42,41 @@ export class HubClient {
35
42
  this.socket = socket;
36
43
 
37
44
  socket.addEventListener("open", () => {
45
+ if (this.socket !== socket) {
46
+ socket.close();
47
+ return;
48
+ }
49
+
38
50
  this.reconnectAttempt = 0;
51
+ this.markSocketActivity();
39
52
  this.log.info("hub websocket connected");
40
- this.send(registration(this.config));
53
+ this.sendNow(registration(this.config));
54
+ this.flushOutboundQueue();
41
55
  this.startHeartbeat();
56
+ this.startWatchdog();
42
57
  });
43
58
 
44
59
  socket.addEventListener("message", (event) => {
60
+ this.markSocketActivity();
45
61
  void this.handleMessage(event.data);
46
62
  });
47
63
 
48
64
  socket.addEventListener("close", (event) => {
65
+ if (this.socket !== socket) {
66
+ return;
67
+ }
68
+
49
69
  this.log.info(`hub websocket closed code=${event.code} reason=${event.reason || ""}`);
50
70
  this.stopHeartbeat();
71
+ this.stopWatchdog();
51
72
  this.scheduleReconnect();
52
73
  });
53
74
 
54
75
  socket.addEventListener("error", () => {
76
+ if (this.socket !== socket) {
77
+ return;
78
+ }
79
+
55
80
  this.log.info("hub websocket error");
56
81
  });
57
82
  }
@@ -69,10 +94,10 @@ export class HubClient {
69
94
  try {
70
95
  const response = await this.requestCodexWithLogin(value);
71
96
  const text = codexText(response) || "Codex returned no text.";
72
- this.send(reply(value, this.config, "message", {
97
+ this.sendMessages(value, {
73
98
  model: this.config.model,
74
99
  text,
75
- }));
100
+ });
76
101
  } catch (error) {
77
102
  this.send(reply(value, this.config, "error", {
78
103
  error: error instanceof Error ? error.message : String(error),
@@ -93,10 +118,10 @@ export class HubClient {
93
118
  }
94
119
 
95
120
  await ensureCodexLogin(this.config, this.log, async (text) => {
96
- this.send(reply(value, this.config, "message", {
121
+ this.sendMessages(value, {
97
122
  codexLogin: true,
98
123
  text,
99
- }));
124
+ });
100
125
  });
101
126
 
102
127
  return await requestCodex(this.config, request);
@@ -105,7 +130,7 @@ export class HubClient {
105
130
 
106
131
  startHeartbeat() {
107
132
  this.stopHeartbeat();
108
- const send = () => this.send(heartbeat(this.config));
133
+ const send = () => this.sendNow(heartbeat(this.config));
109
134
  send();
110
135
  this.heartbeatTimer = setInterval(send, this.config.heartbeatIntervalMs);
111
136
  }
@@ -117,6 +142,30 @@ export class HubClient {
117
142
  }
118
143
  }
119
144
 
145
+ startWatchdog() {
146
+ this.stopWatchdog();
147
+ this.watchdogTimer = setInterval(() => {
148
+ if (this.socket?.readyState !== WebSocket.OPEN) {
149
+ return;
150
+ }
151
+
152
+ const idleMs = Date.now() - this.lastSocketActivity;
153
+ if (idleMs < this.config.hubStaleTimeoutMs) {
154
+ return;
155
+ }
156
+
157
+ this.log.info(`hub websocket stale for ${idleMs}ms; reconnecting`);
158
+ this.socket.close();
159
+ }, this.config.hubWatchdogIntervalMs);
160
+ }
161
+
162
+ stopWatchdog() {
163
+ if (this.watchdogTimer) {
164
+ clearInterval(this.watchdogTimer);
165
+ this.watchdogTimer = null;
166
+ }
167
+ }
168
+
120
169
  scheduleReconnect() {
121
170
  if (this.stopped) {
122
171
  return;
@@ -131,19 +180,71 @@ export class HubClient {
131
180
  const delay = base + jitter;
132
181
 
133
182
  this.log.info(`reconnecting in ${delay}ms`);
134
- setTimeout(() => {
183
+ this.reconnectTimer = setTimeout(() => {
184
+ this.reconnectTimer = null;
135
185
  if (!this.stopped) {
136
186
  this.connect();
137
187
  }
138
188
  }, delay);
139
189
  }
140
190
 
191
+ clearReconnectTimer() {
192
+ if (this.reconnectTimer) {
193
+ clearTimeout(this.reconnectTimer);
194
+ this.reconnectTimer = null;
195
+ }
196
+ }
197
+
198
+ markSocketActivity() {
199
+ this.lastSocketActivity = Date.now();
200
+ }
201
+
141
202
  send(value) {
142
203
  if (this.socket?.readyState !== WebSocket.OPEN) {
204
+ this.queueOutbound(value);
143
205
  return;
144
206
  }
145
207
 
146
- this.socket.send(JSON.stringify(value));
208
+ if (!this.sendNow(value)) {
209
+ this.queueOutbound(value);
210
+ }
211
+ }
212
+
213
+ sendMessages(payload, message) {
214
+ for (const value of messageReplies(payload, this.config, message)) {
215
+ this.send(value);
216
+ }
217
+ }
218
+
219
+ sendNow(value) {
220
+ if (this.socket?.readyState !== WebSocket.OPEN) {
221
+ return false;
222
+ }
223
+
224
+ try {
225
+ this.socket.send(JSON.stringify(value));
226
+ this.markSocketActivity();
227
+ return true;
228
+ } catch (error) {
229
+ this.log.info(`hub websocket send failed: ${error instanceof Error ? error.message : String(error)}`);
230
+ this.socket.close();
231
+ return false;
232
+ }
233
+ }
234
+
235
+ queueOutbound(value) {
236
+ if (this.outboundQueue.length >= this.config.outboundQueueMax) {
237
+ this.outboundQueue.shift();
238
+ }
239
+
240
+ this.outboundQueue.push(value);
241
+ }
242
+
243
+ flushOutboundQueue() {
244
+ while (this.outboundQueue.length > 0 && this.socket?.readyState === WebSocket.OPEN) {
245
+ const value = this.outboundQueue.shift();
246
+ this.sendNow(value);
247
+ }
147
248
  }
148
249
  }
149
250
 
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
+ }