codex-relay 1.3.2 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as RuntimePreferencesSchema, A as PairRequestSchema, B as QueuedThreadInputActionResponseSchema, Bt as apiPaths, C as ListModelsResponseSchema, Ct as WorkspaceChangesResponseSchema, D as ListWorkspaceDirectoriesResponseSchema, Dt as WorkspaceGitActionResponseSchema, E as ListThreadsResponseSchema, G as ReasoningEffortSchema, Gt as promptMarkdownWithSkills, Ht as createOpenApiDocument, It as WorkspaceTerminalOutputResponseSchema, J as RunThreadRequestSchema, K as ResolveApprovalRequestSchema, Lt as WorkspaceTerminalResizeRequestSchema, Mt as WorkspaceTailscaleServeRequestSchema, Nt as WorkspaceTailscaleServeResponseSchema, O as ListWorkspaceFilesResponseSchema, Pt as WorkspaceTerminalInputRequestSchema, Q as RuntimePreferencesResponseSchema, Rt as WorkspaceTerminalSessionResponseSchema, S as KnownReasoningEffortSchema, T as ListSkillsResponseSchema, Tt as WorkspaceFileContentResponseSchema, Vt as chatMessageDetailsFromPromptContext, W as RateLimitsResponseSchema, Wt as normalizePromptContext, Xt as stripPromptSkillMentions, _ as EncryptedPayloadSchema, _t as UpdateThreadGoalRequestSchema, a as ArchiveThreadResponseSchema, b as InterruptThreadRunResponseSchema, ct as ThreadGoalResponseSchema, d as CheckoutWorkspaceBranchRequestSchema, dt as ThreadMessageDetailFieldSchema, ft as ThreadMessageDetailResponseSchema, gt as UpdateRuntimePreferencesRequestSchema, h as CreateThreadRequestSchema, ht as ThreadSummarySchema, it as SubmitThreadInputResponseSchema, j as PairResponseSchema, l as ChatMessageSchema, lt as ThreadGoalSchema, m as ContextWindowUsageSchema, nt as StreamThreadRunEventSchema, ot as ThreadContextWindowResponseSchema, p as CommitPushWorkspaceRequestSchema, q as ResolveApprovalResponseSchema, rt as StreamThreadRunRequestSchema, st as ThreadDetailResponseSchema, tt as StatusResponseSchema, ut as ThreadGoalStatusSchema, vt as UpdateWorkspaceFileContentRequestSchema, w as ListQueuedThreadInputsResponseSchema, y as ImageAttachmentUploadResponseSchema, yt as VersionResponseSchema, zt as WorkspaceTerminalStartRequestSchema } from "./api-schema2.js";
1
+ import { A as PairRequestSchema, Bt as WorkspaceTerminalOutputResponseSchema, C as ListModelsResponseSchema, Ct as VersionResponseSchema, D as ListWorkspaceDirectoriesResponseSchema, Dt as WorkspaceChangesResponseSchema, E as ListThreadsResponseSchema, Gt as chatMessageDetailsFromPromptContext, H as PushNotificationSettingsResponseSchema, Ht as WorkspaceTerminalSessionResponseSchema, It as WorkspaceTailscaleServeRequestSchema, J as ReasoningEffortSchema, Jt as normalizePromptContext, Kt as createOpenApiDocument, Lt as WorkspaceTailscaleServeResponseSchema, O as ListWorkspaceFilesResponseSchema, Q as RunThreadRequestSchema, Rt as WorkspaceTerminalInputRequestSchema, S as KnownReasoningEffortSchema, St as UpdateWorkspaceFileContentRequestSchema, T as ListSkillsResponseSchema, U as QueuedThreadInputActionResponseSchema, Ut as WorkspaceTerminalStartRequestSchema, Vt as WorkspaceTerminalResizeRequestSchema, Wt as apiPaths, X as ResolveApprovalRequestSchema, Y as RegisterPushNotificationRequestSchema, Yt as promptMarkdownWithSkills, Z as ResolveApprovalResponseSchema, _ as EncryptedPayloadSchema, a as ArchiveThreadResponseSchema, at as StatusResponseSchema, b as InterruptThreadRunResponseSchema, bt as UpdateRuntimePreferencesRequestSchema, ct as SubmitThreadInputResponseSchema, d as CheckoutWorkspaceBranchRequestSchema, dt as ThreadDetailResponseSchema, en as stripPromptSkillMentions, ft as ThreadGoalResponseSchema, gt as ThreadMessageDetailResponseSchema, h as CreateThreadRequestSchema, ht as ThreadMessageDetailFieldSchema, j as PairResponseSchema, jt as WorkspaceGitActionResponseSchema, kt as WorkspaceFileContentResponseSchema, l as ChatMessageSchema, m as ContextWindowUsageSchema, mt as ThreadGoalStatusSchema, nt as RuntimePreferencesResponseSchema, ot as StreamThreadRunEventSchema, p as CommitPushWorkspaceRequestSchema, pt as ThreadGoalSchema, q as RateLimitsResponseSchema, rt as RuntimePreferencesSchema, st as StreamThreadRunRequestSchema, ut as ThreadContextWindowResponseSchema, w as ListQueuedThreadInputsResponseSchema, xt as UpdateThreadGoalRequestSchema, y as ImageAttachmentUploadResponseSchema, yt as ThreadSummarySchema } from "./api-schema2.js";
2
2
  import { a as getConnectUrlCandidates, i as createPairingQrPayload, o as getConnectUrlGuidance, r as legacyCodexRelayDataPath, s as createTursoPairingSessionStore, t as codexRelayDataPath } from "./paths.js";
3
3
  import { createRequire } from "node:module";
4
4
  import qrcode from "qrcode-terminal";
@@ -19,6 +19,7 @@ import { openRepository } from "es-git";
19
19
  import { Hono } from "hono";
20
20
  import { cors } from "hono/cors";
21
21
  import * as pty from "@lydell/node-pty";
22
+ import { connect } from "node:net";
22
23
  import { createInterface } from "node:readline";
23
24
  import WebSocket from "ws";
24
25
  import { Codex } from "@openai/codex-sdk";
