agent-relay-codex 0.4.24 → 0.4.26

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 CHANGED
@@ -25,6 +25,9 @@ bunx agent-relay-server@latest
25
25
 
26
26
  # start Codex with Agent Relay (after restarting your shell)
27
27
  codex-relay
28
+
29
+ # start a relay-only Codex agent without opening a TUI
30
+ codex-relay --headless
28
31
  ```
29
32
 
30
33
  Without restarting your shell:
@@ -76,6 +79,7 @@ Run with `AGENT_RELAY_PROFILE=frontend-developer agent-relay-codex start`.
76
79
  | `CODEX_THREAD_MODE` | `start` | Thread attach: `start`, `resume`, `auto` |
77
80
  | `CODEX_THREAD_ID` | — | Pin to a specific thread |
78
81
  | `CODEX_APP_SERVER_URL` | `ws://127.0.0.1:4501` | App-server WebSocket URL |
82
+ | `AGENT_RELAY_CODEX_HEADLESS` | — | Set to `1` to run without opening a TUI |
79
83
 
80
84
  ### Advanced tuning
81
85
 
@@ -87,7 +91,6 @@ Run with `AGENT_RELAY_PROFILE=frontend-developer agent-relay-codex start`.
87
91
  | `AGENT_RELAY_CODEX_COALESCE_WINDOW_MS` | `600` | Message batch window |
88
92
  | `AGENT_RELAY_CODEX_RELAY_BACKOFF_INITIAL_MS` | `2000` | Relay API retry backoff |
89
93
  | `AGENT_RELAY_CODEX_RELAY_BACKOFF_MAX_MS` | `60000` | Relay API retry backoff cap |
90
- | `AGENT_RELAY_CODEX_HOOK_TIMEOUT_MS` | `5000` | SessionStart handshake timeout |
91
94
 
92
95
  ## Approval mode
93
96
 
@@ -123,7 +126,13 @@ agent-relay-codex uninstall --purge # also remove runtime state and PATH entrie
123
126
 
124
127
  ## How it works
125
128
 
126
- `codex-relay` launches `codex app-server`, opens Codex through `codex --remote`, and spawns a live sidecar that:
129
+ `codex-relay` launches `codex app-server`, then opens the TUI with `codex --remote <app-server-url>`. The normal SessionStart hook registers the visible TUI thread with Agent Relay, so Agent Relay messages and direct TUI messages share the same Codex context.
130
+
131
+ Use `codex-relay --headless` on servers where Agent Relay is the only interaction surface. It starts the same app-server and sidecar session but does not open a TUI; the launcher prints `codex resume --remote <app-server-url>` if you later want to attach one.
132
+
133
+ The installed SessionStart hook remains for plain `codex` launches. In that mode, the hook registers the visible Codex session with Agent Relay without requiring the managed `codex-relay` launcher.
134
+
135
+ The live sidecar:
127
136
 
128
137
  - Registers the session as an Agent Relay agent
129
138
  - Polls the relay inbox and delivers messages into the active Codex thread
@@ -5,6 +5,7 @@ import { dirname, join, resolve } from "node:path";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import net from "node:net";
8
+ import { CodexAppClient } from "../app-client";
8
9
  import { approvalModeFromPermissions, codexArgsForApprovalMode, parseApprovalMode } from "../approval";
9
10
  import { loadAgentRelayProfile } from "../profile";
10
11
 
@@ -16,24 +17,6 @@ type RelayStats = {
16
17
  version?: string;
17
18
  };
18
19
 
19
- type HookHandshake = {
20
- status: "ok" | "error";
21
- code: string;
22
- message?: string;
23
- pid?: number;
24
- threadId?: string;
25
- timestamp?: string;
26
- };
27
-
28
- type HookWaitResult = {
29
- ok: boolean;
30
- code: string;
31
- message?: string;
32
- pid?: number;
33
- };
34
-
35
- const DEFAULT_HOOK_HANDSHAKE_TIMEOUT_MS = 5000;
36
-
37
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
38
21
  const packageRoot = resolve(__dirname, "..");
39
22
  const home = process.env.HOME || process.env.USERPROFILE || homedir();
