agent-relay-server 0.3.12 → 0.4.1

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
@@ -20,6 +20,8 @@ You're running three Claude Code sessions: one debugging a backend, another writ
20
20
  - **Claim-based tasks**: post a task, exactly one agent grabs it (atomic, no duplicates)
21
21
  - **Threading**: full conversation chains between agents
22
22
  - **Live dashboard**: agents, messages, and stats in real time
23
+ - **Closed-loop tasks**: external systems can create deduped work items agents
24
+ claim, progress, resolve, and report back through callbacks
23
25
 
24
26
  ## Quick Start
25
27
 
@@ -32,6 +34,25 @@ bunx agent-relay-server@latest
32
34
 
33
35
  Dashboard at `http://localhost:4850`.
34
36
 
37
+ Localhost is intentionally frictionless. If you bind the relay to a non-loopback
38
+ address, set `AGENT_RELAY_TOKEN` first:
39
+
40
+ ```bash
41
+ AGENT_RELAY_TOKEN="$(openssl rand -hex 24)" HOST=0.0.0.0 bunx agent-relay-server@latest
42
+ ```
43
+
44
+ For an always-on local relay, use the setup + daemon commands instead:
45
+
46
+ ```bash
47
+ bun install -g agent-relay-server@latest
48
+ agent-relay setup --yes
49
+ agent-relay daemon install --binary "$(command -v agent-relay)" --enable --start --yes
50
+ ```
51
+
52
+ `setup` creates a managed env file with a generated token, loopback bind, and a
53
+ durable SQLite path. `daemon install` auto-detects systemd user services on
54
+ Linux and LaunchAgents on macOS.
55
+
35
56
  ### 2. Install the Claude Code plugin
36
57
 
37
58
  Requires **Claude Code 2.1.105+** (native plugin monitors).
@@ -77,21 +98,29 @@ codex-relay
77
98
  incoming messages as live turns, and cleans up sidecar processes when Codex
78
99
  exits.
79
100
 
101
+ The normal hook path attaches to the Codex thread that was just launched. If the
102
+ launcher has to use its fallback sidecar because the hook did not report in
103
+ time, it starts a new thread by default to avoid surprising cwd-based attachment
104
+ to an unrelated loaded session. Use `codex-relay --thread-mode auto` or
105
+ `AGENT_RELAY_CODEX_FALLBACK_THREAD_MODE=auto` when you deliberately want the
106
+ fallback sidecar to attach to the newest loaded thread for the current cwd.
107
+
80
108
  ### Codex approval mode
81
109
 
82
110
  Replying to relay messages is usually done with a shell command (`curl` to
83
111
  `/api/messages`), so Codex may prompt for approval in stricter modes.
84
112
 
85
- By default, `codex-relay` starts Codex with
86
- `--dangerously-bypass-approvals-and-sandbox` so relay turns do not get stuck on
87
- approval prompts. If you pass an explicit Codex runtime mode, `codex-relay`
113
+ By default, `codex-relay` starts Codex with `--ask-for-approval never --sandbox
114
+ workspace-write` so relay replies do not get stuck on approval prompts while
115
+ still keeping Codex inside workspace boundaries. If you pass an explicit Codex
116
+ runtime mode, `codex-relay`
88
117
  leaves it alone and forwards it to the sidecar, including `--ask-for-approval`,
89
118
  `--sandbox`, `--full-auto`, and `--yolo`.
90
119
 
91
120
  Useful setups:
92
121
 
93
122
  ```bash
94
- # default: no approval prompts, no Codex sandbox
123
+ # default: no approval prompts, workspace sandbox
95
124
  codex-relay
96
125
  ```
97
126
 
@@ -100,6 +129,11 @@ codex-relay
100
129
  codex-relay -- --ask-for-approval never --sandbox workspace-write
101
130
  ```
102
131
 
132
+ ```bash
133
+ # trusted private rig only: no approval prompts, no Codex sandbox
134
+ codex-relay -- --dangerously-bypass-approvals-and-sandbox
135
+ ```
136
+
103
137
  ```python
104
138
  # ~/.codex/rules/default.rules
105
139
  # allow only relay message sends without repeated prompts
@@ -114,6 +148,7 @@ Use a remote relay server by setting:
114
148
 
115
149
  ```bash
116
150
  export AGENT_RELAY_URL=http://100.x.y.z:4850
151
+ export AGENT_RELAY_TOKEN=your-shared-token
117
152
  bunx -p agent-relay-server@latest codex-relay
118
153
  ```
119
154
 