@@ -33,11 +34,7 @@ const stdioAppServerArgs = [
33
34
  "--listen",
34
35
  "stdio://"
35
36
  ];
36
- const sharedServerArgs = [
37
- "app-server",
38
- "--listen",
39
- "unix://"
40
- ];
37
+ const sharedWindowsServerUrl = "ws://127.0.0.1:8788";
41
38
  const windowsShellExtensions = new Set([".bat", ".cmd"]);
42
39
  function resolveCodexAppServerMode(env = process.env) {
43
40
  const configuredMode = env.CODEX_RELAY_APP_SERVER_MODE?.trim().toLowerCase();
@@ -46,9 +43,9 @@ function resolveCodexAppServerMode(env = process.env) {
46
43
  throw new Error(`Unsupported CODEX_RELAY_APP_SERVER_MODE ${JSON.stringify(configuredMode)}. Expected "stdio" or "socket".`);
47
44
  }
48
45
  function resolveCodexAppServerSpawn(input = {}) {
49
- const platform$1 = input.platform ?? platform();
46
+ const platform$2 = input.platform ?? platform();
50
47
  const command = resolveCodexBinary(input.env ?? process.env);
51
- const isWindows = platform$1 === "win32";
48
+ const isWindows = platform$2 === "win32";
52
49
  return {
53
50
  command,
54
51
  args: [...stdioAppServerArgs],
@@ -57,19 +54,22 @@ function resolveCodexAppServerSpawn(input = {}) {
57
54
  };
58
55
  }
59
56
  function resolveCodexSharedAppServerSpawn(input = {}) {
60
- const platform$2 = input.platform ?? platform();
61
- assertAppServerModeSupported("socket", platform$2);
57
+ const platform$3 = input.platform ?? platform();
62
58
  const command = resolveCodexBinary(input.env ?? process.env);
63
- const isWindows = platform$2 === "win32";
59
+ const isWindows = platform$3 === "win32";
64
60
  return {
65
61
  command,
66
- args: [...sharedServerArgs],
62
+ args: [
63
+ "app-server",
64
+ "--listen",
65
+ resolveCodexSharedAppServerRemoteAddress(platform$3)
66
+ ],
67
67
  shell: isWindows && shouldUseWindowsShell(command),
68
68
  windowsHide: isWindows
69
69
  };
70
70
  }
71
- function assertAppServerModeSupported(mode, platform) {
72
- if (mode === "socket" && platform === "win32") throw new Error("Shared Codex app-server mode requires macOS, Linux, or WSL.");
71
+ function resolveCodexSharedAppServerRemoteAddress(platform$1 = platform()) {
72
+ return platform$1 === "win32" ? sharedWindowsServerUrl : "unix://";
73
73
  }
74
74
  function resolveCodexBinary(env) {
75
75
  return env.CODEX_BIN?.trim() || "codex";
@@ -79,17 +79,68 @@ function shouldUseWindowsShell(command) {
79
79
  return extension === "" || windowsShellExtensions.has(extension);
80
80
  }
81
81
  //#endregion
82
+ //#region src/debug-log.ts
83
+ function isRelayDebugEnabled() {
84
+ return process.env.CODEX_RELAY_DEBUG === "1" || process.env.CODEX_RELAY_DEBUG_STREAM === "1";
85
+ }
86
+ function relayDebugLog(event, fields = {}) {
87
+ if (!isRelayDebugEnabled()) return;
88
+ const entry = sanitizeDebugValue({
89
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
90
+ event,
91
+ ...fields
92
+ });
93
+ const line = `${JSON.stringify(entry)}\n`;
94
+ const path = process.env.CODEX_RELAY_DEBUG_LOG_PATH?.trim();
95
+ if (path) mkdir(dirname(path), { recursive: true }).then(() => appendFile(path, line, { mode: 384 })).catch(() => void 0);
96
+ console.log(`[debug] ${event} ${formatDebugFields(fields)}`.trimEnd());
97
+ }
98
+ function sanitizeDebugValue(value) {
99
+ if (value instanceof Error) return {
100
+ message: value.message,
101
+ name: value.name,
102
+ stack: value.stack
103
+ };
104
+ if (Array.isArray(value)) return value.map(sanitizeDebugValue);
105
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeDebugValue(item)]));
106
+ return value;
107
+ }
108
+ function formatDebugFields(fields) {
109
+ return Object.entries(fields).map(([key, value]) => `${key}=${formatDebugValue(value)}`).join(" ");
110
+ }
111
+ function formatDebugValue(value) {
112
+ if (value === void 0) return "undefined";
113
+ if (value === null) return "null";
114
+ if (typeof value === "string") return value.includes(" ") ? JSON.stringify(value) : value;
115
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
116
+ return JSON.stringify(sanitizeDebugValue(value));
117
+ }
118
+ //#endregion
82
119
  //#region src/app-server.ts
120
+ const sharedSocketReconnectDelaysMs = [
121
+ 50,
122
+ 100,
123
+ 250,
124
+ 500,
125
+ 1e3,
126
+ 2e3
127
+ ];
83
128
  var CodexAppServerClient = class {
84
129
  child;
130
+ closed = false;
85
131
  initialized;
86
132
  notificationHandlers = /* @__PURE__ */ new Set();
87
133
  nextId = 1;
88
134
  pending = /* @__PURE__ */ new Map();
135
+ reconnecting;
89
136
  requestHandlers = /* @__PURE__ */ new Set();
90
137
  readline;
91
138
  sharedServer;
92
139
  socket;
140
+ startSharedServer;
141
+ constructor(options = {}) {
142
+ this.startSharedServer = options.startSharedServer ?? startSharedCodexAppServer;
143
+ }
93
144
  initialize() {
94
145
  return this.ensureInitialized();
95
146
  }
@@ -165,15 +216,18 @@ var CodexAppServerClient = class {
165
216
  });
166
217
  }
