agent-relay-codex 0.6.1 → 0.10.0
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 +7 -171
- package/bin/agent-relay-codex.ts +31 -1130
- package/package.json +6 -4
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/app-client.ts +0 -239
- package/approval.ts +0 -29
- package/hooks/session-start-lib.ts +0 -25
- package/hooks/session-start.ts +0 -169
- package/install-codex.ps1 +0 -47
- package/install-codex.sh +0 -75
- package/live-sidecar.ts +0 -988
- package/plugin/skills/agent-relay/SKILL.md +0 -63
- package/plugin/skills/disconnect/SKILL.md +0 -16
- package/plugin/skills/label/SKILL.md +0 -23
- package/plugin/skills/message/SKILL.md +0 -24
- package/plugin/skills/pair/SKILL.md +0 -26
- package/plugin/skills/send-claimable/SKILL.md +0 -24
- package/plugin/skills/status/SKILL.md +0 -16
- package/plugin/skills/tags/SKILL.md +0 -25
- package/profile.ts +0 -96
- package/relay.ts +0 -188
- package/start-live.sh +0 -64
package/live-sidecar.ts
DELETED
|
@@ -1,988 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { dirname, resolve } from "node:path";
|
|
4
|
-
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
-
import { approvalModeFromPermissions, type ApprovalMode } from "./approval";
|
|
6
|
-
import { CodexAppClient, type ClientEvent, type Thread, type ThreadStatus } from "./app-client";
|
|
7
|
-
import { loadAgentRelayProfile, messageMatchesProfileChannels } from "./profile";
|
|
8
|
-
import { RelayClient, RelayHttpError, type RelayAgentSession, type RelayAgentStatus, type RelayMessage } from "./relay";
|
|
9
|
-
|
|
10
|
-
type AgentControlAction = "restart" | "shutdown";
|
|
11
|
-
|
|
12
|
-
interface Config {
|
|
13
|
-
relayUrl: string;
|
|
14
|
-
appServerUrl: string;
|
|
15
|
-
cwd: string;
|
|
16
|
-
rig: string;
|
|
17
|
-
label?: string;
|
|
18
|
-
capabilities: string[];
|
|
19
|
-
tags: string[];
|
|
20
|
-
channels: string[];
|
|
21
|
-
profileName?: string;
|
|
22
|
-
profileMeta: Record<string, unknown>;
|
|
23
|
-
statePath: string;
|
|
24
|
-
contextPath?: string;
|
|
25
|
-
pollIntervalMs: number;
|
|
26
|
-
heartbeatIntervalMs: number;
|
|
27
|
-
threadCheckIntervalMs: number;
|
|
28
|
-
relayBackoffInitialMs: number;
|
|
29
|
-
relayBackoffMaxMs: number;
|
|
30
|
-
coalesceWindowMs: number;
|
|
31
|
-
reconnectInitialDelayMs: number;
|
|
32
|
-
reconnectMaxDelayMs: number;
|
|
33
|
-
threadMode: "auto" | "resume" | "start";
|
|
34
|
-
threadId?: string;
|
|
35
|
-
model?: string;
|
|
36
|
-
approvalPolicy?: string;
|
|
37
|
-
sandbox?: string;
|
|
38
|
-
approvalMode: ApprovalMode;
|
|
39
|
-
parentPid?: number;
|
|
40
|
-
assumeLoadedThread: boolean;
|
|
41
|
-
headless: boolean;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
interface RuntimeState {
|
|
45
|
-
agentId: string;
|
|
46
|
-
agentInstanceId: string;
|
|
47
|
-
agentEpoch: number;
|
|
48
|
-
threadId: string;
|
|
49
|
-
activeTurnId: string | null;
|
|
50
|
-
threadStatus: ThreadStatus["type"];
|
|
51
|
-
lastSeenMessageId: number;
|
|
52
|
-
pendingMessageCount: number;
|
|
53
|
-
appConnected: boolean;
|
|
54
|
-
appServerUrl: string;
|
|
55
|
-
relayUrl: string;
|
|
56
|
-
cwd: string;
|
|
57
|
-
updatedAt: string;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
interface DeliveryBatch {
|
|
61
|
-
messages: RelayMessage[];
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
class ThreadLostError extends Error {}
|
|
65
|
-
|
|
66
|
-
export function activeClaimRenewalIds(
|
|
67
|
-
messageIds: Iterable<number>,
|
|
68
|
-
activeTurnId: string | null,
|
|
69
|
-
threadStatus: ThreadStatus["type"],
|
|
70
|
-
): number[] {
|
|
71
|
-
if (!activeTurnId && threadStatus !== "active") return [];
|
|
72
|
-
return [...messageIds];
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function canUseSyntheticLoadedThread(threadId: string, loadedThreadIds: readonly string[]): boolean {
|
|
76
|
-
return loadedThreadIds.includes(threadId);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
class CodexLiveSidecar {
|
|
80
|
-
private readonly relay: RelayClient;
|
|
81
|
-
private readonly logPrefix = "[codex-live]";
|
|
82
|
-
private app: CodexAppClient;
|
|
83
|
-
private agentId = "";
|
|
84
|
-
private readonly agentInstanceId = randomUUID();
|
|
85
|
-
private agentEpoch = 0;
|
|
86
|
-
private threadId = "";
|
|
87
|
-
private activeTurnId: string | null = null;
|
|
88
|
-
private threadStatus: ThreadStatus["type"] = "notLoaded";
|
|
89
|
-
private lastSeenMessageId = 0;
|
|
90
|
-
private lastHeartbeatAt = 0;
|
|
91
|
-
private lastThreadCheckAt = 0;
|
|
92
|
-
private hasSuccessfulPoll = false;
|
|
93
|
-
private relayBackoffMs = 0;
|
|
94
|
-
private lastClaimRenewalAt = 0;
|
|
95
|
-
private stopping = false;
|
|
96
|
-
private appConnected = false;
|
|
97
|
-
private draining = false;
|
|
98
|
-
private drainDueAt = 0;
|
|
99
|
-
private reconnecting: Promise<void> | null = null;
|
|
100
|
-
private relayPrimerDelivered = false;
|
|
101
|
-
private readonly pendingMessages = new Map<number, RelayMessage>();
|
|
102
|
-
private readonly activeClaimedMessageIds = new Set<number>();
|
|
103
|
-
|
|
104
|
-
constructor(private readonly config: Config) {
|
|
105
|
-
this.relay = new RelayClient(config.relayUrl, (msg) => this.log(msg));
|
|
106
|
-
this.app = this.createAppClient();
|
|
107
|
-
this.threadId = config.threadId || "";
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
async run(): Promise<void> {
|
|
111
|
-
mkdirSync(dirname(this.config.statePath), { recursive: true });
|
|
112
|
-
if (this.config.assumeLoadedThread && this.config.threadId) {
|
|
113
|
-
this.log(`assuming remote TUI thread ${this.config.threadId} is already loaded`);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
process.on("SIGINT", () => void this.stop("SIGINT"));
|
|
117
|
-
process.on("SIGTERM", () => void this.stop("SIGTERM"));
|
|
118
|
-
|
|
119
|
-
await this.ensureAppReady();
|
|
120
|
-
if (this.stopping) return;
|
|
121
|
-
|
|
122
|
-
await this.registerRelayAgent();
|
|
123
|
-
|
|
124
|
-
while (!this.stopping) {
|
|
125
|
-
let sleepMs = this.config.pollIntervalMs;
|
|
126
|
-
try {
|
|
127
|
-
if (this.parentExited()) {
|
|
128
|
-
await this.stop("parent-exit");
|
|
129
|
-
break;
|
|
130
|
-
}
|
|
131
|
-
const now = Date.now();
|
|
132
|
-
if (now - this.lastThreadCheckAt >= this.config.threadCheckIntervalMs) {
|
|
133
|
-
this.lastThreadCheckAt = now;
|
|
134
|
-
await this.verifyCurrentThread();
|
|
135
|
-
}
|
|
136
|
-
if (now - this.lastHeartbeatAt >= this.config.heartbeatIntervalMs) {
|
|
137
|
-
await this.relay.heartbeat(this.agentId, this.agentSession());
|
|
138
|
-
this.lastHeartbeatAt = now;
|
|
139
|
-
}
|
|
140
|
-
if (now - this.lastClaimRenewalAt >= this.config.heartbeatIntervalMs) {
|
|
141
|
-
await this.renewActiveClaims();
|
|
142
|
-
this.lastClaimRenewalAt = now;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const messages = await this.relay.pollMessages(this.agentId, this.lastSeenMessageId);
|
|
146
|
-
if (!this.hasSuccessfulPoll) {
|
|
147
|
-
this.hasSuccessfulPoll = true;
|
|
148
|
-
await this.refreshRelayStatus();
|
|
149
|
-
}
|
|
150
|
-
if (messages.length > 0) {
|
|
151
|
-
const deliverableMessages = messages.filter((message) => messageMatchesProfileChannels(message, this.config.channels));
|
|
152
|
-
const skippedMessages = messages.filter((message) => !messageMatchesProfileChannels(message, this.config.channels));
|
|
153
|
-
if (skippedMessages.length > 0) this.advanceCursor(skippedMessages);
|
|
154
|
-
this.addPendingMessages(deliverableMessages);
|
|
155
|
-
this.writeState();
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
if (this.pendingMessages.size > 0 && now >= this.drainDueAt && !this.draining) {
|
|
159
|
-
await this.drainPendingMessages();
|
|
160
|
-
}
|
|
161
|
-
this.relayBackoffMs = 0;
|
|
162
|
-
} catch (error) {
|
|
163
|
-
if (error instanceof RelayHttpError && error.status === 404 && error.path.includes("/heartbeat")) {
|
|
164
|
-
this.log("heartbeat returned 404; re-registering relay agent");
|
|
165
|
-
await this.registerRelayAgent();
|
|
166
|
-
} else if (error instanceof RelayHttpError && error.status === 409) {
|
|
167
|
-
this.log("relay rejected this stale agent instance; stopping sidecar");
|
|
168
|
-
this.stopping = true;
|
|
169
|
-
this.writeState();
|
|
170
|
-
this.app.close();
|
|
171
|
-
} else if (error instanceof ThreadLostError) {
|
|
172
|
-
await this.stopAndUnregister(error.message);
|
|
173
|
-
} else {
|
|
174
|
-
sleepMs = this.nextRelayBackoffMs();
|
|
175
|
-
this.log(`loop error: ${describeError(error)}; retrying in ${sleepMs}ms`);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
await this.delayWithParentCheck(sleepMs);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
private async registerRelayAgent(): Promise<void> {
|
|
183
|
-
const registration = await this.relay.registerAgent({
|
|
184
|
-
relayUrl: this.config.relayUrl,
|
|
185
|
-
cwd: this.config.cwd,
|
|
186
|
-
rig: this.config.rig,
|
|
187
|
-
label: this.config.label,
|
|
188
|
-
capabilities: this.config.capabilities,
|
|
189
|
-
tags: this.config.tags,
|
|
190
|
-
channels: this.config.channels,
|
|
191
|
-
profileName: this.config.profileName,
|
|
192
|
-
profileMeta: this.config.profileMeta,
|
|
193
|
-
threadId: this.threadId,
|
|
194
|
-
model: this.config.model,
|
|
195
|
-
appServerUrl: this.config.appServerUrl,
|
|
196
|
-
approvalMode: this.config.approvalMode,
|
|
197
|
-
instanceId: this.agentInstanceId,
|
|
198
|
-
});
|
|
199
|
-
this.agentId = registration.agentId;
|
|
200
|
-
this.agentEpoch = registration.session?.epoch ?? 0;
|
|
201
|
-
this.lastSeenMessageId = this.agentEpoch > 1 ? 0 : await this.relay.getCursor();
|
|
202
|
-
this.lastHeartbeatAt = 0;
|
|
203
|
-
this.hasSuccessfulPoll = false;
|
|
204
|
-
this.log(`registered agent ${this.agentId}`);
|
|
205
|
-
this.log(`using thread ${this.threadId}`);
|
|
206
|
-
this.log(`bootstrapped relay cursor ${this.lastSeenMessageId}${this.agentEpoch > 1 ? " (resuming unread backlog)" : ""}`);
|
|
207
|
-
this.writeState();
|
|
208
|
-
await this.refreshRelayStatus();
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
private createAppClient(): CodexAppClient {
|
|
212
|
-
const app = new CodexAppClient(this.config.appServerUrl, (msg) => this.log(msg));
|
|
213
|
-
app.onEvent((event) => this.handleAppEvent(event));
|
|
214
|
-
app.onConnectionChange((connected) => {
|
|
215
|
-
this.appConnected = connected;
|
|
216
|
-
if (!connected) {
|
|
217
|
-
this.activeTurnId = null;
|
|
218
|
-
this.threadStatus = "notLoaded";
|
|
219
|
-
if (!this.stopping) void this.ensureAppReady();
|
|
220
|
-
}
|
|
221
|
-
void this.refreshRelayStatus();
|
|
222
|
-
this.writeState();
|
|
223
|
-
});
|
|
224
|
-
return app;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
private async ensureAppReady(force = false): Promise<void> {
|
|
228
|
-
if (this.stopping) return;
|
|
229
|
-
if (this.appConnected && !force) return;
|
|
230
|
-
if (this.reconnecting) return this.reconnecting;
|
|
231
|
-
|
|
232
|
-
this.reconnecting = this.connectWithRetry(force).finally(() => {
|
|
233
|
-
this.reconnecting = null;
|
|
234
|
-
});
|
|
235
|
-
return this.reconnecting;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
private async connectWithRetry(force: boolean): Promise<void> {
|
|
239
|
-
let waitMs = this.config.reconnectInitialDelayMs;
|
|
240
|
-
|
|
241
|
-
while (!this.stopping) {
|
|
242
|
-
try {
|
|
243
|
-
if (force) {
|
|
244
|
-
try {
|
|
245
|
-
this.app.close();
|
|
246
|
-
} catch {
|
|
247
|
-
// Ignore close failures during forced reconnect.
|
|
248
|
-
}
|
|
249
|
-
this.appConnected = false;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
if (!this.app.isConnected()) {
|
|
253
|
-
this.app = this.createAppClient();
|
|
254
|
-
await this.app.connect();
|
|
255
|
-
await this.app.initialize();
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const thread = this.threadId
|
|
259
|
-
? await this.attachKnownThread(this.threadId)
|
|
260
|
-
: await this.resolveThread();
|
|
261
|
-
this.threadId = thread.id;
|
|
262
|
-
this.syncThreadState(thread);
|
|
263
|
-
if (this.agentId) {
|
|
264
|
-
this.log(`app connection restored for thread ${thread.id}`);
|
|
265
|
-
}
|
|
266
|
-
return;
|
|
267
|
-
} catch (error) {
|
|
268
|
-
if (error instanceof ThreadLostError) {
|
|
269
|
-
await this.stopAndUnregister(error.message);
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
this.appConnected = false;
|
|
273
|
-
this.log(`app connection failed: ${describeError(error)}; retrying in ${waitMs}ms`);
|
|
274
|
-
await delay(waitMs);
|
|
275
|
-
waitMs = Math.min(this.config.reconnectMaxDelayMs, waitMs * 2);
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
private async stop(signal: string): Promise<void> {
|
|
281
|
-
if (this.stopping) return;
|
|
282
|
-
this.stopping = true;
|
|
283
|
-
this.log(`stopping on ${signal}`);
|
|
284
|
-
if (this.agentId) {
|
|
285
|
-
try {
|
|
286
|
-
await this.relay.setStatus(this.agentId, "offline", this.agentSession());
|
|
287
|
-
} catch {
|
|
288
|
-
// Best effort during shutdown.
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
this.writeState();
|
|
292
|
-
this.app.close();
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
private async stopAndUnregister(reason: string): Promise<void> {
|
|
296
|
-
if (this.stopping) return;
|
|
297
|
-
this.stopping = true;
|
|
298
|
-
this.log(`${reason}; unregistering relay agent and stopping sidecar`);
|
|
299
|
-
if (this.agentId) {
|
|
300
|
-
try {
|
|
301
|
-
await this.relay.unregisterAgent(this.agentId);
|
|
302
|
-
} catch (error) {
|
|
303
|
-
this.log(`failed to unregister relay agent ${this.agentId}: ${describeError(error)}`);
|
|
304
|
-
try {
|
|
305
|
-
await this.relay.setStatus(this.agentId, "offline", this.agentSession());
|
|
306
|
-
} catch {
|
|
307
|
-
// Best effort after unregister failure.
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
this.threadStatus = "notLoaded";
|
|
312
|
-
this.activeTurnId = null;
|
|
313
|
-
this.writeState();
|
|
314
|
-
this.app.close();
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
private parentExited(): boolean {
|
|
318
|
-
return Boolean(this.config.parentPid && !isAlive(this.config.parentPid));
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
private async delayWithParentCheck(ms: number): Promise<void> {
|
|
322
|
-
const deadline = Date.now() + ms;
|
|
323
|
-
while (!this.stopping && Date.now() < deadline) {
|
|
324
|
-
if (this.parentExited()) {
|
|
325
|
-
await this.stop("parent-exit");
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
await delay(Math.min(500, Math.max(0, deadline - Date.now())));
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
private async attachKnownThread(threadId: string): Promise<Thread> {
|
|
333
|
-
if (this.config.assumeLoadedThread) {
|
|
334
|
-
return this.readAssumedLoadedThread(threadId);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
try {
|
|
338
|
-
const resumed = await this.app.threadResume({
|
|
339
|
-
threadId,
|
|
340
|
-
cwd: this.config.cwd,
|
|
341
|
-
...this.threadPermissions(),
|
|
342
|
-
persistExtendedHistory: false,
|
|
343
|
-
});
|
|
344
|
-
return normalizeThread(resumed.thread);
|
|
345
|
-
} catch (error) {
|
|
346
|
-
if (isThreadMissingRolloutError(error)) {
|
|
347
|
-
try {
|
|
348
|
-
return await this.readThreadWithFallback(threadId);
|
|
349
|
-
} catch {
|
|
350
|
-
// Fall through to normal resolution when the thread is neither loaded nor persisted.
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
this.log(`resume failed for thread ${threadId}: ${describeError(error)}; falling back to thread resolution`);
|
|
354
|
-
this.threadId = "";
|
|
355
|
-
return this.resolveThread();
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
private async resolveThread(): Promise<Thread> {
|
|
360
|
-
if (this.config.threadId && this.config.threadMode !== "start") {
|
|
361
|
-
const resumed = await this.app.threadResume({
|
|
362
|
-
threadId: this.config.threadId,
|
|
363
|
-
cwd: this.config.cwd,
|
|
364
|
-
...this.threadPermissions(),
|
|
365
|
-
persistExtendedHistory: false,
|
|
366
|
-
});
|
|
367
|
-
return normalizeThread(resumed.thread);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
if (this.config.threadMode !== "start") {
|
|
371
|
-
const loaded = await this.app.threadLoadedList(20);
|
|
372
|
-
const loadedThreads: Thread[] = [];
|
|
373
|
-
for (const loadedThreadId of loaded.data) {
|
|
374
|
-
const thread = await this.readThreadWithFallback(loadedThreadId);
|
|
375
|
-
if (thread.cwd === this.config.cwd) loadedThreads.push(thread);
|
|
376
|
-
}
|
|
377
|
-
const activeLoaded = loadedThreads
|
|
378
|
-
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
379
|
-
.find((thread) => thread.status.type === "active") ?? loadedThreads.sort((a, b) => b.updatedAt - a.updatedAt)[0];
|
|
380
|
-
if (activeLoaded) return activeLoaded;
|
|
381
|
-
|
|
382
|
-
const listed = await this.app.threadList({ cwd: this.config.cwd, limit: 10, archived: false });
|
|
383
|
-
const latest = [...listed.data].sort((a, b) => b.updatedAt - a.updatedAt)[0];
|
|
384
|
-
if (latest) {
|
|
385
|
-
const resumed = await this.app.threadResume({
|
|
386
|
-
threadId: latest.id,
|
|
387
|
-
cwd: this.config.cwd,
|
|
388
|
-
...this.threadPermissions(),
|
|
389
|
-
persistExtendedHistory: false,
|
|
390
|
-
});
|
|
391
|
-
return normalizeThread(resumed.thread);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
const started = await this.app.threadStart({
|
|
396
|
-
cwd: this.config.cwd,
|
|
397
|
-
...this.threadPermissions(),
|
|
398
|
-
ephemeral: false,
|
|
399
|
-
sessionStartSource: "startup",
|
|
400
|
-
model: this.config.model ?? null,
|
|
401
|
-
});
|
|
402
|
-
return normalizeThread(started.thread);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
private threadPermissions(): Record<string, string> {
|
|
406
|
-
const payload: Record<string, string> = {};
|
|
407
|
-
if (this.config.approvalPolicy) payload.approvalPolicy = this.config.approvalPolicy;
|
|
408
|
-
if (this.config.sandbox) payload.sandbox = this.config.sandbox;
|
|
409
|
-
return payload;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
private async readAssumedLoadedThread(threadId: string): Promise<Thread> {
|
|
413
|
-
try {
|
|
414
|
-
return await this.readThreadWithFallback(threadId);
|
|
415
|
-
} catch {
|
|
416
|
-
const loaded = await this.app.threadLoadedList(100);
|
|
417
|
-
if (canUseSyntheticLoadedThread(threadId, loaded.data)) {
|
|
418
|
-
return syntheticLoadedThread(threadId, this.config.cwd);
|
|
419
|
-
}
|
|
420
|
-
throw new ThreadLostError(`codex thread ${threadId} is no longer loaded`);
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
private async verifyCurrentThread(): Promise<void> {
|
|
425
|
-
if (!this.threadId || !this.appConnected) return;
|
|
426
|
-
if (!this.config.assumeLoadedThread) return;
|
|
427
|
-
const thread = await this.readAssumedLoadedThread(this.threadId);
|
|
428
|
-
this.syncThreadState(thread);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
private async readThreadWithFallback(threadId: string): Promise<Thread> {
|
|
432
|
-
try {
|
|
433
|
-
const read = await this.app.threadRead(threadId, true);
|
|
434
|
-
return normalizeThread(read.thread);
|
|
435
|
-
} catch (error) {
|
|
436
|
-
if (!isThreadMaterializationError(error)) throw error;
|
|
437
|
-
const read = await this.app.threadRead(threadId, false);
|
|
438
|
-
return normalizeThread(read.thread);
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
private syncThreadState(thread: Thread): void {
|
|
443
|
-
this.threadStatus = thread.status.type;
|
|
444
|
-
const turns = thread.turns ?? [];
|
|
445
|
-
const activeTurn = [...turns].reverse().find((turn) => turn.status === "inProgress");
|
|
446
|
-
this.activeTurnId = activeTurn?.id ?? null;
|
|
447
|
-
if (!this.activeTurnId && this.threadStatus !== "active") this.activeClaimedMessageIds.clear();
|
|
448
|
-
void this.refreshRelayStatus();
|
|
449
|
-
this.writeState();
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
private handleAppEvent(event: ClientEvent): void {
|
|
453
|
-
if (event.type !== "notification") return;
|
|
454
|
-
const { method, params } = event.message;
|
|
455
|
-
|
|
456
|
-
if (method === "thread/status/changed") {
|
|
457
|
-
if (params?.threadId === this.threadId && params.status && typeof params.status === "object" && "type" in params.status) {
|
|
458
|
-
const nextStatus = String(params.status.type) as ThreadStatus["type"];
|
|
459
|
-
if (this.config.assumeLoadedThread && nextStatus === "notLoaded") {
|
|
460
|
-
void this.stopAndUnregister(`codex thread ${this.threadId} reported notLoaded`);
|
|
461
|
-
return;
|
|
462
|
-
}
|
|
463
|
-
this.threadStatus = nextStatus;
|
|
464
|
-
void this.refreshRelayStatus();
|
|
465
|
-
this.writeState();
|
|
466
|
-
}
|
|
467
|
-
return;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
if (method === "turn/started") {
|
|
471
|
-
if (params?.threadId === this.threadId && params.turn && typeof params.turn === "object" && "id" in params.turn) {
|
|
472
|
-
this.activeTurnId = String(params.turn.id);
|
|
473
|
-
this.threadStatus = "active";
|
|
474
|
-
void this.refreshRelayStatus();
|
|
475
|
-
this.writeState();
|
|
476
|
-
}
|
|
477
|
-
return;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
if (method === "turn/completed") {
|
|
481
|
-
if (params?.threadId === this.threadId && params.turn && typeof params.turn === "object") {
|
|
482
|
-
const completedId = "id" in params.turn ? String(params.turn.id) : null;
|
|
483
|
-
if (completedId && this.activeTurnId === completedId) this.activeTurnId = null;
|
|
484
|
-
this.threadStatus = "idle";
|
|
485
|
-
this.activeClaimedMessageIds.clear();
|
|
486
|
-
void this.refreshRelayStatus();
|
|
487
|
-
this.writeState();
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
private async refreshRelayStatus(): Promise<void> {
|
|
493
|
-
if (!this.agentId) return;
|
|
494
|
-
|
|
495
|
-
const status: RelayAgentStatus = !this.appConnected
|
|
496
|
-
? "online"
|
|
497
|
-
: this.activeTurnId || this.threadStatus === "active"
|
|
498
|
-
? "busy"
|
|
499
|
-
: "idle";
|
|
500
|
-
const ready = this.hasSuccessfulPoll && this.appConnected && Boolean(this.threadId);
|
|
501
|
-
|
|
502
|
-
try {
|
|
503
|
-
await Promise.all([
|
|
504
|
-
this.relay.setStatus(this.agentId, status, this.agentSession()),
|
|
505
|
-
this.relay.setReady(this.agentId, ready, this.agentSession()),
|
|
506
|
-
]);
|
|
507
|
-
} catch {
|
|
508
|
-
// Best-effort status sync.
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
private async renewActiveClaims(): Promise<void> {
|
|
513
|
-
if (!this.agentId || this.activeClaimedMessageIds.size === 0) return;
|
|
514
|
-
const renewals = activeClaimRenewalIds(this.activeClaimedMessageIds, this.activeTurnId, this.threadStatus);
|
|
515
|
-
if (renewals.length === 0) {
|
|
516
|
-
this.activeClaimedMessageIds.clear();
|
|
517
|
-
return;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
for (const messageId of renewals) {
|
|
521
|
-
try {
|
|
522
|
-
const renewed = await this.relay.renewMessageClaim(messageId, this.agentId, this.agentSession());
|
|
523
|
-
if (!renewed) {
|
|
524
|
-
this.activeClaimedMessageIds.delete(messageId);
|
|
525
|
-
this.log(`claim renewal rejected for message ${messageId}`);
|
|
526
|
-
}
|
|
527
|
-
} catch (error) {
|
|
528
|
-
this.log(`claim renewal failed for message ${messageId}: ${describeError(error)}`);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
private addPendingMessages(messages: RelayMessage[]): void {
|
|
534
|
-
let added = false;
|
|
535
|
-
for (const message of messages) {
|
|
536
|
-
if (this.pendingMessages.has(message.id)) continue;
|
|
537
|
-
this.pendingMessages.set(message.id, message);
|
|
538
|
-
added = true;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
if (added && this.drainDueAt === 0) {
|
|
542
|
-
this.drainDueAt = Date.now() + this.config.coalesceWindowMs;
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
private async drainPendingMessages(): Promise<void> {
|
|
547
|
-
if (this.draining || this.pendingMessages.size === 0) return;
|
|
548
|
-
|
|
549
|
-
this.draining = true;
|
|
550
|
-
const sorted = [...this.pendingMessages.values()].sort((a, b) => a.id - b.id);
|
|
551
|
-
this.pendingMessages.clear();
|
|
552
|
-
this.drainDueAt = 0;
|
|
553
|
-
|
|
554
|
-
try {
|
|
555
|
-
await this.ensureAppReady();
|
|
556
|
-
const batches = createDeliveryBatches(sorted);
|
|
557
|
-
for (let index = 0; index < batches.length; index += 1) {
|
|
558
|
-
const batch = batches[index]!;
|
|
559
|
-
try {
|
|
560
|
-
await this.deliverBatch(batch);
|
|
561
|
-
} catch (error) {
|
|
562
|
-
this.requeueBatches(batches.slice(index));
|
|
563
|
-
this.log(`delivery error: ${describeError(error)}`);
|
|
564
|
-
await this.ensureAppReady(true);
|
|
565
|
-
break;
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
} finally {
|
|
569
|
-
this.draining = false;
|
|
570
|
-
this.writeState();
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
private requeueBatches(batches: DeliveryBatch[]): void {
|
|
575
|
-
for (const batch of batches) {
|
|
576
|
-
for (const message of batch.messages) {
|
|
577
|
-
this.pendingMessages.set(message.id, message);
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
if (this.pendingMessages.size > 0) {
|
|
581
|
-
this.drainDueAt = Date.now() + Math.min(this.config.coalesceWindowMs, 250);
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
private async deliverBatch(batch: DeliveryBatch): Promise<void> {
|
|
586
|
-
const control = firstAgentControlMessage(batch.messages);
|
|
587
|
-
if (control) {
|
|
588
|
-
await this.handleAgentControl(control.message, control.action);
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
await this.ensureAppReady();
|
|
593
|
-
|
|
594
|
-
const claimedMessageIds: number[] = [];
|
|
595
|
-
for (const message of batch.messages) {
|
|
596
|
-
if (!message.claimable) continue;
|
|
597
|
-
const claimed = await this.relay.claimMessage(message.id, this.agentId, this.agentSession());
|
|
598
|
-
if (!claimed) {
|
|
599
|
-
this.log(`skipping unclaimed message ${message.id}`);
|
|
600
|
-
this.advanceCursor([message]);
|
|
601
|
-
this.writeState();
|
|
602
|
-
return;
|
|
603
|
-
}
|
|
604
|
-
claimedMessageIds.push(message.id);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
const delivery = this.pickDeliveryMode(batch.messages);
|
|
608
|
-
const prompt = formatRelayPrompt(batch.messages, { includePrimer: !this.relayPrimerDelivered, headless: this.config.headless });
|
|
609
|
-
const ids = batch.messages.map((message) => message.id).join(", ");
|
|
610
|
-
this.log(`delivering message ${ids} via ${delivery}`);
|
|
611
|
-
|
|
612
|
-
if (delivery === "interrupt" && this.activeTurnId) {
|
|
613
|
-
try {
|
|
614
|
-
await this.app.turnInterrupt(this.threadId, this.activeTurnId);
|
|
615
|
-
this.activeTurnId = null;
|
|
616
|
-
this.threadStatus = "idle";
|
|
617
|
-
await this.app.settle(200);
|
|
618
|
-
} catch (error) {
|
|
619
|
-
this.log(`interrupt failed for messages ${ids}: ${describeError(error)}`);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
if (delivery === "steer" && this.activeTurnId) {
|
|
624
|
-
try {
|
|
625
|
-
await this.app.turnSteer(this.threadId, this.activeTurnId, prompt);
|
|
626
|
-
for (const id of claimedMessageIds) this.activeClaimedMessageIds.add(id);
|
|
627
|
-
this.relayPrimerDelivered = true;
|
|
628
|
-
await this.markBatchRead(batch.messages);
|
|
629
|
-
this.advanceCursor(batch.messages);
|
|
630
|
-
this.writeState();
|
|
631
|
-
return;
|
|
632
|
-
} catch (error) {
|
|
633
|
-
this.log(`steer failed for messages ${ids}, falling back to start: ${describeError(error)}`);
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
await this.app.turnStart(this.threadId, prompt);
|
|
638
|
-
for (const id of claimedMessageIds) this.activeClaimedMessageIds.add(id);
|
|
639
|
-
this.relayPrimerDelivered = true;
|
|
640
|
-
await this.markBatchRead(batch.messages);
|
|
641
|
-
this.advanceCursor(batch.messages);
|
|
642
|
-
this.writeState();
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
private async handleAgentControl(message: RelayMessage, action: AgentControlAction): Promise<void> {
|
|
646
|
-
this.log(`agent control ${action} requested by message ${message.id}`);
|
|
647
|
-
await this.relay.markRead(message.id, this.agentId);
|
|
648
|
-
this.advanceCursor([message]);
|
|
649
|
-
|
|
650
|
-
if (action === "shutdown") {
|
|
651
|
-
await this.stopAndUnregister(`dashboard shutdown requested by message ${message.id}`);
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
if (this.agentId) {
|
|
656
|
-
try {
|
|
657
|
-
await this.relay.unregisterAgent(this.agentId);
|
|
658
|
-
} catch (error) {
|
|
659
|
-
this.log(`failed to unregister before restart: ${describeError(error)}`);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
this.agentEpoch = 0;
|
|
663
|
-
this.activeTurnId = null;
|
|
664
|
-
this.activeClaimedMessageIds.clear();
|
|
665
|
-
this.app.close();
|
|
666
|
-
this.appConnected = false;
|
|
667
|
-
await this.ensureAppReady(true);
|
|
668
|
-
await this.registerRelayAgent();
|
|
669
|
-
this.writeState();
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
private advanceCursor(messages: RelayMessage[]): void {
|
|
673
|
-
for (const message of messages) {
|
|
674
|
-
this.lastSeenMessageId = Math.max(this.lastSeenMessageId, message.id);
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
private async markBatchRead(messages: RelayMessage[]): Promise<void> {
|
|
679
|
-
for (const message of messages) {
|
|
680
|
-
await this.relay.markRead(message.id, this.agentId);
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
private agentSession(): RelayAgentSession | undefined {
|
|
685
|
-
return this.agentEpoch > 0 ? { instanceId: this.agentInstanceId, epoch: this.agentEpoch } : undefined;
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
private pickDeliveryMode(messages: RelayMessage[]): "start" | "steer" | "interrupt" {
|
|
689
|
-
const first = messages[0]!;
|
|
690
|
-
const meta = first.meta ?? {};
|
|
691
|
-
const requested = typeof meta.delivery === "string" ? meta.delivery : null;
|
|
692
|
-
const priority = typeof meta.priority === "string" ? meta.priority : null;
|
|
693
|
-
|
|
694
|
-
if (requested === "interrupt") return "interrupt";
|
|
695
|
-
if (requested === "start") return "start";
|
|
696
|
-
if (requested === "steer") return this.activeTurnId ? "steer" : "start";
|
|
697
|
-
if (priority === "urgent") return this.activeTurnId ? "interrupt" : "start";
|
|
698
|
-
return this.activeTurnId ? "steer" : "start";
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
private writeState(): void {
|
|
702
|
-
if (!this.threadId) return;
|
|
703
|
-
const updatedAt = new Date().toISOString();
|
|
704
|
-
const state: RuntimeState = {
|
|
705
|
-
agentId: this.agentId,
|
|
706
|
-
agentInstanceId: this.agentInstanceId,
|
|
707
|
-
agentEpoch: this.agentEpoch,
|
|
708
|
-
threadId: this.threadId,
|
|
709
|
-
activeTurnId: this.activeTurnId,
|
|
710
|
-
threadStatus: this.threadStatus,
|
|
711
|
-
lastSeenMessageId: this.lastSeenMessageId,
|
|
712
|
-
pendingMessageCount: this.pendingMessages.size,
|
|
713
|
-
appConnected: this.appConnected,
|
|
714
|
-
appServerUrl: this.config.appServerUrl,
|
|
715
|
-
relayUrl: this.config.relayUrl,
|
|
716
|
-
cwd: this.config.cwd,
|
|
717
|
-
updatedAt,
|
|
718
|
-
};
|
|
719
|
-
writeFileSync(this.config.statePath, JSON.stringify(state, null, 2));
|
|
720
|
-
if (this.config.contextPath) {
|
|
721
|
-
mkdirSync(dirname(this.config.contextPath), { recursive: true });
|
|
722
|
-
writeFileSync(this.config.contextPath, JSON.stringify({
|
|
723
|
-
version: 1,
|
|
724
|
-
agentId: this.agentId,
|
|
725
|
-
provider: "codex",
|
|
726
|
-
relayUrl: this.config.relayUrl,
|
|
727
|
-
cwd: this.config.cwd,
|
|
728
|
-
statePath: this.config.statePath,
|
|
729
|
-
matchEnv: [
|
|
730
|
-
{ name: "AGENT_RELAY_CONTEXT_PATH", value: this.config.contextPath },
|
|
731
|
-
],
|
|
732
|
-
updatedAt,
|
|
733
|
-
}, null, 2));
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
private log(message: string): void {
|
|
738
|
-
console.error(`${this.logPrefix} ${message}`);
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
private nextRelayBackoffMs(): number {
|
|
742
|
-
this.relayBackoffMs = this.relayBackoffMs === 0
|
|
743
|
-
? this.config.relayBackoffInitialMs
|
|
744
|
-
: Math.min(this.config.relayBackoffMaxMs, this.relayBackoffMs * 2);
|
|
745
|
-
return this.relayBackoffMs;
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
function createDeliveryBatches(messages: RelayMessage[]): DeliveryBatch[] {
|
|
750
|
-
const batches: DeliveryBatch[] = [];
|
|
751
|
-
let current: RelayMessage[] = [];
|
|
752
|
-
|
|
753
|
-
for (const message of messages) {
|
|
754
|
-
if (shouldIsolateMessage(message)) {
|
|
755
|
-
if (current.length > 0) {
|
|
756
|
-
batches.push({ messages: current });
|
|
757
|
-
current = [];
|
|
758
|
-
}
|
|
759
|
-
batches.push({ messages: [message] });
|
|
760
|
-
continue;
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
current.push(message);
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
if (current.length > 0) batches.push({ messages: current });
|
|
767
|
-
return batches;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
function shouldIsolateMessage(message: RelayMessage): boolean {
|
|
771
|
-
if (message.claimable) return true;
|
|
772
|
-
const meta = message.meta ?? {};
|
|
773
|
-
const delivery = typeof meta.delivery === "string" ? meta.delivery : null;
|
|
774
|
-
const priority = typeof meta.priority === "string" ? meta.priority : null;
|
|
775
|
-
return delivery === "interrupt" || delivery === "start" || delivery === "steer" || priority === "urgent";
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
export function formatRelayPrompt(
|
|
779
|
-
messages: RelayMessage[],
|
|
780
|
-
opts: { includePrimer?: boolean; headless?: boolean } = {},
|
|
781
|
-
): string {
|
|
782
|
-
const includePrimer = opts.includePrimer === true;
|
|
783
|
-
const relayUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
|
|
784
|
-
const primer = [
|
|
785
|
-
"Agent Relay live-message primer:",
|
|
786
|
-
"Treat Agent Relay deliveries as live incoming messages from other agents. Respond or act on them as appropriate.",
|
|
787
|
-
...(opts.headless ? ["This Codex session is running headless; Agent Relay is the primary user and agent communication surface."] : []),
|
|
788
|
-
"For normal replies, prefer `agent-relay /message TARGET BODY --reply-to MSG_ID`; the CLI detects this session's Agent Relay ID.",
|
|
789
|
-
"For pair chat replies, prefer `agent-relay /pair send PAIR_ID BODY`.",
|
|
790
|
-
`If the CLI is unavailable, call ${relayUrl}/api/messages or /api/pairs/{pairId}/messages directly and include AGENT_RELAY_TOKEN as X-Agent-Relay-Token when set.`,
|
|
791
|
-
"",
|
|
792
|
-
];
|
|
793
|
-
|
|
794
|
-
if (messages.length === 1) {
|
|
795
|
-
const message = messages[0]!;
|
|
796
|
-
const lines = [
|
|
797
|
-
...(includePrimer ? primer : []),
|
|
798
|
-
"Agent Relay message received.",
|
|
799
|
-
"",
|
|
800
|
-
formatMessageSummary(message),
|
|
801
|
-
"",
|
|
802
|
-
`Message ID: ${message.id}`,
|
|
803
|
-
`From: ${message.from}`,
|
|
804
|
-
`To: ${message.to}`,
|
|
805
|
-
`Created: ${new Date(message.createdAt).toISOString()}`,
|
|
806
|
-
];
|
|
807
|
-
|
|
808
|
-
if (message.subject) lines.push(`Subject: ${message.subject}`);
|
|
809
|
-
if (message.replyTo) lines.push(`Reply To: ${message.replyTo}`);
|
|
810
|
-
const pairId = typeof message.payload?.pairId === "string" ? message.payload.pairId : undefined;
|
|
811
|
-
if (pairId) {
|
|
812
|
-
lines.push(`Pair ID: ${pairId}`);
|
|
813
|
-
}
|
|
814
|
-
lines.push(
|
|
815
|
-
"",
|
|
816
|
-
"Body:",
|
|
817
|
-
message.body,
|
|
818
|
-
"",
|
|
819
|
-
pairId
|
|
820
|
-
? `Reply routing: pairId=${JSON.stringify(pairId)}.`
|
|
821
|
-
: `Reply routing: to=${JSON.stringify(message.from)}, replyTo=${message.id}.`,
|
|
822
|
-
);
|
|
823
|
-
return lines.join("\n");
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
const lines = [
|
|
827
|
-
...(includePrimer ? primer : []),
|
|
828
|
-
"Agent Relay message batch received.",
|
|
829
|
-
"",
|
|
830
|
-
`Count: ${messages.length}`,
|
|
831
|
-
"",
|
|
832
|
-
];
|
|
833
|
-
|
|
834
|
-
for (const message of messages) {
|
|
835
|
-
lines.push(
|
|
836
|
-
formatMessageSummary(message),
|
|
837
|
-
`Message ID: ${message.id}`,
|
|
838
|
-
`From: ${message.from}`,
|
|
839
|
-
`To: ${message.to}`,
|
|
840
|
-
`Created: ${new Date(message.createdAt).toISOString()}`,
|
|
841
|
-
);
|
|
842
|
-
if (message.subject) lines.push(`Subject: ${message.subject}`);
|
|
843
|
-
if (message.replyTo) lines.push(`Reply To: ${message.replyTo}`);
|
|
844
|
-
const pairId = typeof message.payload?.pairId === "string" ? message.payload.pairId : undefined;
|
|
845
|
-
if (pairId) lines.push(`Pair ID: ${pairId}`);
|
|
846
|
-
lines.push(
|
|
847
|
-
pairId
|
|
848
|
-
? `Reply routing: pairId=${JSON.stringify(pairId)}.`
|
|
849
|
-
: `Reply routing: to=${JSON.stringify(message.from)}, replyTo=${message.id}.`,
|
|
850
|
-
);
|
|
851
|
-
lines.push("Body:", message.body, "", "---", "");
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
lines.push(
|
|
855
|
-
"Synthesize these messages into one coherent response or action. Use each message's Reply routing line when replying.",
|
|
856
|
-
);
|
|
857
|
-
return lines.join("\n");
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
function formatMessageSummary(message: RelayMessage): string {
|
|
861
|
-
const subject = message.subject || "message";
|
|
862
|
-
if (message.kind === "system" || message.kind === "control") return `SYSTEM [msg:${message.id}]: ${message.body}`;
|
|
863
|
-
return `[msg:${message.id}] ${message.from} -> ${message.to} | ${subject}: ${message.body}`;
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
export function agentControlActionForMessage(message: RelayMessage): AgentControlAction | null {
|
|
867
|
-
if (message.kind !== "control") return null;
|
|
868
|
-
const control = message.payload?.agentControl;
|
|
869
|
-
if (!control || typeof control !== "object" || Array.isArray(control)) return null;
|
|
870
|
-
const action = (control as { action?: unknown }).action;
|
|
871
|
-
return action === "restart" || action === "shutdown" ? action : null;
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
function firstAgentControlMessage(messages: RelayMessage[]): { message: RelayMessage; action: AgentControlAction } | null {
|
|
875
|
-
for (const message of messages) {
|
|
876
|
-
const action = agentControlActionForMessage(message);
|
|
877
|
-
if (action) return { message, action };
|
|
878
|
-
}
|
|
879
|
-
return null;
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
function normalizeThread(thread: Thread): Thread {
|
|
883
|
-
return {
|
|
884
|
-
...thread,
|
|
885
|
-
turns: thread.turns ?? [],
|
|
886
|
-
};
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
function syntheticLoadedThread(threadId: string, cwd: string): Thread {
|
|
890
|
-
return {
|
|
891
|
-
id: threadId,
|
|
892
|
-
cwd,
|
|
893
|
-
status: { type: "idle" },
|
|
894
|
-
updatedAt: Date.now(),
|
|
895
|
-
preview: "",
|
|
896
|
-
turns: [],
|
|
897
|
-
};
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
function isThreadMaterializationError(error: unknown): boolean {
|
|
901
|
-
return describeError(error).includes("not materialized yet");
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
function isThreadMissingRolloutError(error: unknown): boolean {
|
|
905
|
-
return describeError(error).includes("no rollout found for thread id");
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
function describeError(error: unknown): string {
|
|
909
|
-
return error instanceof Error ? error.message : String(error);
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
function envNumber(env: NodeJS.ProcessEnv, name: string, fallback: number): number {
|
|
913
|
-
const raw = env[name];
|
|
914
|
-
if (!raw) return fallback;
|
|
915
|
-
const parsed = Number(raw);
|
|
916
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
function isAlive(pid: number): boolean {
|
|
920
|
-
try {
|
|
921
|
-
process.kill(pid, 0);
|
|
922
|
-
return true;
|
|
923
|
-
} catch {
|
|
924
|
-
return false;
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
export function parseThreadMode(raw: string | undefined): Config["threadMode"] {
|
|
929
|
-
if (raw === "auto" || raw === "resume" || raw === "start") return raw;
|
|
930
|
-
return "start";
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
|
934
|
-
const cwd = env.AGENT_RELAY_CODEX_CWD || process.cwd();
|
|
935
|
-
const rig = env.AGENT_RELAY_CODEX_RIG || "codex-live";
|
|
936
|
-
const project = cwd.split("/").filter(Boolean).at(-1) || "unknown";
|
|
937
|
-
const profile = loadAgentRelayProfile(env, { provider: "codex", rig, project });
|
|
938
|
-
|
|
939
|
-
return {
|
|
940
|
-
relayUrl: env.AGENT_RELAY_URL || "http://127.0.0.1:4850",
|
|
941
|
-
appServerUrl: env.CODEX_APP_SERVER_URL || "ws://127.0.0.1:4501",
|
|
942
|
-
cwd,
|
|
943
|
-
rig,
|
|
944
|
-
label: profile.label,
|
|
945
|
-
capabilities: profile.capabilities,
|
|
946
|
-
tags: profile.tags,
|
|
947
|
-
channels: profile.channels,
|
|
948
|
-
profileName: profile.profileName,
|
|
949
|
-
profileMeta: profile.meta,
|
|
950
|
-
statePath: env.AGENT_RELAY_CODEX_STATE_PATH || resolve(cwd, "codex/runtime/live-state.json"),
|
|
951
|
-
contextPath: env.AGENT_RELAY_CONTEXT_PATH || undefined,
|
|
952
|
-
pollIntervalMs: envNumber(env, "AGENT_RELAY_CODEX_POLL_INTERVAL_MS", 2000),
|
|
953
|
-
heartbeatIntervalMs: envNumber(env, "AGENT_RELAY_CODEX_HEARTBEAT_INTERVAL_MS", 30000),
|
|
954
|
-
threadCheckIntervalMs: envNumber(env, "AGENT_RELAY_CODEX_THREAD_CHECK_INTERVAL_MS", 30000),
|
|
955
|
-
relayBackoffInitialMs: envNumber(env, "AGENT_RELAY_CODEX_RELAY_BACKOFF_INITIAL_MS", 2000),
|
|
956
|
-
relayBackoffMaxMs: envNumber(env, "AGENT_RELAY_CODEX_RELAY_BACKOFF_MAX_MS", 60000),
|
|
957
|
-
coalesceWindowMs: envNumber(env, "AGENT_RELAY_CODEX_COALESCE_WINDOW_MS", 600),
|
|
958
|
-
reconnectInitialDelayMs: envNumber(env, "AGENT_RELAY_CODEX_RECONNECT_INITIAL_MS", 1000),
|
|
959
|
-
reconnectMaxDelayMs: envNumber(env, "AGENT_RELAY_CODEX_RECONNECT_MAX_MS", 10000),
|
|
960
|
-
threadMode: parseThreadMode(env.CODEX_THREAD_MODE),
|
|
961
|
-
threadId: env.CODEX_THREAD_ID || undefined,
|
|
962
|
-
model: env.CODEX_MODEL || undefined,
|
|
963
|
-
approvalPolicy: env.AGENT_RELAY_CODEX_APPROVAL_POLICY || undefined,
|
|
964
|
-
sandbox: env.AGENT_RELAY_CODEX_SANDBOX || undefined,
|
|
965
|
-
approvalMode: profile.approval
|
|
966
|
-
? profile.approval
|
|
967
|
-
: approvalModeFromPermissions({
|
|
968
|
-
approvalPolicy: env.AGENT_RELAY_CODEX_APPROVAL_POLICY,
|
|
969
|
-
sandbox: env.AGENT_RELAY_CODEX_SANDBOX,
|
|
970
|
-
}),
|
|
971
|
-
parentPid: envNumber(env, "AGENT_RELAY_CODEX_PARENT_PID", 0) || undefined,
|
|
972
|
-
assumeLoadedThread: env.AGENT_RELAY_CODEX_ASSUME_LOADED_THREAD === "1" || env.AGENT_RELAY_CODEX_ASSUME_LOADED_THREAD === "true",
|
|
973
|
-
headless: env.AGENT_RELAY_CODEX_HEADLESS === "1" || env.AGENT_RELAY_CODEX_HEADLESS === "true",
|
|
974
|
-
};
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
async function main(): Promise<void> {
|
|
978
|
-
const config = loadConfig();
|
|
979
|
-
const sidecar = new CodexLiveSidecar(config);
|
|
980
|
-
await sidecar.run();
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
if (import.meta.main) {
|
|
984
|
-
main().catch((error) => {
|
|
985
|
-
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
|
986
|
-
process.exit(1);
|
|
987
|
-
});
|
|
988
|
-
}
|