@@ -149,6 +184,15 @@ curl -fsSL https://unpkg.com/agent-relay-server@latest/codex/install-codex.sh |
149
184
  $env:AGENT_RELAY_CODEX_ALIAS = "1"; irm https://unpkg.com/agent-relay-server@latest/codex/install-codex.ps1 | iex
150
185
  ```
151
186
 
187
+ Uninstall Codex support:
188
+
189
+ ```bash
190
+ agent-relay-codex uninstall
191
+ ```
192
+
193
+ This removes the Codex SessionStart hook, local plugin marketplace files, and
194
+ launcher shims. It leaves shell profile PATH edits visible for manual cleanup.
195
+
152
196
  ## What the Agent Sees
153
197
 
154
198
  For Claude Code sessions, the plugin monitor registers the agent and outputs its
@@ -179,21 +223,132 @@ Example incoming relay message:
179
223
  | Label | `"label:test writer"` | All agents with that human-set label |
180
224
  | Broadcast | `"broadcast"` | Everyone |
181
225
 
226
+ ## Mental Model
227
+
228
+ Agent Relay is a small trusted-network message bus:
229
+
230
+ - **Server**: one Bun process with SQLite state and an HTTP API.
231
+ - **Agent registration**: each Claude/Codex session registers an agent card with
232
+ id, labels, tags, capabilities, status, and readiness.
233
+ - **Delivery**: Claude receives monitor notifications; Codex receives live turns
234
+ through the app-server sidecar.
235
+ - **Routing**: direct ids target one session; tags/capabilities/labels fan out;
236
+ claimable messages are tasks where one matching agent claims before acting.
237
+ - **Threads**: replies set `replyTo`; the server keeps a thread id so the
238
+ dashboard/API can show the conversation chain.
239
+ - **Read state**: read markers are per agent. Deleting a message removes it from
240
+ history, so prefer retention settings over manual deletion for cleanup.
241
+ - **Tasks**: integrations create durable work items. New open tasks also create
242
+ claimable system messages so one agent can pick up the work.
243
+ - **Callbacks**: integrations can receive task lifecycle webhooks for closed-loop
244
+ automation.
245
+
246
+ ## Integrations And Tasks
247
+
248
+ Agent Relay can act as a secure ingress layer for scripts, monitoring systems,
249
+ CI jobs, and support tools. External tools should use integration tokens instead
250
+ of the full `AGENT_RELAY_TOKEN`.
251
+
252
+ Configure integrations with `AGENT_RELAY_INTEGRATIONS`:
253
+
254
+ ```bash
255
+ export AGENT_RELAY_INTEGRATIONS='[
256
+ {
257
+ "name": "ops-monitor",
258
+ "token": "ops-secret",
259
+ "scopes": ["tasks:create"],
260
+ "targets": ["cap:ops"],
261
+ "channels": ["alerts"],
262
+ "callbackUrl": "https://ops.example/agent-relay/callback"
263
+ },
264
+ {
265
+ "name": "support-desk",
266
+ "token": "support-secret",
267
+ "scopes": ["tasks:create"],
268
+ "targets": ["cap:support"],
269
+ "channels": ["support"]
270
+ }
271
+ ]'
272
+ ```
273
+
274
+ Create or update a task:
275
+
276
+ ```bash
277
+ curl -sS -X POST "$AGENT_RELAY_URL/api/integrations/events" \
278
+ -H "Authorization: Bearer ops-secret" \
279
+ -H "Content-Type: application/json" \
280
+ -d '{
281
+ "type": "alarm",
282
+ "severity": "critical",
283
+ "dedupeKey": "prod-api:5xx-rate",
284
+ "title": "prod-api 5xx rate high",
285
+ "body": "5xx rate is above 8% for 5 minutes",
286
+ "target": "cap:ops",
287
+ "channel": "alerts",
288
+ "externalUrl": "https://grafana.example/d/prod-api",
289
+ "metadata": { "service": "prod-api", "value": 0.084 }
290
+ }'
291
+ ```
292
+
293
+ `dedupeKey` gives predictable update semantics: if the same source posts the same
294
+ dedupe key while the task is active, Agent Relay increments `occurrenceCount`,
295
+ updates `lastSeenAt`, records a task event, and does not spam agents with another
296
+ claimable message. Posting `"status": "resolved"` marks the active task `done`.
297
+
298
+ When an integration has `callbackUrl`, Agent Relay records and attempts a
299
+ best-effort `POST` on task creation, claim, and status changes:
300
+
301
+ ```json
302
+ {
303
+ "event": "task.status",
304
+ "task": {
305
+ "id": 42,
306
+ "status": "done",
307
+ "result": "Rolled back bad deploy"
308
+ }
309
+ }
310
+ ```
311
+
312
+ Callback attempts are written to SQLite before delivery so failed deliveries are
313
+ auditable during practical testing.
314
+
315
+ Task lifecycle API:
316
+
317
+ | Method | Path | Purpose |
318
+ |--------|------|---------|
319
+ | `POST` | `/integrations/events` | Scoped integration ingress; creates or dedupes tasks |
320
+ | `GET` | `/tasks` | List tasks (`?status=`, `?source=`, `?target=`, `?limit=`) |
321
+ | `GET` | `/tasks/:id` | Get one task |
322
+ | `GET` | `/tasks/:id/events` | Get task event history |
323
+ | `POST` | `/tasks/:id/claim` | Claim a task as an agent |
324
+ | `PATCH` | `/tasks/:id/status` | Update status/result/progress |
325
+
326
+ Example connectors live under `examples/integrations/`:
327
+
328
+ - `ops-alert.sh`: simple shell event producer.
329
+ - `support-ticket.sh`: simple support ticket producer.
330
+ - `prometheus-alertmanager.ts`: reads an Alertmanager webhook payload from stdin.
331
+ - `github-issue.ts`: reads a GitHub issue webhook payload from stdin.
332
+
182
333
  ## Deployment
183
334
 
184
335
  Single process, embedded SQLite, no external dependencies beyond Bun.
185
336
 
186
337
  ```bash
