agent-relay-server 0.1.0 → 0.3.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.
@@ -0,0 +1,547 @@
1
+ #!/usr/bin/env bun
2
+ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { fileURLToPath } from "node:url";
7
+ import net from "node:net";
8
+
9
+ type HooksJson = {
10
+ hooks?: Record<string, Array<{ matcher?: string; hooks?: Array<Record<string, unknown>> }>>;
11
+ };
12
+
13
+ type RelayStats = {
14
+ version?: string;
15
+ };
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+ const packageRoot = resolve(__dirname, "..");
19
+ const home = process.env.HOME || process.env.USERPROFILE || homedir();
20
+ const installRoot = join(home, ".agent-relay", "codex");
21
+ const installedPackageRoot = join(installRoot, "package");
22
+ const aliasBinDir = join(installRoot, "bin");
23
+ const marketplaceRoot = join(installRoot, "marketplace");
24
+ const marketplacePluginRoot = join(marketplaceRoot, "plugins", "agent-relay");
25
+ const marketplaceFile = join(marketplaceRoot, ".agents", "plugins", "marketplace.json");
26
+ const runtimeRoot = join(installRoot, "runtime");
27
+ const installedHookScript = join(installedPackageRoot, "codex", "hooks", "session-start.ts");
28
+ const packageVersion = readJsonFile<{ version: string }>(join(packageRoot, "package.json"), { version: "0.0.0" }).version;
29
+
30
+ function activePackageRoot(): string {
31
+ return process.env.AGENT_RELAY_CODEX_PACKAGE_ROOT || packageRoot;
32
+ }
33
+
34
+ function usage(exitCode = 0): never {
35
+ console.log(`agent-relay-codex
36
+
37
+ Usage:
38
+ agent-relay-codex [--relay-url URL] [--listen ws://127.0.0.1:PORT] [-- <codex args...>]
39
+ agent-relay-codex install [--alias|--no-alias]
40
+ agent-relay-codex alias install
41
+ agent-relay-codex alias remove
42
+ agent-relay-codex doctor
43
+ agent-relay-codex start [--relay-url URL] [--listen ws://127.0.0.1:PORT] [-- <codex args...>]
44
+ codex-relay [--relay-url URL] [--listen ws://127.0.0.1:PORT] [-- <codex args...>]
45
+
46
+ With no subcommand, this launches Codex with live Agent Relay support.`);
47
+ process.exit(exitCode);
48
+ }
49
+
50
+ function commandExists(command: string): boolean {
51
+ return findOnPath(command) !== null;
52
+ }
53
+
54
+ function shellQuote(value: string): string {
55
+ return `'${value.replaceAll("'", "'\\''")}'`;
56
+ }
57
+
58
+ function runChecked(args: string[], options: { cwd?: string; env?: Record<string, string | undefined>; quiet?: boolean } = {}): void {
59
+ const result = Bun.spawnSync(args, {
60
+ cwd: options.cwd,
61
+ env: { ...process.env, ...options.env },
62
+ stdout: options.quiet ? "pipe" : "inherit",
63
+ stderr: options.quiet ? "pipe" : "inherit",
64
+ });
65
+ if (result.exitCode !== 0) {
66
+ if (options.quiet) {
67
+ const stderr = result.stderr?.toString().trim() || "";
68
+ if (stderr) console.error(stderr);
69
+ }
70
+ throw new Error(`${args.join(" ")} failed with exit code ${result.exitCode}`);
71
+ }
72
+ }
73
+
74
+ function readJsonFile<T>(path: string, fallback: T): T {
75
+ if (!existsSync(path)) return fallback;
76
+ return JSON.parse(readFileSync(path, "utf8")) as T;
77
+ }
78
+
79
+ function compareVersions(left: string, right: string): number {
80
+ const leftParts = left.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
81
+ const rightParts = right.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
82
+ const length = Math.max(leftParts.length, rightParts.length);
83
+ for (let index = 0; index < length; index += 1) {
84
+ const diff = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
85
+ if (diff !== 0) return diff > 0 ? 1 : -1;
86
+ }
87
+ return 0;
88
+ }
89
+
90
+ async function getRelayStats(relayUrl: string): Promise<RelayStats | null> {
91
+ const controller = new AbortController();
92
+ const timeout = setTimeout(() => controller.abort(), 1500);
93
+ try {
94
+ const response = await fetch(new URL("/api/stats", relayUrl), { signal: controller.signal });
95
+ if (!response.ok) return null;
96
+ return (await response.json()) as RelayStats;
97
+ } catch {
98
+ return null;
99
+ } finally {
100
+ clearTimeout(timeout);
101
+ }
102
+ }
103
+
104
+ async function checkRelayServer(): Promise<"missing" | "current" | "old" | "unknown"> {
105
+ const relayUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
106
+ const stats = await getRelayStats(relayUrl);
107
+ if (!stats) {
108
+ console.log(`No Agent Relay server detected at ${relayUrl}. Start it with: bunx agent-relay-server`);
109
+ return "missing";
110
+ }
111
+
112
+ const serverVersion = stats.version || "unknown";
113
+ if (serverVersion === "unknown") {
114
+ console.log(`Agent Relay server detected at ${relayUrl}, but its version is unknown. Current package: ${packageVersion}.`);
115
+ return "unknown";
116
+ }
117
+
118
+ const comparison = compareVersions(serverVersion, packageVersion);
119
+ if (comparison < 0) {
120
+ console.log(`Agent Relay server at ${relayUrl} is older (${serverVersion}); current package is ${packageVersion}.`);
121
+ console.log("Restart that server with the latest package when convenient: bunx agent-relay-server");
122
+ return "old";
123
+ }
124
+
125
+ console.log(`Agent Relay server at ${relayUrl} is current (${serverVersion}).`);
126
+ return "current";
127
+ }
128
+
129
+ function syncInstalledPackage(): void {
130
+ mkdirSync(installedPackageRoot, { recursive: true });
131
+ if (samePath(packageRoot, installedPackageRoot)) return;
132
+
133
+ rmSync(join(installedPackageRoot, "codex"), { recursive: true, force: true });
134
+ rmSync(join(installedPackageRoot, "bin"), { recursive: true, force: true });
135
+ cpSync(join(packageRoot, "codex"), join(installedPackageRoot, "codex"), { recursive: true });
136
+ cpSync(join(packageRoot, "bin"), join(installedPackageRoot, "bin"), { recursive: true });
137
+ }
138
+
139
+ function pathEntries(): string[] {
140
+ return (process.env.PATH || "")
141
+ .split(process.platform === "win32" ? ";" : ":")
142
+ .map((entry) => entry.trim())
143
+ .filter(Boolean);
144
+ }
145
+
146
+ function samePath(left: string, right: string): boolean {
147
+ const a = resolve(left);
148
+ const b = resolve(right);
149
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
150
+ }
151
+
152
+ function candidateNames(command: string): string[] {
153
+ if (process.platform !== "win32") return [command];
154
+ const extensions = (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD;.PS1").split(";").filter(Boolean);
155
+ if (extensions.some((extension) => command.toLowerCase().endsWith(extension.toLowerCase()))) return [command];
156
+ return [command, ...extensions.map((extension) => `${command}${extension.toLowerCase()}`)];
157
+ }
158
+
159
+ function findOnPath(command: string, excludeDirs: string[] = []): string | null {
160
+ for (const dir of pathEntries()) {
161
+ if (excludeDirs.some((excluded) => samePath(dir, excluded))) continue;
162
+ for (const candidate of candidateNames(command)) {
163
+ const path = join(dir, candidate);
164
+ if (existsSync(path)) return path;
165
+ }
166
+ }
167
+ return null;
168
+ }
169
+
170
+ function findCodexBinary(): string {
171
+ const codex = findOnPath("codex", [aliasBinDir]);
172
+ if (!codex) throw new Error("Codex CLI is required");
173
+ return codex;
174
+ }
175
+
176
+ function installMarketplace(quiet = false): void {
177
+ syncInstalledPackage();
178
+
179
+ mkdirSync(marketplacePluginRoot, { recursive: true });
180
+ rmSync(marketplacePluginRoot, { recursive: true, force: true });
181
+ cpSync(join(installedPackageRoot, "codex", "plugin"), marketplacePluginRoot, { recursive: true });
182
+ mkdirSync(dirname(marketplaceFile), { recursive: true });
183
+
184
+ writeFileSync(
185
+ marketplaceFile,
186
+ `${JSON.stringify(
187
+ {
188
+ name: "agent-relay",
189
+ interface: { displayName: "Agent Relay" },
190
+ plugins: [
191
+ {
192
+ name: "agent-relay",
193
+ source: { source: "local", path: "./plugins/agent-relay" },
194
+ policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" },
195
+ category: "Productivity",
196
+ },
197
+ ],
198
+ },
199
+ null,
200
+ 2,
201
+ )}\n`,
202
+ );
203
+
204
+ runChecked([findCodexBinary(), "plugin", "marketplace", "add", marketplaceRoot], { quiet });
205
+ }
206
+
207
+ function installHook(): void {
208
+ mkdirSync(join(home, ".codex"), { recursive: true });
209
+ const hooksPath = join(home, ".codex", "hooks.json");
210
+ const hooksJson = readJsonFile<HooksJson>(hooksPath, { hooks: {} });
211
+ hooksJson.hooks ??= {};
212
+ hooksJson.hooks.SessionStart ??= [];
213
+
214
+ const command = `bun ${shellQuote(installedHookScript)}`;
215
+ hooksJson.hooks.SessionStart = hooksJson.hooks.SessionStart
216
+ .map((group) => ({
217
+ ...group,
218
+ hooks: (group.hooks ?? []).filter((hook) => {
219
+ if (hook.type !== "command" || typeof hook.command !== "string") return true;
220
+ return !/agent-relay.*codex\/hooks\/session-start\.ts/.test(hook.command);
221
+ }),
222
+ }))
223
+ .filter((group) => (group.hooks ?? []).length > 0);
224
+
225
+ hooksJson.hooks.SessionStart.push({
226
+ matcher: "startup|resume",
227
+ hooks: [
228
+ {
229
+ type: "command",
230
+ command,
231
+ statusMessage: "Starting Agent Relay",
232
+ timeout: 10,
233
+ },
234
+ ],
235
+ });
236
+
237
+ writeFileSync(hooksPath, `${JSON.stringify(hooksJson, null, 2)}\n`);
238
+ }
239
+
240
+ async function pickLoopbackUrl(): Promise<string> {
241
+ const port = await new Promise<number>((resolvePort, reject) => {
242
+ const server = net.createServer();
243
+ server.on("error", reject);
244
+ server.listen(0, "127.0.0.1", () => {
245
+ const address = server.address();
246
+ server.close(() => {
247
+ if (!address || typeof address === "string") reject(new Error("failed to allocate local port"));
248
+ else resolvePort(address.port);
249
+ });
250
+ });
251
+ });
252
+ return `ws://127.0.0.1:${port}`;
253
+ }
254
+
255
+ async function waitForPort(url: string, child: ReturnType<typeof Bun.spawn>): Promise<void> {
256
+ const parsed = new URL(url);
257
+ const port = Number(parsed.port);
258
+ const host = parsed.hostname;
259
+
260
+ for (let attempt = 0; attempt < 100; attempt += 1) {
261
+ if (child.exitCode !== null) throw new Error("codex app-server exited before accepting connections");
262
+ const ok = await new Promise<boolean>((resolveAttempt) => {
263
+ const socket = net.connect({ host, port });
264
+ socket.once("connect", () => {
265
+ socket.destroy();
266
+ resolveAttempt(true);
267
+ });
268
+ socket.once("error", () => resolveAttempt(false));
269
+ socket.setTimeout(200, () => {
270
+ socket.destroy();
271
+ resolveAttempt(false);
272
+ });
273
+ });
274
+ if (ok) return;
275
+ await Bun.sleep(100);
276
+ }
277
+
278
+ throw new Error(`timed out waiting for ${url}`);
279
+ }
280
+
281
+ function cleanupRun(runDir: string, appServer: ReturnType<typeof Bun.spawn> | null): void {
282
+ if (existsSync(runDir)) {
283
+ const pidsPath = join(runDir, "sidecar-pids.txt");
284
+ if (existsSync(pidsPath)) {
285
+ for (const line of readFileSync(pidsPath, "utf8").split("\n")) {
286
+ const pid = Number(line.trim());
287
+ if (!Number.isFinite(pid) || pid <= 0) continue;
288
+ try {
289
+ process.kill(pid, "SIGTERM");
290
+ } catch {
291
+ // Sidecar already exited.
292
+ }
293
+ }
294
+ }
295
+ }
296
+
297
+ if (appServer && appServer.exitCode === null) {
298
+ try {
299
+ appServer.kill("SIGTERM");
300
+ } catch {
301
+ // App server already exited.
302
+ }
303
+ }
304
+ }
305
+
306
+ function installCodexSupport(quiet = false): void {
307
+ if (!commandExists("bun")) throw new Error("Bun is required: https://bun.sh");
308
+ findCodexBinary();
309
+ installMarketplace(quiet);
310
+ installHook();
311
+ }
312
+
313
+ function writeLauncherShim(name: string): void {
314
+ const cliPath = join(installedPackageRoot, "bin", "agent-relay-codex.ts");
315
+
316
+ if (process.platform === "win32") {
317
+ writeFileSync(join(aliasBinDir, `${name}.cmd`), `@echo off\r\nbun "${cliPath}" %*\r\n`);
318
+ writeFileSync(join(aliasBinDir, `${name}.ps1`), `& bun "${cliPath}" @args\r\nexit $LASTEXITCODE\r\n`);
319
+ return;
320
+ }
321
+
322
+ const shimPath = join(aliasBinDir, name);
323
+ writeFileSync(shimPath, `#!/usr/bin/env sh\nexec bun ${shellQuote(cliPath)} "$@"\n`);
324
+ chmodSync(shimPath, 0o755);
325
+ }
326
+
327
+ function removeLauncherShim(name: string): void {
328
+ if (process.platform === "win32") {
329
+ rmSync(join(aliasBinDir, `${name}.cmd`), { force: true });
330
+ rmSync(join(aliasBinDir, `${name}.ps1`), { force: true });
331
+ return;
332
+ }
333
+ rmSync(join(aliasBinDir, name), { force: true });
334
+ }
335
+
336
+ function installLauncherShims(includeCodexAlias: boolean): void {
337
+ mkdirSync(aliasBinDir, { recursive: true });
338
+ writeLauncherShim("codex-relay");
339
+ if (includeCodexAlias) writeLauncherShim("codex");
340
+ else removeLauncherShim("codex");
341
+ }
342
+
343
+ function isAliasBinOnPath(): boolean {
344
+ return pathEntries().some((entry) => samePath(entry, aliasBinDir));
345
+ }
346
+
347
+ function installPathEntry(): boolean {
348
+ if (isAliasBinOnPath()) return true;
349
+
350
+ if (process.platform === "win32") {
351
+ const script = [
352
+ "$dir = [Environment]::GetEnvironmentVariable('AGENT_RELAY_CODEX_BIN', 'User')",
353
+ `$new = ${JSON.stringify(aliasBinDir)}`,
354
+ "$path = [Environment]::GetEnvironmentVariable('Path', 'User')",
355
+ "if (-not $path) { $path = '' }",
356
+ "$parts = $path -split ';' | Where-Object { $_ }",
357
+ "if ($parts -notcontains $new) {",
358
+ " [Environment]::SetEnvironmentVariable('Path', ($new + ';' + $path).TrimEnd(';'), 'User')",
359
+ "}",
360
+ "[Environment]::SetEnvironmentVariable('AGENT_RELAY_CODEX_BIN', $new, 'User')",
361
+ ].join("; ");
362
+ const result = Bun.spawnSync(["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], {
363
+ stdout: "pipe",
364
+ stderr: "pipe",
365
+ });
366
+ return result.exitCode === 0;
367
+ }
368
+
369
+ const shell = process.env.SHELL || "";
370
+ const marker = "# Agent Relay Codex alias";
371
+ const exportLine = `export PATH=${shellQuote(aliasBinDir)}:$PATH`;
372
+ let profilePath = join(home, ".profile");
373
+ let snippet = `\n${marker}\n${exportLine}\n`;
374
+
375
+ if (shell.includes("zsh")) profilePath = join(home, ".zshrc");
376
+ if (shell.includes("bash")) profilePath = join(home, ".bashrc");
377
+ if (shell.includes("fish")) {
378
+ profilePath = join(home, ".config", "fish", "config.fish");
379
+ snippet = `\n${marker}\nfish_add_path ${shellQuote(aliasBinDir)}\n`;
380
+ }
381
+
382
+ mkdirSync(dirname(profilePath), { recursive: true });
383
+ const current = existsSync(profilePath) ? readFileSync(profilePath, "utf8") : "";
384
+ if (!current.includes(marker) && !current.includes(aliasBinDir)) {
385
+ writeFileSync(profilePath, `${current.replace(/\s*$/, "")}${snippet}`);
386
+ }
387
+ return true;
388
+ }
389
+
390
+ function installCodexAlias(): void {
391
+ installLauncherShims(true);
392
+ const updated = installPathEntry();
393
+ console.log("Installed codex alias shim.");
394
+ if (!updated || !isAliasBinOnPath()) {
395
+ console.log(`Restart your shell, or add this directory to PATH: ${aliasBinDir}`);
396
+ }
397
+ }
398
+
399
+ function removeCodexAlias(): void {
400
+ removeLauncherShim("codex");
401
+ console.log("Removed Agent Relay codex alias shims.");
402
+ console.log("The `codex-relay` launcher remains installed.");
403
+ }
404
+
405
+ async function askYesNo(question: string): Promise<boolean> {
406
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
407
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
408
+ try {
409
+ const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
410
+ return answer === "y" || answer === "yes";
411
+ } finally {
412
+ rl.close();
413
+ }
414
+ }
415
+
416
+ async function start(args: string[]): Promise<void> {
417
+ installCodexSupport(true);
418
+
419
+ let relayUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
420
+ let listenUrl = process.env.CODEX_APP_SERVER_URL || "";
421
+ const codexArgs: string[] = [];
422
+
423
+ for (let index = 0; index < args.length; index += 1) {
424
+ const arg = args[index]!;
425
+ if (arg === "--") {
426
+ codexArgs.push(...args.slice(index + 1));
427
+ break;
428
+ }
429
+ if (arg === "--relay-url") {
430
+ relayUrl = args[++index] || relayUrl;
431
+ continue;
432
+ }
433
+ if (arg === "--listen") {
434
+ listenUrl = args[++index] || listenUrl;
435
+ continue;
436
+ }
437
+ codexArgs.push(arg);
438
+ }
439
+
440
+ if (!listenUrl) listenUrl = await pickLoopbackUrl();
441
+
442
+ mkdirSync(runtimeRoot, { recursive: true });
443
+ const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
444
+ const runDir = join(runtimeRoot, runId);
445
+ mkdirSync(runDir, { recursive: true });
446
+
447
+ const env = {
448
+ ...process.env,
449
+ AGENT_RELAY_URL: relayUrl,
450
+ AGENT_RELAY_CODEX_PACKAGE_ROOT: activePackageRoot(),
451
+ AGENT_RELAY_CODEX_RUN_ID: runId,
452
+ AGENT_RELAY_CODEX_RUNTIME_DIR: runDir,
453
+ CODEX_APP_SERVER_URL: listenUrl,
454
+ };
455
+
456
+ const appLog = Bun.file(join(runDir, "app-server.log"));
457
+ const codexBinary = findCodexBinary();
458
+ const appServer = Bun.spawn([codexBinary, "app-server", "--listen", listenUrl], {
459
+ env,
460
+ stdout: appLog,
461
+ stderr: appLog,
462
+ });
463
+
464
+ const shutdown = () => cleanupRun(runDir, appServer);
465
+ process.once("SIGINT", () => {
466
+ shutdown();
467
+ process.exit(130);
468
+ });
469
+ process.once("SIGTERM", () => {
470
+ shutdown();
471
+ process.exit(143);
472
+ });
473
+ process.once("exit", shutdown);
474
+
475
+ await waitForPort(listenUrl, appServer);
476
+ console.error(`Agent Relay Codex session: ${listenUrl}`);
477
+ console.error(`Runtime: ${runDir}`);
478
+
479
+ const codex = Bun.spawn([codexBinary, "--remote", listenUrl, ...codexArgs], {
480
+ env,
481
+ stdin: "inherit",
482
+ stdout: "inherit",
483
+ stderr: "inherit",
484
+ });
485
+ const exitCode = await codex.exited;
486
+ shutdown();
487
+ process.exit(exitCode);
488
+ }
489
+
490
+ async function doctor(): Promise<void> {
491
+ const checks: Array<[string, boolean, string]> = [];
492
+ checks.push(["bun", commandExists("bun"), "Bun is required to run the sidecar"]);
493
+ checks.push(["codex", findOnPath("codex", [aliasBinDir]) !== null, "Codex CLI is required"]);
494
+ checks.push(["hook", existsSync(join(home, ".codex", "hooks.json")), "~/.codex/hooks.json exists"]);
495
+ checks.push(["marketplace", existsSync(marketplaceFile), "Agent Relay marketplace is installed"]);
496
+ checks.push(["launcher", existsSync(join(aliasBinDir, process.platform === "win32" ? "codex-relay.cmd" : "codex-relay")), "codex-relay launcher shim"]);
497
+
498
+ const relayUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
499
+ const stats = await getRelayStats(relayUrl);
500
+ checks.push(["relay", stats !== null, stats?.version ? `${relayUrl}/api/stats responds; version ${stats.version}` : `${relayUrl}/api/stats responds`]);
501
+
502
+ for (const [name, ok, detail] of checks) {
503
+ console.log(`${ok ? "ok " : "err"} ${name}: ${detail}`);
504
+ }
505
+ }
506
+
507
+ async function install(args: string[]): Promise<void> {
508
+ const installAlias = args.includes("--alias");
509
+ const skipAlias = args.includes("--no-alias");
510
+ installCodexSupport(false);
511
+ installLauncherShims(false);
512
+ installPathEntry();
513
+ console.log("Installed Agent Relay for Codex.");
514
+ const relayStatus = await checkRelayServer();
515
+ if (relayStatus === "unknown") console.log("If this server is old, restart it with: bunx agent-relay-server");
516
+ if (isAliasBinOnPath()) {
517
+ console.log("Start Codex sessions with: codex-relay");
518
+ } else {
519
+ console.log("Restart your shell, then start Codex sessions with: codex-relay");
520
+ console.log("Without restarting your shell, use: bunx -p agent-relay-server codex-relay");
521
+ }
522
+
523
+ if (installAlias || (!skipAlias && await askYesNo("Make plain `codex` start with Agent Relay by installing a PATH shim?"))) {
524
+ installCodexAlias();
525
+ } else {
526
+ console.log("Skipped plain `codex` alias. You can always use `codex-relay`.");
527
+ }
528
+ }
529
+
530
+ async function main(): Promise<void> {
531
+ const [command, ...args] = process.argv.slice(2);
532
+ if (command === "help" || command === "--help" || command === "-h") usage(0);
533
+ if (command === "install") return install(args);
534
+ if (command === "alias" && args[0] === "install") {
535
+ installCodexSupport(false);
536
+ return installCodexAlias();
537
+ }
538
+ if (command === "alias" && args[0] === "remove") return removeCodexAlias();
539
+ if (command === "doctor") return doctor();
540
+ if (command === "start") return start(args);
541
+ return start(command ? [command, ...args] : []);
542
+ }
543
+
544
+ main().catch((error) => {
545
+ console.error(error instanceof Error ? error.message : String(error));
546
+ process.exit(1);
547
+ });
@@ -0,0 +1,80 @@
1
+ # Codex Live Sidecar
2
+
3
+ First real Codex integration for Agent Relay.
4
+
5
+ ## Purpose
6
+
7
+ This sidecar connects to a Codex app-server session and to Agent Relay, then delivers incoming relay messages into the active Codex thread using:
8
+
9
+ - `turn/start`
10
+ - `turn/steer`
11
+ - `turn/interrupt`
12
+
13
+ ## Current MVP behavior
14
+
15
+ - attaches to a loaded thread for the current `cwd` when one exists
16
+ - otherwise resumes the newest thread for the current `cwd`
17
+ - otherwise creates a new thread
18
+ - registers a relay agent with `client: codex-live`
19
+ - polls relay inbox and delivers messages into the live thread
20
+ - coalesces ordinary relay bursts into one delivery turn
21
+ - reconnects to the app-server with exponential backoff after disconnects
22
+ - writes runtime state to `codex/runtime/live-state.json`
23
+
24
+ ## Delivery behavior
25
+
26
+ - idle thread: `turn/start`
27
+ - active thread: `turn/steer`
28
+ - urgent or `meta.delivery = "interrupt"`: `turn/interrupt` then `turn/start`
29
+
30
+ ## Run
31
+
32
+ ```bash
33
+ codex/start-live.sh
34
+ ```
35
+
36
+ ## Installable workflow
37
+
38
+ The packaged Codex path is:
39
+
40
+ ```bash
41
+ bunx agent-relay-server
42
+ curl -fsSL https://raw.githubusercontent.com/edimuj/agent-relay/main/codex/install-codex.sh | bash
43
+ # after restarting your shell
44
+ codex-relay
45
+ ```
46
+
47
+ The installer always adds a `codex-relay` launcher and asks whether plain
48
+ `codex` should also route through Agent Relay. `codex-relay` idempotently
49
+ installs or refreshes the Codex hook/plugin, then launches `codex app-server`,
50
+ starts Codex with
51
+ `--remote`, lets the SessionStart hook attach a sidecar to the actual thread,
52
+ and kills sidecars plus the app-server when Codex exits.
53
+
54
+ For local development from this repo:
55
+
56
+ ```bash
57
+ bun run bin/agent-relay-codex.ts
58
+ ```
59
+
60
+ Useful environment variables:
61
+
62
+ - `AGENT_RELAY_URL`
63
+ - `AGENT_RELAY_CAPS`
64
+ - `CODEX_APP_SERVER_URL`
65
+ - `CODEX_THREAD_ID`
66
+ - `CODEX_THREAD_MODE=auto|resume|start`
67
+ - `CODEX_LIVE_STATE_PATH`
68
+ - `CODEX_LIVE_COALESCE_WINDOW_MS`
69
+ - `CODEX_LIVE_RECONNECT_INITIAL_MS`
70
+ - `CODEX_LIVE_RECONNECT_MAX_MS`
71
+ - `CODEX_LIVE_RIG`
72
+ - `CODEX_MODEL`
73
+
74
+ ## Notes
75
+
76
+ This is still an early sidecar cut. It now handles reconnects and basic coalescing, but it still lacks richer policies such as batching by sender, message prioritization queues, and more nuanced retry/backoff behavior.
77
+
78
+ - `CODEX_THREAD_MODE=auto` will attach to an already loaded thread for the same `cwd`. That is what you want for real live control, but it also means the sidecar can attach to your current interactive Codex session if one is already open.
79
+ - For isolated testing, set `CODEX_THREAD_MODE=start` so the sidecar always creates its own thread.
80
+ - A brand-new thread is not materialized for `includeTurns` reads until the first turn starts. That is an app-server behavior, not a relay bug.