agent-relay-server 0.3.11 → 0.4.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 +237 -22
- package/bin/agent-relay-codex.ts +79 -6
- package/codex/README.md +18 -3
- package/codex/hooks/session-start.ts +2 -2
- package/codex/live-sidecar.ts +2 -0
- package/codex/plugin/.codex-plugin/plugin.json +1 -1
- package/codex/plugin/skills/agent-relay/SKILL.md +1 -0
- package/codex/relay.ts +8 -3
- package/examples/integrations/github-issue.ts +54 -0
- package/examples/integrations/ops-alert.sh +27 -0
- package/examples/integrations/prometheus-alertmanager.ts +61 -0
- package/examples/integrations/support-ticket.sh +28 -0
- package/package.json +5 -4
- package/public/dashboard.js +701 -0
- package/public/index.html +143 -504
- package/src/cli.ts +217 -0
- package/src/config.ts +38 -0
- package/src/daemon.ts +453 -0
- package/src/db.ts +442 -16
- package/src/index.ts +96 -70
- package/src/routes.ts +334 -17
- package/src/security.ts +103 -0
- package/src/setup.ts +187 -0
- package/src/sse.ts +18 -2
- package/src/types.ts +67 -1
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
|
-
|
|
87
|
-
|
|
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,
|
|
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
|
-
###
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
package/bin/agent-relay-codex.ts
CHANGED
|
@@ -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
|
|
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: "
|
|
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))
|
|
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
|
-
|
|
62
|
-
|
|
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" : "
|
|
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.`);
|
|
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.`);
|
package/codex/live-sidecar.ts
CHANGED
|
@@ -524,6 +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
|
+
"If AGENT_RELAY_TOKEN is set, include it as the X-Agent-Relay-Token header when calling the relay API.",
|
|
527
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}.`,
|
|
528
529
|
);
|
|
529
530
|
return lines.join("\n");
|
|
@@ -550,6 +551,7 @@ function formatRelayPrompt(messages: RelayMessage[]): string {
|
|
|
550
551
|
|
|
551
552
|
lines.push(
|
|
552
553
|
"Treat these as live incoming messages from other agents. Synthesize them into one coherent response or action.",
|
|
554
|
+
"If AGENT_RELAY_TOKEN is set, include it as the X-Agent-Relay-Token header when calling the relay API.",
|
|
553
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.`,
|
|
554
556
|
);
|
|
555
557
|
return lines.join("\n");
|
|
@@ -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
|
}
|