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 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.readThreadWithFallback(threadId).catch(() => syntheticLoadedThread(threadId, this.config.cwd));
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
- this.threadStatus = String(params.status.type) as ThreadStatus["type"];
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-codex",
3
- "version": "0.4.35",
3
+ "version": "0.4.39",
4
4
  "description": "Codex integration for Agent Relay — auto-registers sessions as agents and enables inter-agent messaging",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay",
3
- "version": "0.4.35",
3
+ "version": "0.4.39",
4
4
  "description": "Agent Relay integration for Codex sessions",
5
5
  "author": {
6
6
  "name": "Edin Mujkanovic"
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);