codex-relay 1.4.2 → 1.4.4

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
@@ -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.
@@ -268,6 +268,7 @@ const ChatMessageSchema = z.object({
268
268
  });
269
269
  const ThreadSummarySchema = z.object({
270
270
  id: z.string().min(1),
271
+ parentThreadId: z.string().min(1).optional(),
271
272
  title: z.string().min(1),
272
273
  createdAt: IsoDateTimeSchema,
273
274
  updatedAt: IsoDateTimeSchema,
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);
@@ -2062,7 +2106,7 @@ function createApp(options = {}) {
2062
2106
  if (appServer) try {
2063
2107
  const appServerThreads = await appServer.listThreads();
2064
2108
  const response = ListThreadsResponseSchema.parse({
2065
- threads: appServerThreads.map((thread) => rememberAppServerThread(threads, thread)),
2109
+ threads: appServerThreads.filter((thread) => !isSubagentThread(thread)).map((thread) => rememberAppServerThread(threads, thread)),
2066
2110
  source: "app-server"
2067
2111
  });
2068
2112
  return secureJson(c, options.pairing, secureSessionsByTokenHash, response);
@@ -2085,7 +2129,7 @@ function createApp(options = {}) {
2085
2129
  const appServerThreads = await appServer.listThreads();
2086
2130
  const response = ArchiveThreadResponseSchema.parse({
2087
2131
  archivedThreadId: threadId,
2088
- threads: appServerThreads.map((thread) => rememberAppServerThread(threads, thread)),
2132
+ threads: appServerThreads.filter((thread) => !isSubagentThread(thread)).map((thread) => rememberAppServerThread(threads, thread)),
2089
2133
  source: "app-server"
2090
2134
  });
2091
2135
  return secureJson(c, options.pairing, secureSessionsByTokenHash, response);
@@ -2112,9 +2156,12 @@ function createApp(options = {}) {
2112
2156
  const detailStartedAt = Date.now();
2113
2157
  relayDebugLog("thread.detail.requested", { threadId });
2114
2158
  const knownThread = threads.get(threadId);
2159
+ if (knownThread && isSubagentThread(knownThread)) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("not_found", `Thread ${threadId} is not known to this server.`), 404);
2115
2160
  const wasKnownRunning = knownThread?.state === "running" || activeAppServerTurnIdsByThreadId.has(threadId);
2116
2161
  if (appServer) try {
2117
- const mappedThread = rememberAppServerThread(threads, await appServer.readThread(threadId, { includeTurns: false }));
2162
+ const thread = await appServer.readThread(threadId, { includeTurns: false });
2163
+ if (isSubagentThread(thread)) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("not_found", `Thread ${threadId} is not known to this server.`), 404);
2164
+ const mappedThread = rememberAppServerThread(threads, thread);
2118
2165
  const cachedMessages = messagesByThreadId.get(threadId) ?? [];
2119
2166
  let loadedMessages = false;
2120
2167
  let messages = cachedMessages;
@@ -2593,20 +2640,30 @@ function createApp(options = {}) {
2593
2640
  });
2594
2641
  const encoder = new TextEncoder();
2595
2642
  const secureSession = getSecureSessionForRequest(c, options.pairing, secureSessionsByTokenHash);
2596
- let streamController;
2597
2643
  let streamSettled = false;
2644
+ let closeStream = () => {};
2598
2645
  const stream = new ReadableStream({
2599
2646
  start(controller) {
2600
- streamController = controller;
2601
- activeStreamControllers.add(controller);
2647
+ const attachmentAbortController = new AbortController();
2648
+ let closed = false;
2649
+ let stopPreviewMonitor = () => {};
2650
+ closeStream = () => {
2651
+ if (closed) return;
2652
+ closed = true;
2653
+ stopPreviewMonitor();
2654
+ attachmentAbortController.abort();
2655
+ activeStreamControllers.delete(controller);
2656
+ closeSseController(controller);
2657
+ };
2658
+ activeStreamControllers.set(controller, closeStream);
2602
2659
  relayDebugLog("thread.stream.started", {
2603
2660
  mode: !runOptions.prompt && appServer ? "attach" : "run",
2604
2661
  threadId
2605
2662
  });
2606
- const stopPreviewMonitor = startWebPreviewTargetMonitor({
2663
+ stopPreviewMonitor = startWebPreviewTargetMonitor({
2607
2664
  bridgeUrl: c.req.url,
2608
2665
  send(target) {
2609
- sendSse(controller, encoder, secureSession, {
2666
+ return sendSse(controller, encoder, secureSession, {
2610
2667
  type: "thread.preview_target.detected",
2611
2668
  threadId,
2612
2669
  target
@@ -2621,6 +2678,7 @@ function createApp(options = {}) {
2621
2678
  messagesByThreadId,
2622
2679
  pendingApprovals,
2623
2680
  secureSession,
2681
+ signal: attachmentAbortController.signal,
2624
2682
  threadId,
2625
2683
  threads
2626
2684
  }).finally(() => {
@@ -2629,8 +2687,7 @@ function createApp(options = {}) {
2629
2687
  mode: "attach",
2630
2688
  threadId
2631
2689
  });
2632
- activeStreamControllers.delete(controller);
2633
- stopPreviewMonitor();
2690
+ closeStream();
2634
2691
  });
2635
2692
  return;
2636
2693
  }
@@ -2641,14 +2698,12 @@ function createApp(options = {}) {
2641
2698
  thread: knownThread,
2642
2699
  error: apiError("unsupported", "Running-thread stream attachment requires the Codex app-server.").error
2643
2700
  });
2644
- closeSseController(controller);
2645
2701
  streamSettled = true;
2646
2702
  relayDebugLog("thread.stream.finished", {
2647
2703
  mode: "unsupported",
2648
2704
  threadId
2649
2705
  });
2650
- activeStreamControllers.delete(controller);
2651
- stopPreviewMonitor();
2706
+ closeStream();
2652
2707
  return;
2653
2708
  }
2654
2709
  const skills = runOptions.skills ?? [];
@@ -2681,12 +2736,11 @@ function createApp(options = {}) {
2681
2736
  mode: "run",
2682
2737
  threadId
2683
2738
  });
2684
- activeStreamControllers.delete(controller);
2685
- stopPreviewMonitor();
2739
+ closeStream();
2686
2740
  });
2687
2741
  },
2688
2742
  cancel(reason) {
2689
- if (streamController) activeStreamControllers.delete(streamController);
2743
+ closeStream();
2690
2744
  relayDebugLog("thread.stream.cancelled_by_client", {
2691
2745
  reason: debugReason(reason),
2692
2746
  settled: streamSettled,
@@ -3030,8 +3084,6 @@ async function runPromptStreamed(input) {
3030
3084
  thread: threadSummary,
3031
3085
  error: errorBody.error
3032
3086
  });
3033
- } finally {
3034
- closeSseController(input.controller);
3035
3087
  }
3036
3088
  }
3037
3089
  async function startAppServerTurn(appServer, threadId, input) {
@@ -3122,7 +3174,16 @@ async function streamRunningAppServerThread(input) {
3122
3174
  let observedTurnActivity = false;
3123
3175
  let producedTurnOutput = false;
3124
3176
  let threadSummary = input.threads.get(input.threadId);
3125
- const cleanupRequestHandler = input.appServer.onRequest((request) => {
3177
+ let cleanupNotificationHandler = () => void 0;
3178
+ let cleanupRequestHandler = () => void 0;
3179
+ let handlersCleaned = false;
3180
+ const cleanupHandlers = () => {
3181
+ if (handlersCleaned) return;
3182
+ handlersCleaned = true;
3183
+ cleanupRequestHandler();
3184
+ cleanupNotificationHandler();
3185
+ };
3186
+ cleanupRequestHandler = input.appServer.onRequest((request) => {
3126
3187
  if (!isApprovalServerRequest(request.method)) {
3127
3188
  input.appServer.rejectRequest(request.id, -32601, `${request.method} is not supported by Codex Relay mobile yet.`);
3128
3189
  return;
@@ -3175,8 +3236,15 @@ async function streamRunningAppServerThread(input) {
3175
3236
  message
3176
3237
  });
3177
3238
  });
3178
- let cleanupNotificationHandler = () => void 0;
3179
3239
  const completed = new Promise((resolve, reject) => {
3240
+ let settled = false;
3241
+ const finish = (error) => {
3242
+ if (settled) return;
3243
+ settled = true;
3244
+ cleanupHandlers();
3245
+ if (error === void 0) resolve();
3246
+ else reject(error);
3247
+ };
3180
3248
  cleanupNotificationHandler = input.appServer.onNotification((notification) => {
3181
3249
  const params = recordParams(notification);
3182
3250
  const notificationThreadId = firstString(params, ["threadId"]);
@@ -3191,10 +3259,7 @@ async function streamRunningAppServerThread(input) {
3191
3259
  type: "thread.state.changed",
3192
3260
  thread: threadSummary
3193
3261
  });
3194
- if (state !== "running") {
3195
- cleanupNotificationHandler();
3196
- resolve();
3197
- }
3262
+ if (state !== "running") finish();
3198
3263
  return;
3199
3264
  }
3200
3265
  case "thread/goal/updated":
@@ -3303,8 +3368,7 @@ async function streamRunningAppServerThread(input) {
3303
3368
  threadId: input.threadId,
3304
3369
  threads: input.threads
3305
3370
  });
3306
- cleanupNotificationHandler();
3307
- resolve();
3371
+ finish();
3308
3372
  return;
3309
3373
  }
3310
3374
  if (assistantMessageId) {
@@ -3342,27 +3406,40 @@ async function streamRunningAppServerThread(input) {
3342
3406
  error: errorBody.error
3343
3407
  });
3344
3408
  }
3345
- cleanupNotificationHandler();
3346
- resolve();
3409
+ finish();
3347
3410
  return;
3348
3411
  }
3349
3412
  }
3350
3413
  } catch (error) {
3351
- cleanupNotificationHandler();
3352
- reject(error);
3414
+ finish(error);
3353
3415
  }
3354
3416
  });
3355
3417
  });
3418
+ let removeAbortListener = () => {};
3419
+ const aborted = new Promise((resolve) => {
3420
+ const onAbort = () => {
3421
+ cleanupHandlers();
3422
+ resolve();
3423
+ };
3424
+ if (input.signal.aborted) {
3425
+ onAbort();
3426
+ return;
3427
+ }
3428
+ input.signal.addEventListener("abort", onAbort, { once: true });
3429
+ removeAbortListener = () => input.signal.removeEventListener("abort", onAbort);
3430
+ });
3356
3431
  try {
3357
- const appServerThread = await input.appServer.readThread(input.threadId, { includeTurns: false });
3432
+ const appServerThread = await Promise.race([input.appServer.readThread(input.threadId, { includeTurns: false }), aborted.then(() => void 0)]);
3433
+ if (!appServerThread || input.signal.aborted) return;
3358
3434
  threadSummary = rememberAppServerThread(input.threads, appServerThread);
3359
3435
  sendSse(input.controller, input.encoder, input.secureSession, {
3360
3436
  type: "thread.state.changed",
3361
3437
  thread: threadSummary
3362
3438
  });
3363
3439
  if (threadSummary.state !== "running") return;
3364
- await completed;
3440
+ await Promise.race([completed, aborted]);
3365
3441
  } catch (error) {
3442
+ if (input.signal.aborted) return;
3366
3443
  threadSummary = updateThread(input.threads, input.messagesByThreadId, input.threadId, {
3367
3444
  state: "failed",
3368
3445
  lastError: errorMessage(error)
@@ -3373,9 +3450,8 @@ async function streamRunningAppServerThread(input) {
3373
3450
  error: apiError("codex_run_failed", threadSummary.lastError ?? "Codex run failed.").error
3374
3451
  });
3375
3452
  } finally {
3376
- cleanupRequestHandler();
3377
- cleanupNotificationHandler();
3378
- closeSseController(input.controller);
3453
+ removeAbortListener();
3454
+ cleanupHandlers();
3379
3455
  }
3380
3456
  }
3381
3457
  async function runAppServerPromptStreamed(input) {
@@ -3843,7 +3919,6 @@ async function runAppServerPromptStreamed(input) {
3843
3919
  input.activeAppServerTurnIdsByThreadId.delete(activeThreadId);
3844
3920
  input.steeringThreads.delete(activeThreadId);
3845
3921
  }
3846
- closeSseController(input.controller);
3847
3922
  }
3848
3923
  }
3849
3924
  function sendEmptyAppServerTurnError(input) {
@@ -4027,14 +4102,15 @@ function createSecureSessionHandle(pairing, tokenHash, session) {
4027
4102
  };
4028
4103
  }
4029
4104
  function sortedThreads(threads) {
4030
- return [...threads.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
4105
+ return [...threads.values()].filter((thread) => !isSubagentThread(thread)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
4031
4106
  }
4032
4107
  async function ensureKnownThread(input) {
4033
4108
  const knownThread = input.threads.get(input.threadId);
4034
- if (knownThread) return knownThread;
4109
+ if (knownThread) return isSubagentThread(knownThread) ? void 0 : knownThread;
4035
4110
  if (!input.appServer) return;
4036
4111
  try {
4037
4112
  const appServerThread = await input.appServer.readThread(input.threadId, { includeTurns: false });
4113
+ if (isSubagentThread(appServerThread)) return;
4038
4114
  return rememberAppServerThread(input.threads, appServerThread);
4039
4115
  } catch {
4040
4116
  return;
@@ -4464,13 +4540,17 @@ function sendSse(controller, encoder, secureSession, event) {
4464
4540
  stage: "event",
4465
4541
  threadId
4466
4542
  });
4467
- return;
4543
+ return false;
4468
4544
  }
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
- });
4545
+ if (!enqueueSseChunk(controller, encoder.encode(`data: ${JSON.stringify(data)}\n\n`))) {
4546
+ relayDebugLog("thread.stream.sse.enqueue_failed", {
4547
+ eventType: parsed.type,
4548
+ stage: "data",
4549
+ threadId
4550
+ });
4551
+ return false;
4552
+ }
4553
+ return true;
4474
4554
  }
