@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 +3 -0
- package/AGENTS.md +5 -0
- package/README.md +12 -0
- package/package.json +1 -1
- package/src/codex.js +1 -1
- package/src/config.js +3 -0
- package/src/hub.js +110 -9
- package/src/protocol.js +49 -0
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
package/src/codex.js
CHANGED
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|