agent-relay-codex 0.4.14

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 ADDED
@@ -0,0 +1,124 @@
1
+ # agent-relay-codex
2
+
3
+ Codex integration for [Agent Relay](https://github.com/edimuj/agent-relay): auto-registers Codex sessions as agents and delivers incoming messages as live turns.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ # one-time setup (requires bun + codex CLI)
9
+ curl -fsSL https://unpkg.com/agent-relay-codex@latest/install-codex.sh | bash
10
+ ```
11
+
12
+ Windows PowerShell:
13
+
14
+ ```powershell
15
+ irm https://unpkg.com/agent-relay-codex@latest/install-codex.ps1 | iex
16
+ ```
17
+
18
+ The installer adds a `codex-relay` launcher and asks whether plain `codex` should also route through Agent Relay.
19
+
20
+ ## Usage
21
+
22
+ ```bash
23
+ # start the relay server (separate terminal)
24
+ bunx agent-relay-server@latest
25
+
26
+ # start Codex with Agent Relay (after restarting your shell)
27
+ codex-relay
28
+ ```
29
+
30
+ Without restarting your shell:
31
+
32
+ ```bash
33
+ bunx -p agent-relay-codex@latest codex-relay
34
+ ```
35
+
36
+ ## Configuration
37
+
38
+ | Env var | Default | Purpose |
39
+ |---------|---------|---------|
40
+ | `AGENT_RELAY_URL` | `http://localhost:4850` | Relay server URL |
41
+ | `AGENT_RELAY_TOKEN` | — | Auth token (for remote relays) |
42
+ | `AGENT_RELAY_CAPS` | `chat` | Comma-separated capabilities |
43
+ | `AGENT_RELAY_APPROVAL` | `open` | Approval mode: `open`, `guarded`, `read-only` |
44
+
45
+ These four env vars are shared with the [Claude plugin](https://www.npmjs.com/package/agent-relay-plugin).
46
+
47
+ ### Codex-specific
48
+
49
+ | Env var | Default | Purpose |
50
+ |---------|---------|---------|
51
+ | `CODEX_MODEL` | — | Model override |
52
+ | `CODEX_THREAD_MODE` | `start` | Thread attach: `start`, `resume`, `auto` |
53
+ | `CODEX_THREAD_ID` | — | Pin to a specific thread |
54
+ | `CODEX_APP_SERVER_URL` | `ws://127.0.0.1:4501` | App-server WebSocket URL |
55
+
56
+ ### Advanced tuning
57
+
58
+ | Env var | Default | Purpose |
59
+ |---------|---------|---------|
60
+ | `AGENT_RELAY_CODEX_RIG` | `codex-live` | Rig name on agent card |
61
+ | `AGENT_RELAY_CODEX_POLL_INTERVAL_MS` | `2000` | Inbox poll cadence |
62
+ | `AGENT_RELAY_CODEX_HEARTBEAT_INTERVAL_MS` | `30000` | Heartbeat cadence |
63
+ | `AGENT_RELAY_CODEX_COALESCE_WINDOW_MS` | `600` | Message batch window |
64
+ | `AGENT_RELAY_CODEX_RELAY_BACKOFF_INITIAL_MS` | `2000` | Relay API retry backoff |
65
+ | `AGENT_RELAY_CODEX_RELAY_BACKOFF_MAX_MS` | `60000` | Relay API retry backoff cap |
66
+ | `AGENT_RELAY_CODEX_HOOK_TIMEOUT_MS` | `5000` | SessionStart handshake timeout |
67
+
68
+ ## Approval mode
69
+
70
+ `codex-relay` maps `AGENT_RELAY_APPROVAL` to Codex runtime flags:
71
+
72
+ | Mode | Codex flags |
73
+ |------|-------------|
74
+ | `open` | `--ask-for-approval never --sandbox danger-full-access` |
75
+ | `guarded` | `--ask-for-approval on-request --sandbox workspace-write` |
76
+ | `read-only` | `--ask-for-approval never --sandbox read-only` |
77
+
78
+ Pass explicit Codex flags to override:
79
+
80
+ ```bash
81
+ # trusted private rig: no sandbox
82
+ codex-relay -- --dangerously-bypass-approvals-and-sandbox
83
+
84
+ # full-auto mode
85
+ codex-relay -- --full-auto
86
+ ```
87
+
88
+ If `AGENT_RELAY_APPROVAL` is set, explicit Codex permission flags must resolve
89
+ to the same effective mode.
90
+
91
+ The approval mode is registered on the agent card as `meta.approvalMode` (`open`, `guarded`, or `read-only`), visible in the dashboard.
92
+
93
+ ## Uninstall
94
+
95
+ ```bash
96
+ agent-relay-codex uninstall # remove hooks, plugins, shims
97
+ agent-relay-codex uninstall --purge # also remove runtime state and PATH entries
98
+ ```
99
+
100
+ ## How it works
101
+
102
+ `codex-relay` launches `codex app-server`, opens Codex through `codex --remote`, and spawns a live sidecar that:
103
+
104
+ - Registers the session as an Agent Relay agent
105
+ - Polls the relay inbox and delivers messages into the active Codex thread
106
+ - Coalesces rapid message bursts into a single turn
107
+ - Claims claimable tasks before delivery
108
+ - Reconnects with exponential backoff after disconnects
109
+ - Marks the agent offline on exit
110
+
111
+ Message delivery adapts to thread state: `turn/start` when idle, `turn/steer` when active, `turn/interrupt` for urgent messages.
112
+
113
+ ## Development
114
+
115
+ ```bash
116
+ # run sidecar directly
117
+ bash codex/start-live.sh
118
+
119
+ # run tests
120
+ bun test codex/
121
+
122
+ # doctor check
123
+ bun run codex/bin/agent-relay-codex.ts doctor
124
+ ```
package/app-client.ts ADDED
@@ -0,0 +1,239 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+
3
+ type JsonRpcId = number | string;
4
+
5
+ type JsonRpcRequest = {
6
+ id: JsonRpcId;
7
+ method: string;
8
+ params?: unknown;
9
+ };
10
+
11
+ type JsonRpcResponse = {
12
+ id: JsonRpcId;
13
+ result?: unknown;
14
+ error?: { code: number; message: string; data?: unknown };
15
+ };
16
+
17
+ type JsonRpcNotification = {
18
+ method: string;
19
+ params?: Record<string, unknown>;
20
+ };
21
+
22
+ export type ThreadStatus =
23
+ | { type: "notLoaded" }
24
+ | { type: "idle" }
25
+ | { type: "systemError" }
26
+ | { type: "active"; activeFlags: string[] };
27
+
28
+ export type TurnStatus = "completed" | "interrupted" | "failed" | "inProgress";
29
+
30
+ export interface Turn {
31
+ id: string;
32
+ status: TurnStatus;
33
+ startedAt: number | null;
34
+ completedAt: number | null;
35
+ }
36
+
37
+ export interface Thread {
38
+ id: string;
39
+ cwd: string;
40
+ status: ThreadStatus;
41
+ updatedAt: number;
42
+ preview: string;
43
+ turns?: Turn[];
44
+ }
45
+
46
+ export type ClientEvent =
47
+ | { type: "notification"; message: JsonRpcNotification }
48
+ | { type: "server-request"; message: JsonRpcRequest }
49
+ | { type: "response"; message: JsonRpcResponse };
50
+
51
+ export interface TurnStartResponse {
52
+ turn: Turn;
53
+ }
54
+
55
+ export interface ThreadStartResponse {
56
+ thread: Thread;
57
+ }
58
+
59
+ export interface ThreadResumeResponse {
60
+ thread: Thread;
61
+ }
62
+
63
+ export interface ThreadReadResponse {
64
+ thread: Thread;
65
+ }
66
+
67
+ export interface ThreadListResponse {
68
+ data: Thread[];
69
+ nextCursor: string | null;
70
+ }
71
+
72
+ export interface ThreadLoadedListResponse {
73
+ data: string[];
74
+ nextCursor: string | null;
75
+ }
76
+
77
+ export class CodexAppClient {
78
+ private ws!: WebSocket;
79
+ private nextId = 1;
80
+ private pending = new Map<JsonRpcId, { resolve: (value: any) => void; reject: (err: unknown) => void }>();
81
+ private events: ClientEvent[] = [];
82
+ private listeners = new Set<(event: ClientEvent) => void>();
83
+ private connected = false;
84
+ private connectionListeners = new Set<(connected: boolean) => void>();
85
+
86
+ constructor(private readonly url: string, private readonly log: (msg: string) => void = () => {}) {}
87
+
88
+ async connect(): Promise<void> {
89
+ if (this.connected) return;
90
+ await new Promise<void>((resolve, reject) => {
91
+ const ws = new WebSocket(this.url);
92
+ this.ws = ws;
93
+
94
+ ws.onopen = () => {
95
+ this.connected = true;
96
+ this.emitConnection(true);
97
+ resolve();
98
+ };
99
+ ws.onerror = (event) => reject(new Error(`websocket error: ${String((event as ErrorEvent).message || "unknown")}`));
100
+ ws.onclose = (event) => {
101
+ this.connected = false;
102
+ this.emitConnection(false);
103
+ const err = new Error(`websocket closed code=${event.code} reason=${event.reason || "(none)"}`);
104
+ for (const pending of this.pending.values()) pending.reject(err);
105
+ this.pending.clear();
106
+ };
107
+ ws.onmessage = (event) => this.handleMessage(String(event.data));
108
+ });
109
+ }
110
+
111
+ close(): void {
112
+ if (!this.ws) return;
113
+ this.ws.close();
114
+ }
115
+
116
+ isConnected(): boolean {
117
+ return this.connected;
118
+ }
119
+
120
+ async initialize(): Promise<unknown> {
121
+ return this.request("initialize", {
122
+ clientInfo: {
123
+ name: "agent-relay-codex-live",
124
+ title: "Agent Relay Codex Live",
125
+ version: "0.1.0",
126
+ },
127
+ capabilities: {
128
+ experimentalApi: true,
129
+ },
130
+ });
131
+ }
132
+
133
+ onEvent(listener: (event: ClientEvent) => void): () => void {
134
+ this.listeners.add(listener);
135
+ return () => this.listeners.delete(listener);
136
+ }
137
+
138
+ onConnectionChange(listener: (connected: boolean) => void): () => void {
139
+ this.connectionListeners.add(listener);
140
+ return () => this.connectionListeners.delete(listener);
141
+ }
142
+
143
+ getEvents(): ClientEvent[] {
144
+ return [...this.events];
145
+ }
146
+
147
+ async settle(ms = 150): Promise<void> {
148
+ await delay(ms);
149
+ }
150
+
151
+ async threadStart(params: Record<string, unknown>): Promise<ThreadStartResponse> {
152
+ return this.request<ThreadStartResponse>("thread/start", params);
153
+ }
154
+
155
+ async threadResume(params: Record<string, unknown>): Promise<ThreadResumeResponse> {
156
+ return this.request<ThreadResumeResponse>("thread/resume", params);
157
+ }
158
+
159
+ async threadRead(threadId: string, includeTurns = false): Promise<ThreadReadResponse> {
160
+ return this.request<ThreadReadResponse>("thread/read", { threadId, includeTurns });
161
+ }
162
+
163
+ async threadList(params: Record<string, unknown>): Promise<ThreadListResponse> {
164
+ return this.request<ThreadListResponse>("thread/list", params);
165
+ }
166
+
167
+ async threadLoadedList(limit = 20): Promise<ThreadLoadedListResponse> {
168
+ return this.request<ThreadLoadedListResponse>("thread/loaded/list", { limit });
169
+ }
170
+
171
+ async turnStart(threadId: string, text: string): Promise<TurnStartResponse> {
172
+ return this.request<TurnStartResponse>("turn/start", {
173
+ threadId,
174
+ input: [{ type: "text", text }],
175
+ });
176
+ }
177
+
178
+ async turnSteer(threadId: string, turnId: string, text: string): Promise<{ turnId: string }> {
179
+ return this.request<{ turnId: string }>("turn/steer", {
180
+ threadId,
181
+ expectedTurnId: turnId,
182
+ input: [{ type: "text", text }],
183
+ });
184
+ }
185
+
186
+ async turnInterrupt(threadId: string, turnId: string): Promise<Record<string, never>> {
187
+ return this.request<Record<string, never>>("turn/interrupt", { threadId, turnId });
188
+ }
189
+
190
+ private async request<T = unknown>(method: string, params?: unknown): Promise<T> {
191
+ if (!this.connected) {
192
+ throw new Error("websocket not connected");
193
+ }
194
+ const id = this.nextId++;
195
+ const payload: JsonRpcRequest = { id, method, params };
196
+ const promise = new Promise<T>((resolve, reject) => {
197
+ this.pending.set(id, { resolve, reject });
198
+ });
199
+ this.ws.send(JSON.stringify(payload));
200
+ return promise;
201
+ }
202
+
203
+ private handleMessage(raw: string): void {
204
+ const parsed = JSON.parse(raw) as JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
205
+
206
+ if ("id" in parsed && ("result" in parsed || "error" in parsed)) {
207
+ const pending = this.pending.get(parsed.id);
208
+ if (pending) {
209
+ this.pending.delete(parsed.id);
210
+ if (parsed.error) {
211
+ pending.reject(new Error(`${parsed.error.message} (${parsed.error.code})`));
212
+ } else {
213
+ pending.resolve(parsed.result);
214
+ }
215
+ }
216
+ this.record({ type: "response", message: parsed });
217
+ return;
218
+ }
219
+
220
+ if ("id" in parsed && "method" in parsed) {
221
+ this.log(`server-request ${parsed.method}`);
222
+ this.record({ type: "server-request", message: parsed });
223
+ return;
224
+ }
225
+
226
+ if ("method" in parsed) {
227
+ this.record({ type: "notification", message: parsed });
228
+ }
229
+ }
230
+
231
+ private record(event: ClientEvent): void {
232
+ this.events.push(event);
233
+ for (const listener of this.listeners) listener(event);
234
+ }
235
+
236
+ private emitConnection(connected: boolean): void {
237
+ for (const listener of this.connectionListeners) listener(connected);
238
+ }
239
+ }
package/approval.ts ADDED
@@ -0,0 +1,29 @@
1
+ export type ApprovalMode = "open" | "guarded" | "read-only";
2
+
3
+ export type SessionPermissions = {
4
+ approvalPolicy?: string;
5
+ sandbox?: string;
6
+ };
7
+
8
+ export function parseApprovalMode(raw: string | undefined): ApprovalMode {
9
+ if (raw === "guarded" || raw === "read-only" || raw === "open") return raw;
10
+ return "open";
11
+ }
12
+
13
+ export function codexArgsForApprovalMode(mode: ApprovalMode): string[] {
14
+ if (mode === "read-only") return ["--ask-for-approval", "never", "--sandbox", "read-only"];
15
+ if (mode === "guarded") return ["--ask-for-approval", "on-request", "--sandbox", "workspace-write"];
16
+ return ["--ask-for-approval", "never", "--sandbox", "danger-full-access"];
17
+ }
18
+
19
+ export function approvalModeFromPermissions(permissions: SessionPermissions): ApprovalMode {
20
+ if (permissions.sandbox === "read-only") return "read-only";
21
+ if (permissions.sandbox === "workspace-write") return "guarded";
22
+ return "open";
23
+ }
24
+
25
+ export function describeApprovalMode(mode: ApprovalMode): string {
26
+ if (mode === "read-only") return "observe, analyze, and report only; no file writes or mutation";
27
+ if (mode === "guarded") return "workspace sandboxing with approval prompts for operations Codex considers risky";
28
+ return "no Agent Relay restrictions; Codex runs without sandbox restrictions";
29
+ }