4475
4555
  function sendTerminalOutputSse(controller, encoder, secureSession, response) {
4476
4556
  const parsed = WorkspaceTerminalOutputResponseSchema.parse(response);
@@ -4502,7 +4582,7 @@ function closeSseController(controller) {
4502
4582
  }
4503
4583
  }
4504
4584
  function closeActiveStreamControllers(controllers) {
4505
- for (const controller of controllers) closeSseController(controller);
4585
+ for (const closeStream of controllers.values()) closeStream();
4506
4586
  controllers.clear();
4507
4587
  }
4508
4588
  function isClosedStreamControllerError(error) {
@@ -4510,28 +4590,36 @@ function isClosedStreamControllerError(error) {
4510
4590
  }
4511
4591
  function startWebPreviewTargetMonitor({ bridgeUrl, send }) {
4512
4592
  const urls = webPreviewCandidateUrls(bridgeUrl);
4513
- const seenUrls = /* @__PURE__ */ new Set();
4593
+ const abortController = new AbortController();
4514
4594
  let stopped = false;
4595
+ let nextScan;
4596
+ const stop = () => {
4597
+ if (stopped) return;
4598
+ stopped = true;
4599
+ abortController.abort();
4600
+ if (nextScan) {
4601
+ clearTimeout(nextScan);
4602
+ nextScan = void 0;
4603
+ }
4604
+ };
4515
4605
  async function scan() {
4516
4606
  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);
4607
+ const target = await detectWebPreviewTarget(urls, abortController.signal);
4608
+ if (stopped) return;
4609
+ if (target) {
4610
+ stop();
4521
4611
  try {
4522
4612
  send(target);
4523
- } catch {
4524
- stopped = true;
4525
- return;
4526
- }
4613
+ } catch {}
4614
+ return;
4527
4615
  }
4616
+ nextScan = setTimeout(() => {
4617
+ nextScan = void 0;
4618
+ scan();
4619
+ }, 1500);
4528
4620
  }
