agent-relay-codex 0.4.35 → 0.4.39
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/live-sidecar.ts +124 -4
- package/package.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/relay.ts +4 -0
package/live-sidecar.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { CodexAppClient, type ClientEvent, type Thread, type ThreadStatus } from
|
|
|
7
7
|
import { loadAgentRelayProfile, messageMatchesProfileChannels } from "./profile";
|
|
8
8
|
import { RelayClient, RelayHttpError, type RelayAgentSession, type RelayAgentStatus, type RelayMessage } from "./relay";
|
|
9
9
|
|
|
10
|
+
type AgentControlAction = "restart" | "shutdown";
|
|
11
|
+
|
|
10
12
|
interface Config {
|
|
11
13
|
relayUrl: string;
|
|
12
14
|
appServerUrl: string;
|
|
@@ -21,6 +23,7 @@ interface Config {
|
|
|
21
23
|
statePath: string;
|
|
22
24
|
pollIntervalMs: number;
|
|
23
25
|
heartbeatIntervalMs: number;
|
|
26
|
+
threadCheckIntervalMs: number;
|
|
24
27
|
relayBackoffInitialMs: number;
|
|
25
28
|
relayBackoffMaxMs: number;
|
|
26
29
|
coalesceWindowMs: number;
|
|
@@ -34,6 +37,7 @@ interface Config {
|
|
|
34
37
|
approvalMode: ApprovalMode;
|
|
35
38
|
parentPid?: number;
|
|
36
39
|
assumeLoadedThread: boolean;
|
|
40
|
+
headless: boolean;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
interface RuntimeState {
|
|
@@ -56,6 +60,8 @@ interface DeliveryBatch {
|
|
|
56
60
|
messages: RelayMessage[];
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
class ThreadLostError extends Error {}
|
|
64
|
+
|
|
59
65
|
export function activeClaimRenewalIds(
|
|
60
66
|
messageIds: Iterable<number>,
|
|
61
67
|
activeTurnId: string | null,
|
|
@@ -65,6 +71,10 @@ export function activeClaimRenewalIds(
|
|
|
65
71
|
return [...messageIds];
|
|
66
72
|
}
|
|
67
73
|
|
|
74
|
+
export function canUseSyntheticLoadedThread(threadId: string, loadedThreadIds: readonly string[]): boolean {
|
|
75
|
+
return loadedThreadIds.includes(threadId);
|
|
76
|
+
}
|
|
77
|
+
|
|
68
78
|
class CodexLiveSidecar {
|
|
69
79
|
private readonly relay: RelayClient;
|
|
70
80
|
private readonly logPrefix = "[codex-live]";
|
|
@@ -77,6 +87,7 @@ class CodexLiveSidecar {
|
|
|
77
87
|
private threadStatus: ThreadStatus["type"] = "notLoaded";
|
|
78
88
|
private lastSeenMessageId = 0;
|
|
79
89
|
private lastHeartbeatAt = 0;
|
|
90
|
+
private lastThreadCheckAt = 0;
|
|
80
91
|
private hasSuccessfulPoll = false;
|
|
81
92
|
private relayBackoffMs = 0;
|
|
82
93
|
private lastClaimRenewalAt = 0;
|
|
@@ -105,6 +116,7 @@ class CodexLiveSidecar {
|
|
|
105
116
|
process.on("SIGTERM", () => void this.stop("SIGTERM"));
|
|
106
117
|
|
|
107
118
|
await this.ensureAppReady();
|
|
119
|
+
if (this.stopping) return;
|
|
108
120
|
|
|
109
121
|
await this.registerRelayAgent();
|
|
110
122
|
|
|
@@ -116,6 +128,10 @@ class CodexLiveSidecar {
|
|
|
116
128
|
break;
|
|
117
129
|
}
|
|
118
130
|
const now = Date.now();
|
|
131
|
+
if (now - this.lastThreadCheckAt >= this.config.threadCheckIntervalMs) {
|
|
132
|
+
this.lastThreadCheckAt = now;
|
|
133
|
+
await this.verifyCurrentThread();
|
|
134
|
+
}
|
|
119
135
|
if (now - this.lastHeartbeatAt >= this.config.heartbeatIntervalMs) {
|
|
120
136
|
await this.relay.heartbeat(this.agentId, this.agentSession());
|
|
121
137
|
this.lastHeartbeatAt = now;
|
|
@@ -151,6 +167,8 @@ class CodexLiveSidecar {
|
|
|
151
167
|
this.stopping = true;
|
|
152
168
|
this.writeState();
|
|
153
169
|
this.app.close();
|
|
170
|
+
} else if (error instanceof ThreadLostError) {
|
|
171
|
+
await this.stopAndUnregister(error.message);
|
|
154
172
|
} else {
|
|
155
173
|
sleepMs = this.nextRelayBackoffMs();
|
|
156
174
|
this.log(`loop error: ${describeError(error)}; retrying in ${sleepMs}ms`);
|
|
@@ -246,6 +264,10 @@ class CodexLiveSidecar {
|
|
|
246
264
|
}
|
|
247
265
|
return;
|
|
248
266
|
} catch (error) {
|
|
267
|
+
if (error instanceof ThreadLostError) {
|
|
268
|
+
await this.stopAndUnregister(error.message);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
249
271
|
this.appConnected = false;
|
|
250
272
|
this.log(`app connection failed: ${describeError(error)}; retrying in ${waitMs}ms`);
|
|
251
273
|
await delay(waitMs);
|
|
@@ -269,6 +291,28 @@ class CodexLiveSidecar {
|
|
|
269
291
|
this.app.close();
|
|
270
292
|
}
|
|
271
293
|
|
|
294
|
+
private async stopAndUnregister(reason: string): Promise<void> {
|
|
295
|
+
if (this.stopping) return;
|
|
296
|
+
this.stopping = true;
|
|
297
|
+
this.log(`${reason}; unregistering relay agent and stopping sidecar`);
|
|
298
|
+
if (this.agentId) {
|
|
299
|
+
try {
|
|
300
|
+
await this.relay.unregisterAgent(this.agentId);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
this.log(`failed to unregister relay agent ${this.agentId}: ${describeError(error)}`);
|
|
303
|
+
try {
|
|
304
|
+
await this.relay.setStatus(this.agentId, "offline", this.agentSession());
|
|
305
|
+
} catch {
|
|
306
|
+
// Best effort after unregister failure.
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
this.threadStatus = "notLoaded";
|
|
311
|
+
this.activeTurnId = null;
|
|
312
|
+
this.writeState();
|
|
313
|
+
this.app.close();
|
|
314
|
+
}
|
|
315
|
+
|
|
272
316
|
private parentExited(): boolean {
|
|
273
317
|
return Boolean(this.config.parentPid && !isAlive(this.config.parentPid));
|
|
274
318
|
}
|
|
@@ -286,7 +330,7 @@ class CodexLiveSidecar {
|
|
|
286
330
|
|
|
287
331
|
private async attachKnownThread(threadId: string): Promise<Thread> {
|
|
288
332
|
if (this.config.assumeLoadedThread) {
|
|
289
|
-
return this.
|
|
333
|
+
return this.readAssumedLoadedThread(threadId);
|
|
290
334
|
}
|
|
291
335
|
|
|
292
336
|
try {
|
|
@@ -364,6 +408,25 @@ class CodexLiveSidecar {
|
|
|
364
408
|
return payload;
|
|
365
409
|
}
|
|
366
410
|
|
|
411
|
+
private async readAssumedLoadedThread(threadId: string): Promise<Thread> {
|
|
412
|
+
try {
|
|
413
|
+
return await this.readThreadWithFallback(threadId);
|
|
414
|
+
} catch {
|
|
415
|
+
const loaded = await this.app.threadLoadedList(100);
|
|
416
|
+
if (canUseSyntheticLoadedThread(threadId, loaded.data)) {
|
|
417
|
+
return syntheticLoadedThread(threadId, this.config.cwd);
|
|
418
|
+
}
|
|
419
|
+
throw new ThreadLostError(`codex thread ${threadId} is no longer loaded`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private async verifyCurrentThread(): Promise<void> {
|
|
424
|
+
if (!this.threadId || !this.appConnected) return;
|
|
425
|
+
if (!this.config.assumeLoadedThread) return;
|
|
426
|
+
const thread = await this.readAssumedLoadedThread(this.threadId);
|
|
427
|
+
this.syncThreadState(thread);
|
|
428
|
+
}
|
|
429
|
+
|
|
367
430
|
private async readThreadWithFallback(threadId: string): Promise<Thread> {
|
|
368
431
|
try {
|
|
369
432
|
const read = await this.app.threadRead(threadId, true);
|
|
@@ -391,7 +454,12 @@ class CodexLiveSidecar {
|
|
|
391
454
|
|
|
392
455
|
if (method === "thread/status/changed") {
|
|
393
456
|
if (params?.threadId === this.threadId && params.status && typeof params.status === "object" && "type" in params.status) {
|
|
394
|
-
|
|
457
|
+
const nextStatus = String(params.status.type) as ThreadStatus["type"];
|
|
458
|
+
if (this.config.assumeLoadedThread && nextStatus === "notLoaded") {
|
|
459
|
+
void this.stopAndUnregister(`codex thread ${this.threadId} reported notLoaded`);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
this.threadStatus = nextStatus;
|
|
395
463
|
void this.refreshRelayStatus();
|
|
396
464
|
this.writeState();
|
|
397
465
|
}
|
|
@@ -514,6 +582,12 @@ class CodexLiveSidecar {
|
|
|
514
582
|
}
|
|
515
583
|
|
|
516
584
|
private async deliverBatch(batch: DeliveryBatch): Promise<void> {
|
|
585
|
+
const control = firstAgentControlMessage(batch.messages);
|
|
586
|
+
if (control) {
|
|
587
|
+
await this.handleAgentControl(control.message, control.action);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
517
591
|
await this.ensureAppReady();
|
|
518
592
|
|
|
519
593
|
const claimedMessageIds: number[] = [];
|
|
@@ -530,7 +604,7 @@ class CodexLiveSidecar {
|
|
|
530
604
|
}
|
|
531
605
|
|
|
532
606
|
const delivery = this.pickDeliveryMode(batch.messages);
|
|
533
|
-
const prompt = formatRelayPrompt(batch.messages, { includePrimer: !this.relayPrimerDelivered });
|
|
607
|
+
const prompt = formatRelayPrompt(batch.messages, { includePrimer: !this.relayPrimerDelivered, headless: this.config.headless });
|
|
534
608
|
const ids = batch.messages.map((message) => message.id).join(", ");
|
|
535
609
|
this.log(`delivering message ${ids} via ${delivery}`);
|
|
536
610
|
|
|
@@ -567,6 +641,33 @@ class CodexLiveSidecar {
|
|
|
567
641
|
this.writeState();
|
|
568
642
|
}
|
|
569
643
|
|
|
644
|
+
private async handleAgentControl(message: RelayMessage, action: AgentControlAction): Promise<void> {
|
|
645
|
+
this.log(`agent control ${action} requested by message ${message.id}`);
|
|
646
|
+
await this.relay.markRead(message.id, this.agentId);
|
|
647
|
+
this.advanceCursor([message]);
|
|
648
|
+
|
|
649
|
+
if (action === "shutdown") {
|
|
650
|
+
await this.stopAndUnregister(`dashboard shutdown requested by message ${message.id}`);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (this.agentId) {
|
|
655
|
+
try {
|
|
656
|
+
await this.relay.unregisterAgent(this.agentId);
|
|
657
|
+
} catch (error) {
|
|
658
|
+
this.log(`failed to unregister before restart: ${describeError(error)}`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
this.agentEpoch = 0;
|
|
662
|
+
this.activeTurnId = null;
|
|
663
|
+
this.activeClaimedMessageIds.clear();
|
|
664
|
+
this.app.close();
|
|
665
|
+
this.appConnected = false;
|
|
666
|
+
await this.ensureAppReady(true);
|
|
667
|
+
await this.registerRelayAgent();
|
|
668
|
+
this.writeState();
|
|
669
|
+
}
|
|
670
|
+
|
|
570
671
|
private advanceCursor(messages: RelayMessage[]): void {
|
|
571
672
|
for (const message of messages) {
|
|
572
673
|
this.lastSeenMessageId = Math.max(this.lastSeenMessageId, message.id);
|
|
@@ -659,13 +760,14 @@ function shouldIsolateMessage(message: RelayMessage): boolean {
|
|
|
659
760
|
|
|
660
761
|
export function formatRelayPrompt(
|
|
661
762
|
messages: RelayMessage[],
|
|
662
|
-
opts: { includePrimer?: boolean } = {},
|
|
763
|
+
opts: { includePrimer?: boolean; headless?: boolean } = {},
|
|
663
764
|
): string {
|
|
664
765
|
const includePrimer = opts.includePrimer === true;
|
|
665
766
|
const relayUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
|
|
666
767
|
const primer = [
|
|
667
768
|
"Agent Relay live-message primer:",
|
|
668
769
|
"Treat Agent Relay deliveries as live incoming messages from other agents. Respond or act on them as appropriate.",
|
|
770
|
+
...(opts.headless ? ["This Codex session is running headless; Agent Relay is the primary user and agent communication surface."] : []),
|
|
669
771
|
"When calling the relay API, include AGENT_RELAY_TOKEN as the X-Agent-Relay-Token header if that environment variable is set.",
|
|
670
772
|
`For normal replies, POST JSON to ${relayUrl}/api/messages with from set to your Agent Relay ID, to set to the sender Agent Relay ID, body set to your response, and replyTo set to the incoming message id.`,
|
|
671
773
|
`For pair chat replies, POST JSON to ${relayUrl}/api/pairs/{pairId}/messages with from set to your Agent Relay ID and body set to your response.`,
|
|
@@ -742,6 +844,22 @@ function formatMessageSummary(message: RelayMessage): string {
|
|
|
742
844
|
return `[msg:${message.id}] ${message.from} -> ${message.to} | ${subject}: ${message.body}`;
|
|
743
845
|
}
|
|
744
846
|
|
|
847
|
+
export function agentControlActionForMessage(message: RelayMessage): AgentControlAction | null {
|
|
848
|
+
if (message.type !== "system") return null;
|
|
849
|
+
const control = message.meta?.agentControl;
|
|
850
|
+
if (!control || typeof control !== "object" || Array.isArray(control)) return null;
|
|
851
|
+
const action = (control as { action?: unknown }).action;
|
|
852
|
+
return action === "restart" || action === "shutdown" ? action : null;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function firstAgentControlMessage(messages: RelayMessage[]): { message: RelayMessage; action: AgentControlAction } | null {
|
|
856
|
+
for (const message of messages) {
|
|
857
|
+
const action = agentControlActionForMessage(message);
|
|
858
|
+
if (action) return { message, action };
|
|
859
|
+
}
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
|
|
745
863
|
function normalizeThread(thread: Thread): Thread {
|
|
746
864
|
return {
|
|
747
865
|
...thread,
|
|
@@ -813,6 +931,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
|
|
813
931
|
statePath: env.AGENT_RELAY_CODEX_STATE_PATH || resolve(cwd, "codex/runtime/live-state.json"),
|
|
814
932
|
pollIntervalMs: envNumber(env, "AGENT_RELAY_CODEX_POLL_INTERVAL_MS", 2000),
|
|
815
933
|
heartbeatIntervalMs: envNumber(env, "AGENT_RELAY_CODEX_HEARTBEAT_INTERVAL_MS", 30000),
|
|
934
|
+
threadCheckIntervalMs: envNumber(env, "AGENT_RELAY_CODEX_THREAD_CHECK_INTERVAL_MS", 30000),
|
|
816
935
|
relayBackoffInitialMs: envNumber(env, "AGENT_RELAY_CODEX_RELAY_BACKOFF_INITIAL_MS", 2000),
|
|
817
936
|
relayBackoffMaxMs: envNumber(env, "AGENT_RELAY_CODEX_RELAY_BACKOFF_MAX_MS", 60000),
|
|
818
937
|
coalesceWindowMs: envNumber(env, "AGENT_RELAY_CODEX_COALESCE_WINDOW_MS", 600),
|
|
@@ -831,6 +950,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
|
|
831
950
|
}),
|
|
832
951
|
parentPid: envNumber(env, "AGENT_RELAY_CODEX_PARENT_PID", 0) || undefined,
|
|
833
952
|
assumeLoadedThread: env.AGENT_RELAY_CODEX_ASSUME_LOADED_THREAD === "1" || env.AGENT_RELAY_CODEX_ASSUME_LOADED_THREAD === "true",
|
|
953
|
+
headless: env.AGENT_RELAY_CODEX_HEADLESS === "1" || env.AGENT_RELAY_CODEX_HEADLESS === "true",
|
|
834
954
|
};
|
|
835
955
|
}
|
|
836
956
|
|
package/package.json
CHANGED
package/relay.ts
CHANGED
|
@@ -105,6 +105,10 @@ export class RelayClient {
|
|
|
105
105
|
await this.json("PATCH", `/api/agents/${encodeURIComponent(agentId)}/ready`, { ready, ...session });
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
async unregisterAgent(agentId: string): Promise<void> {
|
|
109
|
+
await this.json("DELETE", `/api/agents/${encodeURIComponent(agentId)}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
108
112
|
async pollMessages(agentId: string, sinceId: number): Promise<RelayMessage[]> {
|
|
109
113
|
const url = new URL(`/api/messages`, this.baseUrl);
|
|
110
114
|
url.searchParams.set("for", agentId);
|