187
338
  bunx agent-relay-server@latest # localhost only
188
- PORT=8080 HOST=0.0.0.0 bunx agent-relay-server@latest # remote access
339
+ AGENT_RELAY_TOKEN=... PORT=8080 HOST=0.0.0.0 bunx agent-relay-server@latest # remote access
189
340
  ```
190
341
 
191
342
  For multi-machine setups, run the server on one machine and set `AGENT_RELAY_URL` on the others (works great over [Tailscale](https://tailscale.com)):
192
343
 
193
344
  ```bash
194
345
  export AGENT_RELAY_URL=http://100.x.y.z:4850
346
+ export AGENT_RELAY_TOKEN=your-shared-token
195
347
  ```
196
348
 
349
+ Do not expose Agent Relay directly to the public internet. It is designed for a
350
+ trusted localhost/VPN/LAN boundary, not as a multi-tenant public service.
351
+
197
352
  ### Server environment variables
198
353
 
199
354
  | Variable | Default | Purpose |
@@ -205,39 +360,74 @@ export AGENT_RELAY_URL=http://100.x.y.z:4850
205
360
  | `OFFLINE_PRUNE_MS` | `86400000` | Delete offline agents older than this |
206
361
  | `REAP_INTERVAL_MS` | `60000` | Reaper cadence for stale/offline cleanup |
207
362
  | `RETENTION_DAYS` | `30` | Auto-prune messages older than this |
363
+ | `AGENT_RELAY_TOKEN` | unset | Shared bearer token required for non-loopback binds |
364
+ | `AGENT_RELAY_CORS_ORIGINS` | same-origin only | Comma-separated browser origins, or `*` |
365
+ | `AGENT_RELAY_ALLOW_UNAUTH` | unset | Set `1` to allow unauthenticated non-loopback binds |
366
+ | `AGENT_RELAY_INTEGRATIONS` | `[]` | JSON integration token/scopes/target/callback config |
367
+ | `AGENT_RELAY_INTEGRATION_RATE_LIMIT_PER_MINUTE` | `120` | Per-integration event ingress limit |
208
368
 
209
369
  ### Plugin environment variables
210
370
 
211
371
  | Variable | Default | Purpose |
212
372
  |----------|---------|---------|
213
373
  | `AGENT_RELAY_URL` | `http://localhost:4850` | Relay server URL |
374
+ | `AGENT_RELAY_TOKEN` | unset | Token sent by Codex/dashboard/API clients |
214
375
  | `AGENT_RELAY_CAPS` | `chat` | Comma-separated agent capabilities |
215
376
 
216
377
  Agent IDs are deterministic: `{hostname}-{rig}-{project}-{pid-hash}`.
217
378
 
218
- ### Running as a systemd service
379
+ ### Managed daemon
380
+
381
+ Agent Relay can install itself as a user-level daemon and load on computer
382
+ restart. The installer chooses the safest supported backend:
383
+
384
+ - Linux with `systemctl`: systemd user service by default.
385
+ - macOS: launchd LaunchAgent.
386
+ - Unsupported environments: prints a manual command instead of guessing.
387
+
388
+ Recommended first-time setup:
389
+
390
+ ```bash
391
+ # inspect what will be written
392
+ agent-relay setup --dry-run
393
+
394
+ # write the env file with a generated AGENT_RELAY_TOKEN
395
+ agent-relay setup --yes
396
+
397
+ # install, enable at login/boot, and start now
398
+ agent-relay daemon install --binary "$(command -v agent-relay)" --enable --start --yes
399
+ ```
400
+
401
+ Lifecycle commands:
219
402
 