@@ -55,14 +38,15 @@ function usage(exitCode = 0): never {
55
38
  console.log(`agent-relay-codex
56
39
 
57
40
  Usage:
58
- agent-relay-codex [--relay-url URL] [--listen ws://127.0.0.1:PORT] [-- <codex args...>]
41
+ agent-relay-codex [--headless] [--relay-url URL] [--listen ws://127.0.0.1:PORT] [-- <codex args...>]
59
42
  agent-relay-codex install [--alias|--no-alias]
60
43
  agent-relay-codex uninstall [--purge]
61
44
  agent-relay-codex alias install
62
45
  agent-relay-codex alias remove
63
46
  agent-relay-codex doctor
64
- agent-relay-codex start [--relay-url URL] [--listen ws://127.0.0.1:PORT] [--thread-mode auto|resume|start] [-- <codex args...>]
65
- codex-relay [--relay-url URL] [--listen ws://127.0.0.1:PORT] [--thread-mode auto|resume|start] [-- <codex args...>]
47
+ agent-relay-codex upgrade [--dry-run] [--version VERSION] [--no-restart] [--yes]
48
+ agent-relay-codex start [--headless] [--relay-url URL] [--listen ws://127.0.0.1:PORT] [--thread-mode auto|resume|start] [--thread-id ID] [-- <codex args...>]
49
+ codex-relay [--headless] [--relay-url URL] [--listen ws://127.0.0.1:PORT] [--thread-mode auto|resume|start] [--thread-id ID] [-- <codex args...>]
66
50
 
67
51
  With no subcommand, this launches Codex with live Agent Relay support.`);
68
52
  process.exit(exitCode);
@@ -99,21 +83,14 @@ function readJsonFile<T>(path: string, fallback: T): T {
99
83
  return JSON.parse(readFileSync(path, "utf8")) as T;
100
84
  }
101
85
 
102
- function errorMessage(error: unknown): string {
103
- return error instanceof Error ? error.message : String(error);
104
- }
105
-
106
- function envPositiveInt(name: string, fallback: number): number {
107
- const raw = process.env[name];
108
- if (!raw) return fallback;
109
- const parsed = Number.parseInt(raw, 10);
110
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
111
- }
112
-
113
86
  function appendLauncherLog(runDir: string, line: string): void {
114
87
  appendFileSync(join(runDir, "launcher.log"), `${new Date().toISOString()} ${line}\n`);
115
88
  }
116
89
 
90
+ function sanitizeSessionKey(value: string): string {
91
+ return value.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 96) || "thread";
92
+ }
93
+
117
94
  function isAgentRelaySessionStartCommand(command: string): boolean {
118
95
  return /agent-relay.*hooks\/session-start\.ts/.test(command);
119
96
  }
@@ -424,67 +401,43 @@ async function waitForPort(url: string, child: ReturnType<typeof Bun.spawn>): Pr
424
401
  throw new Error(`timed out waiting for ${url}`);
425
402
  }
426
403
 
427
- async function waitForHookHandshake(runDir: string, timeoutMs: number): Promise<HookWaitResult> {
428
- const handshakePath = join(runDir, "session-start-handshake.json");
429
- const deadline = Date.now() + timeoutMs;
430
-
431
- while (Date.now() < deadline) {
432
- if (existsSync(handshakePath)) {
433
- try {
434
- const parsed = JSON.parse(readFileSync(handshakePath, "utf8")) as HookHandshake;
435
- const code = typeof parsed.code === "string" && parsed.code.trim() ? parsed.code.trim() : "HOOK_HANDSHAKE_INVALID";
436
- const message = typeof parsed.message === "string" ? parsed.message : undefined;
437
- const pid = typeof parsed.pid === "number" && Number.isFinite(parsed.pid) && parsed.pid > 0
438
- ? parsed.pid
439
- : undefined;
440
-
441
- if (parsed.status === "ok") return { ok: true, code, message, pid };
442
- if (parsed.status === "error") return { ok: false, code, message, pid };
443
- return { ok: false, code: "HOOK_HANDSHAKE_INVALID", message: `unexpected status ${String((parsed as any).status)}` };
444
- } catch (error) {
445
- return { ok: false, code: "HOOK_HANDSHAKE_INVALID", message: errorMessage(error) };
446
- }
447
- }
448
- await Bun.sleep(100);
449
- }
450
-
451
- return {
452
- ok: false,
453
- code: "HOOK_HANDSHAKE_TIMEOUT",
454
- message: `no hook handshake observed within ${timeoutMs}ms`,
455
- };
456
- }
404
+ type SessionPermissions = {
405
+ approvalPolicy?: string;
406
+ sandbox?: string;
407
+ };
457
408
 