4529
4621
  scan();
4530
- const interval = setInterval(() => void scan(), 1500);
4531
- return () => {
4532
- stopped = true;
4533
- clearInterval(interval);
4534
- };
4622
+ return stop;
4535
4623
  }
4536
4624
  function webPreviewCandidateUrls(bridgeUrl) {
4537
4625
  const bridge = new URL(bridgeUrl);
@@ -4554,11 +4642,14 @@ function webPreviewCandidatePorts() {
4554
4642
  function readCollaborationModeTemplate(name) {
4555
4643
  return readFileSync(new URL(`./collaboration-mode-templates/${name}.md`, import.meta.url), "utf8").replaceAll("{{KNOWN_MODE_NAMES}}", knownCollaborationModeNames).trim();
4556
4644
  }
4557
- async function detectWebPreviewTargets(urls) {
4558
- return (await Promise.all(urls.map((url) => probeWebPreviewTarget(url)))).filter((target) => Boolean(target));
4645
+ async function detectWebPreviewTarget(urls, signal) {
4646
+ return (await Promise.all(urls.map((url) => probeWebPreviewTarget(url, signal)))).find((target) => Boolean(target));
4559
4647
  }
4560
- async function probeWebPreviewTarget(url) {
4648
+ async function probeWebPreviewTarget(url, signal) {
4561
4649
  const controller = new AbortController();
4650
+ const abort = () => controller.abort();
4651
+ if (signal.aborted) abort();
4652
+ else signal.addEventListener("abort", abort, { once: true });
4562
4653
  const timeout = setTimeout(() => controller.abort(), 700);
4563
4654
  try {
4564
4655
  const response = await fetch(url, { signal: controller.signal });
@@ -4579,6 +4670,7 @@ async function probeWebPreviewTarget(url) {
4579
4670
  return;
4580
4671
  } finally {
4581
4672
  clearTimeout(timeout);
4673
+ signal.removeEventListener("abort", abort);
4582
4674
  }
4583
4675
  }
4584
4676
  function looksLikeHtml(value) {
@@ -4758,6 +4850,7 @@ function mapAppServerThread(thread, fallbackMessageCount) {
4758
4850
  const mappedState = mapAppServerThreadState(thread.status, thread.turns);
4759
4851
  return ThreadSummarySchema.parse({
4760
4852
  id: thread.id,
4853
+ parentThreadId: thread.parentThreadId ?? void 0,
4761
4854
  title: thread.name ?? preview(thread.preview || "Untitled thread"),
4762
4855
  createdAt,
4763
4856
  updatedAt,
@@ -4781,6 +4874,9 @@ function rememberAppServerThread(threads, thread) {
4781
4874
  threads.set(threadWithLocalRuntime.id, threadWithLocalRuntime);
4782
4875
  return threadWithLocalRuntime;
4783
4876
  }
4877
+ function isSubagentThread(thread) {
4878
+ return Boolean(thread.parentThreadId);
4879
+ }
4784
4880
  function mapAppServerThreadGoal(goal) {
4785
4881
  if (!goal) return null;
4786
4882
  return ThreadGoalSchema.parse({
@@ -5771,7 +5867,12 @@ function observeAppServerPushNotifications(appServer, dispatcher) {
5771
5867
  appServer.onRequest((request) => {
5772
5868
  const event = pushNotificationEventFromApprovalRequest(request);
5773
5869
  if (!event) return;
5774
- dispatch(event, `${event.intent}:${event.threadId}:${event.turnId ?? ""}:${request.id}`);
5870
+ const eventId = `${event.intent}:${event.threadId}:${event.turnId ?? ""}:${request.id}`;
5871
+ if (subagentThreadIds.has(event.threadId)) {
5872
+ rememberEventId(eventId);
5873
+ return;
5874
+ }
5875
+ dispatch(event, eventId);
5775
5876
  });
5776
5877
  }
5777
5878
  function rememberSubagentThreadIds(notification, subagentThreadIds) {
@@ -6784,16 +6885,21 @@ const npxCommand = "npx codex-relay@latest";
6784
6885
  const sessionStore = await createTursoPairingSessionStore(process.env.CODEX_RELAY_AUTH_DB_PATH ?? await prepareCodexRelayDataPath("auth.db", ["auth.db-shm", "auth.db-wal"]));
6785
6886
  const preferencesStore = createFileRuntimePreferencesStore(process.env.CODEX_RELAY_PREFERENCES_PATH ?? await prepareCodexRelayDataPath("preferences.json"));
6786
6887
  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());
6888
+ const relayAppServer = appServerMode.mode === "socket" ? new CodexAppServerClient({
6889
+ mode: appServerMode,
6890
+ onStartupFallback: (error) => {
6891
+ logRuntimeEvent("Fallback", `Shared app-server unavailable; continuing with a private app-server (${error.message}).`);
6892
+ }
6893
+ }) : void 0;
6894
+ if (relayAppServer) {
6895
+ await relayAppServer.initialize();
6896
+ process.once("SIGINT", () => stopRelayAppServer(130));
6897
+ process.once("SIGTERM", () => stopRelayAppServer(143));
6898
+ process.once("exit", () => relayAppServer.close());
6793
6899
  }
6794
6900
  serve({
6795
6901
  fetch: createApp({
6796
- appServer: sharedAppServer,
6902
+ appServer: relayAppServer,
6797
6903
  pairing: {
6798
6904
  approvalSecret,
6799
6905
  dangerouslyAutoApprove,
@@ -6865,11 +6971,11 @@ serve({
6865
6971
  listenUrl,
6866
6972
  pairingPayload,
6867
6973
  port: info.port,
6868
- sharedAppServerRemoteAddress: appServerMode === "socket" ? resolveCodexSharedAppServerRemoteAddress() : void 0
6974
+ sharedAppServerRemoteAddress: relayAppServer?.appServerMode === "socket" ? resolveCodexSharedAppServerRemoteAddress() : void 0
6869
6975
  }));
6870
6976
  });
6871
- function stopSharedAppServer(exitCode) {
6872
- sharedAppServer?.close();
6977
+ function stopRelayAppServer(exitCode) {
6978
+ relayAppServer?.close();
6873
6979
  process.exit(exitCode);
6874
6980
  }
6875
6981
  function formatStartupInstructions(details) {
@@ -6893,6 +6999,7 @@ function formatStartupInstructions(details) {
6893
6999
  `${color.prompt("›")} Commands`,
6894
7000
  ` ${color.command(npxCommand)} Start and print a pairing QR`,
6895
7001
  ` ${color.command(`${npxCommand} --bg`)} Start in the background`,
7002
+ ` ${color.command(`${npxCommand} stop`)} Stop the background relay`,
6896
7003
  ` ${color.command(`${npxCommand} qr`)} Print this QR again`,
6897
7004
  ` ${color.command(`${npxCommand} approve <code>`)} Approve a device`,
6898
7005
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-relay",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
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",
package/src/api-schema.ts CHANGED
@@ -304,6 +304,7 @@ export const ChatMessageSchema = z.object({
304
304
 
305
305
  export const ThreadSummarySchema = z.object({
306
306
  id: z.string().min(1),
307
+ parentThreadId: z.string().min(1).optional(),
307
308
  title: z.string().min(1),
308
309
  createdAt: IsoDateTimeSchema,
309
310
  updatedAt: IsoDateTimeSchema,