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 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
@@ -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 !/agent-relay.*codex\/hooks\/session-start\.ts/.test(hook.command);
287
+ return !isAgentRelaySessionStartCommand(hook.command);
230
288
  }),
231
289
  }))
232
290
  .filter((group) => (group.hooks ?? []).length > 0);
233
291
 
234
- hooksJson.hooks.SessionStart.push({
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
- writeFileSync(hooksPath, `${JSON.stringify(hooksJson, null, 2)}\n`);
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
- installMarketplace(quiet);
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
- checks.push(["hook", existsSync(join(home, ".codex", "hooks.json")), "~/.codex/hooks.json exists"]);
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
 
@@ -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
- approvalPolicy: "on-request",
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
- approvalPolicy: "on-request",
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
- approvalPolicy: "on-request",
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
- approvalPolicy: "on-request",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
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.