codex-relay 1.4.2 → 1.4.3

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.
Files changed (4) hide show
  1. package/README.md +28 -18
  2. package/dist/cli.js +60 -2
  3. package/dist/src.js +197 -103
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -30,14 +30,18 @@ After approval, the phone can list Codex threads, start new work, stream message
30
30
 
31
31
  ## Shared Terminal and Mobile Sessions
32
32
 
33
- By default, Codex Relay starts a private app-server process. A terminal TUI that was started separately can resume the same saved thread, but it does not receive the relay process's live events.
33
+ On macOS, Codex Relay prefers Codex's shared Unix socket so terminal and mobile clients can follow the same live sessions. If the shared app-server cannot start or initialize, the relay prints a warning and continues with a private app-server.
34
34
 
35
- Opt in to Codex's shared app-server:
35
+ Linux, WSL, and native Windows keep using a private app-server by default. A terminal TUI that was started separately can resume the same saved thread, but it does not receive the relay process's live events.
36
+
37
+ Require the shared app-server on any platform:
36
38
 
37
39
  ```sh
38
40
  npx codex-relay@latest --shared-app-server
39
41
  ```
40
42
 
43
+ This explicit mode does not fall back to a private app-server when shared startup fails.
44
+
41
45
  When a shared app-server is already running, the relay attaches to it instead of starting another one. If the relay's own socket connection resets, it reconnects without deliberately stopping the shared app-server.
42
46
 
43
47
  Then connect a new terminal TUI to the shared app-server. On macOS, Linux, or WSL:
@@ -56,7 +60,7 @@ Pass a thread ID after the remote endpoint to open a specific thread. The relay
56
60
 
57
61
  Shared mode uses Codex's experimental app-server transport. A directly connected terminal TUI has its own WebSocket connection, which the relay cannot observe or reconnect. If that terminal reports a socket reset while the thread continues on mobile, reconnect it with the matching remote endpoint above and append the thread ID if needed.
58
62
 
59
- Shared mode requires a recent Codex CLI with app-server and `resume --remote` support. It uses a Unix socket on macOS, Linux, and WSL, or a loopback-only WebSocket on Windows. If those features are unavailable, update Codex or omit `--shared-app-server` to keep the existing private mode.
63
+ Shared mode requires a recent Codex CLI with app-server and `resume --remote` support. It uses a Unix socket on macOS, Linux, and WSL, or a loopback-only WebSocket on Windows. If explicit shared mode is unavailable, update Codex or omit `--shared-app-server`. On macOS, set `CODEX_RELAY_APP_SERVER_MODE=stdio` to force private mode instead of using the shared-first default.
60
64
 
61
65
  ## Background Mode
62
66
 
@@ -79,10 +83,10 @@ Print the current pairing QR again:
79
83
  npx codex-relay@latest qr
80
84
  ```
81
85
 
82
- Stop a background server with the printed process id:
86
+ Stop a background server:
83
87
 
84
88
  ```sh
85
- kill -TERM <pid>
89
+ npx codex-relay@latest stop
86
90
  ```
87
91
 
88
92
  ## Commands
@@ -99,11 +103,17 @@ npx codex-relay@latest --bg
99
103
 
100
104
  Start the relay in the background.
101
105
 
106
+ ```sh
107
+ npx codex-relay@latest stop
108
+ ```
109
+
110
+ Stop the background relay. Repeating this command is safe when no background server is running.
111
+
102
112
  ```sh
103
113
  npx codex-relay@latest --shared-app-server
104
114
  ```
105
115
 
106
- Start the relay through Codex's shared app-server socket.
116
+ Require the relay to start through Codex's shared app-server socket.
107
117
 
108
118
  ```sh
109
119
  npx codex-relay@latest qr
@@ -127,17 +137,17 @@ Start the relay and automatically approve mobile pairing requests. Use this only
127
137
 
128
138
  The relay listens on `0.0.0.0:8787` by default. Configure it with environment variables:
129
139
 
130
- | Variable | Purpose |
131
- | -------------------------------------- | -------------------------------------------------------------------------------------------------- |
132
- | `PORT` | Server port. Defaults to `8787`. |
133
- | `HOST` | Listen host. Defaults to `0.0.0.0`. |
134
- | `CODEX_RELAY_WORKSPACE_PATH` | Workspace path Codex should use. Defaults to the directory where you run `npx codex-relay@latest`. |
135
- | `CODEX_RELAY_AUTH_DB_PATH` | Pairing and session database path. Defaults to `.codex-relay/auth.db`. |
136
- | `CODEX_RELAY_APPROVAL_SECRET` | Secret used by the local approve command. Usually generated automatically. |
137
- | `CODEX_RELAY_DANGEROUSLY_AUTO_APPROVE` | Set to `1` to auto-approve mobile pairing requests. Prefer the CLI flag for local use. |
138
- | `CODEX_RELAY_APP_SERVER_MODE` | Set to `socket` for shared terminal/mobile sessions. Defaults to `stdio`. |
139
- | `CODEX_HOME` | Codex home directory, used when reading Codex session metadata. |
140
- | `CODEX_BIN` | Codex CLI executable path. |
140
+ | Variable | Purpose |
141
+ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
142
+ | `PORT` | Server port. Defaults to `8787`. |
143
+ | `HOST` | Listen host. Defaults to `0.0.0.0`. |
144
+ | `CODEX_RELAY_WORKSPACE_PATH` | Workspace path Codex should use. Defaults to the directory where you run `npx codex-relay@latest`. |
145
+ | `CODEX_RELAY_AUTH_DB_PATH` | Pairing and session database path. Defaults to `.codex-relay/auth.db`. |
146
+ | `CODEX_RELAY_APPROVAL_SECRET` | Secret used by the local approve command. Usually generated automatically. |
147
+ | `CODEX_RELAY_DANGEROUSLY_AUTO_APPROVE` | Set to `1` to auto-approve mobile pairing requests. Prefer the CLI flag for local use. |
148
+ | `CODEX_RELAY_APP_SERVER_MODE` | Set to `socket` to require shared mode or `stdio` to require private mode. Unset prefers shared mode with startup fallback on macOS and private mode elsewhere. |
149
+ | `CODEX_HOME` | Codex home directory, used when reading Codex session metadata. |
150
+ | `CODEX_BIN` | Codex CLI executable path. |
141
151
 
