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.
- package/README.md +177 -91
- package/bin/agent-relay-codex.ts +547 -0
- package/codex/README.md +80 -0
- package/codex/app-client.ts +239 -0
- package/codex/hooks/session-start.ts +114 -0
- package/codex/install-codex.ps1 +47 -0
- package/codex/install-codex.sh +75 -0
- package/codex/live-sidecar.ts +606 -0
- package/codex/plugin/.codex-plugin/plugin.json +25 -0
- package/codex/plugin/skills/agent-relay/SKILL.md +28 -0
- package/codex/relay.ts +116 -0
- package/codex/start-live.sh +64 -0
- package/package.json +14 -3
- package/public/index.html +1078 -446
- package/src/config.ts +8 -0
- package/src/db.ts +49 -20
- package/src/index.ts +5 -1
- package/src/routes.ts +83 -15
- package/src/sse.ts +115 -0
- package/src/types.ts +6 -0
|
@@ -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
|
+
});
|
package/codex/README.md
ADDED
|
@@ -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.
|