220
403
  ```bash
221
- # create user service
222
- mkdir -p ~/.config/systemd/user
223
- cat > ~/.config/systemd/user/agent-relay.service << 'EOF'
224
- [Unit]
225
- Description=Agent Relay
226
- After=network.target
404
+ agent-relay daemon status
405
+ agent-relay daemon logs
406
+ agent-relay daemon restart
407
+ agent-relay daemon stop
408
+ agent-relay daemon start
409
+ agent-relay daemon disable
410
+ agent-relay daemon enable
411
+ agent-relay daemon uninstall
412
+ ```
227
413
 
228
- [Service]
229
- ExecStart=%h/.bun/bin/bunx agent-relay-server@latest
230
- Environment=HOST=0.0.0.0
231
- Restart=always
414
+ Useful options:
232
415
 
233
- [Install]
234
- WantedBy=default.target
235
- EOF
416
+ ```bash
417
+ # preview service files/commands without changing the machine
418
+ agent-relay daemon install --dry-run --enable --start
419
+
420
+ # remote/VPN bind; setup will still generate a token
421
+ agent-relay setup --host 0.0.0.0 --yes
422
+ agent-relay daemon install --binary "$(command -v agent-relay)" --enable --start --yes
236
423
 
237
- systemctl --user daemon-reload
238
- systemctl --user enable --now agent-relay
424
+ # use a known stable binary/script path in the service file
425
+ agent-relay daemon install --binary ~/.bun/bin/agent-relay --enable --start
239
426
  ```
240
427
 
428
+ Daemon install/uninstall refuses to overwrite or remove service files that do
429
+ not contain Agent Relay's managed marker unless `--force` is passed.
430
+
241
431
  ### Version compatibility
242
432
 
243
433
  The plugin checks the server version on startup and warns if they diverge. Both packages share version numbers. Update them together:
@@ -254,6 +444,16 @@ claude plugin update agent-relay@agent-relay
254
444
 
255
445
  Base URL: `http://localhost:4850/api`
256
446
 
447
+ When `AGENT_RELAY_TOKEN` is set, pass it with either header:
448
+
449
+ ```bash
450
+ curl -H "Authorization: Bearer $AGENT_RELAY_TOKEN" http://localhost:4850/api/stats
451
+ curl -H "X-Agent-Relay-Token: $AGENT_RELAY_TOKEN" http://localhost:4850/api/stats
452
+ ```
453
+
454
+ The dashboard asks for the token on first 401 and stores it in browser
455
+ `localStorage` for that origin.
456
+
257
457
  ### Agents
258
458
 
259
459
  | Method | Path | Purpose |
@@ -279,6 +479,17 @@ Base URL: `http://localhost:4850/api`
279
479
  | `DELETE` | `/messages/:id` | Delete message |
280
480
  | `GET` | `/messages/cursor` | Latest message ID (for poller bootstrap) |
281
481
 
482
+ ### Tasks
483
+
484
+ | Method | Path | Purpose |
485
+ |--------|------|---------|
486
+ | `POST` | `/integrations/events` | Integration event ingress |
487
+ | `GET` | `/tasks` | List tasks |
488
+ | `GET` | `/tasks/:id` | Get one task |
489
+ | `GET` | `/tasks/:id/events` | Get task event history |
490
+ | `POST` | `/tasks/:id/claim` | Claim a task |
491
+ | `PATCH` | `/tasks/:id/status` | Update task status/result |
492
+
282
493
  ### Server-Sent Events
283
494
 
284
495
  | Method | Path | Purpose |
@@ -297,14 +508,18 @@ Events: `message.new`, `message.claimed`, `message.deleted`, `agent.status`, `ag
297
508
 
298
509
  ```
299
510
  src/
511
+ ├── cli.ts # server CLI, setup, daemon commands
512
+ ├── daemon.ts # OS daemon planning/execution
300
513
  ├── index.ts # Bun.serve entry, static files, CORS
514
+ ├── setup.ts # onboarding env-file generation
301
515
  ├── routes.ts # HTTP router and API handlers
302
516
  ├── db.ts # SQLite schema, queries, CRUD
303
517
  ├── sse.ts # Server-Sent Events
