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