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 +11 -2
- package/bin/agent-relay-codex.ts +185 -116
- package/hooks/session-start.ts +6 -0
- package/live-sidecar.ts +31 -1
- package/package.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
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
|
|
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
|
package/bin/agent-relay-codex.ts
CHANGED
|
@@ -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
|
|
65
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
|
459
|
-
|
|
460
|
-
|
|
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:
|
|
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:
|
|
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(
|
|
473
|
-
stderr: Bun.file(join(
|
|
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(
|
|
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.
|
|
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
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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
|
|
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
|
}
|
package/hooks/session-start.ts
CHANGED
|
@@ -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
|
|
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