167
218
  close() {
219
+ if (this.closed) return;
220
+ this.closed = true;
168
221
  for (const pending of this.pending.values()) pending.reject(/* @__PURE__ */ new Error("Codex app-server was closed."));
169
222
  this.pending.clear();
170
223
  this.readline?.close();
171
224
  this.child?.kill();
172
- this.socket?.close();
225
+ const socket = this.socket;
226
+ this.socket = void 0;
227
+ socket?.close();
173
228
  this.sharedServer?.kill();
174
229
  this.readline = void 0;
175
230
  this.child = void 0;
176
- this.socket = void 0;
177
231
  this.sharedServer = void 0;
178
232
  this.initialized = void 0;
179
233
  }
@@ -197,35 +251,37 @@ var CodexAppServerClient = class {
197
251
  });
198
252
  }
199
253
  ensureInitialized() {
200
- if (!this.initialized) this.initialized = this.start();
254
+ if (this.closed) return Promise.reject(/* @__PURE__ */ new Error("Codex app-server was closed."));
255
+ if (!this.initialized) {
256
+ const initialized = this.start();
257
+ this.initialized = initialized;
258
+ initialized.catch(() => {
259
+ if (this.initialized === initialized) this.initialized = void 0;
260
+ });
261
+ }
201
262
  return this.initialized;
202
263
  }
203
264
  async start() {
265
+ const mode = resolveCodexAppServerMode();
204
266
  try {
205
- if (resolveCodexAppServerMode() === "socket") {
206
- this.sharedServer = await startSharedCodexAppServer();
207
- await this.connectSharedCodexAppServer();
208
- } else this.startStdioCodexAppServer();
209
- await this.requestRaw("initialize", {
210
- clientInfo: {
211
- name: "codex-relay",
212
- title: "Codex Relay Mobile Server",
213
- version: "1.2.0"
214
- },
215
- capabilities: { experimentalApi: true }
216
- });
267
+ if (mode === "socket") await this.startOrAttachSharedCodexAppServer();
268
+ else this.startStdioCodexAppServer();
269
+ await this.initializeAppServer();
217
270
  } catch (error) {
218
- this.readline?.close();
219
- this.readline = void 0;
220
- this.child?.kill();
221
- this.child = void 0;
222
- this.socket?.close();
223
- this.socket = void 0;
224
- this.sharedServer?.kill();
225
- this.sharedServer = void 0;
271
+ if (mode === "stdio") this.stopStdioCodexAppServer();
226
272
  throw error;
227
273
  }
228
274
  }