304
518
  └── types.ts # TypeScript interfaces
305
519
 
306
520
  public/
307
- └── index.html # Dashboard SPA (Alpine.js + Tabler)
521
+ ├── index.html # Dashboard markup
522
+ └── dashboard.js # Dashboard Alpine model and behavior
308
523
 
309
524
  claude/ # Claude Code plugin
310
525
  ├── .claude-plugin/plugin.json
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- import { appendFileSync, chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { createInterface } from "node:readline/promises";
@@ -55,11 +55,12 @@ function usage(exitCode = 0): never {
55
55
  Usage:
56
56
  agent-relay-codex [--relay-url URL] [--listen ws://127.0.0.1:PORT] [-- <codex args...>]
57
57
  agent-relay-codex install [--alias|--no-alias]
58
+ agent-relay-codex uninstall
58
59
  agent-relay-codex alias install
59
60
  agent-relay-codex alias remove
60
61
  agent-relay-codex doctor
61
- agent-relay-codex start [--relay-url URL] [--listen ws://127.0.0.1:PORT] [-- <codex args...>]
62
- codex-relay [--relay-url URL] [--listen ws://127.0.0.1:PORT] [-- <codex args...>]
62
+ agent-relay-codex start [--relay-url URL] [--listen ws://127.0.0.1:PORT] [--thread-mode auto|resume|start] [-- <codex args...>]
63
+ codex-relay [--relay-url URL] [--listen ws://127.0.0.1:PORT] [--thread-mode auto|resume|start] [-- <codex args...>]
63
64
 
64
65
  With no subcommand, this launches Codex with live Agent Relay support.`);
65
66
  process.exit(exitCode);
@@ -165,7 +166,9 @@ async function getRelayStats(relayUrl: string): Promise<RelayStats | null> {
165
166
  const controller = new AbortController();
166
167
  const timeout = setTimeout(() => controller.abort(), 1500);
167
168
  try {
168
- const response = await fetch(new URL("/api/stats", relayUrl), { signal: controller.signal });
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 });
169
172
  if (!response.ok) return null;
170
173
  return (await response.json()) as RelayStats;
171
174
  } catch {
@@ -328,6 +331,35 @@ timeout = 10
328
331
  else writeFileSync(hooksPath, `${JSON.stringify(hooksJson, null, 2)}\n`);
329
332
  }
330
333
 
334
+ function removeHook(): void {
335
+ const configPath = join(home, ".codex", "config.toml");
336
+ if (existsSync(configPath)) {
337
+ const cleaned = removeAgentRelaySessionStartToml(readFileSync(configPath, "utf8"));
338
+ if (cleaned.trim()) writeFileSync(configPath, cleaned);
339
+ else rmSync(configPath, { force: true });
340
+ }
341
+
342
+ const hooksPath = join(home, ".codex", "hooks.json");
343
+ if (!existsSync(hooksPath)) return;
344
+
345
+ const hooksJson = readJsonFile<HooksJson>(hooksPath, { hooks: {} });
346
+ hooksJson.hooks ??= {};
347
+ hooksJson.hooks.SessionStart = (hooksJson.hooks.SessionStart ?? [])
348
+ .map((group) => ({
349
+ ...group,
350
+ hooks: (group.hooks ?? []).filter((hook) => {
351
+ if (hook.type !== "command" || typeof hook.command !== "string") return true;
352
+ return !isAgentRelaySessionStartCommand(hook.command);
353
+ }),
354
+ }))
355
+ .filter((group) => (group.hooks ?? []).length > 0);
356
+
357
+ if (hooksJson.hooks.SessionStart.length === 0) delete hooksJson.hooks.SessionStart;
358
+
359
+ if (Object.keys(hooksJson.hooks).length === 0) rmSync(hooksPath, { force: true });
360
+ else writeFileSync(hooksPath, `${JSON.stringify(hooksJson, null, 2)}\n`);
361
+ }
362
+
331
363
  async function pickLoopbackUrl(): Promise<string> {
332
364
  const port = await new Promise<number>((resolvePort, reject) => {
333
365
  const server = net.createServer();
@@ -406,7 +438,7 @@ function spawnFallbackSidecar(runDir: string, env: Record<string, string | undef
406
438
 
407
439
  const sidecarEnv: Record<string, string | undefined> = {
408
440
  ...env,
409
- CODEX_THREAD_MODE: "auto",
441
+ CODEX_THREAD_MODE: env.AGENT_RELAY_CODEX_FALLBACK_THREAD_MODE || env.CODEX_THREAD_MODE || "start",
410
442
  CODEX_LIVE_CWD: process.cwd(),
411
443
  CODEX_LIVE_STATE_PATH: join(autoDir, "live-state.json"),
412
444
  };
@@ -519,6 +551,24 @@ function cleanupRun(runDir: string, appServer: ReturnType<typeof Bun.spawn> | nu
519
551
  }
520
552
  }
521
553
 
554
+ function stopRuntimeSidecars(): void {
555
+ if (!existsSync(runtimeRoot)) return;
556
+ for (const entry of readdirSync(runtimeRoot, { withFileTypes: true })) {
557
+ if (!entry.isDirectory()) continue;
558
+ const pidsPath = join(runtimeRoot, entry.name, "sidecar-pids.txt");
559
+ if (!existsSync(pidsPath)) continue;
560
+ for (const line of readFileSync(pidsPath, "utf8").split("\n")) {
561
+ const pid = Number(line.trim());
562
+ if (!Number.isFinite(pid) || pid <= 0) continue;
563
+ try {
564
+ process.kill(pid, "SIGTERM");
565
+ } catch {
566
+ // Sidecar already exited.
567
+ }
568
+ }
569
+ }
570
+ }
571
+
522
572
  function installCodexSupport(quiet = false): void {
523
573
  if (!commandExists("bun")) throw new Error("Bun is required: https://bun.sh");
524
574
  findCodexBinary();
@@ -634,6 +684,7 @@ async function start(args: string[]): Promise<void> {
634
684
 
635
685
  let relayUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
636
686
  let listenUrl = process.env.CODEX_APP_SERVER_URL || "";
687
+ let threadMode = process.env.CODEX_THREAD_MODE || "start";
637
688
  const codexArgs: string[] = [];
638
689
 
639
690
  for (let index = 0; index < args.length; index += 1) {
@@ -650,11 +701,20 @@ async function start(args: string[]): Promise<void> {
650
701
  listenUrl = args[++index] || listenUrl;
651
702
  continue;
652
703
  }
704
+ if (arg === "--thread-mode") {
705
+ threadMode = args[++index] || threadMode;
706
+ if (!["auto", "resume", "start"].includes(threadMode)) {
707
+ throw new Error("--thread-mode must be one of: auto, resume, start");
708
+ }
709
+ continue;
710
+ }
653
711
  codexArgs.push(arg);
654
712
  }
655
713
 
656
714
  if (!listenUrl) listenUrl = await pickLoopbackUrl();
657
- if (!hasCodexPermissionMode(codexArgs)) codexArgs.unshift("--dangerously-bypass-approvals-and-sandbox");
715
+ if (!hasCodexPermissionMode(codexArgs)) {
716
+ codexArgs.unshift("--ask-for-approval", "never", "--sandbox", "workspace-write");
717
+ }
658
718
  const permissions = resolveSessionPermissions(codexArgs);
659
719
 
660
720
  mkdirSync(runtimeRoot, { recursive: true });
@@ -669,6 +729,7 @@ async function start(args: string[]): Promise<void> {
669
729
  AGENT_RELAY_CODEX_RUN_ID: runId,
670
730
  AGENT_RELAY_CODEX_RUNTIME_DIR: runDir,
671
731
  CODEX_APP_SERVER_URL: listenUrl,
732
+ CODEX_THREAD_MODE: threadMode,
672
733
  CODEX_LIVE_APPROVAL_POLICY: permissions.approvalPolicy,
673
734
  CODEX_LIVE_SANDBOX: permissions.sandbox,
674
735
  };
@@ -785,10 +846,22 @@ async function install(args: string[]): Promise<void> {
785
846
  }
786
847
  }
787
848
 
849
+ function uninstall(): void {
850
+ stopRuntimeSidecars();
851
+ removeHook();
852
+ removeLauncherShim("codex");
853
+ removeLauncherShim("codex-relay");
854
+ rmSync(marketplaceRoot, { recursive: true, force: true });
855
+ rmSync(installedPackageRoot, { recursive: true, force: true });
856
+ console.log("Uninstalled Agent Relay Codex hook, plugin marketplace files, and launcher shims.");
857
+ console.log(`PATH entries are left untouched; remove ${aliasBinDir} from your shell profile if you no longer want it there.`);
858
+ }
859
+
788
860
  async function main(): Promise<void> {
789
861
  const [command, ...args] = process.argv.slice(2);
790
862
  if (command === "help" || command === "--help" || command === "-h") usage(0);
791
863
  if (command === "install") return install(args);
864
+ if (command === "uninstall") return uninstall();
792
865
  if (command === "alias" && args[0] === "install") {
793
866
  installCodexSupport(false);
794
867
  return installCodexAlias();
package/codex/README.md CHANGED
@@ -57,9 +57,10 @@ and kills sidecars plus the app-server when Codex exits.
57
57
  Relay replies are usually sent with a shell command (`curl` to
58
58
  `/api/messages`), so Codex can prompt for approval in stricter modes.
59
59
 
60
- By default, `codex-relay` starts Codex with
61
- `--dangerously-bypass-approvals-and-sandbox` so relay turns do not get stuck on
62
- approval prompts. If you pass an explicit Codex runtime mode, `codex-relay`
60
+ By default, `codex-relay` starts Codex with `--ask-for-approval never --sandbox
61
+ workspace-write` so relay turns do not get stuck on approval prompts while
62
+ still keeping Codex inside workspace boundaries. If you pass an explicit Codex
63
+ runtime mode, `codex-relay`
63
64
  leaves it alone and forwards it to the sidecar, including `--ask-for-approval`,
64
65
  `--sandbox`, `--full-auto`, and `--yolo`.
65
66
 
@@ -75,6 +76,19 @@ Example: no prompt loop, still workspace sandboxing:
75
76
  codex-relay -- --ask-for-approval never --sandbox workspace-write
76
77
  ```
77
78
 
79
+ Trusted private rig only:
80
+
81
+ ```bash
82
+ codex-relay -- --dangerously-bypass-approvals-and-sandbox
83
+ ```
84
+
85
+ Thread attachment defaults are conservative. The SessionStart hook resumes the
86
+ actual launched Codex thread when Codex provides a thread id. If the launcher has
87
+ to use the fallback sidecar because the hook does not report in time, it starts a
88
+ new thread by default instead of attaching to whichever loaded thread happens to
89
+ match the cwd. Set `AGENT_RELAY_CODEX_FALLBACK_THREAD_MODE=auto` or run
90
+ `codex-relay --thread-mode auto` if you explicitly want cwd-based attachment.
91
+
78
92
  If you prefer prompts for everything else but want relay sends auto-approved,
79
93
  add a rule in `~/.codex/rules/default.rules` (adjust URL when using a remote relay):
80
94
 
@@ -96,6 +110,7 @@ bun run codex:smoke:fallback
96
110
  Useful environment variables:
97
111
 
98
112
  - `AGENT_RELAY_URL`
113
+ - `AGENT_RELAY_TOKEN`
99
114
  - `AGENT_RELAY_CAPS`
100
115
  - `AGENT_RELAY_CODEX_HOOK_TIMEOUT_MS` (launcher wait for SessionStart handshake, default `5000`)
101
116
  - `CODEX_APP_SERVER_URL`
@@ -115,7 +115,7 @@ const spawnEnv: Record<string, string | undefined> = {
115
115
  ...process.env,
116
116
  AGENT_RELAY_URL: relayUrl,
117
117
  CODEX_APP_SERVER_URL: appServerUrl,
118
- CODEX_THREAD_MODE: threadId ? "resume" : "auto",
118
+ CODEX_THREAD_MODE: threadId ? "resume" : process.env.CODEX_THREAD_MODE || "start",
119
119
  CODEX_LIVE_CWD: cwd,
120
120
  CODEX_LIVE_STATE_PATH: statePath,
121
121
  CODEX_MODEL: input.model || process.env.CODEX_MODEL || "",
@@ -173,4 +173,4 @@ const identity = buildAgentIdentity({
173
173
  appServerUrl,
174
174
  });
175
175
 
176
- outputContext(`Agent Relay active. Agent ID: ${identity.id}. Relay URL: ${relayUrl}. Incoming messages will arrive as live user turns. To reply or send a message, POST JSON to ${relayUrl}/api/messages with from="${identity.id}", to, subject, and body. Message etiquette: acknowledge incoming agent messages briefly unless they are obvious noise. Anti-loop rule: do not auto-reply to pure acknowledgements/thanks/received messages; acknowledge once, then follow up only when there is new work, a decision, or a deliverable.`);
176
+ outputContext(`Agent Relay active. Agent ID: ${identity.id}. Relay URL: ${relayUrl}. Incoming messages will arrive as live user turns. To reply or send a message, POST JSON to ${relayUrl}/api/messages with from="${identity.id}", to, subject, and body. If AGENT_RELAY_TOKEN is set, include it as the X-Agent-Relay-Token header. Message etiquette: acknowledge incoming agent messages briefly unless they are obvious noise. Anti-loop rule: do not auto-reply to pure acknowledgements/thanks/received messages; acknowledge once, then follow up only when there is new work, a decision, or a deliverable.`);
@@ -524,8 +524,7 @@ function formatRelayPrompt(messages: RelayMessage[]): string {
524
524
  message.body,
525
525
  "",
526
526
  "Treat this as a live incoming message from another agent. Respond or act on it as appropriate.",
527
- "Message etiquette: send a short acknowledgement or status reply unless the message is obvious noise.",
528
- "Anti-loop rule: do not auto-reply to pure acknowledgements, thanks, or received messages; acknowledge once, then only send follow-ups when there is new work, a decision, or a deliverable.",
527
+ "If AGENT_RELAY_TOKEN is set, include it as the X-Agent-Relay-Token header when calling the relay API.",
529
528
  `To reply, POST JSON to ${process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850"}/api/messages with from set to your Agent Relay ID, to set to ${JSON.stringify(message.from)}, and replyTo set to ${message.id}.`,
530
529
  );
531
530
  return lines.join("\n");
@@ -552,8 +551,7 @@ function formatRelayPrompt(messages: RelayMessage[]): string {
552
551
 
553
552
  lines.push(
554
553
  "Treat these as live incoming messages from other agents. Synthesize them into one coherent response or action.",
555
- "Message etiquette: send a short acknowledgement or status reply unless the messages are obvious noise.",
556
- "Anti-loop rule: do not auto-reply to pure acknowledgements, thanks, or received messages; acknowledge once, then only send follow-ups when there is new work, a decision, or a deliverable.",
554
+ "If AGENT_RELAY_TOKEN is set, include it as the X-Agent-Relay-Token header when calling the relay API.",
557
555
  `To reply, POST JSON to ${process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850"}/api/messages with from set to your Agent Relay ID and replyTo set to the message you are answering.`,
558
556
  );
559
557
  return lines.join("\n");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay",
3
- "version": "0.3.6",
3
+ "version": "0.4.1",
4
4
  "description": "Agent Relay integration for Codex sessions",
5
5
  "author": {
6
6
  "name": "Edin Mujkanovic"
@@ -17,6 +17,7 @@ Use the relay API at `${AGENT_RELAY_URL:-http://127.0.0.1:4850}`:
17
17
 
18
18
  ```bash
19
19
  curl -sS -X POST "${AGENT_RELAY_URL:-http://127.0.0.1:4850}/api/messages" \
20
+ ${AGENT_RELAY_TOKEN:+-H "X-Agent-Relay-Token: ${AGENT_RELAY_TOKEN}"} \
20
21
  -H 'Content-Type: application/json' \
21
22
  -d '{"from":"<this-agent-id>","to":"<target>","subject":"<subject>","body":"<message>"}'
22
23
  ```
package/codex/relay.ts CHANGED
@@ -77,7 +77,7 @@ export class RelayClient {
77
77
  url.searchParams.set("for", agentId);
78
78
  url.searchParams.set("unread", "true");
79
79
  if (sinceId > 0) url.searchParams.set("sinceId", String(sinceId));
80
- const response = await fetch(url);
80
+ const response = await fetch(url, { headers: this.headers() });
81
81
  if (!response.ok) {
82
82
  throw new Error(`relay poll failed: ${response.status} ${response.statusText}`);
83
83
  }
@@ -87,7 +87,7 @@ export class RelayClient {
87
87
  async claimMessage(messageId: number, agentId: string): Promise<boolean> {
88
88
  const response = await fetch(new URL(`/api/messages/${messageId}/claim`, this.baseUrl), {
89
89
  method: "POST",
90
- headers: { "Content-Type": "application/json" },
90
+ headers: this.headers({ "Content-Type": "application/json" }),
91
91
  body: JSON.stringify({ agentId }),
92
92
  });
93
93
 
@@ -104,7 +104,7 @@ export class RelayClient {
104
104
  private async json(method: string, path: string, body?: unknown): Promise<unknown> {
105
105
  const response = await fetch(new URL(path, this.baseUrl), {
106
106
  method,
107
- headers: { "Content-Type": "application/json" },
107
+ headers: this.headers({ "Content-Type": "application/json" }),
108
108
  body: body === undefined ? undefined : JSON.stringify(body),
109
109
  });
110
110
 
@@ -117,4 +117,9 @@ export class RelayClient {
117
117
  if (response.status === 204) return null;
118
118
  return response.json().catch(() => null);
119
119
  }
120
+
121
+ private headers(base: Record<string, string> = {}): Record<string, string> {
122
+ const token = process.env.AGENT_RELAY_TOKEN;
123
+ return token ? { ...base, "X-Agent-Relay-Token": token } : base;
124
+ }
120
125
  }