agent-relay-codex 0.4.14
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/README.md +124 -0
- package/app-client.ts +239 -0
- package/approval.ts +29 -0
- package/bin/agent-relay-codex.ts +988 -0
- package/hooks/session-start-lib.ts +25 -0
- package/hooks/session-start.ts +194 -0
- package/install-codex.ps1 +47 -0
- package/install-codex.sh +75 -0
- package/live-sidecar.ts +685 -0
- package/package.json +48 -0
- package/plugin/.codex-plugin/plugin.json +40 -0
- package/plugin/skills/agent-relay/SKILL.md +29 -0
- package/relay.ts +145 -0
- package/start-live.sh +64 -0
package/live-sidecar.ts
ADDED
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import { approvalModeFromPermissions, parseApprovalMode, type ApprovalMode } from "./approval";
|
|
5
|
+
import { CodexAppClient, type ClientEvent, type Thread, type ThreadStatus } from "./app-client";
|
|
6
|
+
import { RelayClient, RelayHttpError, type RelayAgentStatus, type RelayMessage } from "./relay";
|
|
7
|
+
|
|
8
|
+
interface Config {
|
|
9
|
+
relayUrl: string;
|
|
10
|
+
appServerUrl: string;
|
|
11
|
+
cwd: string;
|
|
12
|
+
rig: string;
|
|
13
|
+
capabilities: string[];
|
|
14
|
+
tags: string[];
|
|
15
|
+
statePath: string;
|
|
16
|
+
pollIntervalMs: number;
|
|
17
|
+
heartbeatIntervalMs: number;
|
|
18
|
+
relayBackoffInitialMs: number;
|
|
19
|
+
relayBackoffMaxMs: number;
|
|
20
|
+
coalesceWindowMs: number;
|
|
21
|
+
reconnectInitialDelayMs: number;
|
|
22
|
+
reconnectMaxDelayMs: number;
|
|
23
|
+
threadMode: "auto" | "resume" | "start";
|
|
24
|
+
threadId?: string;
|
|
25
|
+
model?: string;
|
|
26
|
+
approvalPolicy?: string;
|
|
27
|
+
sandbox?: string;
|
|
28
|
+
approvalMode: ApprovalMode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface RuntimeState {
|
|
32
|
+
agentId: string;
|
|
33
|
+
threadId: string;
|
|
34
|
+
activeTurnId: string | null;
|
|
35
|
+
threadStatus: ThreadStatus["type"];
|
|
36
|
+
lastSeenMessageId: number;
|
|
37
|
+
pendingMessageCount: number;
|
|
38
|
+
appConnected: boolean;
|
|
39
|
+
appServerUrl: string;
|
|
40
|
+
relayUrl: string;
|
|
41
|
+
cwd: string;
|
|
42
|
+
updatedAt: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface DeliveryBatch {
|
|
46
|
+
messages: RelayMessage[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class CodexLiveSidecar {
|
|
50
|
+
private readonly relay: RelayClient;
|
|
51
|
+
private readonly logPrefix = "[codex-live]";
|
|
52
|
+
private app: CodexAppClient;
|
|
53
|
+
private agentId = "";
|
|
54
|
+
private threadId = "";
|
|
55
|
+
private activeTurnId: string | null = null;
|
|
56
|
+
private threadStatus: ThreadStatus["type"] = "notLoaded";
|
|
57
|
+
private lastSeenMessageId = 0;
|
|
58
|
+
private lastHeartbeatAt = 0;
|
|
59
|
+
private hasSuccessfulPoll = false;
|
|
60
|
+
private relayBackoffMs = 0;
|
|
61
|
+
private stopping = false;
|
|
62
|
+
private appConnected = false;
|
|
63
|
+
private draining = false;
|
|
64
|
+
private drainDueAt = 0;
|
|
65
|
+
private reconnecting: Promise<void> | null = null;
|
|
66
|
+
private readonly pendingMessages = new Map<number, RelayMessage>();
|
|
67
|
+
|
|
68
|
+
constructor(private readonly config: Config) {
|
|
69
|
+
this.relay = new RelayClient(config.relayUrl, (msg) => this.log(msg));
|
|
70
|
+
this.app = this.createAppClient();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async run(): Promise<void> {
|
|
74
|
+
mkdirSync(dirname(this.config.statePath), { recursive: true });
|
|
75
|
+
|
|
76
|
+
process.on("SIGINT", () => void this.stop("SIGINT"));
|
|
77
|
+
process.on("SIGTERM", () => void this.stop("SIGTERM"));
|
|
78
|
+
|
|
79
|
+
await this.ensureAppReady();
|
|
80
|
+
|
|
81
|
+
await this.registerRelayAgent();
|
|
82
|
+
|
|
83
|
+
while (!this.stopping) {
|
|
84
|
+
let sleepMs = this.config.pollIntervalMs;
|
|
85
|
+
try {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
if (now - this.lastHeartbeatAt >= this.config.heartbeatIntervalMs) {
|
|
88
|
+
await this.relay.heartbeat(this.agentId);
|
|
89
|
+
this.lastHeartbeatAt = now;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const messages = await this.relay.pollMessages(this.agentId, this.lastSeenMessageId);
|
|
93
|
+
if (!this.hasSuccessfulPoll) {
|
|
94
|
+
this.hasSuccessfulPoll = true;
|
|
95
|
+
await this.refreshRelayStatus();
|
|
96
|
+
}
|
|
97
|
+
if (messages.length > 0) {
|
|
98
|
+
this.addPendingMessages(messages);
|
|
99
|
+
this.writeState();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (this.pendingMessages.size > 0 && now >= this.drainDueAt && !this.draining) {
|
|
103
|
+
await this.drainPendingMessages();
|
|
104
|
+
}
|
|
105
|
+
this.relayBackoffMs = 0;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error instanceof RelayHttpError && error.status === 404 && error.path.includes("/heartbeat")) {
|
|
108
|
+
this.log("heartbeat returned 404; re-registering relay agent");
|
|
109
|
+
await this.registerRelayAgent();
|
|
110
|
+
} else {
|
|
111
|
+
sleepMs = this.nextRelayBackoffMs();
|
|
112
|
+
this.log(`loop error: ${describeError(error)}; retrying in ${sleepMs}ms`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
await delay(sleepMs);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private async registerRelayAgent(): Promise<void> {
|
|
120
|
+
const registration = await this.relay.registerAgent({
|
|
121
|
+
relayUrl: this.config.relayUrl,
|
|
122
|
+
cwd: this.config.cwd,
|
|
123
|
+
rig: this.config.rig,
|
|
124
|
+
capabilities: this.config.capabilities,
|
|
125
|
+
tags: this.config.tags,
|
|
126
|
+
threadId: this.threadId,
|
|
127
|
+
model: this.config.model,
|
|
128
|
+
appServerUrl: this.config.appServerUrl,
|
|
129
|
+
approvalMode: this.config.approvalMode,
|
|
130
|
+
});
|
|
131
|
+
this.agentId = registration.agentId;
|
|
132
|
+
this.lastSeenMessageId = await this.relay.getCursor();
|
|
133
|
+
this.lastHeartbeatAt = 0;
|
|
134
|
+
this.hasSuccessfulPoll = false;
|
|
135
|
+
this.log(`registered agent ${this.agentId}`);
|
|
136
|
+
this.log(`using thread ${this.threadId}`);
|
|
137
|
+
this.log(`bootstrapped relay cursor ${this.lastSeenMessageId}`);
|
|
138
|
+
this.writeState();
|
|
139
|
+
await this.refreshRelayStatus();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private createAppClient(): CodexAppClient {
|
|
143
|
+
const app = new CodexAppClient(this.config.appServerUrl, (msg) => this.log(msg));
|
|
144
|
+
app.onEvent((event) => this.handleAppEvent(event));
|
|
145
|
+
app.onConnectionChange((connected) => {
|
|
146
|
+
this.appConnected = connected;
|
|
147
|
+
if (!connected) {
|
|
148
|
+
this.activeTurnId = null;
|
|
149
|
+
this.threadStatus = "notLoaded";
|
|
150
|
+
if (!this.stopping) void this.ensureAppReady();
|
|
151
|
+
}
|
|
152
|
+
void this.refreshRelayStatus();
|
|
153
|
+
this.writeState();
|
|
154
|
+
});
|
|
155
|
+
return app;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async ensureAppReady(force = false): Promise<void> {
|
|
159
|
+
if (this.stopping) return;
|
|
160
|
+
if (this.appConnected && !force) return;
|
|
161
|
+
if (this.reconnecting) return this.reconnecting;
|
|
162
|
+
|
|
163
|
+
this.reconnecting = this.connectWithRetry(force).finally(() => {
|
|
164
|
+
this.reconnecting = null;
|
|
165
|
+
});
|
|
166
|
+
return this.reconnecting;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async connectWithRetry(force: boolean): Promise<void> {
|
|
170
|
+
let waitMs = this.config.reconnectInitialDelayMs;
|
|
171
|
+
|
|
172
|
+
while (!this.stopping) {
|
|
173
|
+
try {
|
|
174
|
+
if (force) {
|
|
175
|
+
try {
|
|
176
|
+
this.app.close();
|
|
177
|
+
} catch {
|
|
178
|
+
// Ignore close failures during forced reconnect.
|
|
179
|
+
}
|
|
180
|
+
this.appConnected = false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!this.app.isConnected()) {
|
|
184
|
+
this.app = this.createAppClient();
|
|
185
|
+
await this.app.connect();
|
|
186
|
+
await this.app.initialize();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const thread = this.threadId
|
|
190
|
+
? await this.resumeKnownThread(this.threadId)
|
|
191
|
+
: await this.resolveThread();
|
|
192
|
+
this.threadId = thread.id;
|
|
193
|
+
this.syncThreadState(thread);
|
|
194
|
+
if (this.agentId) {
|
|
195
|
+
this.log(`app connection restored for thread ${thread.id}`);
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
} catch (error) {
|
|
199
|
+
this.appConnected = false;
|
|
200
|
+
this.log(`app connection failed: ${describeError(error)}; retrying in ${waitMs}ms`);
|
|
201
|
+
await delay(waitMs);
|
|
202
|
+
waitMs = Math.min(this.config.reconnectMaxDelayMs, waitMs * 2);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private async stop(signal: string): Promise<void> {
|
|
208
|
+
if (this.stopping) return;
|
|
209
|
+
this.stopping = true;
|
|
210
|
+
this.log(`stopping on ${signal}`);
|
|
211
|
+
if (this.agentId) {
|
|
212
|
+
try {
|
|
213
|
+
await this.relay.setStatus(this.agentId, "offline");
|
|
214
|
+
} catch {
|
|
215
|
+
// Best effort during shutdown.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
this.writeState();
|
|
219
|
+
this.app.close();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private async resumeKnownThread(threadId: string): Promise<Thread> {
|
|
223
|
+
try {
|
|
224
|
+
const resumed = await this.app.threadResume({
|
|
225
|
+
threadId,
|
|
226
|
+
cwd: this.config.cwd,
|
|
227
|
+
...this.threadPermissions(),
|
|
228
|
+
persistExtendedHistory: false,
|
|
229
|
+
});
|
|
230
|
+
return normalizeThread(resumed.thread);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
this.log(`resume failed for thread ${threadId}: ${describeError(error)}; falling back to thread resolution`);
|
|
233
|
+
this.threadId = "";
|
|
234
|
+
return this.resolveThread();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private async resolveThread(): Promise<Thread> {
|
|
239
|
+
if (this.config.threadId && this.config.threadMode !== "start") {
|
|
240
|
+
const resumed = await this.app.threadResume({
|
|
241
|
+
threadId: this.config.threadId,
|
|
242
|
+
cwd: this.config.cwd,
|
|
243
|
+
...this.threadPermissions(),
|
|
244
|
+
persistExtendedHistory: false,
|
|
245
|
+
});
|
|
246
|
+
return normalizeThread(resumed.thread);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (this.config.threadMode !== "start") {
|
|
250
|
+
const loaded = await this.app.threadLoadedList(20);
|
|
251
|
+
const loadedThreads: Thread[] = [];
|
|
252
|
+
for (const loadedThreadId of loaded.data) {
|
|
253
|
+
const thread = await this.readThreadWithFallback(loadedThreadId);
|
|
254
|
+
if (thread.cwd === this.config.cwd) loadedThreads.push(thread);
|
|
255
|
+
}
|
|
256
|
+
const activeLoaded = loadedThreads
|
|
257
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
258
|
+
.find((thread) => thread.status.type === "active") ?? loadedThreads.sort((a, b) => b.updatedAt - a.updatedAt)[0];
|
|
259
|
+
if (activeLoaded) return activeLoaded;
|
|
260
|
+
|
|
261
|
+
const listed = await this.app.threadList({ cwd: this.config.cwd, limit: 10, archived: false });
|
|
262
|
+
const latest = [...listed.data].sort((a, b) => b.updatedAt - a.updatedAt)[0];
|
|
263
|
+
if (latest) {
|
|
264
|
+
const resumed = await this.app.threadResume({
|
|
265
|
+
threadId: latest.id,
|
|
266
|
+
cwd: this.config.cwd,
|
|
267
|
+
...this.threadPermissions(),
|
|
268
|
+
persistExtendedHistory: false,
|
|
269
|
+
});
|
|
270
|
+
return normalizeThread(resumed.thread);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const started = await this.app.threadStart({
|
|
275
|
+
cwd: this.config.cwd,
|
|
276
|
+
...this.threadPermissions(),
|
|
277
|
+
ephemeral: false,
|
|
278
|
+
sessionStartSource: "startup",
|
|
279
|
+
model: this.config.model ?? null,
|
|
280
|
+
});
|
|
281
|
+
return normalizeThread(started.thread);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private threadPermissions(): Record<string, string> {
|
|
285
|
+
const payload: Record<string, string> = {};
|
|
286
|
+
if (this.config.approvalPolicy) payload.approvalPolicy = this.config.approvalPolicy;
|
|
287
|
+
if (this.config.sandbox) payload.sandbox = this.config.sandbox;
|
|
288
|
+
return payload;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private async readThreadWithFallback(threadId: string): Promise<Thread> {
|
|
292
|
+
try {
|
|
293
|
+
const read = await this.app.threadRead(threadId, true);
|
|
294
|
+
return normalizeThread(read.thread);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (!isThreadMaterializationError(error)) throw error;
|
|
297
|
+
const read = await this.app.threadRead(threadId, false);
|
|
298
|
+
return normalizeThread(read.thread);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private syncThreadState(thread: Thread): void {
|
|
303
|
+
this.threadStatus = thread.status.type;
|
|
304
|
+
const turns = thread.turns ?? [];
|
|
305
|
+
const activeTurn = [...turns].reverse().find((turn) => turn.status === "inProgress");
|
|
306
|
+
this.activeTurnId = activeTurn?.id ?? null;
|
|
307
|
+
void this.refreshRelayStatus();
|
|
308
|
+
this.writeState();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private handleAppEvent(event: ClientEvent): void {
|
|
312
|
+
if (event.type !== "notification") return;
|
|
313
|
+
const { method, params } = event.message;
|
|
314
|
+
|
|
315
|
+
if (method === "thread/status/changed") {
|
|
316
|
+
if (params?.threadId === this.threadId && params.status && typeof params.status === "object" && "type" in params.status) {
|
|
317
|
+
this.threadStatus = String(params.status.type) as ThreadStatus["type"];
|
|
318
|
+
void this.refreshRelayStatus();
|
|
319
|
+
this.writeState();
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (method === "turn/started") {
|
|
325
|
+
if (params?.threadId === this.threadId && params.turn && typeof params.turn === "object" && "id" in params.turn) {
|
|
326
|
+
this.activeTurnId = String(params.turn.id);
|
|
327
|
+
this.threadStatus = "active";
|
|
328
|
+
void this.refreshRelayStatus();
|
|
329
|
+
this.writeState();
|
|
330
|
+
}
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (method === "turn/completed") {
|
|
335
|
+
if (params?.threadId === this.threadId && params.turn && typeof params.turn === "object") {
|
|
336
|
+
const completedId = "id" in params.turn ? String(params.turn.id) : null;
|
|
337
|
+
if (completedId && this.activeTurnId === completedId) this.activeTurnId = null;
|
|
338
|
+
this.threadStatus = "idle";
|
|
339
|
+
void this.refreshRelayStatus();
|
|
340
|
+
this.writeState();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private async refreshRelayStatus(): Promise<void> {
|
|
346
|
+
if (!this.agentId) return;
|
|
347
|
+
|
|
348
|
+
const status: RelayAgentStatus = !this.appConnected
|
|
349
|
+
? "online"
|
|
350
|
+
: this.activeTurnId || this.threadStatus === "active"
|
|
351
|
+
? "busy"
|
|
352
|
+
: "idle";
|
|
353
|
+
const ready = this.hasSuccessfulPoll && this.appConnected && Boolean(this.threadId);
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
await Promise.all([
|
|
357
|
+
this.relay.setStatus(this.agentId, status),
|
|
358
|
+
this.relay.setReady(this.agentId, ready),
|
|
359
|
+
]);
|
|
360
|
+
} catch {
|
|
361
|
+
// Best-effort status sync.
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private addPendingMessages(messages: RelayMessage[]): void {
|
|
366
|
+
let added = false;
|
|
367
|
+
for (const message of messages) {
|
|
368
|
+
if (this.pendingMessages.has(message.id)) continue;
|
|
369
|
+
this.pendingMessages.set(message.id, message);
|
|
370
|
+
added = true;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (added && this.drainDueAt === 0) {
|
|
374
|
+
this.drainDueAt = Date.now() + this.config.coalesceWindowMs;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private async drainPendingMessages(): Promise<void> {
|
|
379
|
+
if (this.draining || this.pendingMessages.size === 0) return;
|
|
380
|
+
|
|
381
|
+
this.draining = true;
|
|
382
|
+
const sorted = [...this.pendingMessages.values()].sort((a, b) => a.id - b.id);
|
|
383
|
+
this.pendingMessages.clear();
|
|
384
|
+
this.drainDueAt = 0;
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
await this.ensureAppReady();
|
|
388
|
+
const batches = createDeliveryBatches(sorted);
|
|
389
|
+
for (let index = 0; index < batches.length; index += 1) {
|
|
390
|
+
const batch = batches[index]!;
|
|
391
|
+
try {
|
|
392
|
+
await this.deliverBatch(batch);
|
|
393
|
+
} catch (error) {
|
|
394
|
+
this.requeueBatches(batches.slice(index));
|
|
395
|
+
this.log(`delivery error: ${describeError(error)}`);
|
|
396
|
+
await this.ensureAppReady(true);
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
} finally {
|
|
401
|
+
this.draining = false;
|
|
402
|
+
this.writeState();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private requeueBatches(batches: DeliveryBatch[]): void {
|
|
407
|
+
for (const batch of batches) {
|
|
408
|
+
for (const message of batch.messages) {
|
|
409
|
+
this.pendingMessages.set(message.id, message);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (this.pendingMessages.size > 0) {
|
|
413
|
+
this.drainDueAt = Date.now() + Math.min(this.config.coalesceWindowMs, 250);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private async deliverBatch(batch: DeliveryBatch): Promise<void> {
|
|
418
|
+
await this.ensureAppReady();
|
|
419
|
+
|
|
420
|
+
for (const message of batch.messages) {
|
|
421
|
+
if (!message.claimable) continue;
|
|
422
|
+
const claimed = await this.relay.claimMessage(message.id, this.agentId);
|
|
423
|
+
if (!claimed) {
|
|
424
|
+
this.log(`skipping unclaimed message ${message.id}`);
|
|
425
|
+
this.advanceCursor([message]);
|
|
426
|
+
this.writeState();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const delivery = this.pickDeliveryMode(batch.messages);
|
|
432
|
+
const prompt = formatRelayPrompt(batch.messages);
|
|
433
|
+
const ids = batch.messages.map((message) => message.id).join(", ");
|
|
434
|
+
this.log(`delivering message ${ids} via ${delivery}`);
|
|
435
|
+
|
|
436
|
+
if (delivery === "interrupt" && this.activeTurnId) {
|
|
437
|
+
try {
|
|
438
|
+
await this.app.turnInterrupt(this.threadId, this.activeTurnId);
|
|
439
|
+
this.activeTurnId = null;
|
|
440
|
+
this.threadStatus = "idle";
|
|
441
|
+
await this.app.settle(200);
|
|
442
|
+
} catch (error) {
|
|
443
|
+
this.log(`interrupt failed for messages ${ids}: ${describeError(error)}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (delivery === "steer" && this.activeTurnId) {
|
|
448
|
+
try {
|
|
449
|
+
await this.app.turnSteer(this.threadId, this.activeTurnId, prompt);
|
|
450
|
+
await this.markBatchRead(batch.messages);
|
|
451
|
+
this.advanceCursor(batch.messages);
|
|
452
|
+
this.writeState();
|
|
453
|
+
return;
|
|
454
|
+
} catch (error) {
|
|
455
|
+
this.log(`steer failed for messages ${ids}, falling back to start: ${describeError(error)}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
await this.app.turnStart(this.threadId, prompt);
|
|
460
|
+
await this.markBatchRead(batch.messages);
|
|
461
|
+
this.advanceCursor(batch.messages);
|
|
462
|
+
this.writeState();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private advanceCursor(messages: RelayMessage[]): void {
|
|
466
|
+
for (const message of messages) {
|
|
467
|
+
this.lastSeenMessageId = Math.max(this.lastSeenMessageId, message.id);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private async markBatchRead(messages: RelayMessage[]): Promise<void> {
|
|
472
|
+
for (const message of messages) {
|
|
473
|
+
await this.relay.markRead(message.id, this.agentId);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private pickDeliveryMode(messages: RelayMessage[]): "start" | "steer" | "interrupt" {
|
|
478
|
+
const first = messages[0]!;
|
|
479
|
+
const meta = first.meta ?? {};
|
|
480
|
+
const requested = typeof meta.delivery === "string" ? meta.delivery : null;
|
|
481
|
+
const priority = typeof meta.priority === "string" ? meta.priority : null;
|
|
482
|
+
|
|
483
|
+
if (requested === "interrupt") return "interrupt";
|
|
484
|
+
if (requested === "start") return "start";
|
|
485
|
+
if (requested === "steer") return this.activeTurnId ? "steer" : "start";
|
|
486
|
+
if (priority === "urgent") return this.activeTurnId ? "interrupt" : "start";
|
|
487
|
+
return this.activeTurnId ? "steer" : "start";
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private writeState(): void {
|
|
491
|
+
if (!this.threadId) return;
|
|
492
|
+
const state: RuntimeState = {
|
|
493
|
+
agentId: this.agentId,
|
|
494
|
+
threadId: this.threadId,
|
|
495
|
+
activeTurnId: this.activeTurnId,
|
|
496
|
+
threadStatus: this.threadStatus,
|
|
497
|
+
lastSeenMessageId: this.lastSeenMessageId,
|
|
498
|
+
pendingMessageCount: this.pendingMessages.size,
|
|
499
|
+
appConnected: this.appConnected,
|
|
500
|
+
appServerUrl: this.config.appServerUrl,
|
|
501
|
+
relayUrl: this.config.relayUrl,
|
|
502
|
+
cwd: this.config.cwd,
|
|
503
|
+
updatedAt: new Date().toISOString(),
|
|
504
|
+
};
|
|
505
|
+
writeFileSync(this.config.statePath, JSON.stringify(state, null, 2));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private log(message: string): void {
|
|
509
|
+
console.error(`${this.logPrefix} ${message}`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private nextRelayBackoffMs(): number {
|
|
513
|
+
this.relayBackoffMs = this.relayBackoffMs === 0
|
|
514
|
+
? this.config.relayBackoffInitialMs
|
|
515
|
+
: Math.min(this.config.relayBackoffMaxMs, this.relayBackoffMs * 2);
|
|
516
|
+
return this.relayBackoffMs;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function createDeliveryBatches(messages: RelayMessage[]): DeliveryBatch[] {
|
|
521
|
+
const batches: DeliveryBatch[] = [];
|
|
522
|
+
let current: RelayMessage[] = [];
|
|
523
|
+
|
|
524
|
+
for (const message of messages) {
|
|
525
|
+
if (shouldIsolateMessage(message)) {
|
|
526
|
+
if (current.length > 0) {
|
|
527
|
+
batches.push({ messages: current });
|
|
528
|
+
current = [];
|
|
529
|
+
}
|
|
530
|
+
batches.push({ messages: [message] });
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
current.push(message);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (current.length > 0) batches.push({ messages: current });
|
|
538
|
+
return batches;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function shouldIsolateMessage(message: RelayMessage): boolean {
|
|
542
|
+
if (message.claimable) return true;
|
|
543
|
+
const meta = message.meta ?? {};
|
|
544
|
+
const delivery = typeof meta.delivery === "string" ? meta.delivery : null;
|
|
545
|
+
const priority = typeof meta.priority === "string" ? meta.priority : null;
|
|
546
|
+
return delivery === "interrupt" || delivery === "start" || delivery === "steer" || priority === "urgent";
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function formatRelayPrompt(messages: RelayMessage[]): string {
|
|
550
|
+
if (messages.length === 1) {
|
|
551
|
+
const message = messages[0]!;
|
|
552
|
+
const lines = [
|
|
553
|
+
"Agent Relay message received.",
|
|
554
|
+
"",
|
|
555
|
+
formatMessageSummary(message),
|
|
556
|
+
"",
|
|
557
|
+
`Message ID: ${message.id}`,
|
|
558
|
+
`From: ${message.from}`,
|
|
559
|
+
`To: ${message.to}`,
|
|
560
|
+
`Created: ${new Date(message.createdAt).toISOString()}`,
|
|
561
|
+
];
|
|
562
|
+
|
|
563
|
+
if (message.subject) lines.push(`Subject: ${message.subject}`);
|
|
564
|
+
if (message.replyTo) lines.push(`Reply To: ${message.replyTo}`);
|
|
565
|
+
lines.push(
|
|
566
|
+
"",
|
|
567
|
+
"Body:",
|
|
568
|
+
message.body,
|
|
569
|
+
"",
|
|
570
|
+
"Treat this as a live incoming message from another agent. Respond or act on it as appropriate.",
|
|
571
|
+
"If AGENT_RELAY_TOKEN is set, include it as the X-Agent-Relay-Token header when calling the relay API.",
|
|
572
|
+
`To reply, POST JSON to ${process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850"}/api/messages with from set to your Agent Relay ID, to set to ${JSON.stringify(message.from)}, and replyTo set to ${message.id}.`,
|
|
573
|
+
);
|
|
574
|
+
return lines.join("\n");
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const lines = [
|
|
578
|
+
"Agent Relay message batch received.",
|
|
579
|
+
"",
|
|
580
|
+
`Count: ${messages.length}`,
|
|
581
|
+
"",
|
|
582
|
+
];
|
|
583
|
+
|
|
584
|
+
for (const message of messages) {
|
|
585
|
+
lines.push(
|
|
586
|
+
formatMessageSummary(message),
|
|
587
|
+
`Message ID: ${message.id}`,
|
|
588
|
+
`From: ${message.from}`,
|
|
589
|
+
`To: ${message.to}`,
|
|
590
|
+
`Created: ${new Date(message.createdAt).toISOString()}`,
|
|
591
|
+
);
|
|
592
|
+
if (message.subject) lines.push(`Subject: ${message.subject}`);
|
|
593
|
+
if (message.replyTo) lines.push(`Reply To: ${message.replyTo}`);
|
|
594
|
+
lines.push("Body:", message.body, "", "---", "");
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
lines.push(
|
|
598
|
+
"Treat these as live incoming messages from other agents. Synthesize them into one coherent response or action.",
|
|
599
|
+
"If AGENT_RELAY_TOKEN is set, include it as the X-Agent-Relay-Token header when calling the relay API.",
|
|
600
|
+
`To reply, POST JSON to ${process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850"}/api/messages with from set to your Agent Relay ID and replyTo set to the message you are answering.`,
|
|
601
|
+
);
|
|
602
|
+
return lines.join("\n");
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function formatMessageSummary(message: RelayMessage): string {
|
|
606
|
+
const subject = message.subject || "message";
|
|
607
|
+
if (message.type === "system") return `SYSTEM [msg:${message.id}]: ${message.body}`;
|
|
608
|
+
return `[msg:${message.id}] ${message.from} -> ${message.to} | ${subject}: ${message.body}`;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function normalizeThread(thread: Thread): Thread {
|
|
612
|
+
return {
|
|
613
|
+
...thread,
|
|
614
|
+
turns: thread.turns ?? [],
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function isThreadMaterializationError(error: unknown): boolean {
|
|
619
|
+
return describeError(error).includes("not materialized yet");
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function describeError(error: unknown): string {
|
|
623
|
+
return error instanceof Error ? error.message : String(error);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function envNumber(env: NodeJS.ProcessEnv, name: string, fallback: number): number {
|
|
627
|
+
const raw = env[name];
|
|
628
|
+
if (!raw) return fallback;
|
|
629
|
+
const parsed = Number(raw);
|
|
630
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export function parseThreadMode(raw: string | undefined): Config["threadMode"] {
|
|
634
|
+
if (raw === "auto" || raw === "resume" || raw === "start") return raw;
|
|
635
|
+
return "start";
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
|
639
|
+
const cwd = env.AGENT_RELAY_CODEX_CWD || process.cwd();
|
|
640
|
+
const capabilities = (env.AGENT_RELAY_CAPS || "chat")
|
|
641
|
+
.split(",")
|
|
642
|
+
.map((value) => value.trim())
|
|
643
|
+
.filter(Boolean);
|
|
644
|
+
|
|
645
|
+
return {
|
|
646
|
+
relayUrl: env.AGENT_RELAY_URL || "http://127.0.0.1:4850",
|
|
647
|
+
appServerUrl: env.CODEX_APP_SERVER_URL || "ws://127.0.0.1:4501",
|
|
648
|
+
cwd,
|
|
649
|
+
rig: env.AGENT_RELAY_CODEX_RIG || "codex-live",
|
|
650
|
+
capabilities,
|
|
651
|
+
tags: ["codex", env.AGENT_RELAY_CODEX_RIG || "codex-live", cwd.split("/").filter(Boolean).at(-1) || "unknown"],
|
|
652
|
+
statePath: env.AGENT_RELAY_CODEX_STATE_PATH || resolve(cwd, "codex/runtime/live-state.json"),
|
|
653
|
+
pollIntervalMs: envNumber(env, "AGENT_RELAY_CODEX_POLL_INTERVAL_MS", 2000),
|
|
654
|
+
heartbeatIntervalMs: envNumber(env, "AGENT_RELAY_CODEX_HEARTBEAT_INTERVAL_MS", 30000),
|
|
655
|
+
relayBackoffInitialMs: envNumber(env, "AGENT_RELAY_CODEX_RELAY_BACKOFF_INITIAL_MS", 2000),
|
|
656
|
+
relayBackoffMaxMs: envNumber(env, "AGENT_RELAY_CODEX_RELAY_BACKOFF_MAX_MS", 60000),
|
|
657
|
+
coalesceWindowMs: envNumber(env, "AGENT_RELAY_CODEX_COALESCE_WINDOW_MS", 600),
|
|
658
|
+
reconnectInitialDelayMs: envNumber(env, "AGENT_RELAY_CODEX_RECONNECT_INITIAL_MS", 1000),
|
|
659
|
+
reconnectMaxDelayMs: envNumber(env, "AGENT_RELAY_CODEX_RECONNECT_MAX_MS", 10000),
|
|
660
|
+
threadMode: parseThreadMode(env.CODEX_THREAD_MODE),
|
|
661
|
+
threadId: env.CODEX_THREAD_ID || undefined,
|
|
662
|
+
model: env.CODEX_MODEL || undefined,
|
|
663
|
+
approvalPolicy: env.AGENT_RELAY_CODEX_APPROVAL_POLICY || undefined,
|
|
664
|
+
sandbox: env.AGENT_RELAY_CODEX_SANDBOX || undefined,
|
|
665
|
+
approvalMode: env.AGENT_RELAY_APPROVAL
|
|
666
|
+
? parseApprovalMode(env.AGENT_RELAY_APPROVAL)
|
|
667
|
+
: approvalModeFromPermissions({
|
|
668
|
+
approvalPolicy: env.AGENT_RELAY_CODEX_APPROVAL_POLICY,
|
|
669
|
+
sandbox: env.AGENT_RELAY_CODEX_SANDBOX,
|
|
670
|
+
}),
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function main(): Promise<void> {
|
|
675
|
+
const config = loadConfig();
|
|
676
|
+
const sidecar = new CodexLiveSidecar(config);
|
|
677
|
+
await sidecar.run();
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (import.meta.main) {
|
|
681
|
+
main().catch((error) => {
|
|
682
|
+
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
|
683
|
+
process.exit(1);
|
|
684
|
+
});
|
|
685
|
+
}
|