458
- function spawnFallbackSidecar(runDir: string, env: Record<string, string | undefined>): number {
459
- const autoDir = join(runDir, "auto");
460
- mkdirSync(autoDir, { recursive: true });
409
+ function spawnManagedSidecar(params: {
410
+ runDir: string;
411
+ env: Record<string, string | undefined>;
412
+ sessionDir: string;
413
+ statePath: string;
414
+ threadMode: string;
415
+ threadId?: string;
416
+ }): number {
417
+ const { runDir, env, sessionDir, statePath, threadMode, threadId } = params;
418
+ mkdirSync(sessionDir, { recursive: true });
461
419
 
462
420
  const sidecarEnv: Record<string, string | undefined> = {
463
421
  ...env,
464
- CODEX_THREAD_MODE: env.AGENT_RELAY_CODEX_FALLBACK_THREAD_MODE || env.CODEX_THREAD_MODE || "start",
422
+ CODEX_THREAD_MODE: threadMode,
423
+ CODEX_THREAD_ID: threadId || undefined,
465
424
  AGENT_RELAY_CODEX_CWD: process.cwd(),
466
- AGENT_RELAY_CODEX_STATE_PATH: join(autoDir, "live-state.json"),
425
+ AGENT_RELAY_CODEX_STATE_PATH: statePath,
467
426
  };
468
- delete sidecarEnv.CODEX_THREAD_ID;
469
427
 
470
428
  const sidecar = Bun.spawn(["bun", "run", join(activePackageRoot(), "live-sidecar.ts")], {
471
429
  env: sidecarEnv,
472
- stdout: Bun.file(join(autoDir, "sidecar.log")),
473
- stderr: Bun.file(join(autoDir, "sidecar.log")),
430
+ stdout: Bun.file(join(sessionDir, "sidecar.log")),
431
+ stderr: Bun.file(join(sessionDir, "sidecar.log")),
474
432
  });
475
433
  sidecar.unref();
476
434
 
477
- writeFileSync(join(autoDir, "sidecar.pid"), String(sidecar.pid));
435
+ writeFileSync(join(sessionDir, "sidecar.pid"), String(sidecar.pid));
478
436
  appendFileSync(join(runDir, "sidecar-pids.txt"), `${sidecar.pid}\n`);
479
437
 
480
438
  return sidecar.pid;
481
439
  }
482
440
 
483
- type SessionPermissions = {
484
- approvalPolicy?: string;
485
- sandbox?: string;
486
- };
487
-
488
441
  function hasCodexPermissionMode(codexArgs: string[]): boolean {
489
442
  for (const arg of codexArgs) {
490
443
  if (
@@ -549,6 +502,77 @@ function resolveSessionPermissions(codexArgs: string[]): SessionPermissions {
549
502
  return { approvalPolicy, sandbox };
550
503
  }
551
504
 
505
+ function codexModelFromArgs(codexArgs: string[]): string | undefined {
506
+ for (let index = 0; index < codexArgs.length; index += 1) {
507
+ const arg = codexArgs[index]!;
508
+ if (arg === "--model" || arg === "-m") {
509
+ const next = codexArgs[index + 1];
510
+ if (next && !next.startsWith("-")) return next;
511
+ continue;
512
+ }
513
+ if (arg.startsWith("--model=")) return arg.slice("--model=".length);
514
+ }
515
+ return undefined;
516
+ }
517
+
518
+ async function waitForManagedAgent(statePath: string, sidecarPid: number, timeoutMs = 30000): Promise<{ threadId: string; agentId: string }> {
519
+ const deadline = Date.now() + timeoutMs;
520
+ let threadId = "";
521
+ while (Date.now() < deadline) {
522
+ if (!isAlive(sidecarPid)) {
523
+ throw new Error(`managed sidecar exited before registering with Agent Relay; inspect ${dirname(statePath)}/sidecar.log`);
524
+ }
525
+ if (existsSync(statePath)) {
526
+ try {
527
+ const parsed = JSON.parse(readFileSync(statePath, "utf8")) as { threadId?: unknown; agentId?: unknown };
528
+ if (typeof parsed.threadId === "string" && parsed.threadId.trim()) threadId = parsed.threadId.trim();
529
+ if (threadId && typeof parsed.agentId === "string" && parsed.agentId.trim()) {
530
+ return { threadId, agentId: parsed.agentId.trim() };
531
+ }
532
+ } catch {
533
+ // The sidecar may be writing the state file; retry until timeout.
534
+ }
535
+ }
536
+ await Bun.sleep(100);
537
+ }
538
+ throw new Error(`timed out waiting for managed sidecar to register with Agent Relay; inspect ${dirname(statePath)}/sidecar.log`);
539
+ }
540
+
541
+ async function loadedThreadIds(listenUrl: string): Promise<string[]> {
542
+ const client = new CodexAppClient(listenUrl);
543
+ try {
544
+ await client.connect();
545
+ await client.initialize();
546
+ const loaded = await client.threadLoadedList(50);
547
+ return loaded.data.filter((id) => typeof id === "string" && id.trim());
548
+ } finally {
549
+ client.close();
550
+ }
551
+ }
552
+
553
+ async function waitForRemoteTuiThread(
554
+ listenUrl: string,
555
+ beforeIds: Set<string>,
556
+ codex: ReturnType<typeof Bun.spawn>,
557
+ timeoutMs = 15000,
558
+ ): Promise<string | null> {
559
+ const deadline = Date.now() + timeoutMs;
560
+
561
+ while (Date.now() < deadline) {
562
+ try {
563
+ const loaded = await loadedThreadIds(listenUrl);
564
+ const candidate = loaded.find((id) => !beforeIds.has(id)) ?? loaded[0];
565
+ if (candidate) return candidate;
566
+ } catch {
567
+ // The remote TUI may still be negotiating its app-server connection.
568
+ }
569
+ if (codex.exitCode !== null) return null;
570
+ await Bun.sleep(250);
571
+ }
572
+
573
+ return null;
574
+ }
575
+
552
576
  function cleanupRun(runDir: string, appServer: ReturnType<typeof Bun.spawn> | null): void {
553
577
  if (existsSync(runDir)) {
554
578
  const pidsPath = join(runDir, "sidecar-pids.txt");
@@ -790,8 +814,10 @@ async function start(args: string[]): Promise<void> {
790
814
  installCodexSupport(true);
791
815
 
792
816
  let relayUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
793
- let listenUrl = process.env.CODEX_APP_SERVER_URL || "";
817
+ let listenUrl = process.env.AGENT_RELAY_CODEX_LISTEN || "";
794
818
  let threadMode = process.env.CODEX_THREAD_MODE || "start";
819
+ let headless = process.env.AGENT_RELAY_CODEX_HEADLESS === "1";
820
+ let threadId = process.env.CODEX_THREAD_ID || "";
795
821
  const cwd = process.cwd();
796
822
  const rig = process.env.AGENT_RELAY_CODEX_RIG || "codex-live";
797
823
  const project = cwd.split("/").filter(Boolean).at(-1) || "unknown";
@@ -814,6 +840,14 @@ async function start(args: string[]): Promise<void> {
814
840
  listenUrl = args[++index] || listenUrl;
815
841
  continue;
816
842
  }
843
+ if (arg === "--headless" || arg === "--no-tui") {
844
+ headless = true;
845
+ continue;
846
+ }
847
+ if (arg === "--thread-id") {
848
+ threadId = args[++index] || threadId;
849
+ continue;
850
+ }
817
851
  if (arg === "--thread-mode") {
818
852
  threadMode = args[++index] || threadMode;
819
853
  if (!["auto", "resume", "start"].includes(threadMode)) {
@@ -823,6 +857,7 @@ async function start(args: string[]): Promise<void> {
823
857
  }
824
858
  codexArgs.push(arg);
825
859
  }
860
+ if (!["auto", "resume", "start"].includes(threadMode)) threadMode = "start";
826
861
 
827
862
  if (!listenUrl) listenUrl = await pickLoopbackUrl();
828
863
  if (!hasCodexPermissionMode(codexArgs)) {
@@ -830,6 +865,7 @@ async function start(args: string[]): Promise<void> {
830
865
  }
831
866
  const permissions = resolveSessionPermissions(codexArgs);
832
867
  const effectiveApprovalMode = approvalModeFromPermissions(permissions);
868
+ const model = process.env.CODEX_MODEL || codexModelFromArgs(codexArgs);
833
869
  if (hasApprovalEnv && effectiveApprovalMode !== requestedApprovalMode) {
834
870
  throw new Error(
835
871
  `Codex permission flags resolve to AGENT_RELAY_APPROVAL=${effectiveApprovalMode}, but AGENT_RELAY_APPROVAL=${requestedApprovalMode} was requested.`,
@@ -849,9 +885,12 @@ async function start(args: string[]): Promise<void> {
849
885
  AGENT_RELAY_CODEX_RUNTIME_DIR: runDir,
850
886
  CODEX_APP_SERVER_URL: listenUrl,
851
887
  CODEX_THREAD_MODE: threadMode,
888
+ CODEX_THREAD_ID: threadId || undefined,
889
+ CODEX_MODEL: model || undefined,
852
890
  AGENT_RELAY_CODEX_APPROVAL_POLICY: permissions.approvalPolicy,
853
891
  AGENT_RELAY_CODEX_SANDBOX: permissions.sandbox,
854
892
  AGENT_RELAY_APPROVAL: effectiveApprovalMode,
893
+ AGENT_RELAY_CODEX_PARENT_PID: String(process.pid),
855
894
  };
856
895
 
857
896
  const appLog = Bun.file(join(runDir, "app-server.log"));
@@ -874,52 +913,76 @@ async function start(args: string[]): Promise<void> {
874
913
  process.once("exit", shutdown);
875
914
 
876
915
  await waitForPort(listenUrl, appServer);
916
+
877
917
  console.log(`Agent Relay Codex session: ${listenUrl}`);
878
918
  console.log(`Runtime: ${runDir}`);
879
919
 
880
- const codex = Bun.spawn([codexBinary, "--remote", listenUrl, ...codexArgs], {
881
- env,
882
- stdin: "inherit",
883
- stdout: "inherit",
884
- stderr: "inherit",
885
- });
920
+ if (!headless) {
921
+ const loadedBefore = new Set(await loadedThreadIds(listenUrl).catch(() => []));
922
+ const codex = Bun.spawn([codexBinary, "--remote", listenUrl, ...codexArgs], {
923
+ env,
924
+ stdin: "inherit",
925
+ stdout: "inherit",
926
+ stderr: "inherit",
927
+ });
886
928
 
887
- const hookTimeoutMs = envPositiveInt("AGENT_RELAY_CODEX_HOOK_TIMEOUT_MS", DEFAULT_HOOK_HANDSHAKE_TIMEOUT_MS);
888
- const handshake = await waitForHookHandshake(runDir, hookTimeoutMs);
889
- let fallbackReason: HookWaitResult | null = null;
890
-
891
- if (!handshake.ok) {
892
- fallbackReason = handshake;
893
- } else if (handshake.pid === undefined) {
894
- fallbackReason = {
895
- ok: false,
896
- code: "HOOK_HANDSHAKE_NO_PID",
897
- message: "hook reported success without a sidecar pid",
898
- };
899
- } else if (!isAlive(handshake.pid)) {
900
- fallbackReason = {
901
- ok: false,
902
- code: "HOOK_HANDSHAKE_PID_NOT_ALIVE",
903
- message: `hook reported pid ${handshake.pid} but it is not running`,
904
- };
905
- } else {
906
- appendLauncherLog(runDir, `HOOK_HANDSHAKE_OK code=${handshake.code} pid=${handshake.pid}`);
907
- }
929
+ const tuiThreadId = await waitForRemoteTuiThread(listenUrl, loadedBefore, codex);
930
+ if (tuiThreadId) {
931
+ const sidecarDir = join(runDir, sanitizeSessionKey(tuiThreadId));
932
+ const statePath = join(sidecarDir, "live-state.json");
933
+ const sidecarPid = spawnManagedSidecar({
934
+ runDir,
935
+ env: { ...env, AGENT_RELAY_CODEX_MANAGED: "1" },
936
+ sessionDir: sidecarDir,
937
+ statePath,
938
+ threadMode: "resume",
939
+ threadId: tuiThreadId,
940
+ });
941
+ appendLauncherLog(runDir, `REMOTE_TUI_THREAD_BOUND thread=${tuiThreadId} sidecarPid=${sidecarPid}`);
942
+ } else {
943
+ const fallbackDir = join(runDir, "auto");
944
+ const fallbackStatePath = join(fallbackDir, "live-state.json");
945
+ const fallbackPid = spawnManagedSidecar({
946
+ runDir,
947
+ env: { ...env, AGENT_RELAY_CODEX_MANAGED: "1" },
948
+ sessionDir: fallbackDir,
949
+ statePath: fallbackStatePath,
950
+ threadMode,
951
+ threadId: threadId || undefined,
952
+ });
953
+ appendLauncherLog(runDir, `REMOTE_TUI_FALLBACK_STARTED reason=THREAD_DISCOVERY_TIMEOUT sidecarPid=${fallbackPid}`);
954
+ }
908
955
 
909
- if (fallbackReason && codex.exitCode === null) {
910
- const pid = spawnFallbackSidecar(runDir, env);
911
- appendFileSync(
912
- join(runDir, "launcher.log"),
913
- `${new Date().toISOString()} HOOK_FALLBACK_STARTED reason=${fallbackReason.code} fallbackPid=${pid}${fallbackReason.message ? ` detail=${fallbackReason.message}` : ""}\n`,
914
- );
915
- } else if (fallbackReason) {
916
- appendLauncherLog(
917
- runDir,
918
- `HOOK_FALLBACK_SKIPPED reason=${fallbackReason.code} codexExit=${codex.exitCode}${fallbackReason.message ? ` detail=${fallbackReason.message}` : ""}`,
919
- );
956
+ const exitCode = await codex.exited;
957
+ shutdown();
958
+ process.exit(exitCode);
920
959
  }
921
960
 
922
- const exitCode = await codex.exited;
961
+ const managedSidecarEnv = {
962
+ ...env,
963
+ AGENT_RELAY_CODEX_MANAGED: "1",
964
+ };
965
+ const initialSessionKey = threadId ? sanitizeSessionKey(threadId) : "managed";
966
+ const sidecarDir = join(runDir, initialSessionKey);
967
+ const statePath = join(sidecarDir, "live-state.json");
968
+ const sidecarPid = spawnManagedSidecar({
969
+ runDir,
970
+ env: managedSidecarEnv,
971
+ sessionDir: sidecarDir,
972
+ statePath,
973
+ threadMode,
974
+ threadId: threadId || undefined,
975
+ });
976
+ const managedAgent = await waitForManagedAgent(statePath, sidecarPid);
977
+ const canonicalThreadId = managedAgent.threadId;
978
+
979
+ console.log(`Thread: ${canonicalThreadId}`);
980
+ console.log(`Agent: ${managedAgent.agentId}`);
981
+ appendLauncherLog(runDir, `MANAGED_THREAD thread=${canonicalThreadId} agent=${managedAgent.agentId} sidecarPid=${sidecarPid} headless=${headless}`);
982
+
983
+ console.log(`Headless relay sidecar pid: ${sidecarPid}`);
984
+ console.log(`Attach TUI with: ${codexBinary} resume --remote ${listenUrl}`);
985
+ const exitCode = await appServer.exited;
923
986
  shutdown();
924
987
  process.exit(exitCode);
925
988
  }
@@ -943,6 +1006,11 @@ async function doctor(): Promise<void> {
943
1006
  }
944
1007
  }
945
1008
 
1009
+ function upgrade(args: string[]): void {
1010
+ const hasProviderOverride = args.some((arg) => arg === "--providers" || arg === "--provider" || arg === "--codex" || arg === "--claude" || arg === "--all");
1011
+ runChecked(["agent-relay", "upgrade", ...(hasProviderOverride ? [] : ["--providers", "codex"]), ...args]);
1012
+ }
1013
+
946
1014
  async function install(args: string[]): Promise<void> {
947
1015
  const installAlias = args.includes("--alias");
948
1016
  const skipAlias = args.includes("--no-alias");
@@ -1002,6 +1070,7 @@ async function main(): Promise<void> {
1002
1070
  }
1003
1071
  if (command === "alias" && args[0] === "remove") return removeCodexAlias();
1004
1072
  if (command === "doctor") return doctor();
1073
+ if (command === "upgrade") return upgrade(args);
1005
1074
  if (command === "start") return start(args);
1006
1075
  return start(command ? [command, ...args] : []);
1007
1076
  }
@@ -74,6 +74,11 @@ const project = cwd.split("/").filter(Boolean).at(-1) || "unknown";
74
74
  const profile = loadAgentRelayProfile(process.env, { provider: "codex", rig, project });
75
75
  const approvalMode = profile.approval ?? parseApprovalMode(process.env.AGENT_RELAY_APPROVAL);
76
76
 
77
+ if (process.env.AGENT_RELAY_CODEX_MANAGED === "1") {
78
+ outputContext("Agent Relay is managed by codex-relay for this session; the launcher attached Relay and TUI to the same Codex App Server thread.");
79
+ process.exit(0);
80
+ }
81
+
77
82
  if (!appServerUrl || !runId) {
78
83
  outputContext(
79
84
  "Agent Relay for Codex is installed. For live incoming relay messages, start Codex with `agent-relay-codex start` so a managed app-server and sidecar can attach to this session.",
@@ -129,6 +134,7 @@ const spawnEnv: Record<string, string | undefined> = {
129
134
  AGENT_RELAY_CODEX_STATE_PATH: statePath,
130
135
  CODEX_MODEL: input.model || process.env.CODEX_MODEL || "",
131
136
  AGENT_RELAY_APPROVAL: process.env.AGENT_RELAY_APPROVAL || profile.approval || "",
137
+ AGENT_RELAY_CODEX_PARENT_PID: String(process.ppid),
132
138
  };
133
139
  if (threadId) {
134
140
  spawnEnv.CODEX_THREAD_ID = threadId;
package/live-sidecar.ts CHANGED
@@ -32,6 +32,7 @@ interface Config {
32
32
  approvalPolicy?: string;
33
33
  sandbox?: string;
34
34
  approvalMode: ApprovalMode;
35
+ parentPid?: number;
35
36
  }
36
37
 
37
38
  interface RuntimeState {
@@ -104,6 +105,10 @@ class CodexLiveSidecar {
104
105
  while (!this.stopping) {
105
106
  let sleepMs = this.config.pollIntervalMs;
106
107
  try {
108
+ if (this.parentExited()) {
109
+ await this.stop("parent-exit");
110
+ break;
111
+ }
107
112
  const now = Date.now();
108
113
  if (now - this.lastHeartbeatAt >= this.config.heartbeatIntervalMs) {
109
114
  await this.relay.heartbeat(this.agentId, this.agentSession());
@@ -145,7 +150,7 @@ class CodexLiveSidecar {
145
150
  this.log(`loop error: ${describeError(error)}; retrying in ${sleepMs}ms`);
146
151
  }
147
152
  }
148
- await delay(sleepMs);
153
+ await this.delayWithParentCheck(sleepMs);
149
154
  }
150
155
  }
151
156
 
@@ -258,6 +263,21 @@ class CodexLiveSidecar {
258
263
  this.app.close();
259
264
  }
260
265
 
266
+ private parentExited(): boolean {
267
+ return Boolean(this.config.parentPid && !isAlive(this.config.parentPid));
268
+ }
269
+
270
+ private async delayWithParentCheck(ms: number): Promise<void> {
271
+ const deadline = Date.now() + ms;
272
+ while (!this.stopping && Date.now() < deadline) {
273
+ if (this.parentExited()) {
274
+ await this.stop("parent-exit");
275
+ return;
276
+ }
277
+ await delay(Math.min(500, Math.max(0, deadline - Date.now())));
278
+ }
279
+ }
280
+
261
281
  private async resumeKnownThread(threadId: string): Promise<Thread> {
262
282
  try {
263
283
  const resumed = await this.app.threadResume({
@@ -708,6 +728,15 @@ function envNumber(env: NodeJS.ProcessEnv, name: string, fallback: number): numb
708
728
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
709
729
  }
710
730
 
731
+ function isAlive(pid: number): boolean {
732
+ try {
733
+ process.kill(pid, 0);
734
+ return true;
735
+ } catch {
736
+ return false;
737
+ }
738
+ }
739
+
711
740
  export function parseThreadMode(raw: string | undefined): Config["threadMode"] {
712
741
  if (raw === "auto" || raw === "resume" || raw === "start") return raw;
713
742
  return "start";
@@ -749,6 +778,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
749
778
  approvalPolicy: env.AGENT_RELAY_CODEX_APPROVAL_POLICY,
750
779
  sandbox: env.AGENT_RELAY_CODEX_SANDBOX,
751
780
  }),
781
+ parentPid: envNumber(env, "AGENT_RELAY_CODEX_PARENT_PID", 0) || undefined,
752
782
  };
753
783
  }
754
784
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-codex",
3
- "version": "0.4.24",
3
+ "version": "0.4.26",
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.24",
3
+ "version": "0.4.26",
4
4
  "description": "Agent Relay integration for Codex sessions",
5
5
  "author": {
6
6
  "name": "Edin Mujkanovic"