agent-relay-server 0.3.6 → 0.3.8
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 +25 -0
- package/bin/agent-relay-codex.ts +124 -19
- package/codex/live-sidecar.ts +15 -8
- package/package.json +1 -1
- package/src/index.ts +0 -0
- package/codex/README.md +0 -82
package/README.md
CHANGED
|
@@ -77,6 +77,31 @@ codex-relay
|
|
|
77
77
|
incoming messages as live turns, and cleans up sidecar processes when Codex
|
|
78
78
|
exits.
|
|
79
79
|
|
|
80
|
+
### Codex approval prompts
|
|
81
|
+
|
|
82
|
+
Replying to relay messages is usually done with a shell command (`curl` to
|
|
83
|
+
`/api/messages`), so Codex may prompt for approval in stricter modes.
|
|
84
|
+
|
|
85
|
+
`codex-relay` now forwards your Codex runtime mode to the sidecar, including
|
|
86
|
+
`--ask-for-approval`, `--sandbox`, `--full-auto`, and `--yolo`.
|
|
87
|
+
|
|
88
|
+
Useful setups:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# no approval prompts, still sandboxed to workspace boundaries
|
|
92
|
+
codex-relay -- --ask-for-approval never --sandbox workspace-write
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
# ~/.codex/rules/default.rules
|
|
97
|
+
# allow only relay message sends without repeated prompts
|
|
98
|
+
prefix_rule(
|
|
99
|
+
pattern = ["curl", "-sS", "-X", "POST", "http://127.0.0.1:4850/api/messages"],
|
|
100
|
+
decision = "allow",
|
|
101
|
+
justification = "Allow local Agent Relay message posts",
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
80
105
|
Use a remote relay server by setting:
|
|
81
106
|
|
|
82
107
|
```bash
|
package/bin/agent-relay-codex.ts
CHANGED
|
@@ -76,6 +76,47 @@ function readJsonFile<T>(path: string, fallback: T): T {
|
|
|
76
76
|
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
function isAgentRelaySessionStartCommand(command: string): boolean {
|
|
80
|
+
return /agent-relay.*codex\/hooks\/session-start\.ts/.test(command);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function removeAgentRelaySessionStartToml(input: string): string {
|
|
84
|
+
const lines = input.split(/\r?\n/);
|
|
85
|
+
const blocks: Array<{ header: string | null; text: string }> = [];
|
|
86
|
+
|
|
87
|
+
for (let index = 0; index < lines.length; ) {
|
|
88
|
+
const line = lines[index] ?? "";
|
|
89
|
+
const header = /^\[\[?/.test(line) ? line : null;
|
|
90
|
+
const block: string[] = [];
|
|
91
|
+
do {
|
|
92
|
+
block.push(lines[index] ?? "");
|
|
93
|
+
index += 1;
|
|
94
|
+
} while (index < lines.length && !/^\[\[?/.test(lines[index] ?? ""));
|
|
95
|
+
blocks.push({ header, text: block.join("\n") });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let output = "";
|
|
99
|
+
for (let index = 0; index < blocks.length; ) {
|
|
100
|
+
const block = blocks[index];
|
|
101
|
+
if (block?.header?.startsWith("[[hooks.SessionStart")) {
|
|
102
|
+
const group: typeof blocks = [];
|
|
103
|
+
while (index < blocks.length && blocks[index]?.header?.startsWith("[[hooks.SessionStart")) {
|
|
104
|
+
group.push(blocks[index]!);
|
|
105
|
+
index += 1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const groupText = group.map((block) => block.text).join("\n");
|
|
109
|
+
if (!isAgentRelaySessionStartCommand(groupText)) output += `${groupText}\n`;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
output += `${block?.text ?? ""}\n`;
|
|
114
|
+
index += 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return output.replace(/\s+$/, "\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
79
120
|
function compareVersions(left: string, right: string): number {
|
|
80
121
|
const leftParts = left.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
|
|
81
122
|
const rightParts = right.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
|
|
@@ -215,35 +256,43 @@ function installMarketplace(quiet = false): void {
|
|
|
215
256
|
|
|
216
257
|
function installHook(): void {
|
|
217
258
|
mkdirSync(join(home, ".codex"), { recursive: true });
|
|
259
|
+
const command = `bun ${shellQuote(installedHookScript)}`;
|
|
260
|
+
|
|
261
|
+
const configPath = join(home, ".codex", "config.toml");
|
|
262
|
+
const existingConfig = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
|
|
263
|
+
let output = removeAgentRelaySessionStartToml(existingConfig);
|
|
264
|
+
if (!output.endsWith("\n\n")) output += output.endsWith("\n") ? "\n" : "\n\n";
|
|
265
|
+
output += `[[hooks.SessionStart]]
|
|
266
|
+
matcher = "startup|resume"
|
|
267
|
+
|
|
268
|
+
[[hooks.SessionStart.hooks]]
|
|
269
|
+
type = "command"
|
|
270
|
+
command = "${command.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"
|
|
271
|
+
statusMessage = "Starting Agent Relay"
|
|
272
|
+
timeout = 10
|
|
273
|
+
`;
|
|
274
|
+
|
|
275
|
+
writeFileSync(configPath, output);
|
|
276
|
+
|
|
218
277
|
const hooksPath = join(home, ".codex", "hooks.json");
|
|
278
|
+
if (!existsSync(hooksPath)) return;
|
|
279
|
+
|
|
219
280
|
const hooksJson = readJsonFile<HooksJson>(hooksPath, { hooks: {} });
|
|
220
281
|
hooksJson.hooks ??= {};
|
|
221
|
-
hooksJson.hooks.SessionStart
|
|
222
|
-
|
|
223
|
-
const command = `bun ${shellQuote(installedHookScript)}`;
|
|
224
|
-
hooksJson.hooks.SessionStart = hooksJson.hooks.SessionStart
|
|
282
|
+
hooksJson.hooks.SessionStart = (hooksJson.hooks.SessionStart ?? [])
|
|
225
283
|
.map((group) => ({
|
|
226
284
|
...group,
|
|
227
285
|
hooks: (group.hooks ?? []).filter((hook) => {
|
|
228
286
|
if (hook.type !== "command" || typeof hook.command !== "string") return true;
|
|
229
|
-
return
|
|
287
|
+
return !isAgentRelaySessionStartCommand(hook.command);
|
|
230
288
|
}),
|
|
231
289
|
}))
|
|
232
290
|
.filter((group) => (group.hooks ?? []).length > 0);
|
|
233
291
|
|
|
234
|
-
hooksJson.hooks.SessionStart.
|
|
235
|
-
matcher: "startup|resume",
|
|
236
|
-
hooks: [
|
|
237
|
-
{
|
|
238
|
-
type: "command",
|
|
239
|
-
command,
|
|
240
|
-
statusMessage: "Starting Agent Relay",
|
|
241
|
-
timeout: 10,
|
|
242
|
-
},
|
|
243
|
-
],
|
|
244
|
-
});
|
|
292
|
+
if (hooksJson.hooks.SessionStart.length === 0) delete hooksJson.hooks.SessionStart;
|
|
245
293
|
|
|
246
|
-
|
|
294
|
+
if (Object.keys(hooksJson.hooks).length === 0) rmSync(hooksPath, { force: true });
|
|
295
|
+
else writeFileSync(hooksPath, `${JSON.stringify(hooksJson, null, 2)}\n`);
|
|
247
296
|
}
|
|
248
297
|
|
|
249
298
|
async function pickLoopbackUrl(): Promise<string> {
|
|
@@ -329,6 +378,56 @@ function spawnFallbackSidecar(runDir: string, env: Record<string, string | undef
|
|
|
329
378
|
return sidecar.pid;
|
|
330
379
|
}
|
|
331
380
|
|
|
381
|
+
type SessionPermissions = {
|
|
382
|
+
approvalPolicy?: string;
|
|
383
|
+
sandbox?: string;
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
function resolveSessionPermissions(codexArgs: string[]): SessionPermissions {
|
|
387
|
+
let approvalPolicy: string | undefined;
|
|
388
|
+
let sandbox: string | undefined;
|
|
389
|
+
|
|
390
|
+
for (let index = 0; index < codexArgs.length; index += 1) {
|
|
391
|
+
const arg = codexArgs[index]!;
|
|
392
|
+
if (arg === "--yolo" || arg === "--dangerously-bypass-approvals-and-sandbox") {
|
|
393
|
+
approvalPolicy = "never";
|
|
394
|
+
sandbox = "danger-full-access";
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (arg === "--full-auto") {
|
|
398
|
+
approvalPolicy = "on-request";
|
|
399
|
+
sandbox = "workspace-write";
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (arg === "--ask-for-approval" || arg === "-a") {
|
|
403
|
+
const next = codexArgs[index + 1];
|
|
404
|
+
if (next && !next.startsWith("-")) {
|
|
405
|
+
approvalPolicy = next;
|
|
406
|
+
index += 1;
|
|
407
|
+
}
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (arg.startsWith("--ask-for-approval=")) {
|
|
411
|
+
approvalPolicy = arg.slice("--ask-for-approval=".length);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (arg === "--sandbox" || arg === "-s") {
|
|
415
|
+
const next = codexArgs[index + 1];
|
|
416
|
+
if (next && !next.startsWith("-")) {
|
|
417
|
+
sandbox = next;
|
|
418
|
+
index += 1;
|
|
419
|
+
}
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (arg.startsWith("--sandbox=")) {
|
|
423
|
+
sandbox = arg.slice("--sandbox=".length);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return { approvalPolicy, sandbox };
|
|
429
|
+
}
|
|
430
|
+
|
|
332
431
|
function cleanupRun(runDir: string, appServer: ReturnType<typeof Bun.spawn> | null): void {
|
|
333
432
|
if (existsSync(runDir)) {
|
|
334
433
|
const pidsPath = join(runDir, "sidecar-pids.txt");
|
|
@@ -357,8 +456,9 @@ function cleanupRun(runDir: string, appServer: ReturnType<typeof Bun.spawn> | nu
|
|
|
357
456
|
function installCodexSupport(quiet = false): void {
|
|
358
457
|
if (!commandExists("bun")) throw new Error("Bun is required: https://bun.sh");
|
|
359
458
|
findCodexBinary();
|
|
360
|
-
|
|
459
|
+
syncInstalledPackage();
|
|
361
460
|
installHook();
|
|
461
|
+
installMarketplace(quiet);
|
|
362
462
|
}
|
|
363
463
|
|
|
364
464
|
function writeLauncherShim(name: string): void {
|
|
@@ -489,6 +589,7 @@ async function start(args: string[]): Promise<void> {
|
|
|
489
589
|
}
|
|
490
590
|
|
|
491
591
|
if (!listenUrl) listenUrl = await pickLoopbackUrl();
|
|
592
|
+
const permissions = resolveSessionPermissions(codexArgs);
|
|
492
593
|
|
|
493
594
|
mkdirSync(runtimeRoot, { recursive: true });
|
|
494
595
|
const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -502,6 +603,8 @@ async function start(args: string[]): Promise<void> {
|
|
|
502
603
|
AGENT_RELAY_CODEX_RUN_ID: runId,
|
|
503
604
|
AGENT_RELAY_CODEX_RUNTIME_DIR: runDir,
|
|
504
605
|
CODEX_APP_SERVER_URL: listenUrl,
|
|
606
|
+
CODEX_LIVE_APPROVAL_POLICY: permissions.approvalPolicy,
|
|
607
|
+
CODEX_LIVE_SANDBOX: permissions.sandbox,
|
|
505
608
|
};
|
|
506
609
|
|
|
507
610
|
const appLog = Bun.file(join(runDir, "app-server.log"));
|
|
@@ -549,7 +652,9 @@ async function doctor(): Promise<void> {
|
|
|
549
652
|
const checks: Array<[string, boolean, string]> = [];
|
|
550
653
|
checks.push(["bun", commandExists("bun"), "Bun is required to run the sidecar"]);
|
|
551
654
|
checks.push(["codex", findOnPath("codex", [aliasBinDir]) !== null, "Codex CLI is required"]);
|
|
552
|
-
|
|
655
|
+
const configPath = join(home, ".codex", "config.toml");
|
|
656
|
+
const config = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
|
|
657
|
+
checks.push(["hook", isAgentRelaySessionStartCommand(config), "~/.codex/config.toml has Agent Relay SessionStart hook"]);
|
|
553
658
|
checks.push(["marketplace", existsSync(marketplaceFile), "Agent Relay marketplace is installed"]);
|
|
554
659
|
checks.push(["launcher", existsSync(join(aliasBinDir, process.platform === "win32" ? "codex-relay.cmd" : "codex-relay")), "codex-relay launcher shim"]);
|
|
555
660
|
|
package/codex/live-sidecar.ts
CHANGED
|
@@ -20,6 +20,8 @@ interface Config {
|
|
|
20
20
|
threadMode: "auto" | "resume" | "start";
|
|
21
21
|
threadId?: string;
|
|
22
22
|
model?: string;
|
|
23
|
+
approvalPolicy?: string;
|
|
24
|
+
sandbox?: string;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
interface RuntimeState {
|
|
@@ -196,8 +198,7 @@ class CodexLiveSidecar {
|
|
|
196
198
|
const resumed = await this.app.threadResume({
|
|
197
199
|
threadId,
|
|
198
200
|
cwd: this.config.cwd,
|
|
199
|
-
|
|
200
|
-
sandbox: "workspace-write",
|
|
201
|
+
...this.threadPermissions(),
|
|
201
202
|
persistExtendedHistory: false,
|
|
202
203
|
});
|
|
203
204
|
return normalizeThread(resumed.thread);
|
|
@@ -213,8 +214,7 @@ class CodexLiveSidecar {
|
|
|
213
214
|
const resumed = await this.app.threadResume({
|
|
214
215
|
threadId: this.config.threadId,
|
|
215
216
|
cwd: this.config.cwd,
|
|
216
|
-
|
|
217
|
-
sandbox: "workspace-write",
|
|
217
|
+
...this.threadPermissions(),
|
|
218
218
|
persistExtendedHistory: false,
|
|
219
219
|
});
|
|
220
220
|
return normalizeThread(resumed.thread);
|
|
@@ -238,8 +238,7 @@ class CodexLiveSidecar {
|
|
|
238
238
|
const resumed = await this.app.threadResume({
|
|
239
239
|
threadId: latest.id,
|
|
240
240
|
cwd: this.config.cwd,
|
|
241
|
-
|
|
242
|
-
sandbox: "workspace-write",
|
|
241
|
+
...this.threadPermissions(),
|
|
243
242
|
persistExtendedHistory: false,
|
|
244
243
|
});
|
|
245
244
|
return normalizeThread(resumed.thread);
|
|
@@ -248,8 +247,7 @@ class CodexLiveSidecar {
|
|
|
248
247
|
|
|
249
248
|
const started = await this.app.threadStart({
|
|
250
249
|
cwd: this.config.cwd,
|
|
251
|
-
|
|
252
|
-
sandbox: "workspace-write",
|
|
250
|
+
...this.threadPermissions(),
|
|
253
251
|
ephemeral: false,
|
|
254
252
|
sessionStartSource: "startup",
|
|
255
253
|
model: this.config.model ?? null,
|
|
@@ -257,6 +255,13 @@ class CodexLiveSidecar {
|
|
|
257
255
|
return normalizeThread(started.thread);
|
|
258
256
|
}
|
|
259
257
|
|
|
258
|
+
private threadPermissions(): Record<string, string> {
|
|
259
|
+
const payload: Record<string, string> = {};
|
|
260
|
+
if (this.config.approvalPolicy) payload.approvalPolicy = this.config.approvalPolicy;
|
|
261
|
+
if (this.config.sandbox) payload.sandbox = this.config.sandbox;
|
|
262
|
+
return payload;
|
|
263
|
+
}
|
|
264
|
+
|
|
260
265
|
private async readThreadWithFallback(threadId: string): Promise<Thread> {
|
|
261
266
|
try {
|
|
262
267
|
const read = await this.app.threadRead(threadId, true);
|
|
@@ -595,6 +600,8 @@ function loadConfig(): Config {
|
|
|
595
600
|
threadMode: (process.env.CODEX_THREAD_MODE as Config["threadMode"]) || "auto",
|
|
596
601
|
threadId: process.env.CODEX_THREAD_ID || undefined,
|
|
597
602
|
model: process.env.CODEX_MODEL || undefined,
|
|
603
|
+
approvalPolicy: process.env.CODEX_LIVE_APPROVAL_POLICY || undefined,
|
|
604
|
+
sandbox: process.env.CODEX_LIVE_SANDBOX || undefined,
|
|
598
605
|
};
|
|
599
606
|
}
|
|
600
607
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
File without changes
|
package/codex/README.md
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
# Codex Live Sidecar
|
|
2
|
-
|
|
3
|
-
Codex integration for Agent Relay.
|
|
4
|
-
|
|
5
|
-
## Purpose
|
|
6
|
-
|
|
7
|
-
This sidecar connects to a Codex app-server session and to Agent Relay, then delivers incoming relay messages into the active Codex thread using:
|
|
8
|
-
|
|
9
|
-
- `turn/start`
|
|
10
|
-
- `turn/steer`
|
|
11
|
-
- `turn/interrupt`
|
|
12
|
-
|
|
13
|
-
## Current behavior
|
|
14
|
-
|
|
15
|
-
- attaches to a loaded thread for the current `cwd` when one exists
|
|
16
|
-
- otherwise resumes the newest thread for the current `cwd`
|
|
17
|
-
- otherwise creates a new thread
|
|
18
|
-
- registers a relay agent with `client: codex-live`
|
|
19
|
-
- marks the relay agent `ready=true` once app-server + thread are attached
|
|
20
|
-
- polls relay inbox and delivers messages into the live thread
|
|
21
|
-
- coalesces ordinary relay bursts into one delivery turn
|
|
22
|
-
- reconnects to the app-server with exponential backoff after disconnects
|
|
23
|
-
- writes runtime state to `codex/runtime/live-state.json`
|
|
24
|
-
|
|
25
|
-
## Delivery behavior
|
|
26
|
-
|
|
27
|
-
- idle thread: `turn/start`
|
|
28
|
-
- active thread: `turn/steer`
|
|
29
|
-
- urgent or `meta.delivery = "interrupt"`: `turn/interrupt` then `turn/start`
|
|
30
|
-
|
|
31
|
-
## Run
|
|
32
|
-
|
|
33
|
-
```bash
|
|
34
|
-
codex/start-live.sh
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
## Installable workflow
|
|
38
|
-
|
|
39
|
-
The packaged Codex path is:
|
|
40
|
-
|
|
41
|
-
```bash
|
|
42
|
-
bunx agent-relay-server@latest
|
|
43
|
-
curl -fsSL https://unpkg.com/agent-relay-server@latest/codex/install-codex.sh | bash
|
|
44
|
-
# after restarting your shell
|
|
45
|
-
codex-relay
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
The installer always adds a `codex-relay` launcher and asks whether plain
|
|
49
|
-
`codex` should also route through Agent Relay. `codex-relay` idempotently
|
|
50
|
-
installs or refreshes the Codex hook/plugin, then launches `codex app-server`,
|
|
51
|
-
starts Codex with
|
|
52
|
-
`--remote`, lets the SessionStart hook attach a sidecar to the actual thread,
|
|
53
|
-
and kills sidecars plus the app-server when Codex exits.
|
|
54
|
-
|
|
55
|
-
For local development from this repo:
|
|
56
|
-
|
|
57
|
-
```bash
|
|
58
|
-
bun run bin/agent-relay-codex.ts
|
|
59
|
-
bun run codex:smoke:fallback
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
Useful environment variables:
|
|
63
|
-
|
|
64
|
-
- `AGENT_RELAY_URL`
|
|
65
|
-
- `AGENT_RELAY_CAPS`
|
|
66
|
-
- `CODEX_APP_SERVER_URL`
|
|
67
|
-
- `CODEX_THREAD_ID`
|
|
68
|
-
- `CODEX_THREAD_MODE=auto|resume|start`
|
|
69
|
-
- `CODEX_LIVE_STATE_PATH`
|
|
70
|
-
- `CODEX_LIVE_COALESCE_WINDOW_MS`
|
|
71
|
-
- `CODEX_LIVE_RECONNECT_INITIAL_MS`
|
|
72
|
-
- `CODEX_LIVE_RECONNECT_MAX_MS`
|
|
73
|
-
- `CODEX_LIVE_RIG`
|
|
74
|
-
- `CODEX_MODEL`
|
|
75
|
-
|
|
76
|
-
## Notes
|
|
77
|
-
|
|
78
|
-
Current sidecar behavior is stable for live delivery. Remaining gaps are advanced policies such as batching by sender, message prioritization queues, and more nuanced retry/backoff behavior.
|
|
79
|
-
|
|
80
|
-
- `CODEX_THREAD_MODE=auto` will attach to an already loaded thread for the same `cwd`. That is what you want for real live control, but it also means the sidecar can attach to your current interactive Codex session if one is already open.
|
|
81
|
-
- For isolated testing, set `CODEX_THREAD_MODE=start` so the sidecar always creates its own thread.
|
|
82
|
-
- A brand-new thread is not materialized for `includeTurns` reads until the first turn starts. That is an app-server behavior, not a relay bug.
|