agent-relay-codex 0.4.14
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 +124 -0
- package/app-client.ts +239 -0
- package/approval.ts +29 -0
- package/bin/agent-relay-codex.ts +988 -0
- package/hooks/session-start-lib.ts +25 -0
- package/hooks/session-start.ts +194 -0
- package/install-codex.ps1 +47 -0
- package/install-codex.sh +75 -0
- package/live-sidecar.ts +685 -0
- package/package.json +48 -0
- package/plugin/.codex-plugin/plugin.json +40 -0
- package/plugin/skills/agent-relay/SKILL.md +29 -0
- package/relay.ts +145 -0
- package/start-live.sh +64 -0
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { appendFileSync, chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, 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
|
+
import { approvalModeFromPermissions, codexArgsForApprovalMode, parseApprovalMode } from "../approval";
|
|
9
|
+
|
|
10
|
+
type HooksJson = {
|
|
11
|
+
hooks?: Record<string, Array<{ matcher?: string; hooks?: Array<Record<string, unknown>> }>>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type RelayStats = {
|
|
15
|
+
version?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type HookHandshake = {
|
|
19
|
+
status: "ok" | "error";
|
|
20
|
+
code: string;
|
|
21
|
+
message?: string;
|
|
22
|
+
pid?: number;
|
|
23
|
+
threadId?: string;
|
|
24
|
+
timestamp?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type HookWaitResult = {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
code: string;
|
|
30
|
+
message?: string;
|
|
31
|
+
pid?: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const DEFAULT_HOOK_HANDSHAKE_TIMEOUT_MS = 5000;
|
|
35
|
+
|
|
36
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const packageRoot = resolve(__dirname, "..");
|
|
38
|
+
const home = process.env.HOME || process.env.USERPROFILE || homedir();
|
|
39
|
+
const installRoot = join(home, ".agent-relay", "codex");
|
|
40
|
+
const installedPackageRoot = join(installRoot, "package");
|
|
41
|
+
const aliasBinDir = join(installRoot, "bin");
|
|
42
|
+
const marketplaceRoot = join(installRoot, "marketplace");
|
|
43
|
+
const marketplacePluginRoot = join(marketplaceRoot, "plugins", "agent-relay");
|
|
44
|
+
const marketplaceFile = join(marketplaceRoot, ".agents", "plugins", "marketplace.json");
|
|
45
|
+
const runtimeRoot = join(installRoot, "runtime");
|
|
46
|
+
const installedHookScript = join(installedPackageRoot, "hooks", "session-start.ts");
|
|
47
|
+
const packageVersion = readJsonFile<{ version: string }>(join(packageRoot, "package.json"), { version: "0.0.0" }).version;
|
|
48
|
+
|
|
49
|
+
function activePackageRoot(): string {
|
|
50
|
+
return process.env.AGENT_RELAY_CODEX_PACKAGE_ROOT || packageRoot;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function usage(exitCode = 0): never {
|
|
54
|
+
console.log(`agent-relay-codex
|
|
55
|
+
|
|
56
|
+
Usage:
|
|
57
|
+
agent-relay-codex [--relay-url URL] [--listen ws://127.0.0.1:PORT] [-- <codex args...>]
|
|
58
|
+
agent-relay-codex install [--alias|--no-alias]
|
|
59
|
+
agent-relay-codex uninstall [--purge]
|
|
60
|
+
agent-relay-codex alias install
|
|
61
|
+
agent-relay-codex alias remove
|
|
62
|
+
agent-relay-codex doctor
|
|
63
|
+
agent-relay-codex start [--relay-url URL] [--listen ws://127.0.0.1:PORT] [--thread-mode auto|resume|start] [-- <codex args...>]
|
|
64
|
+
codex-relay [--relay-url URL] [--listen ws://127.0.0.1:PORT] [--thread-mode auto|resume|start] [-- <codex args...>]
|
|
65
|
+
|
|
66
|
+
With no subcommand, this launches Codex with live Agent Relay support.`);
|
|
67
|
+
process.exit(exitCode);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const pathMarker = "# Agent Relay Codex alias";
|
|
71
|
+
|
|
72
|
+
function commandExists(command: string): boolean {
|
|
73
|
+
return findOnPath(command) !== null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function shellQuote(value: string): string {
|
|
77
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function runChecked(args: string[], options: { cwd?: string; env?: Record<string, string | undefined>; quiet?: boolean } = {}): void {
|
|
81
|
+
const result = Bun.spawnSync(args, {
|
|
82
|
+
cwd: options.cwd,
|
|
83
|
+
env: { ...process.env, ...options.env },
|
|
84
|
+
stdout: options.quiet ? "pipe" : "inherit",
|
|
85
|
+
stderr: options.quiet ? "pipe" : "inherit",
|
|
86
|
+
});
|
|
87
|
+
if (result.exitCode !== 0) {
|
|
88
|
+
if (options.quiet) {
|
|
89
|
+
const stderr = result.stderr?.toString().trim() || "";
|
|
90
|
+
if (stderr) console.error(stderr);
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`${args.join(" ")} failed with exit code ${result.exitCode}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readJsonFile<T>(path: string, fallback: T): T {
|
|
97
|
+
if (!existsSync(path)) return fallback;
|
|
98
|
+
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function errorMessage(error: unknown): string {
|
|
102
|
+
return error instanceof Error ? error.message : String(error);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function envPositiveInt(name: string, fallback: number): number {
|
|
106
|
+
const raw = process.env[name];
|
|
107
|
+
if (!raw) return fallback;
|
|
108
|
+
const parsed = Number.parseInt(raw, 10);
|
|
109
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function appendLauncherLog(runDir: string, line: string): void {
|
|
113
|
+
appendFileSync(join(runDir, "launcher.log"), `${new Date().toISOString()} ${line}\n`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isAgentRelaySessionStartCommand(command: string): boolean {
|
|
117
|
+
return /agent-relay.*hooks\/session-start\.ts/.test(command);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function removeAgentRelaySessionStartToml(input: string): string {
|
|
121
|
+
const lines = input.split(/\r?\n/);
|
|
122
|
+
const blocks: Array<{ header: string | null; text: string }> = [];
|
|
123
|
+
|
|
124
|
+
for (let index = 0; index < lines.length; ) {
|
|
125
|
+
const line = lines[index] ?? "";
|
|
126
|
+
const header = /^\[\[?/.test(line) ? line : null;
|
|
127
|
+
const block: string[] = [];
|
|
128
|
+
do {
|
|
129
|
+
block.push(lines[index] ?? "");
|
|
130
|
+
index += 1;
|
|
131
|
+
} while (index < lines.length && !/^\[\[?/.test(lines[index] ?? ""));
|
|
132
|
+
blocks.push({ header, text: block.join("\n") });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let output = "";
|
|
136
|
+
for (let index = 0; index < blocks.length; ) {
|
|
137
|
+
const block = blocks[index];
|
|
138
|
+
if (block?.header?.startsWith("[[hooks.SessionStart")) {
|
|
139
|
+
const group: typeof blocks = [];
|
|
140
|
+
while (index < blocks.length && blocks[index]?.header?.startsWith("[[hooks.SessionStart")) {
|
|
141
|
+
group.push(blocks[index]!);
|
|
142
|
+
index += 1;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const groupText = group.map((block) => block.text).join("\n");
|
|
146
|
+
if (!isAgentRelaySessionStartCommand(groupText)) output += `${groupText}\n`;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
output += `${block?.text ?? ""}\n`;
|
|
151
|
+
index += 1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return output.replace(/\s+$/, "\n");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function compareVersions(left: string, right: string): number {
|
|
158
|
+
const leftParts = left.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
|
|
159
|
+
const rightParts = right.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
|
|
160
|
+
const length = Math.max(leftParts.length, rightParts.length);
|
|
161
|
+
for (let index = 0; index < length; index += 1) {
|
|
162
|
+
const diff = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
|
|
163
|
+
if (diff !== 0) return diff > 0 ? 1 : -1;
|
|
164
|
+
}
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function getRelayStats(relayUrl: string): Promise<RelayStats | null> {
|
|
169
|
+
const controller = new AbortController();
|
|
170
|
+
const timeout = setTimeout(() => controller.abort(), 1500);
|
|
171
|
+
try {
|
|
172
|
+
const headers: Record<string, string> = {};
|
|
173
|
+
if (process.env.AGENT_RELAY_TOKEN) headers["X-Agent-Relay-Token"] = process.env.AGENT_RELAY_TOKEN;
|
|
174
|
+
const response = await fetch(new URL("/api/stats", relayUrl), { signal: controller.signal, headers });
|
|
175
|
+
if (!response.ok) return null;
|
|
176
|
+
return (await response.json()) as RelayStats;
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
} finally {
|
|
180
|
+
clearTimeout(timeout);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function checkRelayServer(): Promise<"missing" | "current" | "old" | "unknown"> {
|
|
185
|
+
const relayUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
|
|
186
|
+
const stats = await getRelayStats(relayUrl);
|
|
187
|
+
if (!stats) {
|
|
188
|
+
console.log(`No Agent Relay server detected at ${relayUrl}. Start it with: bunx agent-relay-server@latest`);
|
|
189
|
+
return "missing";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const serverVersion = stats.version || "unknown";
|
|
193
|
+
if (serverVersion === "unknown") {
|
|
194
|
+
console.log(`Agent Relay server detected at ${relayUrl}, but its version is unknown. Current package: ${packageVersion}.`);
|
|
195
|
+
return "unknown";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const comparison = compareVersions(serverVersion, packageVersion);
|
|
199
|
+
if (comparison < 0) {
|
|
200
|
+
console.log(`Agent Relay server at ${relayUrl} is older (${serverVersion}); current package is ${packageVersion}.`);
|
|
201
|
+
console.log("Restart that server with the latest package when convenient: bunx agent-relay-server@latest");
|
|
202
|
+
return "old";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
console.log(`Agent Relay server at ${relayUrl} is current (${serverVersion}).`);
|
|
206
|
+
return "current";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function syncInstalledPackage(): void {
|
|
210
|
+
mkdirSync(installedPackageRoot, { recursive: true });
|
|
211
|
+
if (samePath(packageRoot, installedPackageRoot)) return;
|
|
212
|
+
|
|
213
|
+
for (const entry of readdirSync(installedPackageRoot)) {
|
|
214
|
+
rmSync(join(installedPackageRoot, entry), { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
cpSync(packageRoot, installedPackageRoot, { recursive: true });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function pathEntries(): string[] {
|
|
220
|
+
return (process.env.PATH || "")
|
|
221
|
+
.split(process.platform === "win32" ? ";" : ":")
|
|
222
|
+
.map((entry) => entry.trim())
|
|
223
|
+
.filter(Boolean);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function samePath(left: string, right: string): boolean {
|
|
227
|
+
const a = resolve(left);
|
|
228
|
+
const b = resolve(right);
|
|
229
|
+
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isAlive(pid: number): boolean {
|
|
233
|
+
try {
|
|
234
|
+
process.kill(pid, 0);
|
|
235
|
+
return true;
|
|
236
|
+
} catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function candidateNames(command: string): string[] {
|
|
242
|
+
if (process.platform !== "win32") return [command];
|
|
243
|
+
const extensions = (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD;.PS1").split(";").filter(Boolean);
|
|
244
|
+
if (extensions.some((extension) => command.toLowerCase().endsWith(extension.toLowerCase()))) return [command];
|
|
245
|
+
return [command, ...extensions.map((extension) => `${command}${extension.toLowerCase()}`)];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function findOnPath(command: string, excludeDirs: string[] = []): string | null {
|
|
249
|
+
for (const dir of pathEntries()) {
|
|
250
|
+
if (excludeDirs.some((excluded) => samePath(dir, excluded))) continue;
|
|
251
|
+
for (const candidate of candidateNames(command)) {
|
|
252
|
+
const path = join(dir, candidate);
|
|
253
|
+
if (existsSync(path)) return path;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function findCodexBinary(): string {
|
|
260
|
+
const codex = findOnPath("codex", [aliasBinDir]);
|
|
261
|
+
if (!codex) throw new Error("Codex CLI is required");
|
|
262
|
+
return codex;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function installMarketplace(quiet = false): void {
|
|
266
|
+
syncInstalledPackage();
|
|
267
|
+
|
|
268
|
+
mkdirSync(marketplacePluginRoot, { recursive: true });
|
|
269
|
+
rmSync(marketplacePluginRoot, { recursive: true, force: true });
|
|
270
|
+
cpSync(join(installedPackageRoot, "plugin"), marketplacePluginRoot, { recursive: true });
|
|
271
|
+
mkdirSync(dirname(marketplaceFile), { recursive: true });
|
|
272
|
+
|
|
273
|
+
writeFileSync(
|
|
274
|
+
marketplaceFile,
|
|
275
|
+
`${JSON.stringify(
|
|
276
|
+
{
|
|
277
|
+
name: "agent-relay",
|
|
278
|
+
interface: { displayName: "Agent Relay" },
|
|
279
|
+
plugins: [
|
|
280
|
+
{
|
|
281
|
+
name: "agent-relay",
|
|
282
|
+
source: { source: "local", path: "./plugins/agent-relay" },
|
|
283
|
+
policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" },
|
|
284
|
+
category: "Productivity",
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
},
|
|
288
|
+
null,
|
|
289
|
+
2,
|
|
290
|
+
)}\n`,
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
runChecked([findCodexBinary(), "plugin", "marketplace", "add", marketplaceRoot], { quiet });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function installHook(): void {
|
|
297
|
+
mkdirSync(join(home, ".codex"), { recursive: true });
|
|
298
|
+
const command = `bun ${shellQuote(installedHookScript)}`;
|
|
299
|
+
|
|
300
|
+
const configPath = join(home, ".codex", "config.toml");
|
|
301
|
+
const existingConfig = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
|
|
302
|
+
let output = removeAgentRelaySessionStartToml(existingConfig);
|
|
303
|
+
if (!output.endsWith("\n\n")) output += output.endsWith("\n") ? "\n" : "\n\n";
|
|
304
|
+
output += `[[hooks.SessionStart]]
|
|
305
|
+
matcher = "startup|resume"
|
|
306
|
+
|
|
307
|
+
[[hooks.SessionStart.hooks]]
|
|
308
|
+
type = "command"
|
|
309
|
+
command = "${command.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"
|
|
310
|
+
statusMessage = "Starting Agent Relay"
|
|
311
|
+
timeout = 10
|
|
312
|
+
`;
|
|
313
|
+
|
|
314
|
+
writeFileSync(configPath, output);
|
|
315
|
+
|
|
316
|
+
const hooksPath = join(home, ".codex", "hooks.json");
|
|
317
|
+
if (!existsSync(hooksPath)) return;
|
|
318
|
+
|
|
319
|
+
const hooksJson = readJsonFile<HooksJson>(hooksPath, { hooks: {} });
|
|
320
|
+
hooksJson.hooks ??= {};
|
|
321
|
+
hooksJson.hooks.SessionStart = (hooksJson.hooks.SessionStart ?? [])
|
|
322
|
+
.map((group) => ({
|
|
323
|
+
...group,
|
|
324
|
+
hooks: (group.hooks ?? []).filter((hook) => {
|
|
325
|
+
if (hook.type !== "command" || typeof hook.command !== "string") return true;
|
|
326
|
+
return !isAgentRelaySessionStartCommand(hook.command);
|
|
327
|
+
}),
|
|
328
|
+
}))
|
|
329
|
+
.filter((group) => (group.hooks ?? []).length > 0);
|
|
330
|
+
|
|
331
|
+
if (hooksJson.hooks.SessionStart.length === 0) delete hooksJson.hooks.SessionStart;
|
|
332
|
+
|
|
333
|
+
if (Object.keys(hooksJson.hooks).length === 0) rmSync(hooksPath, { force: true });
|
|
334
|
+
else writeFileSync(hooksPath, `${JSON.stringify(hooksJson, null, 2)}\n`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function removeHook(): void {
|
|
338
|
+
const configPath = join(home, ".codex", "config.toml");
|
|
339
|
+
if (existsSync(configPath)) {
|
|
340
|
+
const cleaned = removeAgentRelaySessionStartToml(readFileSync(configPath, "utf8"));
|
|
341
|
+
if (cleaned.trim()) writeFileSync(configPath, cleaned);
|
|
342
|
+
else rmSync(configPath, { force: true });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const hooksPath = join(home, ".codex", "hooks.json");
|
|
346
|
+
if (!existsSync(hooksPath)) return;
|
|
347
|
+
|
|
348
|
+
const hooksJson = readJsonFile<HooksJson>(hooksPath, { hooks: {} });
|
|
349
|
+
hooksJson.hooks ??= {};
|
|
350
|
+
hooksJson.hooks.SessionStart = (hooksJson.hooks.SessionStart ?? [])
|
|
351
|
+
.map((group) => ({
|
|
352
|
+
...group,
|
|
353
|
+
hooks: (group.hooks ?? []).filter((hook) => {
|
|
354
|
+
if (hook.type !== "command" || typeof hook.command !== "string") return true;
|
|
355
|
+
return !isAgentRelaySessionStartCommand(hook.command);
|
|
356
|
+
}),
|
|
357
|
+
}))
|
|
358
|
+
.filter((group) => (group.hooks ?? []).length > 0);
|
|
359
|
+
|
|
360
|
+
if (hooksJson.hooks.SessionStart.length === 0) delete hooksJson.hooks.SessionStart;
|
|
361
|
+
|
|
362
|
+
if (Object.keys(hooksJson.hooks).length === 0) rmSync(hooksPath, { force: true });
|
|
363
|
+
else writeFileSync(hooksPath, `${JSON.stringify(hooksJson, null, 2)}\n`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function pickLoopbackUrl(): Promise<string> {
|
|
367
|
+
const port = await new Promise<number>((resolvePort, reject) => {
|
|
368
|
+
const server = net.createServer();
|
|
369
|
+
server.on("error", reject);
|
|
370
|
+
server.listen(0, "127.0.0.1", () => {
|
|
371
|
+
const address = server.address();
|
|
372
|
+
server.close(() => {
|
|
373
|
+
if (!address || typeof address === "string") reject(new Error("failed to allocate local port"));
|
|
374
|
+
else resolvePort(address.port);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
return `ws://127.0.0.1:${port}`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function waitForPort(url: string, child: ReturnType<typeof Bun.spawn>): Promise<void> {
|
|
382
|
+
const parsed = new URL(url);
|
|
383
|
+
const port = Number(parsed.port);
|
|
384
|
+
const host = parsed.hostname;
|
|
385
|
+
|
|
386
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
387
|
+
if (child.exitCode !== null) throw new Error("codex app-server exited before accepting connections");
|
|
388
|
+
const ok = await new Promise<boolean>((resolveAttempt) => {
|
|
389
|
+
const socket = net.connect({ host, port });
|
|
390
|
+
socket.once("connect", () => {
|
|
391
|
+
socket.destroy();
|
|
392
|
+
resolveAttempt(true);
|
|
393
|
+
});
|
|
394
|
+
socket.once("error", () => resolveAttempt(false));
|
|
395
|
+
socket.setTimeout(200, () => {
|
|
396
|
+
socket.destroy();
|
|
397
|
+
resolveAttempt(false);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
if (ok) return;
|
|
401
|
+
await Bun.sleep(100);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
throw new Error(`timed out waiting for ${url}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function waitForHookHandshake(runDir: string, timeoutMs: number): Promise<HookWaitResult> {
|
|
408
|
+
const handshakePath = join(runDir, "session-start-handshake.json");
|
|
409
|
+
const deadline = Date.now() + timeoutMs;
|
|
410
|
+
|
|
411
|
+
while (Date.now() < deadline) {
|
|
412
|
+
if (existsSync(handshakePath)) {
|
|
413
|
+
try {
|
|
414
|
+
const parsed = JSON.parse(readFileSync(handshakePath, "utf8")) as HookHandshake;
|
|
415
|
+
const code = typeof parsed.code === "string" && parsed.code.trim() ? parsed.code.trim() : "HOOK_HANDSHAKE_INVALID";
|
|
416
|
+
const message = typeof parsed.message === "string" ? parsed.message : undefined;
|
|
417
|
+
const pid = typeof parsed.pid === "number" && Number.isFinite(parsed.pid) && parsed.pid > 0
|
|
418
|
+
? parsed.pid
|
|
419
|
+
: undefined;
|
|
420
|
+
|
|
421
|
+
if (parsed.status === "ok") return { ok: true, code, message, pid };
|
|
422
|
+
if (parsed.status === "error") return { ok: false, code, message, pid };
|
|
423
|
+
return { ok: false, code: "HOOK_HANDSHAKE_INVALID", message: `unexpected status ${String((parsed as any).status)}` };
|
|
424
|
+
} catch (error) {
|
|
425
|
+
return { ok: false, code: "HOOK_HANDSHAKE_INVALID", message: errorMessage(error) };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
await Bun.sleep(100);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
ok: false,
|
|
433
|
+
code: "HOOK_HANDSHAKE_TIMEOUT",
|
|
434
|
+
message: `no hook handshake observed within ${timeoutMs}ms`,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function spawnFallbackSidecar(runDir: string, env: Record<string, string | undefined>): number {
|
|
439
|
+
const autoDir = join(runDir, "auto");
|
|
440
|
+
mkdirSync(autoDir, { recursive: true });
|
|
441
|
+
|
|
442
|
+
const sidecarEnv: Record<string, string | undefined> = {
|
|
443
|
+
...env,
|
|
444
|
+
CODEX_THREAD_MODE: env.AGENT_RELAY_CODEX_FALLBACK_THREAD_MODE || env.CODEX_THREAD_MODE || "start",
|
|
445
|
+
AGENT_RELAY_CODEX_CWD: process.cwd(),
|
|
446
|
+
AGENT_RELAY_CODEX_STATE_PATH: join(autoDir, "live-state.json"),
|
|
447
|
+
};
|
|
448
|
+
delete sidecarEnv.CODEX_THREAD_ID;
|
|
449
|
+
|
|
450
|
+
const sidecar = Bun.spawn(["bun", "run", join(activePackageRoot(), "live-sidecar.ts")], {
|
|
451
|
+
env: sidecarEnv,
|
|
452
|
+
stdout: Bun.file(join(autoDir, "sidecar.log")),
|
|
453
|
+
stderr: Bun.file(join(autoDir, "sidecar.log")),
|
|
454
|
+
});
|
|
455
|
+
sidecar.unref();
|
|
456
|
+
|
|
457
|
+
writeFileSync(join(autoDir, "sidecar.pid"), String(sidecar.pid));
|
|
458
|
+
appendFileSync(join(runDir, "sidecar-pids.txt"), `${sidecar.pid}\n`);
|
|
459
|
+
|
|
460
|
+
return sidecar.pid;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
type SessionPermissions = {
|
|
464
|
+
approvalPolicy?: string;
|
|
465
|
+
sandbox?: string;
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
function hasCodexPermissionMode(codexArgs: string[]): boolean {
|
|
469
|
+
for (const arg of codexArgs) {
|
|
470
|
+
if (
|
|
471
|
+
arg === "--yolo" ||
|
|
472
|
+
arg === "--dangerously-bypass-approvals-and-sandbox" ||
|
|
473
|
+
arg === "--full-auto" ||
|
|
474
|
+
arg === "--ask-for-approval" ||
|
|
475
|
+
arg === "-a" ||
|
|
476
|
+
arg.startsWith("--ask-for-approval=") ||
|
|
477
|
+
arg === "--sandbox" ||
|
|
478
|
+
arg === "-s" ||
|
|
479
|
+
arg.startsWith("--sandbox=")
|
|
480
|
+
) {
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function resolveSessionPermissions(codexArgs: string[]): SessionPermissions {
|
|
488
|
+
let approvalPolicy: string | undefined;
|
|
489
|
+
let sandbox: string | undefined;
|
|
490
|
+
|
|
491
|
+
for (let index = 0; index < codexArgs.length; index += 1) {
|
|
492
|
+
const arg = codexArgs[index]!;
|
|
493
|
+
if (arg === "--yolo" || arg === "--dangerously-bypass-approvals-and-sandbox") {
|
|
494
|
+
approvalPolicy = "never";
|
|
495
|
+
sandbox = "danger-full-access";
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (arg === "--full-auto") {
|
|
499
|
+
approvalPolicy = "on-request";
|
|
500
|
+
sandbox = "workspace-write";
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (arg === "--ask-for-approval" || arg === "-a") {
|
|
504
|
+
const next = codexArgs[index + 1];
|
|
505
|
+
if (next && !next.startsWith("-")) {
|
|
506
|
+
approvalPolicy = next;
|
|
507
|
+
index += 1;
|
|
508
|
+
}
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (arg.startsWith("--ask-for-approval=")) {
|
|
512
|
+
approvalPolicy = arg.slice("--ask-for-approval=".length);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (arg === "--sandbox" || arg === "-s") {
|
|
516
|
+
const next = codexArgs[index + 1];
|
|
517
|
+
if (next && !next.startsWith("-")) {
|
|
518
|
+
sandbox = next;
|
|
519
|
+
index += 1;
|
|
520
|
+
}
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (arg.startsWith("--sandbox=")) {
|
|
524
|
+
sandbox = arg.slice("--sandbox=".length);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return { approvalPolicy, sandbox };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function cleanupRun(runDir: string, appServer: ReturnType<typeof Bun.spawn> | null): void {
|
|
533
|
+
if (existsSync(runDir)) {
|
|
534
|
+
const pidsPath = join(runDir, "sidecar-pids.txt");
|
|
535
|
+
if (existsSync(pidsPath)) {
|
|
536
|
+
for (const line of readFileSync(pidsPath, "utf8").split("\n")) {
|
|
537
|
+
const pid = Number(line.trim());
|
|
538
|
+
if (!Number.isFinite(pid) || pid <= 0) continue;
|
|
539
|
+
try {
|
|
540
|
+
process.kill(pid, "SIGTERM");
|
|
541
|
+
} catch {
|
|
542
|
+
// Sidecar already exited.
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (appServer && appServer.exitCode === null) {
|
|
549
|
+
try {
|
|
550
|
+
appServer.kill("SIGTERM");
|
|
551
|
+
} catch {
|
|
552
|
+
// App server already exited.
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function stopRuntimeSidecars(): void {
|
|
558
|
+
if (!existsSync(runtimeRoot)) return;
|
|
559
|
+
for (const entry of readdirSync(runtimeRoot, { withFileTypes: true })) {
|
|
560
|
+
if (!entry.isDirectory()) continue;
|
|
561
|
+
const pidsPath = join(runtimeRoot, entry.name, "sidecar-pids.txt");
|
|
562
|
+
if (!existsSync(pidsPath)) continue;
|
|
563
|
+
for (const line of readFileSync(pidsPath, "utf8").split("\n")) {
|
|
564
|
+
const pid = Number(line.trim());
|
|
565
|
+
if (!Number.isFinite(pid) || pid <= 0) continue;
|
|
566
|
+
try {
|
|
567
|
+
process.kill(pid, "SIGTERM");
|
|
568
|
+
} catch {
|
|
569
|
+
// Sidecar already exited.
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function installCodexSupport(quiet = false): void {
|
|
576
|
+
if (!commandExists("bun")) throw new Error("Bun is required: https://bun.sh");
|
|
577
|
+
findCodexBinary();
|
|
578
|
+
syncInstalledPackage();
|
|
579
|
+
installHook();
|
|
580
|
+
installMarketplace(quiet);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function writeLauncherShim(name: string): void {
|
|
584
|
+
const cliPath = join(installedPackageRoot, "bin", "agent-relay-codex.ts");
|
|
585
|
+
|
|
586
|
+
if (process.platform === "win32") {
|
|
587
|
+
writeFileSync(join(aliasBinDir, `${name}.cmd`), `@echo off\r\nbun "${cliPath}" %*\r\n`);
|
|
588
|
+
writeFileSync(join(aliasBinDir, `${name}.ps1`), `& bun "${cliPath}" @args\r\nexit $LASTEXITCODE\r\n`);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const shimPath = join(aliasBinDir, name);
|
|
593
|
+
writeFileSync(shimPath, `#!/usr/bin/env sh\nexec bun ${shellQuote(cliPath)} "$@"\n`);
|
|
594
|
+
chmodSync(shimPath, 0o755);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function removeLauncherShim(name: string): void {
|
|
598
|
+
if (process.platform === "win32") {
|
|
599
|
+
rmSync(join(aliasBinDir, `${name}.cmd`), { force: true });
|
|
600
|
+
rmSync(join(aliasBinDir, `${name}.ps1`), { force: true });
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
rmSync(join(aliasBinDir, name), { force: true });
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function installLauncherShims(includeCodexAlias: boolean): void {
|
|
607
|
+
mkdirSync(aliasBinDir, { recursive: true });
|
|
608
|
+
writeLauncherShim("codex-relay");
|
|
609
|
+
if (includeCodexAlias) writeLauncherShim("codex");
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function isAliasBinOnPath(): boolean {
|
|
613
|
+
return pathEntries().some((entry) => samePath(entry, aliasBinDir));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function installPathEntry(): boolean {
|
|
617
|
+
if (isAliasBinOnPath()) return true;
|
|
618
|
+
|
|
619
|
+
if (process.platform === "win32") {
|
|
620
|
+
const script = [
|
|
621
|
+
"$dir = [Environment]::GetEnvironmentVariable('AGENT_RELAY_CODEX_BIN', 'User')",
|
|
622
|
+
`$new = ${JSON.stringify(aliasBinDir)}`,
|
|
623
|
+
"$path = [Environment]::GetEnvironmentVariable('Path', 'User')",
|
|
624
|
+
"if (-not $path) { $path = '' }",
|
|
625
|
+
"$parts = $path -split ';' | Where-Object { $_ }",
|
|
626
|
+
"if ($parts -notcontains $new) {",
|
|
627
|
+
" [Environment]::SetEnvironmentVariable('Path', ($new + ';' + $path).TrimEnd(';'), 'User')",
|
|
628
|
+
"}",
|
|
629
|
+
"[Environment]::SetEnvironmentVariable('AGENT_RELAY_CODEX_BIN', $new, 'User')",
|
|
630
|
+
].join("; ");
|
|
631
|
+
const result = Bun.spawnSync(["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], {
|
|
632
|
+
stdout: "pipe",
|
|
633
|
+
stderr: "pipe",
|
|
634
|
+
});
|
|
635
|
+
return result.exitCode === 0;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const shell = process.env.SHELL || "";
|
|
639
|
+
const exportLine = `export PATH=${shellQuote(aliasBinDir)}:$PATH`;
|
|
640
|
+
let profilePath = join(home, ".profile");
|
|
641
|
+
let snippet = `\n${pathMarker}\n${exportLine}\n`;
|
|
642
|
+
|
|
643
|
+
if (shell.includes("zsh")) profilePath = join(home, ".zshrc");
|
|
644
|
+
if (shell.includes("bash")) profilePath = join(home, ".bashrc");
|
|
645
|
+
if (shell.includes("fish")) {
|
|
646
|
+
profilePath = join(home, ".config", "fish", "config.fish");
|
|
647
|
+
snippet = `\n${pathMarker}\nfish_add_path ${shellQuote(aliasBinDir)}\n`;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
mkdirSync(dirname(profilePath), { recursive: true });
|
|
651
|
+
const current = existsSync(profilePath) ? readFileSync(profilePath, "utf8") : "";
|
|
652
|
+
if (!current.includes(pathMarker) && !current.includes(aliasBinDir)) {
|
|
653
|
+
writeFileSync(profilePath, `${current.replace(/\s*$/, "")}${snippet}`);
|
|
654
|
+
}
|
|
655
|
+
return true;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function managedProfilePaths(): string[] {
|
|
659
|
+
return [
|
|
660
|
+
join(home, ".profile"),
|
|
661
|
+
join(home, ".bashrc"),
|
|
662
|
+
join(home, ".zshrc"),
|
|
663
|
+
join(home, ".config", "fish", "config.fish"),
|
|
664
|
+
];
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function removeManagedPathSnippet(input: string): string {
|
|
668
|
+
const hadTrailingNewline = /\r?\n$/.test(input);
|
|
669
|
+
const lines = input.split(/\r?\n/);
|
|
670
|
+
const output: string[] = [];
|
|
671
|
+
|
|
672
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
673
|
+
const line = lines[index]!;
|
|
674
|
+
if (line === pathMarker) {
|
|
675
|
+
const next = lines[index + 1] || "";
|
|
676
|
+
if (next.includes(aliasBinDir)) index += 1;
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
output.push(line);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const cleaned = output.join("\n").replace(/\n{3,}/g, "\n\n").replace(/[ \t]+\n/g, "\n");
|
|
683
|
+
return hadTrailingNewline && cleaned ? `${cleaned.replace(/\n*$/, "")}\n` : cleaned.replace(/\n*$/, "");
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function removeUnixPathEntries(): number {
|
|
687
|
+
let changed = 0;
|
|
688
|
+
for (const profilePath of managedProfilePaths()) {
|
|
689
|
+
if (!existsSync(profilePath)) continue;
|
|
690
|
+
const current = readFileSync(profilePath, "utf8");
|
|
691
|
+
if (!current.includes(pathMarker)) continue;
|
|
692
|
+
const cleaned = removeManagedPathSnippet(current);
|
|
693
|
+
if (cleaned !== current) {
|
|
694
|
+
writeFileSync(profilePath, cleaned);
|
|
695
|
+
changed += 1;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return changed;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function removeWindowsPathEntry(): boolean {
|
|
702
|
+
const script = [
|
|
703
|
+
`$target = ${JSON.stringify(aliasBinDir)}`,
|
|
704
|
+
"$path = [Environment]::GetEnvironmentVariable('Path', 'User')",
|
|
705
|
+
"if ($path) {",
|
|
706
|
+
" $parts = $path -split ';' | Where-Object { $_ -and ($_ -ne $target) }",
|
|
707
|
+
" [Environment]::SetEnvironmentVariable('Path', ($parts -join ';'), 'User')",
|
|
708
|
+
"}",
|
|
709
|
+
"$bin = [Environment]::GetEnvironmentVariable('AGENT_RELAY_CODEX_BIN', 'User')",
|
|
710
|
+
"if ($bin -eq $target) { [Environment]::SetEnvironmentVariable('AGENT_RELAY_CODEX_BIN', $null, 'User') }",
|
|
711
|
+
].join("; ");
|
|
712
|
+
const result = Bun.spawnSync(["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], {
|
|
713
|
+
stdout: "pipe",
|
|
714
|
+
stderr: "pipe",
|
|
715
|
+
});
|
|
716
|
+
return result.exitCode === 0;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function removeManagedPathEntries(): string {
|
|
720
|
+
if (process.platform === "win32") {
|
|
721
|
+
return removeWindowsPathEntry() ? "Removed managed Windows user PATH entries." : "Could not update Windows user PATH entries.";
|
|
722
|
+
}
|
|
723
|
+
const changed = removeUnixPathEntries();
|
|
724
|
+
return changed > 0 ? `Removed managed PATH snippets from ${changed} shell profile(s).` : "No managed shell profile PATH snippets found.";
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function removeEmptyDirectory(path: string): boolean {
|
|
728
|
+
if (!existsSync(path)) return false;
|
|
729
|
+
try {
|
|
730
|
+
if (readdirSync(path).length > 0) return false;
|
|
731
|
+
rmSync(path, { recursive: false, force: true });
|
|
732
|
+
return true;
|
|
733
|
+
} catch {
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function removeEmptyInstallParents(): void {
|
|
739
|
+
removeEmptyDirectory(installRoot);
|
|
740
|
+
removeEmptyDirectory(dirname(installRoot));
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function installCodexAlias(): void {
|
|
744
|
+
installLauncherShims(true);
|
|
745
|
+
const updated = installPathEntry();
|
|
746
|
+
console.log("Installed codex alias shim.");
|
|
747
|
+
if (!updated || !isAliasBinOnPath()) {
|
|
748
|
+
console.log(`Restart your shell, or add this directory to PATH: ${aliasBinDir}`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function removeCodexAlias(): void {
|
|
753
|
+
removeLauncherShim("codex");
|
|
754
|
+
console.log("Removed Agent Relay codex alias shims.");
|
|
755
|
+
console.log("The `codex-relay` launcher remains installed.");
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function askYesNo(question: string): Promise<boolean> {
|
|
759
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
|
|
760
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
761
|
+
try {
|
|
762
|
+
const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
|
|
763
|
+
return answer === "y" || answer === "yes";
|
|
764
|
+
} finally {
|
|
765
|
+
rl.close();
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function start(args: string[]): Promise<void> {
|
|
770
|
+
installCodexSupport(true);
|
|
771
|
+
|
|
772
|
+
let relayUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
|
|
773
|
+
let listenUrl = process.env.CODEX_APP_SERVER_URL || "";
|
|
774
|
+
let threadMode = process.env.CODEX_THREAD_MODE || "start";
|
|
775
|
+
const requestedApprovalMode = parseApprovalMode(process.env.AGENT_RELAY_APPROVAL);
|
|
776
|
+
const hasApprovalEnv = process.env.AGENT_RELAY_APPROVAL !== undefined;
|
|
777
|
+
const codexArgs: string[] = [];
|
|
778
|
+
|
|
779
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
780
|
+
const arg = args[index]!;
|
|
781
|
+
if (arg === "--") {
|
|
782
|
+
codexArgs.push(...args.slice(index + 1));
|
|
783
|
+
break;
|
|
784
|
+
}
|
|
785
|
+
if (arg === "--relay-url") {
|
|
786
|
+
relayUrl = args[++index] || relayUrl;
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (arg === "--listen") {
|
|
790
|
+
listenUrl = args[++index] || listenUrl;
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
if (arg === "--thread-mode") {
|
|
794
|
+
threadMode = args[++index] || threadMode;
|
|
795
|
+
if (!["auto", "resume", "start"].includes(threadMode)) {
|
|
796
|
+
throw new Error("--thread-mode must be one of: auto, resume, start");
|
|
797
|
+
}
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
codexArgs.push(arg);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (!listenUrl) listenUrl = await pickLoopbackUrl();
|
|
804
|
+
if (!hasCodexPermissionMode(codexArgs)) {
|
|
805
|
+
codexArgs.unshift(...codexArgsForApprovalMode(requestedApprovalMode));
|
|
806
|
+
}
|
|
807
|
+
const permissions = resolveSessionPermissions(codexArgs);
|
|
808
|
+
const effectiveApprovalMode = approvalModeFromPermissions(permissions);
|
|
809
|
+
if (hasApprovalEnv && effectiveApprovalMode !== requestedApprovalMode) {
|
|
810
|
+
throw new Error(
|
|
811
|
+
`Codex permission flags resolve to AGENT_RELAY_APPROVAL=${effectiveApprovalMode}, but AGENT_RELAY_APPROVAL=${requestedApprovalMode} was requested.`,
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
mkdirSync(runtimeRoot, { recursive: true });
|
|
816
|
+
const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
817
|
+
const runDir = join(runtimeRoot, runId);
|
|
818
|
+
mkdirSync(runDir, { recursive: true });
|
|
819
|
+
|
|
820
|
+
const env = {
|
|
821
|
+
...process.env,
|
|
822
|
+
AGENT_RELAY_URL: relayUrl,
|
|
823
|
+
AGENT_RELAY_CODEX_PACKAGE_ROOT: activePackageRoot(),
|
|
824
|
+
AGENT_RELAY_CODEX_RUN_ID: runId,
|
|
825
|
+
AGENT_RELAY_CODEX_RUNTIME_DIR: runDir,
|
|
826
|
+
CODEX_APP_SERVER_URL: listenUrl,
|
|
827
|
+
CODEX_THREAD_MODE: threadMode,
|
|
828
|
+
AGENT_RELAY_CODEX_APPROVAL_POLICY: permissions.approvalPolicy,
|
|
829
|
+
AGENT_RELAY_CODEX_SANDBOX: permissions.sandbox,
|
|
830
|
+
AGENT_RELAY_APPROVAL: effectiveApprovalMode,
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
const appLog = Bun.file(join(runDir, "app-server.log"));
|
|
834
|
+
const codexBinary = findCodexBinary();
|
|
835
|
+
const appServer = Bun.spawn([codexBinary, "app-server", "--listen", listenUrl], {
|
|
836
|
+
env,
|
|
837
|
+
stdout: appLog,
|
|
838
|
+
stderr: appLog,
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
const shutdown = () => cleanupRun(runDir, appServer);
|
|
842
|
+
process.once("SIGINT", () => {
|
|
843
|
+
shutdown();
|
|
844
|
+
process.exit(130);
|
|
845
|
+
});
|
|
846
|
+
process.once("SIGTERM", () => {
|
|
847
|
+
shutdown();
|
|
848
|
+
process.exit(143);
|
|
849
|
+
});
|
|
850
|
+
process.once("exit", shutdown);
|
|
851
|
+
|
|
852
|
+
await waitForPort(listenUrl, appServer);
|
|
853
|
+
console.log(`Agent Relay Codex session: ${listenUrl}`);
|
|
854
|
+
console.log(`Runtime: ${runDir}`);
|
|
855
|
+
|
|
856
|
+
const codex = Bun.spawn([codexBinary, "--remote", listenUrl, ...codexArgs], {
|
|
857
|
+
env,
|
|
858
|
+
stdin: "inherit",
|
|
859
|
+
stdout: "inherit",
|
|
860
|
+
stderr: "inherit",
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
const hookTimeoutMs = envPositiveInt("AGENT_RELAY_CODEX_HOOK_TIMEOUT_MS", DEFAULT_HOOK_HANDSHAKE_TIMEOUT_MS);
|
|
864
|
+
const handshake = await waitForHookHandshake(runDir, hookTimeoutMs);
|
|
865
|
+
let fallbackReason: HookWaitResult | null = null;
|
|
866
|
+
|
|
867
|
+
if (!handshake.ok) {
|
|
868
|
+
fallbackReason = handshake;
|
|
869
|
+
} else if (handshake.pid === undefined) {
|
|
870
|
+
fallbackReason = {
|
|
871
|
+
ok: false,
|
|
872
|
+
code: "HOOK_HANDSHAKE_NO_PID",
|
|
873
|
+
message: "hook reported success without a sidecar pid",
|
|
874
|
+
};
|
|
875
|
+
} else if (!isAlive(handshake.pid)) {
|
|
876
|
+
fallbackReason = {
|
|
877
|
+
ok: false,
|
|
878
|
+
code: "HOOK_HANDSHAKE_PID_NOT_ALIVE",
|
|
879
|
+
message: `hook reported pid ${handshake.pid} but it is not running`,
|
|
880
|
+
};
|
|
881
|
+
} else {
|
|
882
|
+
appendLauncherLog(runDir, `HOOK_HANDSHAKE_OK code=${handshake.code} pid=${handshake.pid}`);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (fallbackReason && codex.exitCode === null) {
|
|
886
|
+
const pid = spawnFallbackSidecar(runDir, env);
|
|
887
|
+
appendFileSync(
|
|
888
|
+
join(runDir, "launcher.log"),
|
|
889
|
+
`${new Date().toISOString()} HOOK_FALLBACK_STARTED reason=${fallbackReason.code} fallbackPid=${pid}${fallbackReason.message ? ` detail=${fallbackReason.message}` : ""}\n`,
|
|
890
|
+
);
|
|
891
|
+
} else if (fallbackReason) {
|
|
892
|
+
appendLauncherLog(
|
|
893
|
+
runDir,
|
|
894
|
+
`HOOK_FALLBACK_SKIPPED reason=${fallbackReason.code} codexExit=${codex.exitCode}${fallbackReason.message ? ` detail=${fallbackReason.message}` : ""}`,
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const exitCode = await codex.exited;
|
|
899
|
+
shutdown();
|
|
900
|
+
process.exit(exitCode);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async function doctor(): Promise<void> {
|
|
904
|
+
const checks: Array<[string, boolean, string]> = [];
|
|
905
|
+
checks.push(["bun", commandExists("bun"), "Bun is required to run the sidecar"]);
|
|
906
|
+
checks.push(["codex", findOnPath("codex", [aliasBinDir]) !== null, "Codex CLI is required"]);
|
|
907
|
+
const configPath = join(home, ".codex", "config.toml");
|
|
908
|
+
const config = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
|
|
909
|
+
checks.push(["hook", isAgentRelaySessionStartCommand(config), "~/.codex/config.toml has Agent Relay SessionStart hook"]);
|
|
910
|
+
checks.push(["marketplace", existsSync(marketplaceFile), "Agent Relay marketplace is installed"]);
|
|
911
|
+
checks.push(["launcher", existsSync(join(aliasBinDir, process.platform === "win32" ? "codex-relay.cmd" : "codex-relay")), "codex-relay launcher shim"]);
|
|
912
|
+
|
|
913
|
+
const relayUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
|
|
914
|
+
const stats = await getRelayStats(relayUrl);
|
|
915
|
+
checks.push(["relay", stats !== null, stats?.version ? `${relayUrl}/api/stats responds; version ${stats.version}` : `${relayUrl}/api/stats responds`]);
|
|
916
|
+
|
|
917
|
+
for (const [name, ok, detail] of checks) {
|
|
918
|
+
console.log(`${ok ? "ok " : "err"} ${name}: ${detail}`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
async function install(args: string[]): Promise<void> {
|
|
923
|
+
const installAlias = args.includes("--alias");
|
|
924
|
+
const skipAlias = args.includes("--no-alias");
|
|
925
|
+
installCodexSupport(false);
|
|
926
|
+
installLauncherShims(false);
|
|
927
|
+
installPathEntry();
|
|
928
|
+
console.log("Installed Agent Relay for Codex.");
|
|
929
|
+
const relayStatus = await checkRelayServer();
|
|
930
|
+
if (relayStatus === "unknown") console.log("If this server is old, restart it with: bunx agent-relay-server@latest");
|
|
931
|
+
if (isAliasBinOnPath()) {
|
|
932
|
+
console.log("Start Codex sessions with: codex-relay");
|
|
933
|
+
} else {
|
|
934
|
+
console.log("Restart your shell, then start Codex sessions with: codex-relay");
|
|
935
|
+
console.log("Without restarting your shell, use: bunx -p agent-relay-codex@latest codex-relay");
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (installAlias || (!skipAlias && await askYesNo("Make plain `codex` start with Agent Relay by installing a PATH shim?"))) {
|
|
939
|
+
installCodexAlias();
|
|
940
|
+
} else {
|
|
941
|
+
console.log("Skipped plain `codex` alias. You can always use `codex-relay`.");
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function uninstall(args: string[] = []): void {
|
|
946
|
+
const purge = args.includes("--purge");
|
|
947
|
+
const unknown = args.filter((arg) => arg !== "--purge");
|
|
948
|
+
if (unknown.length > 0) throw new Error(`Unknown uninstall option: ${unknown.join(" ")}`);
|
|
949
|
+
|
|
950
|
+
stopRuntimeSidecars();
|
|
951
|
+
removeHook();
|
|
952
|
+
removeLauncherShim("codex");
|
|
953
|
+
removeLauncherShim("codex-relay");
|
|
954
|
+
rmSync(marketplaceRoot, { recursive: true, force: true });
|
|
955
|
+
rmSync(installedPackageRoot, { recursive: true, force: true });
|
|
956
|
+
console.log("Uninstalled Agent Relay Codex hook, plugin marketplace files, and launcher shims.");
|
|
957
|
+
|
|
958
|
+
if (!purge) {
|
|
959
|
+
console.log(`PATH entries and runtime logs are left untouched. Run agent-relay-codex uninstall --purge for full managed cleanup.`);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
rmSync(runtimeRoot, { recursive: true, force: true });
|
|
964
|
+
rmSync(aliasBinDir, { recursive: true, force: true });
|
|
965
|
+
console.log(removeManagedPathEntries());
|
|
966
|
+
removeEmptyInstallParents();
|
|
967
|
+
console.log("Purged Agent Relay Codex runtime files, launcher directory, and empty install directories.");
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
async function main(): Promise<void> {
|
|
971
|
+
const [command, ...args] = process.argv.slice(2);
|
|
972
|
+
if (command === "help" || command === "--help" || command === "-h") usage(0);
|
|
973
|
+
if (command === "install") return install(args);
|
|
974
|
+
if (command === "uninstall") return uninstall(args);
|
|
975
|
+
if (command === "alias" && args[0] === "install") {
|
|
976
|
+
installCodexSupport(false);
|
|
977
|
+
return installCodexAlias();
|
|
978
|
+
}
|
|
979
|
+
if (command === "alias" && args[0] === "remove") return removeCodexAlias();
|
|
980
|
+
if (command === "doctor") return doctor();
|
|
981
|
+
if (command === "start") return start(args);
|
|
982
|
+
return start(command ? [command, ...args] : []);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
main().catch((error) => {
|
|
986
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
987
|
+
process.exit(1);
|
|
988
|
+
});
|