275
+ async initializeAppServer() {
276
+ await this.requestRaw("initialize", {
277
+ clientInfo: {
278
+ name: "codex-relay",
279
+ title: "Codex Relay Mobile Server",
280
+ version: "1.2.0"
281
+ },
282
+ capabilities: { experimentalApi: true }
283
+ });
284
+ }
229
285
  startStdioCodexAppServer() {
230
286
  const spawnConfig = resolveCodexAppServerSpawn();
231
287
  this.child = spawn(spawnConfig.command, spawnConfig.args, {
@@ -248,31 +304,153 @@ var CodexAppServerClient = class {
248
304
  this.initialized = void 0;
249
305
  });
250
306
  }
307
+ stopStdioCodexAppServer() {
308
+ this.readline?.close();
309
+ this.readline = void 0;
310
+ this.child?.kill();
311
+ this.child = void 0;
312
+ }
313
+ async startOrAttachSharedCodexAppServer() {
314
+ try {
315
+ await this.connectSharedCodexAppServer();
316
+ relayDebugLog("app_server.shared_socket.attached", {
317
+ ownership: "attached",
318
+ socketPath: sharedCodexAppServerSocketPath()
319
+ });
320
+ return;
321
+ } catch (error) {
322
+ const attachError = asError(error);
323
+ relayDebugLog("app_server.shared_socket.attach_failed", {
324
+ message: attachError.message,
325
+ socketPath: sharedCodexAppServerSocketPath()
326
+ });
327
+ if (this.sharedServer) throw attachError;
328
+ }
329
+ const sharedServer = await this.startSharedServer();
330
+ this.sharedServer = sharedServer;
331
+ this.observeSharedCodexAppServer(sharedServer);
332
+ relayDebugLog("app_server.shared_process.started", {
333
+ ownership: "relay-owned",
334
+ socketPath: sharedCodexAppServerSocketPath()
335
+ });
336
+ try {
337
+ await this.connectSharedCodexAppServer();
338
+ } catch (error) {
339
+ if (this.sharedServer === sharedServer) {
340
+ sharedServer.kill();
341
+ this.sharedServer = void 0;
342
+ }
343
+ throw error;
344
+ }
345
+ }
346
+ observeSharedCodexAppServer(sharedServer) {
347
+ sharedServer.on("error", (error) => {
348
+ relayDebugLog("app_server.shared_process.error", {
349
+ message: error.message,
350
+ ownership: "relay-owned"
351
+ });
352
+ if (this.sharedServer === sharedServer) this.sharedServer = void 0;
353
+ });
354
+ sharedServer.once("exit", (code, signal) => {
355
+ if (this.sharedServer !== sharedServer) return;
356
+ this.sharedServer = void 0;
357
+ const error = /* @__PURE__ */ new Error(`Codex shared app-server exited with ${signal ?? code ?? 1}.`);
358
+ relayDebugLog("app_server.shared_process.exited", {
359
+ message: error.message,
360
+ ownership: "relay-owned"
361
+ });
362
+ if (this.socket) this.handleSharedSocketFailure(this.socket, error);
363
+ });
364
+ }
251
365
  async connectSharedCodexAppServer() {
252
- const socket = new WebSocket(`ws+unix://${sharedCodexAppServerSocketPath()}:/`, { perMessageDeflate: false });
366
+ const remoteAddress = resolveCodexSharedAppServerRemoteAddress();
367
+ const socket = new WebSocket(remoteAddress === "unix://" ? `ws+unix://${sharedCodexAppServerSocketPath()}:/` : remoteAddress, { perMessageDeflate: false });
253
368
  this.socket = socket;
254
- await new Promise((resolve, reject) => {
255
- const handleOpen = () => {
256
- socket.off("error", handleInitialError);
257
- resolve();
258
- };
259
- const handleInitialError = (error) => {
260
- socket.off("open", handleOpen);
261
- reject(error);
262
- };
263
- socket.once("open", handleOpen);
264
- socket.once("error", handleInitialError);
265
- });
369
+ try {
370
+ await new Promise((resolve, reject) => {
371
+ const handleOpen = () => {
372
+ socket.off("error", handleInitialError);
373
+ resolve();
374
+ };
375
+ const handleInitialError = (error) => {
376
+ socket.off("open", handleOpen);
377
+ reject(error);
378
+ };
379
+ socket.once("open", handleOpen);
380
+ socket.once("error", handleInitialError);
381
+ });
382
+ } catch (error) {
383
+ if (this.socket === socket) this.socket = void 0;
384
+ socket.close();
385
+ throw error;
386
+ }
266
387
  socket.on("message", (data) => this.handleLine(String(data)));
267
- socket.on("error", (error) => this.rejectAll(error));
388
+ socket.on("error", (error) => this.handleSharedSocketFailure(socket, error));
268
389
  socket.once("close", (code, reason) => {
269
- this.rejectAll(/* @__PURE__ */ new Error(`Codex app-server socket closed with ${code}${reason.length > 0 ? `: ${String(reason)}` : "."}`));
270
- this.socket = void 0;
271
- this.sharedServer?.kill();
272
- this.sharedServer = void 0;
273
- this.initialized = void 0;
390
+ this.handleSharedSocketFailure(socket, /* @__PURE__ */ new Error(`Codex app-server socket closed with ${code}${reason.length > 0 ? `: ${String(reason)}` : "."}`));
391
+ });
392
+ relayDebugLog("app_server.shared_socket.connected", {
393
+ ownership: this.sharedAppServerOwnership(),
394
+ socketPath: sharedCodexAppServerSocketPath()
274
395
  });
275
396
  }
397
+ handleSharedSocketFailure(socket, error) {
398
+ if (this.socket !== socket) return;
399
+ this.socket = void 0;
400
+ this.rejectAll(error);
401
+ this.initialized = void 0;
402
+ relayDebugLog("app_server.shared_socket.disconnected", {
403
+ message: error.message,
404
+ ownership: this.sharedAppServerOwnership()
405
+ });
406
+ this.scheduleSharedSocketReconnect();
407
+ }
408
+ sharedAppServerOwnership() {
409
+ return this.sharedServer ? "relay-owned" : "attached";
410
+ }
411
+ scheduleSharedSocketReconnect() {
412
+ if (this.closed || this.reconnecting) return;
413
+ const reconnecting = this.reconnectSharedCodexAppServer();
414
+ this.reconnecting = reconnecting;
415
+ this.initialized = reconnecting;
416
+ reconnecting.then(() => {
417
+ if (this.reconnecting === reconnecting) this.reconnecting = void 0;
418
+ }, (error) => {
419
+ relayDebugLog("app_server.shared_socket.reconnect_failed", {
420
+ message: asError(error).message,
421
+ ownership: this.sharedAppServerOwnership()
422
+ });
423
+ if (this.initialized === reconnecting) this.initialized = void 0;
424
+ if (this.reconnecting === reconnecting) this.reconnecting = void 0;
425
+ });
426
+ }
427
+ async reconnectSharedCodexAppServer() {
428
+ let lastError;
429
+ for (const delayMs of sharedSocketReconnectDelaysMs) {
430
+ await setTimeout$1(delayMs);
431
+ if (this.closed) return;
432
+ try {
433
+ await this.connectSharedCodexAppServer();
434
+ await this.initializeAppServer();
435
+ relayDebugLog("app_server.shared_socket.reconnected", {
436
+ ownership: this.sharedAppServerOwnership(),
437
+ socketPath: sharedCodexAppServerSocketPath()
438
+ });
439
+ return;
440
+ } catch (error) {
441
+ lastError = asError(error);
442
+ const socket = this.socket;
443
+ this.socket = void 0;
444
+ socket?.close();
445
+ relayDebugLog("app_server.shared_socket.reconnect_retry", {
446
+ delayMs,
447
+ message: lastError.message,
448
+ ownership: this.sharedAppServerOwnership()
449
+ });
450
+ }
451
+ }
452
+ throw lastError ?? /* @__PURE__ */ new Error("Unable to reconnect to the shared Codex app-server.");
453
+ }
276
454
  requestRaw(method, params) {
277
455
  const id = this.nextId++;
278
456
  const request = JSON.stringify({
@@ -376,11 +554,11 @@ async function startSharedCodexAppServer() {
376
554
  child.once("exit", (code, signal) => {
377
555
  exitReason = signal ?? code ?? 1;
378
556
  });
379
- const socketPath = sharedCodexAppServerSocketPath();
557
+ const remoteAddress = resolveCodexSharedAppServerRemoteAddress();
380
558
  for (let attempt = 0; attempt < 100; attempt += 1) {
381
559
  if (spawnError) throw new Error(`Failed to start the shared Codex app-server: ${spawnError.message}`);
382
560
  try {
383
- await access(socketPath);
561
+ await waitForSharedCodexAppServer(remoteAddress);
384
562
  return child;
385
563
  } catch {
386
564
  if (exitReason !== void 0) {
@@ -391,11 +569,34 @@ async function startSharedCodexAppServer() {
391
569
  }
392
570
  }
393
571
  child.kill();
394
- throw new Error(`Timed out waiting for the shared Codex app-server socket at ${socketPath}.`);
572
+ throw new Error(`Timed out waiting for the shared Codex app-server at ${remoteAddress}.`);
573
+ }
574
+ async function waitForSharedCodexAppServer(remoteAddress) {
575
+ if (remoteAddress === "unix://") {
576
+ await access(sharedCodexAppServerSocketPath());
577
+ return;
578
+ }
579
+ const url = new URL(remoteAddress);
580
+ await new Promise((resolve, reject) => {
581
+ const socket = connect({
582
+ host: url.hostname,
583
+ port: Number(url.port)
584
+ });
585
+ const onError = (error) => reject(error);
586
+ socket.once("error", onError);
587
+ socket.once("connect", () => {
588
+ socket.off("error", onError);
589
+ socket.destroy();
590
+ resolve();
591
+ });
592
+ });
395
593
  }
396
594
  function sharedCodexAppServerSocketPath() {
397
595
  return join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "app-server-control", "app-server-control.sock");
398
596
  }
597
+ function asError(error) {
598
+ return error instanceof Error ? error : new Error(String(error));
599
+ }
399
600
  function debugAppServer(kind, method, id, detail) {
400
601
  if (process.env.CODEX_RELAY_DEBUG_APP_SERVER !== "1") return;
401
602
  console.log(`[app-server] ${kind}${id === void 0 ? "" : ` #${id}`}${method ? ` ${method}` : ""}${detail ? ` ${detail}` : ""}`);
@@ -585,41 +786,79 @@ function sumPositiveIntegers(values) {
585
786
  return found ? sum : null;
586
787
  }
587
788
  //#endregion
588
- //#region src/debug-log.ts
589
- function isRelayDebugEnabled() {
590
- return process.env.CODEX_RELAY_DEBUG === "1" || process.env.CODEX_RELAY_DEBUG_STREAM === "1";
789
+ //#region src/push-notifications.ts
790
+ const expoPushEndpoint = "https://exp.host/--/api/v2/push/send";
791
+ const expoPushChunkSize = 100;
792
+ function createExpoPushNotificationSender(fetchImpl = fetch) {
793
+ return { async send(notifications) {
794
+ const invalidExpoPushTokens = /* @__PURE__ */ new Set();
795
+ for (const chunk of chunks(notifications, expoPushChunkSize)) {
796
+ const response = await fetchImpl(expoPushEndpoint, {
797
+ body: JSON.stringify(chunk.map((notification) => ({
798
+ body: notification.body,
799
+ channelId: "default",
800
+ data: notification.data,
801
+ priority: "high",
802
+ sound: "default",
803
+ title: notification.title,
804
+ to: notification.to
805
+ }))),
806
+ headers: {
807
+ accept: "application/json",
808
+ "content-type": "application/json"
809
+ },
810
+ method: "POST"
811
+ });
812
+ const body = await response.json().catch(() => void 0);
813
+ if (!response.ok) throw new Error(`Expo push service returned ${response.status}.`);
814
+ const tickets = expoPushTickets(body);
815
+ for (const [index, ticket] of tickets.entries()) {
816
+ const notification = chunk[index];
817
+ if (notification && isDeviceNotRegistered(ticket)) invalidExpoPushTokens.add(notification.to);
818
+ }
819
+ }
820
+ return { invalidExpoPushTokens: [...invalidExpoPushTokens] };
821
+ } };
591
822
  }
592
- function relayDebugLog(event, fields = {}) {
593
- if (!isRelayDebugEnabled()) return;
594
- const entry = sanitizeDebugValue({
595
- ts: (/* @__PURE__ */ new Date()).toISOString(),
596
- event,
597
- ...fields
598
- });
599
- const line = `${JSON.stringify(entry)}\n`;
600
- const path = process.env.CODEX_RELAY_DEBUG_LOG_PATH?.trim();
601
- if (path) mkdir(dirname(path), { recursive: true }).then(() => appendFile(path, line, { mode: 384 })).catch(() => void 0);
602
- console.log(`[debug] ${event} ${formatDebugFields(fields)}`.trimEnd());
823
+ function createPushNotificationDispatcher(input) {
824
+ return { async dispatch(event) {
825
+ const subscriptions = await input.sessions.listActivePushNotificationSubscriptions(Date.now());
826
+ const selectedTokens = new Set(subscriptions.filter((subscription) => notificationEnabled(subscription, event.intent)).map((subscription) => subscription.expoPushToken));
827
+ if (selectedTokens.size === 0) return;
828
+ const delivery = await input.sender.send([...selectedTokens].map((expoPushToken) => notificationForEvent(expoPushToken, event)));
829
+ await Promise.all(delivery.invalidExpoPushTokens.map((expoPushToken) => input.sessions.deletePushNotificationSubscriptionsByExpoPushToken(expoPushToken)));
830
+ } };
603
831
  }
604
- function sanitizeDebugValue(value) {
605
- if (value instanceof Error) return {
606
- message: value.message,
607
- name: value.name,
608
- stack: value.stack
832
+ function notificationEnabled(subscription, intent) {
833
+ return intent === "action_required" ? subscription.actionRequired : subscription.turnTerminal;
834
+ }
835
+ function notificationForEvent(expoPushToken, event) {
836
+ return {
837
+ body: event.intent === "action_required" ? "Codex needs your attention." : "A Codex turn has finished.",
838
+ data: {
839
+ intent: event.intent,
840
+ threadId: event.threadId,
841
+ ...event.turnId ? { turnId: event.turnId } : {}
842
+ },
843
+ title: "Codex Relay",
844
+ to: expoPushToken
609
845
  };
610
- if (Array.isArray(value)) return value.map(sanitizeDebugValue);
611
- if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeDebugValue(item)]));
612
- return value;
613
846
  }
614
- function formatDebugFields(fields) {
615
- return Object.entries(fields).map(([key, value]) => `${key}=${formatDebugValue(value)}`).join(" ");
847
+ function expoPushTickets(value) {
848
+ if (!value || typeof value !== "object") return [];
849
+ const data = value.data;
850
+ return Array.isArray(data) ? data : [];
616
851
  }
617
- function formatDebugValue(value) {
618
- if (value === void 0) return "undefined";
619
- if (value === null) return "null";
620
- if (typeof value === "string") return value.includes(" ") ? JSON.stringify(value) : value;
621
- if (typeof value === "number" || typeof value === "boolean") return String(value);
622
- return JSON.stringify(sanitizeDebugValue(value));
852
+ function isDeviceNotRegistered(ticket) {
853
+ if (!ticket || typeof ticket !== "object") return false;
854
+ const record = ticket;
855
+ if (record.status !== "error" || !record.details || typeof record.details !== "object") return false;
856
+ return record.details.error === "DeviceNotRegistered";
857
+ }
858
+ function chunks(items, size) {
859
+ const result = [];
860
+ for (let index = 0; index < items.length; index += size) result.push(items.slice(index, index + size));
861
+ return result;
623
862
  }
624
863
  //#endregion
625
864
  //#region src/preferences-store.ts
@@ -1179,6 +1418,7 @@ function createApp(options = {}) {
1179
1418
  const pendingApprovals = /* @__PURE__ */ new Map();
1180
1419
  const resolvedApprovals = /* @__PURE__ */ new Map();
1181
1420
  const queuedInputsByThreadId = /* @__PURE__ */ new Map();
1421
+ const threadOperationTails = /* @__PURE__ */ new Map();
1182
1422
  const workspaceTerminalSessions = /* @__PURE__ */ new Map();
1183
1423
  const activeAppServerTurnIdsByThreadId = /* @__PURE__ */ new Map();
1184
1424
  const appServerHistoryLoadsByThreadId = /* @__PURE__ */ new Map();
@@ -1186,6 +1426,20 @@ function createApp(options = {}) {
1186
1426
  const secureSessionsByTokenHash = /* @__PURE__ */ new Map();
1187
1427
  const activeStreamControllers = /* @__PURE__ */ new Set();
1188
1428
  const threadOptions = { workingDirectory: workspacePath };
1429
+ function runThreadOperation(threadId, operation) {
1430
+ const result = (threadOperationTails.get(threadId) ?? Promise.resolve()).then(operation, operation);
1431
+ const tail = result.then(() => void 0, () => void 0);
1432
+ threadOperationTails.set(threadId, tail);
1433
+ tail.finally(() => {
1434
+ if (threadOperationTails.get(threadId) === tail) threadOperationTails.delete(threadId);
1435
+ });
1436
+ return result;
1437
+ }
1438
+ const pushNotificationDispatcher = options.pairing ? createPushNotificationDispatcher({
1439
+ sender: options.pushNotificationSender ?? createExpoPushNotificationSender(),
1440
+ sessions: options.pairing.sessions
1441
+ }) : void 0;
1442
+ if (appServer && pushNotificationDispatcher) observeAppServerPushNotifications(appServer, pushNotificationDispatcher);
1189
1443
  const scheduleAppServerHistoryLoad = (threadId, cachedMessages) => {
1190
1444
  if (!appServer || appServerHistoryLoadsByThreadId.has(threadId)) return;
1191
1445
  const startedAt = Date.now();
@@ -1410,6 +1664,48 @@ function createApp(options = {}) {
1410
1664
  });
1411
1665
  return secureJson(c, options.pairing, secureSessionsByTokenHash, response);
1412
1666
  });
1667
+ app.get(apiPaths.pushNotifications, async (c) => {
1668
+ const clientSessionId = await pairedClientSessionIdForAuthorization(c.req.header("authorization"), options.pairing);
1669
+ if (!clientSessionId) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("push_notifications_unavailable", "Pair this device again to manage notifications."), 401);
1670
+ const subscription = await options.pairing.sessions.getPushNotificationSubscription(clientSessionId);
1671
+ const response = PushNotificationSettingsResponseSchema.parse({
1672
+ preferences: subscription ? {
1673
+ actionRequired: subscription.actionRequired,
1674
+ turnTerminal: subscription.turnTerminal
1675
+ } : defaultPushNotificationPreferences(),
1676
+ registered: Boolean(subscription)
1677
+ });
1678
+ return secureJson(c, options.pairing, secureSessionsByTokenHash, response);
1679
+ });
1680
+ app.put(apiPaths.pushNotifications, async (c) => {
1681
+ if (!options.pairing) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("pairing_required", "Pair this device before registering notifications."), 404);
1682
+ const parsed = await parseRequestJson(c, options.pairing, secureSessionsByTokenHash, RegisterPushNotificationRequestSchema);
1683
+ if (!parsed.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, validationError(parsed.error), 400);
1684
+ const clientSessionId = await pairedClientSessionIdForAuthorization(c.req.header("authorization"), options.pairing);
1685
+ if (!clientSessionId) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("push_notifications_unavailable", "Pair this device again to manage notifications."), 401);
1686
+ await options.pairing.sessions.upsertPushNotificationSubscription({
1687
+ actionRequired: parsed.data.preferences.actionRequired,
1688
+ clientSessionId,
1689
+ expoPushToken: parsed.data.expoPushToken,
1690
+ platform: parsed.data.platform,
1691
+ turnTerminal: parsed.data.preferences.turnTerminal
1692
+ });
1693
+ const response = PushNotificationSettingsResponseSchema.parse({
1694
+ preferences: parsed.data.preferences,
1695
+ registered: true
1696
+ });
1697
+ return secureJson(c, options.pairing, secureSessionsByTokenHash, response);
1698
+ });
1699
+ app.delete(apiPaths.pushNotifications, async (c) => {
1700
+ const clientSessionId = await pairedClientSessionIdForAuthorization(c.req.header("authorization"), options.pairing);
1701
+ if (!clientSessionId) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("push_notifications_unavailable", "Pair this device again to manage notifications."), 401);
1702
+ await options.pairing.sessions.deletePushNotificationSubscription(clientSessionId);
1703
+ const response = PushNotificationSettingsResponseSchema.parse({
1704
+ preferences: defaultPushNotificationPreferences(),
1705
+ registered: false
1706
+ });
1707
+ return secureJson(c, options.pairing, secureSessionsByTokenHash, response);
1708
+ });
1413
1709
  app.get(apiPaths.workspaceDirectories, async (c) => {
1414
1710
  const targetPath = resolve(c.req.query("path") ?? workspacePath);
1415
1711
  try {
@@ -2106,44 +2402,46 @@ function createApp(options = {}) {
2106
2402
  });
2107
2403
  app.post("/v1/threads/:threadId/input", async (c) => {
2108
2404
  const threadId = c.req.param("threadId");
2109
- const knownThread = await ensureKnownThread({
2110
- appServer,
2111
- threadId,
2112
- messagesByThreadId,
2113
- threads
2114
- });
2115
- if (!knownThread) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("not_found", `Thread ${threadId} is not known to this server.`), 404);
2116
- if (!appServer) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("unsupported", "Running-thread input requires the Codex app-server."), 409);
2117
- if (knownThread.state !== "running") return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("thread_not_running", `Thread ${threadId} is not currently running.`), 409);
2118
- const parsed = await parseRequestJson(c, options.pairing, secureSessionsByTokenHash, RunThreadRequestSchema);
2119
- if (!parsed.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, validationError(parsed.error), 400);
2120
- const runOptions = withRuntimePreferences(await preferences.read(knownThread.cwd ?? workspacePath), parsed.data);
2121
- const skills = runOptions.skills ?? [];
2122
- const prompt = runOptions.prompt;
2123
- const queuedInputs = queuedInputsByThreadId.get(threadId) ?? [];
2124
- const queuedInput = {
2125
- attachments: runOptions.attachments ?? [],
2126
- id: randomUUID(),
2127
- prompt,
2128
- runOptions,
2129
- skills,
2130
- workspacePath: knownThread.cwd ?? workspacePath
2131
- };
2132
- queuedInputs.push(queuedInput);
2133
- queuedInputsByThreadId.set(threadId, queuedInputs);
2134
- const thread = updateThread(threads, messagesByThreadId, threadId, {
2135
- state: "running",
2136
- lastPrompt: promptWithAttachmentReferences(prompt, runOptions.attachments ?? []),
2137
- lastError: void 0,
2138
- ...runtimeMetadataFromOptions(runOptions)
2139
- });
2140
- const response = SubmitThreadInputResponseSchema.parse({
2141
- acceptedAs: "queued",
2142
- input: queuedThreadInputSummary(queuedInput),
2143
- queueLength: queuedInputsByThreadId.get(threadId)?.length ?? 0,
2144
- thread
2405
+ return runThreadOperation(threadId, async () => {
2406
+ const knownThread = await ensureKnownThread({
2407
+ appServer,
2408
+ threadId,
2409
+ messagesByThreadId,
2410
+ threads
2411
+ });
2412
+ if (!knownThread) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("not_found", `Thread ${threadId} is not known to this server.`), 404);
2413
+ if (!appServer) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("unsupported", "Running-thread input requires the Codex app-server."), 409);
2414
+ if (knownThread.state !== "running") return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("thread_not_running", `Thread ${threadId} is not currently running.`), 409);
2415
+ const parsed = await parseRequestJson(c, options.pairing, secureSessionsByTokenHash, RunThreadRequestSchema);
2416
+ if (!parsed.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, validationError(parsed.error), 400);
2417
+ const runOptions = withRuntimePreferences(await preferences.read(knownThread.cwd ?? workspacePath), parsed.data);
2418
+ const skills = runOptions.skills ?? [];
2419
+ const prompt = runOptions.prompt;
2420
+ const queuedInputs = queuedInputsByThreadId.get(threadId) ?? [];
2421
+ const queuedInput = {
2422
+ attachments: runOptions.attachments ?? [],
2423
+ id: randomUUID(),
2424
+ prompt,
2425
+ runOptions,
2426
+ skills,
2427
+ workspacePath: knownThread.cwd ?? workspacePath
2428
+ };
2429
+ queuedInputs.push(queuedInput);
2430
+ queuedInputsByThreadId.set(threadId, queuedInputs);
2431
+ const thread = updateThread(threads, messagesByThreadId, threadId, {
2432
+ state: "running",
2433
+ lastPrompt: promptWithAttachmentReferences(prompt, runOptions.attachments ?? []),
2434
+ lastError: void 0,
2435
+ ...runtimeMetadataFromOptions(runOptions)
2436
+ });
2437
+ const response = SubmitThreadInputResponseSchema.parse({
2438
+ acceptedAs: "queued",
2439
+ input: queuedThreadInputSummary(queuedInput),
2440
+ queueLength: queuedInputsByThreadId.get(threadId)?.length ?? 0,
2441
+ thread
2442
+ });
2443
+ return secureJson(c, options.pairing, secureSessionsByTokenHash, response, 202);
2145
2444
  });
2146
- return secureJson(c, options.pairing, secureSessionsByTokenHash, response, 202);
2147
2445
  });
2148
2446
  app.delete("/v1/threads/:threadId/input/:inputId", async (c) => {
2149
2447
  const threadId = c.req.param("threadId");
@@ -2364,6 +2662,7 @@ function createApp(options = {}) {
2364
2662
  messagesByThreadId,
2365
2663
  pendingApprovals,
2366
2664
  queuedInputsByThreadId,
2665
+ runThreadOperation,
2367
2666
  prompt: rawPrompt,
2368
2667
  attachments: runOptions.attachments ?? [],
2369
2668
  secureSession,
@@ -2406,6 +2705,18 @@ function createApp(options = {}) {
2406
2705
  function parseBearerToken(value) {
2407
2706
  return (value?.match(/^Bearer\s+(.+)$/i))?.[1];
2408
2707
  }
2708
+ async function pairedClientSessionIdForAuthorization(authorization, pairing) {
2709
+ if (!pairing) return;
2710
+ const token = parseBearerToken(authorization);
2711
+ if (!token) return;
2712
+ return (await pairing.sessions.getValidSession(pairing.hashClientToken(token), Date.now()))?.clientSessionId;
2713
+ }
2714
+ function defaultPushNotificationPreferences() {
2715
+ return {
2716
+ actionRequired: false,
2717
+ turnTerminal: false
2718
+ };
2719
+ }
2409
2720
  function normalizeApprovalCode(value) {
2410
2721
  const normalized = value.toUpperCase().replace(/[^A-Z0-9]/g, "").replaceAll("O", "0").replaceAll("I", "1");
2411
2722
  return normalized.length === 8 ? `${normalized.slice(0, 4)}-${normalized.slice(4)}` : normalized;
@@ -2564,6 +2875,7 @@ async function runPromptStreamed(input) {
2564
2875
  messagesByThreadId: input.messagesByThreadId,
2565
2876
  pendingApprovals: input.pendingApprovals,
2566
2877
  queuedInputsByThreadId: input.queuedInputsByThreadId,
2878
+ runThreadOperation: input.runThreadOperation,
2567
2879
  prompt: input.prompt,
2568
2880
  runOptions: input.runOptions,
2569
2881
  secureSession: input.secureSession,
@@ -3395,11 +3707,16 @@ async function runAppServerPromptStreamed(input) {
3395
3707
  case "turn/failed": {
3396
3708
  const state = terminalTurnState(notification.method, params);
3397
3709
  const terminalTurnId = firstString(params, ["turnId"]) ?? turnIdFromParams(params) ?? activeTurnId;
3398
- finishTerminalTurn({
3399
- lastError: state === "failed" ? turnErrorMessage(params) : void 0,
3400
- method: notification.method,
3401
- state,
3402
- turnId: terminalTurnId
3710
+ input.runThreadOperation(activeThreadId, async () => {
3711
+ finishTerminalTurn({
3712
+ lastError: state === "failed" ? turnErrorMessage(params) : void 0,
3713
+ method: notification.method,
3714
+ state,
3715
+ turnId: terminalTurnId
3716
+ });
3717
+ }).catch((error) => {
3718
+ cleanupNotificationHandler();
3719
+ rejectCompleted(error);
3403
3720
  });
3404
3721
  return;
3405
3722
  }
@@ -5421,6 +5738,74 @@ function structuredStreamMessage(role, event, fallbackContent) {
5421
5738
  function isApprovalServerRequest(method) {
5422
5739
  return method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval" || method === "item/permissions/requestApproval" || method === "item/tool/requestUserInput" || method === "mcpServer/elicitation/request" || method === "execCommandApproval" || method === "applyPatchApproval";
5423
5740
  }
5741
+ function observeAppServerPushNotifications(appServer, dispatcher) {
5742
+ const dispatchedEventIds = /* @__PURE__ */ new Set();
5743
+ const subagentThreadIds = /* @__PURE__ */ new Set();
5744
+ const rememberEventId = (eventId) => {
5745
+ if (dispatchedEventIds.size >= 1e3) dispatchedEventIds.clear();
5746
+ dispatchedEventIds.add(eventId);
5747
+ };
5748
+ const dispatch = (event, eventId) => {
5749
+ if (dispatchedEventIds.has(eventId)) return;
5750
+ rememberEventId(eventId);
5751
+ dispatcher.dispatch(event).catch((error) => {
5752
+ relayDebugLog("push_notification.dispatch_failed", {
5753
+ error: errorMessage(error),
5754
+ intent: event.intent,
5755
+ threadId: event.threadId,
5756
+ turnId: event.turnId
5757
+ });
5758
+ });
5759
+ };
5760
+ appServer.onNotification((notification) => {
5761
+ rememberSubagentThreadIds(notification, subagentThreadIds);
5762
+ const event = pushNotificationEventFromTerminalNotification(notification);
5763
+ if (!event) return;
5764
+ const eventId = `${event.intent}:${event.threadId}:${event.turnId ?? ""}`;
5765
+ if (subagentThreadIds.delete(event.threadId)) {
5766
+ rememberEventId(eventId);
5767
+ return;
5768
+ }
5769
+ dispatch(event, eventId);
5770
+ });
5771
+ appServer.onRequest((request) => {
5772
+ const event = pushNotificationEventFromApprovalRequest(request);
5773
+ if (!event) return;
5774
+ dispatch(event, `${event.intent}:${event.threadId}:${event.turnId ?? ""}:${request.id}`);
5775
+ });
5776
+ }
5777
+ function rememberSubagentThreadIds(notification, subagentThreadIds) {
5778
+ const item = objectRecord(recordParams(notification)?.item);
5779
+ if (item?.type !== "collabAgentToolCall" || ![
5780
+ "spawnAgent",
5781
+ "sendInput",
5782
+ "resumeAgent"
5783
+ ].includes(String(item.tool))) return;
5784
+ for (const threadId of stringArray(item.receiverThreadIds)) subagentThreadIds.add(threadId);
5785
+ }
5786
+ function pushNotificationEventFromTerminalNotification(notification) {
5787
+ if (notification.method !== "turn/completed" && notification.method !== "turn/failed") return;
5788
+ const params = recordParams(notification);
5789
+ const status = turnStatus(params);
5790
+ if (status === "aborted" || status === "cancelled" || status === "canceled" || status === "interrupted") return;
5791
+ const threadId = firstString(params, ["threadId", "conversationId"]);
5792
+ if (!threadId) return;
5793
+ return {
5794
+ intent: "turn_terminal",
5795
+ threadId,
5796
+ turnId: firstString(params, ["turnId"]) ?? turnIdFromParams(params)
5797
+ };
5798
+ }
5799
+ function pushNotificationEventFromApprovalRequest(request) {
5800
+ if (!isApprovalServerRequest(request.method)) return;
5801
+ const approval = approvalMessageFromRequest(request);
5802
+ if (!approval) return;
5803
+ return {
5804
+ intent: "action_required",
5805
+ threadId: approval.threadId,
5806
+ turnId: approval.turnId
5807
+ };
5808
+ }
5424
5809
  function approvalMessageFromRequest(request) {
5425
5810
  const params = recordParams(request);
5426
5811
  const threadId = firstString(params, ["threadId", "conversationId"]);
@@ -6480,7 +6865,7 @@ serve({
6480
6865
  listenUrl,
6481
6866
  pairingPayload,
6482
6867
  port: info.port,
6483
- sharedAppServer: appServerMode === "socket"
6868
+ sharedAppServerRemoteAddress: appServerMode === "socket" ? resolveCodexSharedAppServerRemoteAddress() : void 0
6484
6869
  }));
6485
6870
  });
6486
6871
  function stopSharedAppServer(exitCode) {
@@ -6499,9 +6884,9 @@ function formatStartupInstructions(details) {
6499
6884
  `${color.prompt("›")} Server: ${color.muted(details.listenUrl)}`,
6500
6885
  "",
6501
6886
  `${color.prompt("›")} Pairing: ${color.url(details.pairingPayload)}`,
6502
- ...details.sharedAppServer ? [
6887
+ ...details.sharedAppServerRemoteAddress ? [
6503
6888
  "",
6504
- `${color.prompt("›")} Terminal: ${color.command("codex resume --remote unix://")}`,
6889
+ `${color.prompt("›")} Terminal: ${color.command(`codex resume --remote ${details.sharedAppServerRemoteAddress}`)}`,
6505
6890
  ` ${color.muted("Connect through the shared Codex app-server to follow and steer the same live sessions.")}`
6506
6891
  ] : [],
6507
6892
  "",