142
152
  Examples:
143
153
 
@@ -174,7 +184,7 @@ npx codex-relay@latest qr
174
184
  Or stop the background process shown by the CLI:
175
185
 
176
186
  ```sh
177
- kill -TERM <pid>
187
+ npx codex-relay@latest stop
178
188
  ```
179
189
 
180
190
  If the mobile app cannot connect, confirm that the phone can reach the printed `Mobile:` URL and that the chosen port is not blocked by a firewall.
package/dist/cli.js CHANGED
@@ -20,6 +20,23 @@ async function readRunningRelayPid(pidPath, options = {}) {
20
20
  const command = await (options.commandReader ?? readProcessCommand)(pid);
21
21
  return command && isRelayProcessCommand(command) ? pid : void 0;
22
22
  }
23
+ async function stopRunningRelay(pidPath, options = {}) {
24
+ const pid = await readRunningRelayPid(pidPath, options);
25
+ if (!pid) {
26
+ await rm(pidPath, { force: true });
27
+ return { kind: "not-running" };
28
+ }
29
+ (options.signalProcess ?? signalRelayProcess)(pid);
30
+ if (!await (options.waitForProcessExit ?? waitForRelayProcessExit)(pid)) return {
31
+ kind: "timed-out",
32
+ pid
33
+ };
34
+ await rm(pidPath, { force: true });
35
+ return {
36
+ kind: "stopped",
37
+ pid
38
+ };
39
+ }
23
40
  function defaultIsProcessAlive(pid) {
24
41
  try {
25
42
  process.kill(pid, 0);
@@ -28,6 +45,21 @@ function defaultIsProcessAlive(pid) {
28
45
  return false;
29
46
  }
30
47
  }
48
+ function signalRelayProcess(pid) {
49
+ try {
50
+ process.kill(pid, "SIGTERM");
51
+ } catch (error) {
52
+ if (error instanceof Error && "code" in error && error.code === "ESRCH") return;
53
+ throw error;
54
+ }
55
+ }
56
+ async function waitForRelayProcessExit(pid) {
57
+ for (let attempt = 0; attempt < 100; attempt += 1) {
58
+ if (!defaultIsProcessAlive(pid)) return true;
59
+ await setTimeout(50);
60
+ }
61
+ return false;
62
+ }
31
63
  async function readProcessCommand(pid) {
32
64
  try {
33
65
  const { stdout } = await execFileAsync("ps", [
@@ -48,12 +80,13 @@ function isRelayProcessCommand(command) {
48
80
  //#endregion
49
81
  //#region src/cli.ts
50
82
  const npxCommand = "npx codex-relay@latest";
51
- const program = new Command().name("codex-relay").description("Run and approve the codex-relay local CLI bridge.").option("--bg", "run the Codex Relay server in the background").option("--debug", "write verbose relay diagnostics to debug.log").option("--shared-app-server", "use a shared Codex app-server socket so terminal and mobile can share live sessions").option("--dangerously-auto-approve", "automatically approve mobile pairing requests without a local approval command").addHelpText("after", `
83
+ const program = new Command().name("codex-relay").description("Run and approve the codex-relay local CLI bridge.").option("--bg", "run the Codex Relay server in the background").option("--debug", "write verbose relay diagnostics to debug.log").option("--shared-app-server", "require a shared Codex app-server so terminal and mobile can share live sessions").option("--dangerously-auto-approve", "automatically approve mobile pairing requests without a local approval command").addHelpText("after", `
52
84
 
53
85
  Examples:
54
86
  ${npxCommand} Start the relay and print a pairing QR
55
87
  ${npxCommand} --shared-app-server Share live sessions with a connected terminal
56
88
  ${npxCommand} --bg Start the relay in the background
89
+ ${npxCommand} stop Stop the background relay
57
90
  ${npxCommand} qr Print the current pairing QR
58
91
  ${npxCommand} clear Sign out every paired mobile app
59
92
  ${npxCommand} approve CODE Approve a pending mobile pairing request`).action(async (options) => {
@@ -66,6 +99,9 @@ Examples:
66
99
  }
67
100
  await import("./src.js").catch(handleServerStartError);
68
101
  });
102
+ program.command("stop").description("Stop the background Codex Relay server.").action(async () => {
103
+ await stopBackgroundServer();
104
+ });
69
105
  program.command("qr").description("Print the current pairing QR for an already running server.").action(async () => {
70
106
  await printPairingQr();
71
107
  });
@@ -124,6 +160,7 @@ async function startBackgroundServer() {
124
160
  console.log(`Logs: ${logPath}`);
125
161
  if (process.env.CODEX_RELAY_DEBUG === "1") console.log(`Debug logs: ${debugLogPath}`);
126
162
  console.log(`Print the pairing QR later with: ${npxCommand} qr`);
163
+ console.log(`Stop the background relay with: ${npxCommand} stop`);
127
164
  }
128
165
  function backgroundArgs() {
129
166
  return process.argv.slice(2).filter((arg) => arg !== "--bg");
@@ -140,6 +177,27 @@ async function waitForBackgroundPid(child, pidPath) {
140
177
  await setTimeout(100);
141
178
  }
142
179
  }
180
+ async function stopBackgroundServer() {
181
+ printStopResult(await stopRunningRelay(codexRelayDataPath("server.pid")));
182
+ }
183
+ function printStopResult(result) {
184
+ switch (result.kind) {
185
+ case "not-running":
186
+ console.log("No background Codex Relay server is running.");
187
+ return;
188
+ case "stopped":
189
+ console.log(`Stopped codex-relay background server (pid ${result.pid}).`);
190
+ return;
191
+ case "timed-out":
192
+ console.error(`Timed out stopping codex-relay background server (pid ${result.pid}).`);
193
+ process.exitCode = 1;
194
+ return;
195
+ default: return assertNever(result);
196
+ }
197
+ }
198
+ function assertNever(value) {
199
+ throw new TypeError(`Unexpected background stop result: ${JSON.stringify(value)}`);
200
+ }
143
201
  async function approvePairing(rawCode) {
144
202
  const approvalCode = normalizeApprovalCode(rawCode ?? "");
145
203
  if (!approvalCode) {
@@ -277,7 +335,7 @@ async function handleServerStartError(error) {
277
335
  console.error(` ${npxCommand} qr`);
278
336
  console.error("");
279
337
  console.error("To stop the background server:");
280
- console.error(` kill -TERM ${existingPid}`);
338
+ console.error(` ${npxCommand} stop`);
281
339
  console.error("");
282
340
  console.error(`Logs: ${logPath}`);
283
341
  } else {
package/dist/src.js CHANGED
@@ -36,16 +36,31 @@ const stdioAppServerArgs = [
36
36
  ];
37
37
  const sharedWindowsServerUrl = "ws://127.0.0.1:8788";
38
38
  const windowsShellExtensions = new Set([".bat", ".cmd"]);
39
- function resolveCodexAppServerMode(env = process.env) {
39
+ function resolveCodexAppServerMode(input = {}) {
40
+ const env = input.env ?? process.env;
41
+ const platform$2 = input.platform ?? platform();
40
42
  const configuredMode = env.CODEX_RELAY_APP_SERVER_MODE?.trim().toLowerCase();
41
- if (!configuredMode || configuredMode === "stdio") return "stdio";
42
- if (configuredMode === "socket") return "socket";
43
+ if (!configuredMode) return platform$2 === "darwin" ? {
44
+ fallbackToStdio: true,
45
+ mode: "socket"
46
+ } : {
47
+ fallbackToStdio: false,
48
+ mode: "stdio"
49
+ };
50
+ if (configuredMode === "stdio") return {
51
+ fallbackToStdio: false,
52
+ mode: "stdio"
53
+ };
54
+ if (configuredMode === "socket") return {
55
+ fallbackToStdio: false,
56
+ mode: "socket"
57
+ };
43
58
  throw new Error(`Unsupported CODEX_RELAY_APP_SERVER_MODE ${JSON.stringify(configuredMode)}. Expected "stdio" or "socket".`);
44
59
  }
45
60
  function resolveCodexAppServerSpawn(input = {}) {
46
- const platform$2 = input.platform ?? platform();
61
+ const platform$3 = input.platform ?? platform();
47
62
  const command = resolveCodexBinary(input.env ?? process.env);
48
- const isWindows = platform$2 === "win32";
63
+ const isWindows = platform$3 === "win32";
49
64
  return {
50
65
  command,
51
66
  args: [...stdioAppServerArgs],
@@ -54,15 +69,15 @@ function resolveCodexAppServerSpawn(input = {}) {
54
69
  };
55
70
  }
56
71
  function resolveCodexSharedAppServerSpawn(input = {}) {
57
- const platform$3 = input.platform ?? platform();
72
+ const platform$4 = input.platform ?? platform();
58
73
  const command = resolveCodexBinary(input.env ?? process.env);
59
- const isWindows = platform$3 === "win32";
74
+ const isWindows = platform$4 === "win32";
60
75
  return {
61
76
  command,
62
77
  args: [
63
78
  "app-server",
64
79
  "--listen",
65
- resolveCodexSharedAppServerRemoteAddress(platform$3)
80
+ resolveCodexSharedAppServerRemoteAddress(platform$4)
66
81
  ],
67
82
  shell: isWindows && shouldUseWindowsShell(command),
68
83
  windowsHide: isWindows
@@ -126,8 +141,10 @@ const sharedSocketReconnectDelaysMs = [
126
141
  2e3
127
142
  ];
128
143
  var CodexAppServerClient = class {
144
+ activeMode;
129
145
  child;
130
146
  closed = false;
147
+ fallbackToStdio;
131
148
  initialized;
132
149
  notificationHandlers = /* @__PURE__ */ new Set();
133
150
  nextId = 1;
@@ -135,12 +152,21 @@ var CodexAppServerClient = class {
135
152
  reconnecting;
136
153
  requestHandlers = /* @__PURE__ */ new Set();
137
154
  readline;
155
+ sharedReconnectEnabled = false;
138
156
  sharedServer;
139
157
  socket;
158
+ onStartupFallback;
140
159
  startSharedServer;
141
160
  constructor(options = {}) {
161
+ const mode = options.mode ?? resolveCodexAppServerMode();
162
+ this.activeMode = mode.mode;
163
+ this.fallbackToStdio = mode.fallbackToStdio;
164
+ this.onStartupFallback = options.onStartupFallback;
142
165
  this.startSharedServer = options.startSharedServer ?? startSharedCodexAppServer;
143
166
  }
167
+ get appServerMode() {
168
+ return this.activeMode;
169
+ }
144
170
  initialize() {
145
171
  return this.ensureInitialized();
146
172
  }
@@ -220,15 +246,8 @@ var CodexAppServerClient = class {
220
246
  this.closed = true;
221
247
  for (const pending of this.pending.values()) pending.reject(/* @__PURE__ */ new Error("Codex app-server was closed."));
222
248
  this.pending.clear();
223
- this.readline?.close();
224
- this.child?.kill();
225
- const socket = this.socket;
226
- this.socket = void 0;
227
- socket?.close();
228
- this.sharedServer?.kill();
229
- this.readline = void 0;
230
- this.child = void 0;
231
- this.sharedServer = void 0;
249
+ this.stopStdioCodexAppServer();
250
+ this.stopSharedCodexAppServer();
232
251
  this.initialized = void 0;
233
252
  }
234
253
  async request(method, params) {
@@ -262,13 +281,31 @@ var CodexAppServerClient = class {
262
281
  return this.initialized;
263
282
  }
264
283
  async start() {
265
- const mode = resolveCodexAppServerMode();
284
+ if (this.activeMode === "stdio") {
285
+ await this.startAndInitializeStdioCodexAppServer();
286
+ return;
287
+ }
288
+ try {
289
+ await this.startOrAttachSharedCodexAppServer();
290
+ await this.initializeAppServer();
291
+ this.fallbackToStdio = false;
292
+ this.sharedReconnectEnabled = true;
293
+ } catch (error) {
294
+ const sharedError = error instanceof Error ? error : new Error(String(error));
295
+ this.stopSharedCodexAppServer();
296
+ if (!this.fallbackToStdio) throw sharedError;
297
+ this.activeMode = "stdio";
298
+ relayDebugLog("app_server.shared_socket.startup_fallback", { message: sharedError.message });
299
+ this.onStartupFallback?.(sharedError);
300
+ await this.startAndInitializeStdioCodexAppServer();
301
+ }
302
+ }
303
+ async startAndInitializeStdioCodexAppServer() {
266
304
  try {
267
- if (mode === "socket") await this.startOrAttachSharedCodexAppServer();
268
- else this.startStdioCodexAppServer();
305
+ this.startStdioCodexAppServer();
269
306
  await this.initializeAppServer();
270
307
  } catch (error) {
271
- if (mode === "stdio") this.stopStdioCodexAppServer();
308
+ this.stopStdioCodexAppServer();
272
309
  throw error;
273
310
  }
274
311
  }
@@ -310,6 +347,14 @@ var CodexAppServerClient = class {
310
347
  this.child?.kill();
311
348
  this.child = void 0;
312
349
  }
350
+ stopSharedCodexAppServer() {
351
+ this.sharedReconnectEnabled = false;
352
+ const socket = this.socket;
353
+ this.socket = void 0;
354
+ socket?.close();
355
+ this.sharedServer?.kill();
356
+ this.sharedServer = void 0;
357
+ }
313
358
  async startOrAttachSharedCodexAppServer() {
314
359
  try {
315
360
  await this.connectSharedCodexAppServer();
@@ -319,7 +364,7 @@ var CodexAppServerClient = class {
319
364
  });
320
365
  return;
321
366
  } catch (error) {
322
- const attachError = asError(error);
367
+ const attachError = error instanceof Error ? error : new Error(String(error));
323
368
  relayDebugLog("app_server.shared_socket.attach_failed", {
324
369
  message: attachError.message,
325
370
  socketPath: sharedCodexAppServerSocketPath()
@@ -409,7 +454,7 @@ var CodexAppServerClient = class {
409
454
  return this.sharedServer ? "relay-owned" : "attached";
410
455
  }
411
456
  scheduleSharedSocketReconnect() {
412
- if (this.closed || this.reconnecting) return;
457
+ if (this.closed || !this.sharedReconnectEnabled || this.reconnecting) return;
413
458
  const reconnecting = this.reconnectSharedCodexAppServer();
414
459
  this.reconnecting = reconnecting;
415
460
  this.initialized = reconnecting;
@@ -428,7 +473,7 @@ var CodexAppServerClient = class {
428
473
  let lastError;
429
474
  for (const delayMs of sharedSocketReconnectDelaysMs) {
430
475
  await setTimeout$1(delayMs);
431
- if (this.closed) return;
476
+ if (this.closed || !this.sharedReconnectEnabled) return;
432
477
  try {
433
478
  await this.connectSharedCodexAppServer();
434
479
  await this.initializeAppServer();
@@ -438,7 +483,7 @@ var CodexAppServerClient = class {
438
483
  });
439
484
  return;
440
485
  } catch (error) {
441
- lastError = asError(error);
486
+ lastError = error instanceof Error ? error : new Error(String(error));
442
487
  const socket = this.socket;
443
488
  this.socket = void 0;
444
489
  socket?.close();
@@ -572,16 +617,8 @@ async function startSharedCodexAppServer() {
572
617
  throw new Error(`Timed out waiting for the shared Codex app-server at ${remoteAddress}.`);
573
618
  }
574
619
  async function waitForSharedCodexAppServer(remoteAddress) {
575
- if (remoteAddress === "unix://") {
576
- await access(sharedCodexAppServerSocketPath());
577
- return;
578
- }
579
- const url = new URL(remoteAddress);
580
620
  await new Promise((resolve, reject) => {
581
- const socket = connect({
582
- host: url.hostname,
583
- port: Number(url.port)
584
- });
621
+ const socket = remoteAddress === "unix://" ? connect({ path: sharedCodexAppServerSocketPath() }) : connectRemoteAddress(remoteAddress);
585
622
  const onError = (error) => reject(error);
586
623
  socket.once("error", onError);
587
624
  socket.once("connect", () => {
@@ -591,6 +628,13 @@ async function waitForSharedCodexAppServer(remoteAddress) {
591
628
  });
592
629
  });
593
630
  }
631
+ function connectRemoteAddress(remoteAddress) {
632
+ const url = new URL(remoteAddress);
633
+ return connect({
634
+ host: url.hostname,
635
+ port: Number(url.port)
636
+ });
637
+ }
594
638
  function sharedCodexAppServerSocketPath() {
595
639
  return join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "app-server-control", "app-server-control.sock");
596
640
  }
@@ -1424,7 +1468,7 @@ function createApp(options = {}) {
1424
1468
  const appServerHistoryLoadsByThreadId = /* @__PURE__ */ new Map();
1425
1469
  const steeringThreads = /* @__PURE__ */ new Set();
1426
1470
  const secureSessionsByTokenHash = /* @__PURE__ */ new Map();
1427
- const activeStreamControllers = /* @__PURE__ */ new Set();
1471
+ const activeStreamControllers = /* @__PURE__ */ new Map();
1428
1472
  const threadOptions = { workingDirectory: workspacePath };
1429
1473
  function runThreadOperation(threadId, operation) {
1430
1474
  const result = (threadOperationTails.get(threadId) ?? Promise.resolve()).then(operation, operation);
@@ -1871,7 +1915,7 @@ function createApp(options = {}) {
1871
1915
  const stream = new ReadableStream({
1872
1916
  start(controller) {
1873
1917
  streamController = controller;
1874
- activeStreamControllers.add(controller);
1918
+ activeStreamControllers.set(controller, closeStream);
1875
1919
  send(workspaceTerminalOutputResponse(session, since));
1876
1920
  if (session.exitedAt) return;
1877
1921
  unsubscribe = subscribeWorkspaceTerminalOutput(session, send);
@@ -2593,20 +2637,30 @@ function createApp(options = {}) {
2593
2637
  });
2594
2638
  const encoder = new TextEncoder();
2595
2639
  const secureSession = getSecureSessionForRequest(c, options.pairing, secureSessionsByTokenHash);
2596
- let streamController;
2597
2640
  let streamSettled = false;
2641
+ let closeStream = () => {};
2598
2642
  const stream = new ReadableStream({
2599
2643
  start(controller) {
2600
- streamController = controller;
2601
- activeStreamControllers.add(controller);
2644
+ const attachmentAbortController = new AbortController();
2645
+ let closed = false;
2646
+ let stopPreviewMonitor = () => {};
2647
+ closeStream = () => {
2648
+ if (closed) return;
2649
+ closed = true;
2650
+ stopPreviewMonitor();
2651
+ attachmentAbortController.abort();
2652
+ activeStreamControllers.delete(controller);
2653
+ closeSseController(controller);
2654
+ };
2655
+ activeStreamControllers.set(controller, closeStream);
2602
2656
  relayDebugLog("thread.stream.started", {
2603
2657
  mode: !runOptions.prompt && appServer ? "attach" : "run",
2604
2658
  threadId
2605
2659
  });
2606
- const stopPreviewMonitor = startWebPreviewTargetMonitor({
2660
+ stopPreviewMonitor = startWebPreviewTargetMonitor({
2607
2661
  bridgeUrl: c.req.url,
2608
2662
  send(target) {
2609
- sendSse(controller, encoder, secureSession, {
2663
+ return sendSse(controller, encoder, secureSession, {
2610
2664
  type: "thread.preview_target.detected",
2611
2665
  threadId,
2612
2666
  target
@@ -2621,6 +2675,7 @@ function createApp(options = {}) {
2621
2675
  messagesByThreadId,
2622
2676
  pendingApprovals,
2623
2677
  secureSession,
2678
+ signal: attachmentAbortController.signal,
2624
2679
  threadId,
2625
2680
  threads
2626
2681
  }).finally(() => {
@@ -2629,8 +2684,7 @@ function createApp(options = {}) {
2629
2684
  mode: "attach",
2630
2685
  threadId
2631
2686
  });
2632
- activeStreamControllers.delete(controller);
2633
- stopPreviewMonitor();
2687
+ closeStream();
2634
2688
  });
2635
2689
  return;
2636
2690
  }
@@ -2641,14 +2695,12 @@ function createApp(options = {}) {
2641
2695
  thread: knownThread,
2642
2696
  error: apiError("unsupported", "Running-thread stream attachment requires the Codex app-server.").error
2643
2697
  });
2644
- closeSseController(controller);
2645
2698
  streamSettled = true;
2646
2699
  relayDebugLog("thread.stream.finished", {
2647
2700
  mode: "unsupported",
2648
2701
  threadId
2649
2702
  });
2650
- activeStreamControllers.delete(controller);
2651
- stopPreviewMonitor();
2703
+ closeStream();
2652
2704
  return;
2653
2705
  }
2654
2706
  const skills = runOptions.skills ?? [];
@@ -2681,12 +2733,11 @@ function createApp(options = {}) {
2681
2733
  mode: "run",
2682
2734
  threadId
2683
2735
  });
2684
- activeStreamControllers.delete(controller);
2685
- stopPreviewMonitor();
2736
+ closeStream();
2686
2737
  });
2687
2738
  },
2688
2739
  cancel(reason) {
2689
- if (streamController) activeStreamControllers.delete(streamController);
2740
+ closeStream();
2690
2741
  relayDebugLog("thread.stream.cancelled_by_client", {
2691
2742
  reason: debugReason(reason),
2692
2743
  settled: streamSettled,
@@ -3030,8 +3081,6 @@ async function runPromptStreamed(input) {
3030
3081
  thread: threadSummary,
3031
3082
  error: errorBody.error
3032
3083
  });
3033
- } finally {
3034
- closeSseController(input.controller);
3035
3084
  }
3036
3085
  }
3037
3086
  async function startAppServerTurn(appServer, threadId, input) {
@@ -3122,7 +3171,16 @@ async function streamRunningAppServerThread(input) {
3122
3171
  let observedTurnActivity = false;
3123
3172
  let producedTurnOutput = false;
3124
3173
  let threadSummary = input.threads.get(input.threadId);
3125
- const cleanupRequestHandler = input.appServer.onRequest((request) => {
3174
+ let cleanupNotificationHandler = () => void 0;
3175
+ let cleanupRequestHandler = () => void 0;
3176
+ let handlersCleaned = false;
3177
+ const cleanupHandlers = () => {
3178
+ if (handlersCleaned) return;
3179
+ handlersCleaned = true;
3180
+ cleanupRequestHandler();
3181
+ cleanupNotificationHandler();
3182
+ };
3183
+ cleanupRequestHandler = input.appServer.onRequest((request) => {
3126
3184
  if (!isApprovalServerRequest(request.method)) {
3127
3185
  input.appServer.rejectRequest(request.id, -32601, `${request.method} is not supported by Codex Relay mobile yet.`);
3128
3186
  return;
@@ -3175,8 +3233,15 @@ async function streamRunningAppServerThread(input) {
3175
3233
  message
3176
3234
  });
3177
3235
  });
3178
- let cleanupNotificationHandler = () => void 0;
3179
3236
  const completed = new Promise((resolve, reject) => {
3237
+ let settled = false;
3238
+ const finish = (error) => {
3239
+ if (settled) return;
3240
+ settled = true;
3241
+ cleanupHandlers();
3242
+ if (error === void 0) resolve();
3243
+ else reject(error);
3244
+ };
3180
3245
  cleanupNotificationHandler = input.appServer.onNotification((notification) => {
3181
3246
  const params = recordParams(notification);
3182
3247
  const notificationThreadId = firstString(params, ["threadId"]);
@@ -3191,10 +3256,7 @@ async function streamRunningAppServerThread(input) {
3191
3256
  type: "thread.state.changed",
3192
3257
  thread: threadSummary
3193
3258
  });
3194
- if (state !== "running") {
3195
- cleanupNotificationHandler();
3196
- resolve();
3197
- }
3259
+ if (state !== "running") finish();
3198
3260
  return;
3199
3261
  }
3200
3262
  case "thread/goal/updated":
@@ -3303,8 +3365,7 @@ async function streamRunningAppServerThread(input) {
3303
3365
  threadId: input.threadId,
3304
3366
  threads: input.threads
3305
3367
  });
3306
- cleanupNotificationHandler();
3307
- resolve();
3368
+ finish();
3308
3369
  return;
3309
3370
  }
3310
3371
  if (assistantMessageId) {
@@ -3342,27 +3403,40 @@ async function streamRunningAppServerThread(input) {
3342
3403
  error: errorBody.error
3343
3404
  });
3344
3405
  }
3345
- cleanupNotificationHandler();
3346
- resolve();
3406
+ finish();
3347
3407
  return;
3348
3408
  }
3349
3409
  }
3350
3410
  } catch (error) {
3351
- cleanupNotificationHandler();
3352
- reject(error);
3411
+ finish(error);
3353
3412
  }
3354
3413
  });
3355
3414
  });
3415
+ let removeAbortListener = () => {};
3416
+ const aborted = new Promise((resolve) => {
3417
+ const onAbort = () => {
3418
+ cleanupHandlers();
3419
+ resolve();
3420
+ };
3421
+ if (input.signal.aborted) {
3422
+ onAbort();
3423
+ return;
3424
+ }
3425
+ input.signal.addEventListener("abort", onAbort, { once: true });
3426
+ removeAbortListener = () => input.signal.removeEventListener("abort", onAbort);
3427
+ });
3356
3428
  try {
3357
- const appServerThread = await input.appServer.readThread(input.threadId, { includeTurns: false });
3429
+ const appServerThread = await Promise.race([input.appServer.readThread(input.threadId, { includeTurns: false }), aborted.then(() => void 0)]);
3430
+ if (!appServerThread || input.signal.aborted) return;
3358
3431
  threadSummary = rememberAppServerThread(input.threads, appServerThread);
3359
3432
  sendSse(input.controller, input.encoder, input.secureSession, {
3360
3433
  type: "thread.state.changed",
3361
3434
  thread: threadSummary
3362
3435
  });
3363
3436
  if (threadSummary.state !== "running") return;
3364
- await completed;
3437
+ await Promise.race([completed, aborted]);
3365
3438
  } catch (error) {
3439
+ if (input.signal.aborted) return;
3366
3440
  threadSummary = updateThread(input.threads, input.messagesByThreadId, input.threadId, {
3367
3441
  state: "failed",
3368
3442
  lastError: errorMessage(error)
@@ -3373,9 +3447,8 @@ async function streamRunningAppServerThread(input) {
3373
3447
  error: apiError("codex_run_failed", threadSummary.lastError ?? "Codex run failed.").error
3374
3448
  });
3375
3449
  } finally {
3376
- cleanupRequestHandler();
3377
- cleanupNotificationHandler();
3378
- closeSseController(input.controller);
3450
+ removeAbortListener();
3451
+ cleanupHandlers();
3379
3452
  }
3380
3453
  }
3381
3454
  async function runAppServerPromptStreamed(input) {
@@ -3843,7 +3916,6 @@ async function runAppServerPromptStreamed(input) {
3843
3916
  input.activeAppServerTurnIdsByThreadId.delete(activeThreadId);
3844
3917
  input.steeringThreads.delete(activeThreadId);
3845
3918
  }
3846
- closeSseController(input.controller);
3847
3919
  }
3848
3920
  }
3849
3921
  function sendEmptyAppServerTurnError(input) {
@@ -4464,13 +4536,17 @@ function sendSse(controller, encoder, secureSession, event) {
4464
4536
  stage: "event",
4465
4537
  threadId
4466
4538
  });
4467
- return;
4539
+ return false;
4468
4540
  }
4469
- if (!enqueueSseChunk(controller, encoder.encode(`data: ${JSON.stringify(data)}\n\n`))) relayDebugLog("thread.stream.sse.enqueue_failed", {
4470
- eventType: parsed.type,
4471
- stage: "data",
4472
- threadId
4473
- });
4541
+ if (!enqueueSseChunk(controller, encoder.encode(`data: ${JSON.stringify(data)}\n\n`))) {
4542
+ relayDebugLog("thread.stream.sse.enqueue_failed", {
4543
+ eventType: parsed.type,
4544
+ stage: "data",
4545
+ threadId
4546
+ });
4547
+ return false;
4548
+ }
4549
+ return true;
4474
4550
  }
4475
4551
  function sendTerminalOutputSse(controller, encoder, secureSession, response) {
4476
4552
  const parsed = WorkspaceTerminalOutputResponseSchema.parse(response);
@@ -4502,7 +4578,7 @@ function closeSseController(controller) {
4502
4578
  }
4503
4579
  }
4504
4580
  function closeActiveStreamControllers(controllers) {
4505
- for (const controller of controllers) closeSseController(controller);
4581
+ for (const closeStream of controllers.values()) closeStream();
4506
4582
  controllers.clear();
4507
4583
  }
4508
4584
  function isClosedStreamControllerError(error) {
@@ -4510,28 +4586,36 @@ function isClosedStreamControllerError(error) {
4510
4586
  }
4511
4587
  function startWebPreviewTargetMonitor({ bridgeUrl, send }) {
4512
4588
  const urls = webPreviewCandidateUrls(bridgeUrl);
4513
- const seenUrls = /* @__PURE__ */ new Set();
4589
+ const abortController = new AbortController();
4514
4590
  let stopped = false;
4591
+ let nextScan;
4592
+ const stop = () => {
4593
+ if (stopped) return;
4594
+ stopped = true;
4595
+ abortController.abort();
4596
+ if (nextScan) {
4597
+ clearTimeout(nextScan);
4598
+ nextScan = void 0;
4599
+ }
4600
+ };
4515
4601
  async function scan() {
4516
4602
  if (stopped) return;
4517
- const targets = await detectWebPreviewTargets(urls);
4518
- for (const target of targets) {
4519
- if (stopped || seenUrls.has(target.url)) continue;
4520
- seenUrls.add(target.url);
4603
+ const target = await detectWebPreviewTarget(urls, abortController.signal);
4604
+ if (stopped) return;
4605
+ if (target) {
4606
+ stop();
4521
4607
  try {
4522
4608
  send(target);
4523
- } catch {
4524
- stopped = true;
4525
- return;
4526
- }
4609
+ } catch {}
4610
+ return;
4527
4611
  }
4612
+ nextScan = setTimeout(() => {
4613
+ nextScan = void 0;
4614
+ scan();
4615
+ }, 1500);
4528
4616
  }
4529
4617
  scan();
4530
- const interval = setInterval(() => void scan(), 1500);
4531
- return () => {
4532
- stopped = true;
4533
- clearInterval(interval);
4534
- };
4618
+ return stop;
4535
4619
  }
4536
4620
  function webPreviewCandidateUrls(bridgeUrl) {
4537
4621
  const bridge = new URL(bridgeUrl);
@@ -4554,11 +4638,14 @@ function webPreviewCandidatePorts() {
4554
4638
  function readCollaborationModeTemplate(name) {
4555
4639
  return readFileSync(new URL(`./collaboration-mode-templates/${name}.md`, import.meta.url), "utf8").replaceAll("{{KNOWN_MODE_NAMES}}", knownCollaborationModeNames).trim();
4556
4640
  }
4557
- async function detectWebPreviewTargets(urls) {
4558
- return (await Promise.all(urls.map((url) => probeWebPreviewTarget(url)))).filter((target) => Boolean(target));
4641
+ async function detectWebPreviewTarget(urls, signal) {
4642
+ return (await Promise.all(urls.map((url) => probeWebPreviewTarget(url, signal)))).find((target) => Boolean(target));
4559
4643
  }
4560
- async function probeWebPreviewTarget(url) {
4644
+ async function probeWebPreviewTarget(url, signal) {
4561
4645
  const controller = new AbortController();
4646
+ const abort = () => controller.abort();
4647
+ if (signal.aborted) abort();
4648
+ else signal.addEventListener("abort", abort, { once: true });
4562
4649
  const timeout = setTimeout(() => controller.abort(), 700);
4563
4650
  try {
4564
4651
  const response = await fetch(url, { signal: controller.signal });
@@ -4579,6 +4666,7 @@ async function probeWebPreviewTarget(url) {
4579
4666
  return;
4580
4667
  } finally {
4581
4668
  clearTimeout(timeout);
4669
+ signal.removeEventListener("abort", abort);
4582
4670
  }
4583
4671
  }
4584
4672
  function looksLikeHtml(value) {
@@ -6784,16 +6872,21 @@ const npxCommand = "npx codex-relay@latest";
6784
6872
  const sessionStore = await createTursoPairingSessionStore(process.env.CODEX_RELAY_AUTH_DB_PATH ?? await prepareCodexRelayDataPath("auth.db", ["auth.db-shm", "auth.db-wal"]));
6785
6873
  const preferencesStore = createFileRuntimePreferencesStore(process.env.CODEX_RELAY_PREFERENCES_PATH ?? await prepareCodexRelayDataPath("preferences.json"));
6786
6874
  const appServerMode = resolveCodexAppServerMode();
6787
- const sharedAppServer = appServerMode === "socket" ? new CodexAppServerClient() : void 0;
6788
- if (sharedAppServer) {
6789
- await sharedAppServer.initialize();
6790
- process.once("SIGINT", () => stopSharedAppServer(130));
6791
- process.once("SIGTERM", () => stopSharedAppServer(143));
6792
- process.once("exit", () => sharedAppServer.close());
6875
+ const relayAppServer = appServerMode.mode === "socket" ? new CodexAppServerClient({
6876
+ mode: appServerMode,
6877
+ onStartupFallback: (error) => {
6878
+ logRuntimeEvent("Fallback", `Shared app-server unavailable; continuing with a private app-server (${error.message}).`);
6879
+ }
6880
+ }) : void 0;
6881
+ if (relayAppServer) {
6882
+ await relayAppServer.initialize();
6883
+ process.once("SIGINT", () => stopRelayAppServer(130));
6884
+ process.once("SIGTERM", () => stopRelayAppServer(143));
6885
+ process.once("exit", () => relayAppServer.close());
6793
6886
  }
6794
6887
  serve({
6795
6888
  fetch: createApp({
6796
- appServer: sharedAppServer,
6889
+ appServer: relayAppServer,
6797
6890
  pairing: {
6798
6891
  approvalSecret,
6799
6892
  dangerouslyAutoApprove,
@@ -6865,11 +6958,11 @@ serve({
6865
6958
  listenUrl,
6866
6959
  pairingPayload,
6867
6960
  port: info.port,
6868
- sharedAppServerRemoteAddress: appServerMode === "socket" ? resolveCodexSharedAppServerRemoteAddress() : void 0
6961
+ sharedAppServerRemoteAddress: relayAppServer?.appServerMode === "socket" ? resolveCodexSharedAppServerRemoteAddress() : void 0
6869
6962
  }));
6870
6963
  });
6871
- function stopSharedAppServer(exitCode) {
6872
- sharedAppServer?.close();
6964
+ function stopRelayAppServer(exitCode) {
6965
+ relayAppServer?.close();
6873
6966
  process.exit(exitCode);
6874
6967
  }
6875
6968
  function formatStartupInstructions(details) {
@@ -6893,6 +6986,7 @@ function formatStartupInstructions(details) {
6893
6986
  `${color.prompt("›")} Commands`,
6894
6987
  ` ${color.command(npxCommand)} Start and print a pairing QR`,
6895
6988
  ` ${color.command(`${npxCommand} --bg`)} Start in the background`,
6989
+ ` ${color.command(`${npxCommand} stop`)} Stop the background relay`,
6896
6990
  ` ${color.command(`${npxCommand} qr`)} Print this QR again`,
6897
6991
  ` ${color.command(`${npxCommand} approve <code>`)} Approve a device`,
6898
6992
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-relay",
3
- "version": "1.4.2",
3
+ "version": "1.4.3",
4
4
  "description": "Local Codex Relay CLI bridge for the Codex Relay mobile app.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -41,7 +41,7 @@
41
41
  "@noble/ciphers": "2.2.0",
42
42
  "@noble/curves": "2.2.0",
43
43
  "@noble/hashes": "2.2.0",
44
- "@openai/codex-sdk": "0.144.5",
44
+ "@openai/codex-sdk": "0.145.0",
45
45
  "base64-js": "1.5.1",
46
46
  "commander": "^14.0.3",
47
47
  "es-git": "^0.6.0",