@leo000001/codex-mcp 2.1.3 → 2.1.5

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/index.js CHANGED
@@ -392,7 +392,7 @@ var DEFAULT_TERMINAL_CLEANUP_MS = 5 * 60 * 1e3;
392
392
  var CLEANUP_INTERVAL_MS = 6e4;
393
393
 
394
394
  // src/app-server/client.ts
395
- var CLIENT_VERSION = true ? "2.1.3" : "0.0.0-dev";
395
+ var CLIENT_VERSION = true ? "2.1.5" : "0.0.0-dev";
396
396
  var DEFAULT_REQUEST_TIMEOUT = 3e4;
397
397
  var STARTUP_REQUEST_TIMEOUT = 9e4;
398
398
  var MAX_WRITE_QUEUE_BYTES = 5 * 1024 * 1024;
@@ -417,6 +417,9 @@ var AppServerClient = class extends EventEmitter {
417
417
  get supportsTurnOverrides() {
418
418
  return true;
419
419
  }
420
+ get childPid() {
421
+ return this.process?.pid ?? void 0;
422
+ }
420
423
  /**
421
424
  * Spawn codex app-server and perform initialization handshake.
422
425
  */
@@ -814,7 +817,7 @@ function resolveAndValidateCwd(inputCwd, baseCwd) {
814
817
  function redactPaths(message) {
815
818
  const uncPath = /(^|[\s'"(])\\\\[^\s\\/:]+\\[^\s:]+(?:\\[^\s:]+)*/g;
816
819
  const windowsPath = /\b[A-Za-z]:\\[^\s:]+/g;
817
- const posixPath = /(^|[\s'"(])\/[^\s:'")]+/g;
820
+ const posixPath = /(^|[\s'"(])\/[^\s:'")]*\/[^\s:'")]+/g;
818
821
  return message.replace(uncPath, (_m, prefix) => `${prefix}<path>`).replace(windowsPath, "<path>").replace(posixPath, (_m, prefix) => `${prefix}<path>`);
819
822
  }
820
823
 
@@ -877,19 +880,97 @@ function stripShellNoise(delta) {
877
880
  if (cleaned.length === 0) return "";
878
881
  return cleaned.join("\n");
879
882
  }
883
+ var MAX_WAITERS_PER_SESSION = 4;
884
+ var MAX_WAIT_MS = 12e4;
880
885
  var SessionManager = class {
881
886
  sessions = /* @__PURE__ */ new Map();
882
887
  clients = /* @__PURE__ */ new Map();
883
888
  cancellationInFlight = /* @__PURE__ */ new Map();
884
889
  cleanupTimer = null;
885
890
  createClient;
891
+ /** Optional disk persistence adapter. */
892
+ persistence;
893
+ /** Track last persisted status to avoid redundant writes. */
894
+ lastPersistedStatus = /* @__PURE__ */ new Map();
895
+ /** Sessions for which a TTL warning event has already been emitted this cycle. */
896
+ ttlWarningEmitted = /* @__PURE__ */ new Set();
897
+ /** Long-poll notifiers: set of resolve callbacks waiting for any change in a session. */
898
+ sessionNotifiers = /* @__PURE__ */ new Map();
886
899
  constructor(options = {}) {
887
900
  this.createClient = options.createClient ?? (() => new AppServerClient());
901
+ this.persistence = options.persistence ?? null;
888
902
  if (!options.disableCleanup) {
889
903
  this.cleanupTimer = setInterval(() => this.cleanupSessions(), CLEANUP_INTERVAL_MS);
890
904
  if (this.cleanupTimer.unref) this.cleanupTimer.unref();
891
905
  }
892
906
  }
907
+ /**
908
+ * Ingest recovered sessions from disk into the in-memory session store.
909
+ * Marks previously-running sessions as error, preserves completed results.
910
+ */
911
+ ingestRecovered(recovered) {
912
+ for (const rec of recovered) {
913
+ if (this.sessions.has(rec.sessionId)) continue;
914
+ const VALID_STATUSES = /* @__PURE__ */ new Set([
915
+ "running",
916
+ "idle",
917
+ "waiting_approval",
918
+ "error",
919
+ "cancelled"
920
+ ]);
921
+ const wasActive = rec.meta.status === "running" || rec.meta.status === "waiting_approval";
922
+ const resolvedStatus = wasActive ? "error" : VALID_STATUSES.has(rec.meta.status) ? rec.meta.status : "error";
923
+ const now = (/* @__PURE__ */ new Date()).toISOString();
924
+ const session = {
925
+ sessionId: rec.meta.sessionId,
926
+ threadId: rec.meta.threadId,
927
+ status: resolvedStatus,
928
+ lastEventCursor: 0,
929
+ createdAt: rec.meta.createdAt,
930
+ lastActiveAt: now,
931
+ cancelledAt: rec.meta.cancelledAt,
932
+ cancelledReason: wasActive ? "Server restarted while session was active" : rec.meta.cancelledReason,
933
+ cwd: rec.meta.cwd ?? ".",
934
+ model: rec.meta.model,
935
+ approvalPolicy: rec.meta.approvalPolicy,
936
+ sandbox: rec.meta.sandbox,
937
+ eventBuffer: createEventBuffer(),
938
+ pendingRequests: /* @__PURE__ */ new Map(),
939
+ lastResult: rec.result
940
+ };
941
+ this.sessions.set(rec.sessionId, session);
942
+ if (rec.lastSeq >= 0) {
943
+ this.persistence?.setEventLogNextSeq(rec.sessionId, rec.lastSeq + 1);
944
+ }
945
+ if (wasActive) {
946
+ this.persistSessionIfChanged(session);
947
+ }
948
+ }
949
+ }
950
+ /**
951
+ * Best-effort persist session metadata to disk when status changes.
952
+ * Deduplicates writes if status hasn't changed since last persist.
953
+ */
954
+ persistSessionIfChanged(session) {
955
+ if (!this.persistence) return;
956
+ const lastStatus = this.lastPersistedStatus.get(session.sessionId);
957
+ if (lastStatus === session.status) return;
958
+ try {
959
+ this.persistence.writeSessionMeta(session);
960
+ this.lastPersistedStatus.set(session.sessionId, session.status);
961
+ } catch {
962
+ }
963
+ }
964
+ /**
965
+ * Best-effort persist result to disk.
966
+ */
967
+ persistResult(session) {
968
+ if (!this.persistence || !session.lastResult) return;
969
+ try {
970
+ this.persistence.writeResult(session.sessionId, session.lastResult);
971
+ } catch {
972
+ }
973
+ }
893
974
  // ── Session Creation ─────────────────────────────────────────────
894
975
  async createSession(prompt, cwd, spawnOpts, effort, advanced) {
895
976
  const sessionId = `sess_${randomUUID().slice(0, 12)}`;
@@ -915,9 +996,19 @@ var SessionManager = class {
915
996
  };
916
997
  this.sessions.set(sessionId, session);
917
998
  this.clients.set(sessionId, client);
999
+ try {
1000
+ this.persistence?.writeSessionMeta(session);
1001
+ } catch {
1002
+ }
918
1003
  try {
919
1004
  this.registerHandlers(sessionId, client, approvalTimeoutMs);
920
1005
  await client.start(spawnOpts);
1006
+ if (client.childPid !== void 0) {
1007
+ try {
1008
+ this.persistence?.writePidInfo(sessionId, client.childPid, spawnOpts.model);
1009
+ } catch {
1010
+ }
1011
+ }
921
1012
  const threadStartResult = await client.threadStart({
922
1013
  cwd,
923
1014
  model: spawnOpts.model,
@@ -985,6 +1076,7 @@ var SessionManager = class {
985
1076
  clearTerminalEvents(session.eventBuffer);
986
1077
  session.status = "running";
987
1078
  session.lastActiveAt = (/* @__PURE__ */ new Date()).toISOString();
1079
+ this.persistSessionIfChanged(session);
988
1080
  const input = [{ type: "text", text: prompt }];
989
1081
  const resolvedCwd = overrides?.cwd ? resolveAndValidateCwd(overrides.cwd, session.cwd) : void 0;
990
1082
  const turnParams = {
@@ -1070,6 +1162,18 @@ var SessionManager = class {
1070
1162
  const session = this.getSessionOrThrow(sessionId);
1071
1163
  return includeSensitive ? toSensitiveInfo(session) : toPublicInfo(session);
1072
1164
  }
1165
+ getLastResult(sessionId) {
1166
+ return this.getSessionOrThrow(sessionId).lastResult;
1167
+ }
1168
+ getPendingActionTypes(sessionId) {
1169
+ const session = this.getSessionOrThrow(sessionId);
1170
+ const actionTypes = /* @__PURE__ */ new Set();
1171
+ for (const req of session.pendingRequests.values()) {
1172
+ if (req.resolved) continue;
1173
+ actionTypes.add(req.kind === "user_input" ? "user_input" : "approval");
1174
+ }
1175
+ return Array.from(actionTypes);
1176
+ }
1073
1177
  async cancelSession(sessionId, reason) {
1074
1178
  const existing = this.cancellationInFlight.get(sessionId);
1075
1179
  if (existing) {
@@ -1093,6 +1197,7 @@ var SessionManager = class {
1093
1197
  session.cancelledAt = now;
1094
1198
  session.lastActiveAt = now;
1095
1199
  session.cancelledReason = reason ?? "Cancelled by user";
1200
+ this.persistSessionIfChanged(session);
1096
1201
  for (const [reqId, req] of session.pendingRequests) {
1097
1202
  if (req.timeoutHandle) clearTimeout(req.timeoutHandle);
1098
1203
  if (!req.resolved && req.respond) {
@@ -1129,6 +1234,7 @@ var SessionManager = class {
1129
1234
  { status: "cancelled", reason: session.cancelledReason, turnId: cancelledTurnId },
1130
1235
  true
1131
1236
  );
1237
+ this.notifyWaiters(sessionId);
1132
1238
  if (client) {
1133
1239
  await client.destroy();
1134
1240
  this.clients.delete(sessionId);
@@ -1244,6 +1350,57 @@ var SessionManager = class {
1244
1350
  );
1245
1351
  }
1246
1352
  }
1353
+ // ── Long-poll support ────────────────────────────────────────────
1354
+ /**
1355
+ * Wait until a new event is pushed, a new pending request arrives, a status
1356
+ * change occurs, or `timeoutMs` elapses (whichever comes first).
1357
+ *
1358
+ * Rejects with an error when more than MAX_WAITERS_PER_SESSION concurrent
1359
+ * waiters are already queued for the same session.
1360
+ */
1361
+ waitForChange(sessionId, timeoutMs, signal) {
1362
+ return new Promise((resolve, reject) => {
1363
+ if (signal?.aborted) {
1364
+ resolve();
1365
+ return;
1366
+ }
1367
+ let notifiers = this.sessionNotifiers.get(sessionId);
1368
+ if (!notifiers) {
1369
+ notifiers = /* @__PURE__ */ new Set();
1370
+ this.sessionNotifiers.set(sessionId, notifiers);
1371
+ }
1372
+ if (notifiers.size >= MAX_WAITERS_PER_SESSION) {
1373
+ reject(
1374
+ new Error(
1375
+ `[codex-mcp] Too many concurrent long-poll waiters for session '${sessionId}' (max ${MAX_WAITERS_PER_SESSION})`
1376
+ )
1377
+ );
1378
+ return;
1379
+ }
1380
+ const clampedMs = Math.min(Math.max(0, timeoutMs), MAX_WAIT_MS);
1381
+ const done = () => {
1382
+ notifiers.delete(notifyFn);
1383
+ if (notifiers.size === 0) this.sessionNotifiers.delete(sessionId);
1384
+ clearTimeout(timer);
1385
+ if (signal) signal.removeEventListener("abort", onAbort);
1386
+ resolve();
1387
+ };
1388
+ const notifyFn = done;
1389
+ const timer = setTimeout(done, clampedMs);
1390
+ if (timer.unref) timer.unref();
1391
+ const onAbort = () => done();
1392
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
1393
+ notifiers.add(notifyFn);
1394
+ });
1395
+ }
1396
+ /** Resolve all waiters for a session immediately (called on any state change). */
1397
+ notifyWaiters(sessionId) {
1398
+ const notifiers = this.sessionNotifiers.get(sessionId);
1399
+ if (!notifiers || notifiers.size === 0) return;
1400
+ for (const fn of Array.from(notifiers)) {
1401
+ fn();
1402
+ }
1403
+ }
1247
1404
  // ── Event Polling ────────────────────────────────────────────────
1248
1405
  pollEvents(sessionId, cursor, maxEvents = DEFAULT_MAX_EVENTS, options = {}) {
1249
1406
  const session = this.getSessionOrThrow(sessionId);
@@ -1464,9 +1621,19 @@ var SessionManager = class {
1464
1621
  `Error [${"INTERNAL" /* INTERNAL */}]: Failed to build approval response for request '${requestId}'`
1465
1622
  );
1466
1623
  }
1467
- sendPendingRequestResponseOrThrow(req, response, sessionId, requestId);
1468
1624
  req.resolved = true;
1469
1625
  req.decision = decision;
1626
+ try {
1627
+ sendPendingRequestResponseOrThrow(req, response, sessionId, requestId);
1628
+ } catch (error) {
1629
+ req.resolved = false;
1630
+ req.decision = void 0;
1631
+ session.pendingRequests.set(requestId, req);
1632
+ if (session.status !== "cancelled") {
1633
+ session.status = "waiting_approval";
1634
+ }
1635
+ throw error;
1636
+ }
1470
1637
  if (req.timeoutHandle) clearTimeout(req.timeoutHandle);
1471
1638
  pushEvent(
1472
1639
  session.eventBuffer,
@@ -1484,6 +1651,7 @@ var SessionManager = class {
1484
1651
  if (session.pendingRequests.size === 0 && session.status === "waiting_approval") {
1485
1652
  session.status = "running";
1486
1653
  }
1654
+ this.notifyWaiters(sessionId);
1487
1655
  }
1488
1656
  // ── User Input Response ──────────────────────────────────────────
1489
1657
  resolveUserInput(sessionId, requestId, answers) {
@@ -1494,13 +1662,22 @@ var SessionManager = class {
1494
1662
  `Error [${"REQUEST_NOT_FOUND" /* REQUEST_NOT_FOUND */}]: User input request '${requestId}' not found`
1495
1663
  );
1496
1664
  }
1497
- sendPendingRequestResponseOrThrow(
1498
- req,
1499
- { answers },
1500
- sessionId,
1501
- requestId
1502
- );
1503
1665
  req.resolved = true;
1666
+ try {
1667
+ sendPendingRequestResponseOrThrow(
1668
+ req,
1669
+ { answers },
1670
+ sessionId,
1671
+ requestId
1672
+ );
1673
+ } catch (error) {
1674
+ req.resolved = false;
1675
+ session.pendingRequests.set(requestId, req);
1676
+ if (session.status !== "cancelled") {
1677
+ session.status = "waiting_approval";
1678
+ }
1679
+ throw error;
1680
+ }
1504
1681
  if (req.timeoutHandle) clearTimeout(req.timeoutHandle);
1505
1682
  pushEvent(
1506
1683
  session.eventBuffer,
@@ -1517,6 +1694,7 @@ var SessionManager = class {
1517
1694
  if (session.pendingRequests.size === 0 && session.status === "waiting_approval") {
1518
1695
  session.status = "running";
1519
1696
  }
1697
+ this.notifyWaiters(sessionId);
1520
1698
  }
1521
1699
  // ── Cleanup ──────────────────────────────────────────────────────
1522
1700
  destroy() {
@@ -1625,6 +1803,8 @@ var SessionManager = class {
1625
1803
  },
1626
1804
  true
1627
1805
  );
1806
+ this.persistSessionIfChanged(session);
1807
+ this.persistResult(session);
1628
1808
  break;
1629
1809
  }
1630
1810
  case Methods.ERROR: {
@@ -1651,6 +1831,7 @@ var SessionManager = class {
1651
1831
  );
1652
1832
  } else {
1653
1833
  pushEvent(session.eventBuffer, "error", data, true);
1834
+ this.persistSessionIfChanged(session);
1654
1835
  }
1655
1836
  }
1656
1837
  break;
@@ -1699,6 +1880,7 @@ var SessionManager = class {
1699
1880
  default:
1700
1881
  break;
1701
1882
  }
1883
+ this.notifyWaiters(sessionId);
1702
1884
  });
1703
1885
  client.onServerRequest((id, method, params) => {
1704
1886
  if (session.status === "cancelled" || session.status === "error") {
@@ -1769,6 +1951,7 @@ var SessionManager = class {
1769
1951
  if (session.pendingRequests.size === 0 && session.status === "waiting_approval") {
1770
1952
  session.status = "running";
1771
1953
  }
1954
+ this.notifyWaiters(sessionId);
1772
1955
  }
1773
1956
  }, approvalTimeoutMs);
1774
1957
  session.pendingRequests.set(requestId, pending);
@@ -1836,6 +2019,7 @@ var SessionManager = class {
1836
2019
  if (session.pendingRequests.size === 0 && session.status === "waiting_approval") {
1837
2020
  session.status = "running";
1838
2021
  }
2022
+ this.notifyWaiters(sessionId);
1839
2023
  }
1840
2024
  }, approvalTimeoutMs);
1841
2025
  session.pendingRequests.set(requestId, pending);
@@ -1890,6 +2074,7 @@ var SessionManager = class {
1890
2074
  if (session.pendingRequests.size === 0 && session.status === "waiting_approval") {
1891
2075
  session.status = "running";
1892
2076
  }
2077
+ this.notifyWaiters(sessionId);
1893
2078
  }
1894
2079
  }, approvalTimeoutMs);
1895
2080
  session.pendingRequests.set(requestId, pending);
@@ -1928,6 +2113,7 @@ var SessionManager = class {
1928
2113
  client.respondErrorToServer(id, -32601, `Unhandled server request: ${method}`);
1929
2114
  break;
1930
2115
  }
2116
+ this.notifyWaiters(sessionId);
1931
2117
  });
1932
2118
  client.on("exit", (code) => {
1933
2119
  clearSessionPendingRequests(session);
@@ -1943,6 +2129,9 @@ var SessionManager = class {
1943
2129
  },
1944
2130
  true
1945
2131
  );
2132
+ this.persistSessionIfChanged(session);
2133
+ this.persistResult(session);
2134
+ this.notifyWaiters(sessionId);
1946
2135
  }
1947
2136
  });
1948
2137
  client.on("error", (err) => {
@@ -1959,25 +2148,34 @@ var SessionManager = class {
1959
2148
  },
1960
2149
  true
1961
2150
  );
2151
+ this.persistSessionIfChanged(session);
2152
+ this.persistResult(session);
2153
+ this.notifyWaiters(sessionId);
1962
2154
  }
1963
2155
  });
1964
2156
  }
1965
2157
  cleanupSessions() {
1966
2158
  const now = Date.now();
2159
+ const TTL_WARNING_THRESHOLD_MS = 6e4;
1967
2160
  for (const [id, session] of this.sessions) {
1968
2161
  const lastActive = new Date(session.lastActiveAt).getTime();
1969
2162
  if (Number.isNaN(lastActive)) {
2163
+ this.ttlWarningEmitted.delete(id);
1970
2164
  this.requestCancellation(id, "Invalid timestamp");
1971
2165
  continue;
1972
2166
  }
1973
2167
  const age = now - lastActive;
1974
2168
  if (session.status === "idle" && age > DEFAULT_IDLE_CLEANUP_MS) {
2169
+ this.ttlWarningEmitted.delete(id);
1975
2170
  this.requestCancellation(id, "Idle timeout");
1976
2171
  } else if (session.status === "waiting_approval" && age > DEFAULT_RUNNING_CLEANUP_MS) {
2172
+ this.ttlWarningEmitted.delete(id);
1977
2173
  this.requestCancellation(id, "Approval timeout");
1978
2174
  } else if (session.status === "running" && age > DEFAULT_RUNNING_CLEANUP_MS) {
2175
+ this.ttlWarningEmitted.delete(id);
1979
2176
  this.requestCancellation(id, "Running timeout");
1980
2177
  } else if ((session.status === "cancelled" || session.status === "error") && age > DEFAULT_TERMINAL_CLEANUP_MS) {
2178
+ this.ttlWarningEmitted.delete(id);
1981
2179
  this.clients.get(id)?.destroy().catch((err) => {
1982
2180
  console.error(
1983
2181
  `[codex-mcp] Failed to destroy app-server client during cleanup: session=${id} error=${err instanceof Error ? err.message : String(err)}`
@@ -1985,6 +2183,27 @@ var SessionManager = class {
1985
2183
  });
1986
2184
  this.clients.delete(id);
1987
2185
  this.sessions.delete(id);
2186
+ this.lastPersistedStatus.delete(id);
2187
+ this.ttlWarningEmitted.delete(id);
2188
+ } else {
2189
+ let ttlMs;
2190
+ if (session.status === "idle") {
2191
+ ttlMs = DEFAULT_IDLE_CLEANUP_MS;
2192
+ } else if (session.status === "running" || session.status === "waiting_approval") {
2193
+ ttlMs = DEFAULT_RUNNING_CLEANUP_MS;
2194
+ }
2195
+ if (ttlMs !== void 0 && !this.ttlWarningEmitted.has(id)) {
2196
+ const timeUntilExpiry = ttlMs - age;
2197
+ if (timeUntilExpiry <= TTL_WARNING_THRESHOLD_MS && timeUntilExpiry > 0) {
2198
+ this.ttlWarningEmitted.add(id);
2199
+ pushEvent(session.eventBuffer, "progress", {
2200
+ method: "codex-mcp/ttl_warning",
2201
+ type: "ttl_warning",
2202
+ ttlRemainingMs: timeUntilExpiry,
2203
+ sessionId: id
2204
+ });
2205
+ }
2206
+ }
1988
2207
  }
1989
2208
  }
1990
2209
  }
@@ -2018,6 +2237,14 @@ function clearSessionPendingRequests(session) {
2018
2237
  session.pendingRequests.clear();
2019
2238
  for (const [, req] of entries) {
2020
2239
  if (req.timeoutHandle) clearTimeout(req.timeoutHandle);
2240
+ if (!req.resolved && req.respond) {
2241
+ try {
2242
+ if (req.kind === "command") req.respond({ decision: "cancel" });
2243
+ else if (req.kind === "fileChange") req.respond({ decision: "cancel" });
2244
+ else if (req.kind === "user_input") req.respond({ answers: {} });
2245
+ } catch {
2246
+ }
2247
+ }
2021
2248
  req.resolved = true;
2022
2249
  }
2023
2250
  }
@@ -2299,15 +2526,27 @@ function tryCoalesceProgressDelta(buf, type, data, pinned) {
2299
2526
  return true;
2300
2527
  }
2301
2528
  function evictEvents(buf) {
2302
- while (buf.events.length > buf.maxSize) {
2303
- const idx = buf.events.findIndex((e) => !e.pinned);
2304
- if (idx === -1) break;
2305
- buf.events.splice(idx, 1);
2306
- }
2307
- while (buf.events.length > buf.maxSize) {
2308
- const idx = buf.events.findIndex((e) => e.type === "approval_result");
2309
- if (idx === -1) break;
2310
- buf.events.splice(idx, 1);
2529
+ if (buf.events.length > buf.maxSize) {
2530
+ const overflow2 = buf.events.length - buf.maxSize;
2531
+ const unpinnedIdx = [];
2532
+ const approvalResultIdx2 = [];
2533
+ for (let i = 0; i < buf.events.length; i++) {
2534
+ const event = buf.events[i];
2535
+ if (!event.pinned) unpinnedIdx.push(i);
2536
+ else if (event.type === "approval_result") approvalResultIdx2.push(i);
2537
+ }
2538
+ const drop2 = /* @__PURE__ */ new Set();
2539
+ for (const idx of unpinnedIdx) {
2540
+ if (drop2.size >= overflow2) break;
2541
+ drop2.add(idx);
2542
+ }
2543
+ for (const idx of approvalResultIdx2) {
2544
+ if (drop2.size >= overflow2) break;
2545
+ drop2.add(idx);
2546
+ }
2547
+ if (drop2.size > 0) {
2548
+ buf.events = buf.events.filter((_, idx) => !drop2.has(idx));
2549
+ }
2311
2550
  }
2312
2551
  if (buf.events.length <= buf.hardMaxSize) return;
2313
2552
  const overflow = buf.events.length - buf.hardMaxSize;
@@ -2454,17 +2693,117 @@ function extractSpawnOptions(params) {
2454
2693
  };
2455
2694
  }
2456
2695
 
2696
+ // src/utils/execution.ts
2697
+ var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["idle", "error", "cancelled"]);
2698
+ function interactionStateForStatus(status) {
2699
+ if (status === "waiting_approval") return "waiting_input";
2700
+ if (TERMINAL_STATUSES.has(status)) return "finished";
2701
+ return "working";
2702
+ }
2703
+ function recommendedNextActionForStatus(status, actionTypes = []) {
2704
+ if (status === "waiting_approval") {
2705
+ if (actionTypes.includes("user_input")) return "respond_user_input";
2706
+ if (actionTypes.includes("approval")) return "respond_permission";
2707
+ }
2708
+ if (TERMINAL_STATUSES.has(status)) return "none";
2709
+ return "poll";
2710
+ }
2711
+ function buildExecutionInfo(waitForResultMs, status, fallbackReason) {
2712
+ const requested = waitForResultMs && waitForResultMs > 0 ? "foreground" : "background";
2713
+ const effective = requested === "foreground" && TERMINAL_STATUSES.has(status) ? "foreground" : "background";
2714
+ return {
2715
+ requested,
2716
+ effective,
2717
+ waitForResultMs: waitForResultMs && waitForResultMs > 0 ? waitForResultMs : void 0,
2718
+ fallbackReason: effective === "background" ? fallbackReason : void 0
2719
+ };
2720
+ }
2721
+ async function waitForCodexSessionForegroundResult(sessionManager, sessionId, waitForResultMs, signal) {
2722
+ const deadline = Date.now() + Math.min(waitForResultMs, 3e5);
2723
+ while (Date.now() < deadline) {
2724
+ let status2;
2725
+ try {
2726
+ status2 = sessionManager.getSession(sessionId).status;
2727
+ } catch {
2728
+ status2 = "error";
2729
+ }
2730
+ if (TERMINAL_STATUSES.has(status2)) {
2731
+ const finalResult = sessionManager.getLastResult(sessionId);
2732
+ return {
2733
+ status: status2,
2734
+ result: finalResult,
2735
+ completedAt: finalResult?.completedAt ?? (/* @__PURE__ */ new Date()).toISOString()
2736
+ };
2737
+ }
2738
+ if (status2 === "waiting_approval") {
2739
+ return {
2740
+ status: status2,
2741
+ pendingActionTypes: sessionManager.getPendingActionTypes(sessionId),
2742
+ fallbackReason: "interactive_poll_required"
2743
+ };
2744
+ }
2745
+ const remainingMs = Math.min(deadline - Date.now(), 5e3);
2746
+ if (remainingMs <= 0) break;
2747
+ try {
2748
+ await sessionManager.waitForChange(sessionId, remainingMs, signal);
2749
+ } catch {
2750
+ break;
2751
+ }
2752
+ }
2753
+ let status = "running";
2754
+ try {
2755
+ status = sessionManager.getSession(sessionId).status;
2756
+ } catch {
2757
+ status = "error";
2758
+ }
2759
+ return { status, fallbackReason: "wait_for_result_timeout" };
2760
+ }
2761
+
2457
2762
  // src/tools/codex.ts
2458
- async function executeCodex(args, sessionManager, serverCwd) {
2763
+ async function executeCodex(args, sessionManager, serverCwd, requestSignal) {
2459
2764
  const cwd = resolveAndValidateCwd(args.cwd, serverCwd);
2460
2765
  const spawnOpts = extractSpawnOptions(args);
2461
2766
  const effort = args.effort ?? DEFAULT_EFFORT_LEVEL;
2462
- return sessionManager.createSession(args.prompt, cwd, spawnOpts, effort, args.advanced);
2767
+ const startResult = await sessionManager.createSession(
2768
+ args.prompt,
2769
+ cwd,
2770
+ spawnOpts,
2771
+ effort,
2772
+ args.advanced
2773
+ );
2774
+ const waitMs = args.advanced?.waitForResult;
2775
+ const baseResult = {
2776
+ ...startResult,
2777
+ execution: buildExecutionInfo(waitMs, "running"),
2778
+ interactionState: interactionStateForStatus("running"),
2779
+ recommendedNextAction: recommendedNextActionForStatus("running")
2780
+ };
2781
+ if (!waitMs || waitMs <= 0) return baseResult;
2782
+ const foreground = await waitForCodexSessionForegroundResult(
2783
+ sessionManager,
2784
+ startResult.sessionId,
2785
+ waitMs,
2786
+ requestSignal
2787
+ );
2788
+ return {
2789
+ sessionId: startResult.sessionId,
2790
+ threadId: startResult.threadId,
2791
+ result: foreground.result,
2792
+ status: foreground.status,
2793
+ completedAt: foreground.completedAt,
2794
+ pollInterval: foreground.status === "waiting_approval" ? WAITING_APPROVAL_POLL_INTERVAL : foreground.status === "running" ? startResult.pollInterval : void 0,
2795
+ execution: buildExecutionInfo(waitMs, foreground.status, foreground.fallbackReason),
2796
+ interactionState: interactionStateForStatus(foreground.status),
2797
+ recommendedNextAction: recommendedNextActionForStatus(
2798
+ foreground.status,
2799
+ foreground.pendingActionTypes ?? []
2800
+ )
2801
+ };
2463
2802
  }
2464
2803
 
2465
2804
  // src/tools/codex-reply.ts
2466
- async function executeCodexReply(args, sessionManager) {
2467
- return sessionManager.replyToSession(args.sessionId, args.prompt, {
2805
+ async function executeCodexReply(args, sessionManager, requestSignal) {
2806
+ const startResult = await sessionManager.replyToSession(args.sessionId, args.prompt, {
2468
2807
  model: args.model,
2469
2808
  approvalPolicy: args.approvalPolicy,
2470
2809
  effort: args.effort,
@@ -2474,6 +2813,36 @@ async function executeCodexReply(args, sessionManager) {
2474
2813
  cwd: args.cwd,
2475
2814
  outputSchema: args.outputSchema
2476
2815
  });
2816
+ const waitMs = args.waitForResult;
2817
+ const baseResult = {
2818
+ ...startResult,
2819
+ execution: buildExecutionInfo(waitMs, "running"),
2820
+ interactionState: interactionStateForStatus("running"),
2821
+ recommendedNextAction: recommendedNextActionForStatus("running")
2822
+ };
2823
+ if (!waitMs || waitMs <= 0) {
2824
+ return baseResult;
2825
+ }
2826
+ const foreground = await waitForCodexSessionForegroundResult(
2827
+ sessionManager,
2828
+ startResult.sessionId,
2829
+ waitMs,
2830
+ requestSignal
2831
+ );
2832
+ return {
2833
+ sessionId: startResult.sessionId,
2834
+ threadId: startResult.threadId,
2835
+ status: foreground.status,
2836
+ pollInterval: foreground.status === "waiting_approval" ? WAITING_APPROVAL_POLL_INTERVAL : foreground.status === "running" ? startResult.pollInterval : void 0,
2837
+ result: foreground.result,
2838
+ completedAt: foreground.completedAt,
2839
+ execution: buildExecutionInfo(waitMs, foreground.status, foreground.fallbackReason),
2840
+ interactionState: interactionStateForStatus(foreground.status),
2841
+ recommendedNextAction: recommendedNextActionForStatus(
2842
+ foreground.status,
2843
+ foreground.pendingActionTypes ?? []
2844
+ )
2845
+ };
2477
2846
  }
2478
2847
 
2479
2848
  // src/tools/codex-session.ts
@@ -2536,7 +2905,7 @@ async function executeCodexSession(args, sessionManager) {
2536
2905
  }
2537
2906
 
2538
2907
  // src/tools/codex-check.ts
2539
- function executeCodexCheck(args, sessionManager) {
2908
+ function executeCodexCheck(args, sessionManager, requestSignal) {
2540
2909
  const responseMode = args.responseMode ?? "minimal";
2541
2910
  const pollOptions = args.pollOptions;
2542
2911
  switch (args.action) {
@@ -2548,10 +2917,34 @@ function executeCodexCheck(args, sessionManager) {
2548
2917
  };
2549
2918
  }
2550
2919
  const maxEvents = typeof args.maxEvents === "number" ? Math.max(POLL_MIN_MAX_EVENTS, Math.floor(args.maxEvents)) : POLL_DEFAULT_MAX_EVENTS;
2551
- return sessionManager.pollEvents(args.sessionId, args.cursor, maxEvents, {
2552
- responseMode,
2553
- pollOptions
2554
- });
2920
+ const waitMs = pollOptions?.waitMs;
2921
+ if (typeof waitMs === "number" && waitMs > 0) {
2922
+ const firstCheck = sessionManager.pollEvents(args.sessionId, args.cursor, maxEvents, {
2923
+ responseMode,
2924
+ pollOptions
2925
+ });
2926
+ const hasData = firstCheck.events.length > 0 || firstCheck.actions !== void 0 && firstCheck.actions.length > 0 || firstCheck.result !== void 0;
2927
+ if (!hasData) {
2928
+ const clampedWait = Math.min(waitMs, 12e4);
2929
+ return sessionManager.waitForChange(args.sessionId, clampedWait, requestSignal).catch(() => void 0).then(
2930
+ () => enrichCheckResult(
2931
+ sessionManager,
2932
+ sessionManager.pollEvents(args.sessionId, args.cursor, maxEvents, {
2933
+ responseMode,
2934
+ pollOptions
2935
+ })
2936
+ )
2937
+ );
2938
+ }
2939
+ return enrichCheckResult(sessionManager, firstCheck);
2940
+ }
2941
+ return enrichCheckResult(
2942
+ sessionManager,
2943
+ sessionManager.pollEvents(args.sessionId, args.cursor, maxEvents, {
2944
+ responseMode,
2945
+ pollOptions
2946
+ })
2947
+ );
2555
2948
  }
2556
2949
  case "respond_permission": {
2557
2950
  if (!args.requestId || !args.decision) {
@@ -2621,10 +3014,13 @@ function executeCodexCheck(args, sessionManager) {
2621
3014
  return { error: message, isError: true };
2622
3015
  }
2623
3016
  const maxEvents = typeof args.maxEvents === "number" ? Math.max(0, Math.floor(args.maxEvents)) : RESPOND_DEFAULT_MAX_EVENTS;
2624
- return sessionManager.pollEventsMonotonic(args.sessionId, args.cursor, maxEvents, {
2625
- responseMode,
2626
- pollOptions
2627
- });
3017
+ return enrichCheckResult(
3018
+ sessionManager,
3019
+ sessionManager.pollEventsMonotonic(args.sessionId, args.cursor, maxEvents, {
3020
+ responseMode,
3021
+ pollOptions
3022
+ })
3023
+ );
2628
3024
  }
2629
3025
  case "respond_user_input": {
2630
3026
  if (!args.requestId || !args.answers) {
@@ -2646,10 +3042,13 @@ function executeCodexCheck(args, sessionManager) {
2646
3042
  return { error: message, isError: true };
2647
3043
  }
2648
3044
  const maxEvents = typeof args.maxEvents === "number" ? Math.max(0, Math.floor(args.maxEvents)) : RESPOND_DEFAULT_MAX_EVENTS;
2649
- return sessionManager.pollEventsMonotonic(args.sessionId, args.cursor, maxEvents, {
2650
- responseMode,
2651
- pollOptions
2652
- });
3045
+ return enrichCheckResult(
3046
+ sessionManager,
3047
+ sessionManager.pollEventsMonotonic(args.sessionId, args.cursor, maxEvents, {
3048
+ responseMode,
3049
+ pollOptions
3050
+ })
3051
+ );
2653
3052
  }
2654
3053
  default:
2655
3054
  return {
@@ -2658,9 +3057,218 @@ function executeCodexCheck(args, sessionManager) {
2658
3057
  };
2659
3058
  }
2660
3059
  }
3060
+ function enrichCheckResult(sessionManager, result) {
3061
+ const actionTypes = result.status === "waiting_approval" ? sessionManager.getPendingActionTypes(result.sessionId) : [];
3062
+ return {
3063
+ ...result,
3064
+ interactionState: interactionStateForStatus(result.status),
3065
+ recommendedNextAction: recommendedNextActionForStatus(result.status, actionTypes)
3066
+ };
3067
+ }
2661
3068
 
2662
- // src/resources/register-resources.ts
3069
+ // src/tools/codex-setup.ts
2663
3070
  import { spawnSync } from "child_process";
3071
+ import { existsSync as existsSync5 } from "fs";
3072
+ import { homedir } from "os";
3073
+ import path5 from "path";
3074
+
3075
+ // src/app-server/detect.ts
3076
+ import { spawn as spawn2 } from "child_process";
3077
+ var DETECTION_TIMEOUT_MS = 5e3;
3078
+ async function detectClientMode(codexCommand, codexIsPath = false, env = process.env) {
3079
+ const override = env.CODEX_MCP_MODE;
3080
+ if (override === "app-server" || override === "exec") {
3081
+ return override;
3082
+ }
3083
+ try {
3084
+ const supported = await probeAppServer(codexCommand, codexIsPath, env);
3085
+ return supported ? "app-server" : "exec";
3086
+ } catch {
3087
+ return "exec";
3088
+ }
3089
+ }
3090
+ function probeAppServer(codexCommand, codexIsPath, env) {
3091
+ return new Promise((resolve) => {
3092
+ const invocation = resolveCodexInvocation(["app-server", "--help"], {
3093
+ env,
3094
+ codexCommand,
3095
+ codexIsPath
3096
+ });
3097
+ let settled = false;
3098
+ const settle = (result) => {
3099
+ if (settled) return;
3100
+ settled = true;
3101
+ clearTimeout(timer);
3102
+ resolve(result);
3103
+ };
3104
+ let stdout = "";
3105
+ let stderr = "";
3106
+ const proc = spawn2(invocation.cmd, invocation.args, {
3107
+ stdio: ["ignore", "pipe", "pipe"],
3108
+ env,
3109
+ windowsHide: true
3110
+ });
3111
+ proc.stdout?.on("data", (chunk) => {
3112
+ stdout += chunk.toString();
3113
+ });
3114
+ proc.stderr?.on("data", (chunk) => {
3115
+ stderr += chunk.toString();
3116
+ });
3117
+ proc.on("error", () => {
3118
+ settle(false);
3119
+ });
3120
+ proc.on("exit", (code) => {
3121
+ if (code === 0) {
3122
+ settle(true);
3123
+ } else {
3124
+ const combined = (stdout + stderr).toLowerCase();
3125
+ const isUnknown = combined.includes("unknown") || combined.includes("unrecognized") || combined.includes("not found") || combined.includes("no such subcommand");
3126
+ settle(!isUnknown && combined.includes("app-server"));
3127
+ }
3128
+ });
3129
+ const timer = setTimeout(() => {
3130
+ try {
3131
+ proc.kill("SIGTERM");
3132
+ } catch {
3133
+ }
3134
+ const forceKill = setTimeout(() => {
3135
+ try {
3136
+ if (!proc.killed && proc.exitCode === null) {
3137
+ proc.kill("SIGKILL");
3138
+ }
3139
+ } catch {
3140
+ }
3141
+ }, 2e3);
3142
+ if (forceKill.unref) forceKill.unref();
3143
+ settle(false);
3144
+ }, DETECTION_TIMEOUT_MS);
3145
+ if (timer.unref) timer.unref();
3146
+ });
3147
+ }
3148
+
3149
+ // src/tools/codex-setup.ts
3150
+ function classifyAuthResult(status, combined) {
3151
+ if (status === 0) return "authenticated";
3152
+ if (/(not (logged|authenticated)|login required|run\s+codex\s+login)/i.test(combined)) {
3153
+ return "unauthenticated";
3154
+ }
3155
+ return "unknown";
3156
+ }
3157
+ function resolveCodexStateDir() {
3158
+ const configured = process.env.CODEX_MCP_STATE_DIR?.trim();
3159
+ return configured && configured !== "" ? configured : path5.join(homedir(), ".codex-mcp", "state");
3160
+ }
3161
+ function probeCodexAuth(info) {
3162
+ const invocation = resolveCodexInvocation(["login", "status"], {
3163
+ codexCommand: info.command,
3164
+ codexIsPath: info.isPath
3165
+ });
3166
+ const run = spawnSync(invocation.cmd, invocation.args, {
3167
+ encoding: "utf8",
3168
+ timeout: 5e3,
3169
+ windowsHide: true
3170
+ });
3171
+ const combined = `${run.stdout ?? ""}
3172
+ ${run.stderr ?? ""}`.trim();
3173
+ if (run.error) {
3174
+ return {
3175
+ ok: false,
3176
+ state: "unknown",
3177
+ detail: `Failed to probe auth status: ${run.error.message}`
3178
+ };
3179
+ }
3180
+ const state = classifyAuthResult(run.status, combined);
3181
+ return {
3182
+ ok: state !== "unauthenticated",
3183
+ state,
3184
+ detail: combined || (state === "authenticated" ? "Authenticated." : "Auth status unknown.")
3185
+ };
3186
+ }
3187
+ async function executeCodexSetup(input, serverCwd) {
3188
+ const cwd = input?.cwd && input.cwd.trim() !== "" ? input.cwd : serverCwd;
3189
+ const warnings = [];
3190
+ const nextSteps = [];
3191
+ let executable;
3192
+ let auth;
3193
+ let clientMode;
3194
+ try {
3195
+ const info = resolveDefaultCodexExecutable();
3196
+ const available = info.source !== "default";
3197
+ executable = {
3198
+ ok: available,
3199
+ source: info.source,
3200
+ command: info.command,
3201
+ isPath: info.isPath,
3202
+ detail: info.source === "default" ? "No codex executable was auto-detected; the server would fall back to `codex` and let process spawn fail later." : `Codex resolves via ${info.source}.`
3203
+ };
3204
+ if (available) {
3205
+ auth = probeCodexAuth(info);
3206
+ clientMode = await detectClientMode(info.command, info.isPath);
3207
+ } else {
3208
+ auth = {
3209
+ ok: false,
3210
+ state: "unknown",
3211
+ detail: "Auth status not checked because no codex executable was detected."
3212
+ };
3213
+ }
3214
+ } catch (err) {
3215
+ const message = err instanceof Error ? err.message : String(err);
3216
+ executable = {
3217
+ ok: false,
3218
+ source: "error",
3219
+ detail: message
3220
+ };
3221
+ auth = {
3222
+ ok: false,
3223
+ state: "unknown",
3224
+ detail: "Auth status not checked because executable resolution failed."
3225
+ };
3226
+ }
3227
+ const projectContext = {
3228
+ hasUserConfig: existsSync5(path5.join(homedir(), ".codex", "config.toml")),
3229
+ hasProjectConfig: existsSync5(path5.join(cwd, ".codex", "config.toml"))
3230
+ };
3231
+ if (!executable.ok) {
3232
+ warnings.push(executable.detail);
3233
+ nextSteps.push(
3234
+ "Install Codex or fix CODEX_MCP_COMMAND / CODEX_MCP_PATH so the executable can be resolved."
3235
+ );
3236
+ }
3237
+ if (auth.state === "unauthenticated") {
3238
+ warnings.push(auth.detail);
3239
+ nextSteps.push("Run `codex login` and rerun `codex_setup`.");
3240
+ } else if (auth.state === "unknown") {
3241
+ warnings.push(auth.detail);
3242
+ nextSteps.push(
3243
+ "Verify Codex authentication explicitly (for example with `codex login status`) before relying on this environment."
3244
+ );
3245
+ }
3246
+ if (!projectContext.hasUserConfig && !projectContext.hasProjectConfig) {
3247
+ warnings.push("No Codex config.toml was found in ~/.codex or this project.");
3248
+ }
3249
+ if (clientMode === "exec") {
3250
+ warnings.push(
3251
+ "Codex app-server support was not detected; codex-mcp would run in exec fallback mode with fewer capabilities."
3252
+ );
3253
+ }
3254
+ return {
3255
+ ready: executable.ok && auth.ok,
3256
+ cwd,
3257
+ executable,
3258
+ auth,
3259
+ runtime: {
3260
+ sameMachineRequired: true,
3261
+ clientMode,
3262
+ stateDir: resolveCodexStateDir()
3263
+ },
3264
+ projectContext,
3265
+ warnings,
3266
+ nextSteps
3267
+ };
3268
+ }
3269
+
3270
+ // src/resources/register-resources.ts
3271
+ import { spawnSync as spawnSync2 } from "child_process";
2664
3272
 
2665
3273
  // src/utils/stdio-guard.ts
2666
3274
  var STDIO_MODES = ["auto", "strict", "off"];
@@ -2759,7 +3367,8 @@ var RESOURCE_URIS = {
2759
3367
  config: `${RESOURCE_SCHEME}:///config`,
2760
3368
  gotchas: `${RESOURCE_SCHEME}:///gotchas`,
2761
3369
  quickstart: `${RESOURCE_SCHEME}:///quickstart`,
2762
- errors: `${RESOURCE_SCHEME}:///errors`
3370
+ errors: `${RESOURCE_SCHEME}:///errors`,
3371
+ delegationGuide: `${RESOURCE_SCHEME}:///delegation-guide`
2763
3372
  };
2764
3373
  var RESOURCE_CATALOG = [
2765
3374
  {
@@ -2803,6 +3412,13 @@ var RESOURCE_CATALOG = [
2803
3412
  title: "Errors",
2804
3413
  description: "Error code reference and recovery hints",
2805
3414
  mimeType: "text/markdown"
3415
+ },
3416
+ {
3417
+ key: "delegationGuide",
3418
+ name: "delegation_guide",
3419
+ title: "Delegation Guide",
3420
+ description: "Best practices for delegating tasks to Codex",
3421
+ mimeType: "text/markdown"
2806
3422
  }
2807
3423
  ];
2808
3424
  var ERROR_CODE_HINTS = {
@@ -2833,7 +3449,8 @@ function asTextResource(uri, text, mimeType) {
2833
3449
  }
2834
3450
  function detectCodexCliVersion(timeoutMs = 1500) {
2835
3451
  try {
2836
- const run = spawnSync("codex", ["--version"], {
3452
+ const executable = getDefaultCodexExecutable();
3453
+ const run = spawnSync2(executable.command, ["--version"], {
2837
3454
  encoding: "utf8",
2838
3455
  timeout: timeoutMs,
2839
3456
  windowsHide: true
@@ -2975,6 +3592,8 @@ function buildQuickstartText() {
2975
3592
  return [
2976
3593
  "## Minimal flow",
2977
3594
  "",
3595
+ "0. Optional but recommended: run `codex_setup` first to verify the local Codex CLI, login state, and backend mode.",
3596
+ "",
2978
3597
  "1. Start session (`codex`)",
2979
3598
  "",
2980
3599
  "```json",
@@ -3073,6 +3692,64 @@ function buildErrorsText() {
3073
3692
  lines.push("");
3074
3693
  return lines.join("\n");
3075
3694
  }
3695
+ function buildDelegationGuideText() {
3696
+ return [
3697
+ "# Codex Delegation Guide",
3698
+ "",
3699
+ "## When to delegate",
3700
+ "- Bug investigation or fix that benefits from a second opinion",
3701
+ "- Code review (use read-only sandbox)",
3702
+ "- Refactoring or migration tasks with clear scope",
3703
+ "- Tasks where the calling agent is stuck or wants parallel work",
3704
+ "",
3705
+ "## Permission combinations by task type",
3706
+ "",
3707
+ "| Task | approvalPolicy | sandbox | Notes |",
3708
+ "|------|---------------|---------|-------|",
3709
+ "| Code review / analysis | `never` | `read-only` | Safe: sandbox blocks writes, no approval needed |",
3710
+ "| Quick bug fix | `on-failure` | `workspace-write` | Auto-approves unless error; good with `waitForResult` |",
3711
+ "| Feature implementation | `on-failure` | `workspace-write` | Async mode recommended for longer tasks |",
3712
+ "| Sensitive refactor | `on-request` | `workspace-write` | Codex asks before each action; requires active polling |",
3713
+ "| Full autonomy | `never` | `workspace-write` | No guardrails \u2014 only for well-scoped, trusted tasks |",
3714
+ "| Network access needed | `on-failure` | `danger-full-access` | Rare; avoid unless genuinely required |",
3715
+ "",
3716
+ "**Key rule:** `read-only` sandbox already prevents writes, so `approvalPolicy: 'never'` is safe with it. Avoid `untrusted` + `read-only` \u2014 every read command triggers approval for no safety gain.",
3717
+ "",
3718
+ "## Quick mode: `waitForResult`",
3719
+ "For short tasks (< 2 min), set `advanced.waitForResult` to get the final result in a single tool call:",
3720
+ "```json",
3721
+ '{ "prompt": "Fix the null check in auth.ts", "approvalPolicy": "on-failure", "sandbox": "workspace-write", "effort": "medium", "advanced": { "waitForResult": 120000 } }',
3722
+ "```",
3723
+ "If the task finishes within the timeout, the result is returned directly. Otherwise the response falls back to polling metadata (including `sessionId` and `threadId`).",
3724
+ "",
3725
+ "**Constraint:** `waitForResult` blocks your tool call. If the task hits `waiting_approval`, you cannot respond \u2014 the timeout will always expire. Only use with `approvalPolicy: 'on-failure'` or `'never'`.",
3726
+ "",
3727
+ "## Async mode: poll loop",
3728
+ 'For long tasks, omit `waitForResult`. Use `codex_check(action="poll", pollOptions={ waitMs: 30000 })` with long-polling to avoid empty polls.',
3729
+ "",
3730
+ "## Effort selection",
3731
+ "- `low` (default): quick questions, lookups, simple edits",
3732
+ "- `medium`: multi-file changes, moderate reasoning",
3733
+ "- `high`/`xhigh`: complex architecture decisions, large refactors",
3734
+ "",
3735
+ "## Troubleshooting",
3736
+ "",
3737
+ "**Tool call hangs:** Likely `waitForResult` + approval conflict; cancel and retry with `on-failure`/`never`. See `codex-mcp:///gotchas`.",
3738
+ "",
3739
+ "**Empty polls:** Use `pollOptions.waitMs` for long-polling; stop when status is terminal. See `codex-mcp:///gotchas`.",
3740
+ "",
3741
+ "**Session not found after restart:** Previously-running sessions surface as `status: 'error'` with restart reason. See `codex-mcp:///gotchas`.",
3742
+ "",
3743
+ "**Approval timeout:** Default is 60s; infrequent polling causes silent auto-decline. See `codex-mcp:///gotchas`.",
3744
+ "",
3745
+ "## Security notes",
3746
+ "- `sandbox: 'read-only'` is the strongest isolation \u2014 blocks all writes regardless of approval policy",
3747
+ "- `approvalPolicy: 'never'` + `sandbox: 'workspace-write'` gives the agent full write access with no human oversight \u2014 use only for well-defined, low-risk tasks",
3748
+ "- `danger-full-access` allows network and system access \u2014 treat as root-equivalent",
3749
+ "- Persisted session data (events, results) may contain code snippets and file paths \u2014 stored in `~/.codex-mcp/state/`",
3750
+ ""
3751
+ ].join("\n");
3752
+ }
3076
3753
  function buildCompatReport(deps, codexCliVersion) {
3077
3754
  const runtimeWarnings = [];
3078
3755
  if (!codexCliVersion) {
@@ -3107,7 +3784,7 @@ function buildCompatReport(deps, codexCliVersion) {
3107
3784
  }
3108
3785
  },
3109
3786
  toolCounts: {
3110
- core: 4
3787
+ core: 5
3111
3788
  },
3112
3789
  runtimeWarnings,
3113
3790
  detectedMismatches: [],
@@ -3237,10 +3914,22 @@ function registerResources(server, deps) {
3237
3914
  },
3238
3915
  () => asTextResource(errorsUri, buildErrorsText(), "text/markdown")
3239
3916
  );
3240
- }
3917
+ const delegationGuideMeta = byKey.get("delegationGuide");
3918
+ const delegationGuideUri = new URL(RESOURCE_URIS.delegationGuide);
3919
+ server.registerResource(
3920
+ delegationGuideMeta.name,
3921
+ delegationGuideUri.toString(),
3922
+ {
3923
+ title: delegationGuideMeta.title,
3924
+ description: delegationGuideMeta.description,
3925
+ mimeType: delegationGuideMeta.mimeType
3926
+ },
3927
+ () => asTextResource(delegationGuideUri, buildDelegationGuideText(), "text/markdown")
3928
+ );
3929
+ }
3241
3930
 
3242
3931
  // src/server.ts
3243
- var SERVER_VERSION = true ? "2.1.3" : "0.0.0-dev";
3932
+ var SERVER_VERSION = true ? "2.1.5" : "0.0.0-dev";
3244
3933
  function formatErrorMessage(err) {
3245
3934
  const message = err instanceof Error ? err.message : String(err);
3246
3935
  const m = /^Error \[([A-Z_]+)\]:\s*(.*)$/.exec(message);
@@ -3286,20 +3975,63 @@ function createServer(serverCwd, options) {
3286
3975
  error: z.string().optional(),
3287
3976
  isError: z.boolean().optional()
3288
3977
  };
3978
+ const executionInfoSchema = z.object({
3979
+ requested: z.enum(["background", "foreground"]),
3980
+ effective: z.enum(["background", "foreground"]),
3981
+ waitForResultMs: z.number().int().positive().optional(),
3982
+ fallbackReason: z.enum(["wait_for_result_timeout", "interactive_poll_required"]).optional()
3983
+ });
3984
+ const interactionStateSchema = z.enum(["working", "waiting_input", "finished"]);
3985
+ const nextActionSchema = z.enum(["poll", "respond_permission", "respond_user_input", "none"]);
3986
+ const setupResultShape = {
3987
+ ready: z.boolean(),
3988
+ cwd: z.string(),
3989
+ executable: z.object({
3990
+ ok: z.boolean(),
3991
+ source: z.string(),
3992
+ command: z.string().optional(),
3993
+ isPath: z.boolean().optional(),
3994
+ detail: z.string()
3995
+ }),
3996
+ auth: z.object({
3997
+ ok: z.boolean(),
3998
+ state: z.enum(["authenticated", "unauthenticated", "unknown"]),
3999
+ detail: z.string()
4000
+ }),
4001
+ runtime: z.object({
4002
+ sameMachineRequired: z.boolean(),
4003
+ clientMode: z.enum(["app-server", "exec"]).optional(),
4004
+ stateDir: z.string()
4005
+ }),
4006
+ projectContext: z.object({
4007
+ hasUserConfig: z.boolean(),
4008
+ hasProjectConfig: z.boolean()
4009
+ }),
4010
+ warnings: z.array(z.string()),
4011
+ nextSteps: z.array(z.string())
4012
+ };
3289
4013
  const sessionStartOutputShape = {
3290
4014
  sessionId: z.string().optional(),
3291
4015
  threadId: z.string().optional(),
3292
- status: z.enum(["running", "idle"]).optional(),
4016
+ status: z.enum(["running", "waiting_approval", "idle", "error", "cancelled"]).optional(),
3293
4017
  pollInterval: z.number().int().optional().describe(
3294
4018
  "Recommended minimum delay before next poll (ms): running >=120000, waiting_approval ~=1000."
3295
4019
  ),
4020
+ result: z.unknown().optional().describe("Final result when waitForResult is set and session completed."),
4021
+ completedAt: z.string().optional().describe("ISO timestamp when the session completed (only when waitForResult succeeded)."),
4022
+ execution: executionInfoSchema.optional(),
4023
+ interactionState: interactionStateSchema.optional(),
4024
+ recommendedNextAction: nextActionSchema.optional(),
3296
4025
  ...errorOutputShape
3297
4026
  };
3298
4027
  const codexCheckPollOptionsSchema = z.object({
3299
4028
  includeEvents: z.boolean().optional().describe("Default: true. Include events[] in response."),
3300
4029
  includeActions: z.boolean().optional().describe("Default: true. Include actions[] in response."),
3301
4030
  includeResult: z.boolean().optional().describe("Default: true. Include result in response."),
3302
- maxBytes: z.number().int().positive().optional().describe("Default: unlimited. Best-effort response payload cap in bytes.")
4031
+ maxBytes: z.number().int().positive().optional().describe("Default: unlimited. Best-effort response payload cap in bytes."),
4032
+ waitMs: z.number().int().nonnegative().optional().describe(
4033
+ "Long-poll: block up to this many ms for new events (max 120000). Omit or 0 for immediate return."
4034
+ )
3303
4035
  }).optional().describe("Optional poll shaping controls.");
3304
4036
  const codexCheckInputSchema = z.object({
3305
4037
  action: z.enum(CHECK_ACTIONS),
@@ -3329,10 +4061,10 @@ function createServer(serverCwd, options) {
3329
4061
  })
3330
4062
  ).optional().describe("question-id -> answers map (id from actions[] user_input request).")
3331
4063
  }).superRefine((value, ctx) => {
3332
- const addIssue = (path5, message) => {
4064
+ const addIssue = (path6, message) => {
3333
4065
  ctx.addIssue({
3334
4066
  code: z.ZodIssueCode.custom,
3335
- path: [path5],
4067
+ path: [path6],
3336
4068
  message
3337
4069
  });
3338
4070
  };
@@ -3459,7 +4191,10 @@ function createServer(serverCwd, options) {
3459
4191
  ephemeral: z.boolean().optional().describe("Do not persist thread (default: false)."),
3460
4192
  outputSchema: z.record(z.string(), z.unknown()).optional().describe("Structured output schema."),
3461
4193
  images: z.array(z.string()).optional().describe("Local image paths."),
3462
- approvalTimeoutMs: z.number().int().positive().default(DEFAULT_APPROVAL_TIMEOUT_MS).optional().describe(`Auto-decline timeout in ms (default: ${DEFAULT_APPROVAL_TIMEOUT_MS})`)
4194
+ approvalTimeoutMs: z.number().int().positive().default(DEFAULT_APPROVAL_TIMEOUT_MS).optional().describe(`Auto-decline timeout in ms (default: ${DEFAULT_APPROVAL_TIMEOUT_MS})`),
4195
+ waitForResult: z.number().int().positive().max(3e5).optional().describe(
4196
+ "Block up to this many ms for session completion (max 300000). Falls back to sessionId for polling if not done in time. Only use with approvalPolicy on-failure/never."
4197
+ )
3463
4198
  }).optional().describe("Advanced settings.")
3464
4199
  },
3465
4200
  outputSchema: sessionStartOutputShape,
@@ -3471,9 +4206,9 @@ function createServer(serverCwd, options) {
3471
4206
  openWorldHint: true
3472
4207
  }
3473
4208
  },
3474
- async (args) => {
4209
+ async (args, extra) => {
3475
4210
  try {
3476
- const result = await executeCodex(args, sessionManager, serverCwd);
4211
+ const result = await executeCodex(args, sessionManager, serverCwd, extra.signal);
3477
4212
  return {
3478
4213
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
3479
4214
  structuredContent: toStructuredContent(result),
@@ -3504,7 +4239,10 @@ function createServer(serverCwd, options) {
3504
4239
  personality: z.enum(PERSONALITIES).optional().describe("Override personality."),
3505
4240
  sandbox: z.enum(SANDBOX_MODES).optional().describe("Override sandbox."),
3506
4241
  cwd: z.string().optional().describe("Override cwd."),
3507
- outputSchema: z.record(z.string(), z.unknown()).optional().describe("Structured output schema override (top-level in codex_reply).")
4242
+ outputSchema: z.record(z.string(), z.unknown()).optional().describe("Structured output schema override (top-level in codex_reply)."),
4243
+ waitForResult: z.number().int().positive().max(3e5).optional().describe(
4244
+ "Wait up to this many ms for the reply turn to complete and return the result directly. Max 300000 (5 min). If the turn does not finish in time or enters interactive approval/user-input flow, returns session metadata for polling."
4245
+ )
3508
4246
  },
3509
4247
  outputSchema: sessionStartOutputShape,
3510
4248
  annotations: {
@@ -3515,9 +4253,9 @@ function createServer(serverCwd, options) {
3515
4253
  openWorldHint: true
3516
4254
  }
3517
4255
  },
3518
- async (args) => {
4256
+ async (args, extra) => {
3519
4257
  try {
3520
- const result = await executeCodexReply(args, sessionManager);
4258
+ const result = await executeCodexReply(args, sessionManager, extra.signal);
3521
4259
  return {
3522
4260
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
3523
4261
  structuredContent: toStructuredContent(result),
@@ -3533,6 +4271,32 @@ function createServer(serverCwd, options) {
3533
4271
  }
3534
4272
  }
3535
4273
  );
4274
+ server.registerTool(
4275
+ "codex_setup",
4276
+ {
4277
+ title: "Codex Setup",
4278
+ description: "Run local readiness checks for codex-mcp: executable resolution, login status, detected backend mode, and project config. Use this before starting a session when setup is uncertain.",
4279
+ inputSchema: {
4280
+ cwd: z.string().optional().describe("Optional cwd to inspect for project-local Codex config. Default: server cwd.")
4281
+ },
4282
+ outputSchema: setupResultShape,
4283
+ annotations: {
4284
+ title: "Codex Setup",
4285
+ readOnlyHint: true,
4286
+ destructiveHint: false,
4287
+ idempotentHint: true,
4288
+ openWorldHint: false
4289
+ }
4290
+ },
4291
+ async (args) => {
4292
+ const result = await executeCodexSetup(args, serverCwd);
4293
+ return {
4294
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
4295
+ structuredContent: toStructuredContent(result),
4296
+ isError: false
4297
+ };
4298
+ }
4299
+ );
3536
4300
  server.registerTool(
3537
4301
  "codex_session",
3538
4302
  {
@@ -3604,23 +4368,11 @@ function createServer(serverCwd, options) {
3604
4368
  "codex_check",
3605
4369
  {
3606
4370
  title: "Poll & Respond",
3607
- description: `Poll session for events or respond to approval/input requests.
3608
-
3609
- POLLING FREQUENCY: Do NOT poll every turn. Codex tasks take minutes, not seconds.
3610
- - Treat pollInterval as a minimum hint, not a fixed schedule.
3611
- - "running": sleep at least 2 minutes between polls; increase for complex tasks. Do NOT high-frequency poll \u2014 it wastes tokens and provides no benefit.
3612
- - "waiting_approval": poll about every 1000ms and respond quickly to actions[].
3613
- - When status is "idle"/"error"/"cancelled": stop polling, the session is done.
3614
- - Adapt interval based on task complexity and whether the previous poll returned new events.
4371
+ description: `Poll session for events or respond to approval/input requests. Use pollInterval as a minimum hint; stop polling on terminal status (idle/error/cancelled). See codex-mcp:///gotchas for poll frequency guidance.
3615
4372
 
3616
4373
  poll: events since cursor. Default maxEvents=${POLL_DEFAULT_MAX_EVENTS}.
3617
-
3618
4374
  respond_permission: approval decision. Default maxEvents=${RESPOND_DEFAULT_MAX_EVENTS} (compact ACK).
3619
-
3620
- respond_user_input: user-input answers. Default maxEvents=${RESPOND_DEFAULT_MAX_EVENTS} (compact ACK).
3621
-
3622
- events[].type is coarse-grained; details are in events[].data.method.
3623
- cursor omitted => use session last cursor. cursorResetTo => reset and continue.`,
4375
+ respond_user_input: user-input answers. Default maxEvents=${RESPOND_DEFAULT_MAX_EVENTS} (compact ACK).`,
3624
4376
  inputSchema: codexCheckInputSchema,
3625
4377
  outputSchema: {
3626
4378
  sessionId: z.string().optional(),
@@ -3628,6 +4380,8 @@ cursor omitted => use session last cursor. cursorResetTo => reset and continue.`
3628
4380
  pollInterval: z.number().int().optional().describe(
3629
4381
  "Recommended minimum delay before next poll (ms): running >=120000, waiting_approval ~=1000."
3630
4382
  ),
4383
+ interactionState: interactionStateSchema.optional(),
4384
+ recommendedNextAction: nextActionSchema.optional(),
3631
4385
  events: z.array(
3632
4386
  z.object({
3633
4387
  id: z.number().int(),
@@ -3682,9 +4436,9 @@ cursor omitted => use session last cursor. cursorResetTo => reset and continue.`
3682
4436
  openWorldHint: false
3683
4437
  }
3684
4438
  },
3685
- async (args) => {
4439
+ async (args, extra) => {
3686
4440
  try {
3687
- const result = executeCodexCheck(args, sessionManager);
4441
+ const result = await executeCodexCheck(args, sessionManager, extra.signal);
3688
4442
  const isError = typeof result.isError === "boolean" ? result.isError : false;
3689
4443
  return {
3690
4444
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
@@ -3706,77 +4460,12 @@ cursor omitted => use session last cursor. cursorResetTo => reset and continue.`
3706
4460
  sessionManager.destroy();
3707
4461
  await originalClose();
3708
4462
  };
3709
- return server;
3710
- }
3711
-
3712
- // src/app-server/detect.ts
3713
- import { spawn as spawn2 } from "child_process";
3714
- var DETECTION_TIMEOUT_MS = 5e3;
3715
- async function detectClientMode(codexCommand, codexIsPath = false, env = process.env) {
3716
- const override = env.CODEX_MCP_MODE;
3717
- if (override === "app-server" || override === "exec") {
3718
- return override;
3719
- }
3720
- try {
3721
- const supported = await probeAppServer(codexCommand, codexIsPath, env);
3722
- return supported ? "app-server" : "exec";
3723
- } catch {
3724
- return "exec";
3725
- }
3726
- }
3727
- function probeAppServer(codexCommand, codexIsPath, env) {
3728
- return new Promise((resolve) => {
3729
- const invocation = resolveCodexInvocation(["app-server", "--help"], {
3730
- env,
3731
- codexCommand,
3732
- codexIsPath
3733
- });
3734
- let settled = false;
3735
- const settle = (result) => {
3736
- if (settled) return;
3737
- settled = true;
3738
- clearTimeout(timer);
3739
- resolve(result);
3740
- };
3741
- let stdout = "";
3742
- let stderr = "";
3743
- const proc = spawn2(invocation.cmd, invocation.args, {
3744
- stdio: ["ignore", "pipe", "pipe"],
3745
- env,
3746
- windowsHide: true
3747
- });
3748
- proc.stdout?.on("data", (chunk) => {
3749
- stdout += chunk.toString();
3750
- });
3751
- proc.stderr?.on("data", (chunk) => {
3752
- stderr += chunk.toString();
3753
- });
3754
- proc.on("error", () => {
3755
- settle(false);
3756
- });
3757
- proc.on("exit", (code) => {
3758
- if (code === 0) {
3759
- settle(true);
3760
- } else {
3761
- const combined = (stdout + stderr).toLowerCase();
3762
- const isUnknown = combined.includes("unknown") || combined.includes("unrecognized") || combined.includes("not found") || combined.includes("no such subcommand");
3763
- settle(!isUnknown && combined.includes("app-server"));
3764
- }
3765
- });
3766
- const timer = setTimeout(() => {
3767
- try {
3768
- proc.kill("SIGTERM");
3769
- } catch {
3770
- }
3771
- settle(false);
3772
- }, DETECTION_TIMEOUT_MS);
3773
- if (timer.unref) timer.unref();
3774
- });
4463
+ return { server, sessionManager };
3775
4464
  }
3776
4465
 
3777
4466
  // src/app-server/exec-client.ts
3778
4467
  import { spawn as spawn3 } from "child_process";
3779
- import { writeFileSync, mkdtempSync } from "fs";
4468
+ import { writeFileSync, mkdtempSync, rmSync } from "fs";
3780
4469
  import { EventEmitter as EventEmitter2 } from "events";
3781
4470
  import { randomUUID as randomUUID2 } from "crypto";
3782
4471
  import { tmpdir } from "os";
@@ -3873,6 +4562,7 @@ var ExecClient = class extends EventEmitter2 {
3873
4562
  threadStartParams = null;
3874
4563
  lastAgentMessageText = "";
3875
4564
  turnCompleted = false;
4565
+ schemaTmpDirs = [];
3876
4566
  // Handlers
3877
4567
  notificationHandler = null;
3878
4568
  serverRequestHandler = null;
@@ -3885,6 +4575,9 @@ var ExecClient = class extends EventEmitter2 {
3885
4575
  get supportsTurnOverrides() {
3886
4576
  return this.turnCount <= 1 || this.realThreadId == null;
3887
4577
  }
4578
+ get childPid() {
4579
+ return this.process?.pid ?? void 0;
4580
+ }
3888
4581
  async start(opts) {
3889
4582
  if (this._destroyed) throw new Error("Client destroyed");
3890
4583
  this.spawnOpts = opts;
@@ -3959,6 +4652,7 @@ var ExecClient = class extends EventEmitter2 {
3959
4652
  });
3960
4653
  proc.on("exit", (code, signal) => {
3961
4654
  if (this.turnId && !this._destroyed && !this.turnCompleted) {
4655
+ this.turnCompleted = true;
3962
4656
  if (code !== 0 && code !== null) {
3963
4657
  this.emitNotification(Methods.ERROR, {
3964
4658
  threadId: this.threadId,
@@ -3967,6 +4661,17 @@ var ExecClient = class extends EventEmitter2 {
3967
4661
  willRetry: false
3968
4662
  });
3969
4663
  }
4664
+ const turnId2 = this.turnId ?? "";
4665
+ this.emitNotification(Methods.TURN_COMPLETED, {
4666
+ threadId: this.threadId,
4667
+ turn: {
4668
+ id: turnId2,
4669
+ status: code === 0 ? "completed" : "failed",
4670
+ output: this.lastAgentMessageText || void 0,
4671
+ ...code !== 0 && code !== null ? { error: { message: `exec process exited with code ${code}` } } : signal ? { error: { message: `exec process killed by signal ${signal}` } } : {}
4672
+ }
4673
+ });
4674
+ this.turnId = null;
3970
4675
  }
3971
4676
  if (!this._destroyed) {
3972
4677
  this.emit("exit", code, signal);
@@ -4030,6 +4735,13 @@ var ExecClient = class extends EventEmitter2 {
4030
4735
  }
4031
4736
  this.process = null;
4032
4737
  this.removeAllListeners();
4738
+ for (const dir of this.schemaTmpDirs) {
4739
+ try {
4740
+ rmSync(dir, { recursive: true, force: true });
4741
+ } catch {
4742
+ }
4743
+ }
4744
+ this.schemaTmpDirs = [];
4033
4745
  }
4034
4746
  // ── Private helpers ─────────────────────────────────────────────
4035
4747
  /**
@@ -4057,6 +4769,7 @@ var ExecClient = class extends EventEmitter2 {
4057
4769
  if (params.outputSchema && Object.keys(params.outputSchema).length > 0) {
4058
4770
  try {
4059
4771
  const tmpDir = mkdtempSync(join(tmpdir(), "codex-mcp-schema-"));
4772
+ this.schemaTmpDirs.push(tmpDir);
4060
4773
  const schemaPath = join(tmpDir, "output-schema.json");
4061
4774
  writeFileSync(schemaPath, JSON.stringify(params.outputSchema));
4062
4775
  args.push("--output-schema", schemaPath);
@@ -4143,7 +4856,7 @@ var ExecClient = class extends EventEmitter2 {
4143
4856
  const type = event.type;
4144
4857
  switch (type) {
4145
4858
  case "thread.started": {
4146
- const cliThreadId = event.thread_id;
4859
+ const cliThreadId = event.thread_id ?? event.threadId;
4147
4860
  if (cliThreadId) {
4148
4861
  this.threadId = cliThreadId;
4149
4862
  this.realThreadId = cliThreadId;
@@ -4298,7 +5011,625 @@ var ExecClient = class extends EventEmitter2 {
4298
5011
  }
4299
5012
  };
4300
5013
 
5014
+ // src/session/persistence.ts
5015
+ import { join as join5 } from "path";
5016
+ import { mkdirSync as mkdirSync4, existsSync as existsSync7 } from "fs";
5017
+ import { homedir as homedir2 } from "os";
5018
+
5019
+ // src/persistence/atomic-writer.ts
5020
+ import { writeFileSync as writeFileSync2, renameSync, mkdirSync, unlinkSync } from "fs";
5021
+ import { dirname, join as join2 } from "path";
5022
+ import { randomBytes } from "crypto";
5023
+ function atomicWriteJson(filePath, data) {
5024
+ const dir = dirname(filePath);
5025
+ mkdirSync(dir, { recursive: true });
5026
+ const tmpPath = join2(dir, `.tmp-${randomBytes(6).toString("hex")}`);
5027
+ try {
5028
+ writeFileSync2(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
5029
+ renameSync(tmpPath, filePath);
5030
+ } catch (err) {
5031
+ try {
5032
+ unlinkSync(tmpPath);
5033
+ } catch {
5034
+ }
5035
+ throw err;
5036
+ }
5037
+ }
5038
+
5039
+ // src/persistence/lockfile.ts
5040
+ import {
5041
+ openSync,
5042
+ closeSync,
5043
+ readFileSync as readFileSync2,
5044
+ writeFileSync as writeFileSync3,
5045
+ unlinkSync as unlinkSync2,
5046
+ mkdirSync as mkdirSync2,
5047
+ constants as constants2
5048
+ } from "fs";
5049
+ import { dirname as dirname2 } from "path";
5050
+ function isPidAlive(pid) {
5051
+ try {
5052
+ process.kill(pid, 0);
5053
+ return true;
5054
+ } catch {
5055
+ return false;
5056
+ }
5057
+ }
5058
+ function acquireLock(lockPath) {
5059
+ mkdirSync2(dirname2(lockPath), { recursive: true });
5060
+ try {
5061
+ const raw = readFileSync2(lockPath, "utf-8");
5062
+ const existing = JSON.parse(raw);
5063
+ if (isPidAlive(existing.pid) && existing.pid !== process.pid) {
5064
+ throw new Error(
5065
+ `STATE_DIR is locked by another process (pid=${existing.pid}, started=${existing.startedAt}). If this is stale, delete ${lockPath}`
5066
+ );
5067
+ }
5068
+ unlinkSync2(lockPath);
5069
+ } catch (err) {
5070
+ if (err.code !== "ENOENT") {
5071
+ if (err.message?.includes("STATE_DIR is locked")) throw err;
5072
+ console.error(
5073
+ `[lockfile] Existing lock at ${lockPath} is unreadable \u2014 will attempt O_EXCL create`
5074
+ );
5075
+ }
5076
+ }
5077
+ const content = {
5078
+ pid: process.pid,
5079
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
5080
+ };
5081
+ let fd;
5082
+ try {
5083
+ fd = openSync(lockPath, constants2.O_WRONLY | constants2.O_CREAT | constants2.O_EXCL);
5084
+ writeFileSync3(fd, JSON.stringify(content) + "\n", "utf-8");
5085
+ closeSync(fd);
5086
+ fd = void 0;
5087
+ } catch (err) {
5088
+ if (fd !== void 0) {
5089
+ try {
5090
+ closeSync(fd);
5091
+ } catch {
5092
+ }
5093
+ }
5094
+ if (err.code === "EEXIST") {
5095
+ throw new Error(
5096
+ `Failed to acquire STATE_DIR lock at ${lockPath} \u2014 race with another process`
5097
+ );
5098
+ }
5099
+ try {
5100
+ unlinkSync2(lockPath);
5101
+ } catch {
5102
+ }
5103
+ throw err;
5104
+ }
5105
+ let released = false;
5106
+ return () => {
5107
+ if (released) return;
5108
+ released = true;
5109
+ try {
5110
+ unlinkSync2(lockPath);
5111
+ } catch {
5112
+ }
5113
+ };
5114
+ }
5115
+
5116
+ // src/persistence/event-log.ts
5117
+ import { appendFileSync, mkdirSync as mkdirSync3 } from "fs";
5118
+ import { dirname as dirname3 } from "path";
5119
+ var EventLog = class {
5120
+ filePath;
5121
+ batchIntervalMs;
5122
+ buffer = [];
5123
+ flushTimer = null;
5124
+ nextSeq = 0;
5125
+ destroyed = false;
5126
+ constructor(opts) {
5127
+ this.filePath = opts.filePath;
5128
+ this.batchIntervalMs = opts.batchIntervalMs ?? 100;
5129
+ mkdirSync3(dirname3(this.filePath), { recursive: true });
5130
+ }
5131
+ /**
5132
+ * Append an event to the log.
5133
+ * @param event - Arbitrary JSON-serializable event data
5134
+ * @param criticality - "critical" forces immediate flush; "normal" batches
5135
+ */
5136
+ append(event, criticality = "normal") {
5137
+ if (this.destroyed) return -1;
5138
+ const seq = this.nextSeq++;
5139
+ const line = JSON.stringify({ seq, ...event }) + "\n";
5140
+ this.buffer.push({ line });
5141
+ if (criticality === "critical") {
5142
+ this.flushSync();
5143
+ } else if (!this.flushTimer) {
5144
+ this.flushTimer = setTimeout(() => {
5145
+ this.flushTimer = null;
5146
+ this.flushSync();
5147
+ }, this.batchIntervalMs);
5148
+ if (this.flushTimer.unref) this.flushTimer.unref();
5149
+ }
5150
+ return seq;
5151
+ }
5152
+ /** Set the next sequence number (used during recovery to continue from last known seq). */
5153
+ setNextSeq(seq) {
5154
+ this.nextSeq = seq;
5155
+ }
5156
+ /** Synchronously flush all buffered writes to disk. */
5157
+ flushSync() {
5158
+ if (this.buffer.length === 0) return;
5159
+ const chunk = this.buffer.map((w) => w.line).join("");
5160
+ this.buffer = [];
5161
+ if (this.flushTimer) {
5162
+ clearTimeout(this.flushTimer);
5163
+ this.flushTimer = null;
5164
+ }
5165
+ try {
5166
+ appendFileSync(this.filePath, chunk, "utf-8");
5167
+ } catch (err) {
5168
+ console.error(`[event-log] Failed to flush to ${this.filePath}:`, err);
5169
+ }
5170
+ }
5171
+ /** Destroy the log, flushing any remaining events. */
5172
+ destroy() {
5173
+ if (this.destroyed) return;
5174
+ this.destroyed = true;
5175
+ this.flushSync();
5176
+ if (this.flushTimer) {
5177
+ clearTimeout(this.flushTimer);
5178
+ this.flushTimer = null;
5179
+ }
5180
+ }
5181
+ };
5182
+
5183
+ // src/persistence/recovery-scanner.ts
5184
+ import { readdirSync, readFileSync as readFileSync3, existsSync as existsSync6, statSync as statSync4 } from "fs";
5185
+ import { join as join3 } from "path";
5186
+ var SCHEMA_VERSION = 1;
5187
+ function parseEventsJsonl(filePath) {
5188
+ if (!existsSync6(filePath)) return [];
5189
+ const raw = readFileSync3(filePath, "utf-8");
5190
+ const lines = raw.split("\n");
5191
+ const events = [];
5192
+ for (const line of lines) {
5193
+ const trimmed = line.trim();
5194
+ if (!trimmed) continue;
5195
+ try {
5196
+ const parsed = JSON.parse(trimmed);
5197
+ if (typeof parsed.seq === "number") {
5198
+ events.push(parsed);
5199
+ }
5200
+ } catch {
5201
+ break;
5202
+ }
5203
+ }
5204
+ return events.sort((a, b) => a.seq - b.seq);
5205
+ }
5206
+ function readJsonSafe(filePath) {
5207
+ if (!existsSync6(filePath)) return null;
5208
+ try {
5209
+ return JSON.parse(readFileSync3(filePath, "utf-8"));
5210
+ } catch {
5211
+ return null;
5212
+ }
5213
+ }
5214
+ function scanRecoverableSessions(sessionsDir, maxEvents = 500) {
5215
+ if (!existsSync6(sessionsDir)) return [];
5216
+ const results = [];
5217
+ let entries;
5218
+ try {
5219
+ entries = readdirSync(sessionsDir);
5220
+ } catch {
5221
+ return [];
5222
+ }
5223
+ for (const entry of entries) {
5224
+ const sessionDir = join3(sessionsDir, entry);
5225
+ try {
5226
+ if (!statSync4(sessionDir).isDirectory()) continue;
5227
+ } catch {
5228
+ continue;
5229
+ }
5230
+ const meta = readJsonSafe(join3(sessionDir, "meta.json"));
5231
+ if (!meta || !meta.sessionId) continue;
5232
+ if (meta.schemaVersion !== void 0 && meta.schemaVersion > SCHEMA_VERSION) {
5233
+ console.error(
5234
+ `[recovery] Skipping session ${meta.sessionId}: schema version ${meta.schemaVersion} > ${SCHEMA_VERSION}`
5235
+ );
5236
+ continue;
5237
+ }
5238
+ let events = parseEventsJsonl(join3(sessionDir, "events.jsonl"));
5239
+ if (events.length > maxEvents) {
5240
+ events = events.slice(-maxEvents);
5241
+ }
5242
+ const lastSeq = events.length > 0 ? events[events.length - 1].seq : -1;
5243
+ const result = readJsonSafe(join3(sessionDir, "result.json"));
5244
+ const pidInfo = readJsonSafe(join3(sessionDir, "pid.json"));
5245
+ results.push({
5246
+ sessionId: meta.sessionId,
5247
+ meta,
5248
+ events,
5249
+ lastSeq,
5250
+ result,
5251
+ pidInfo,
5252
+ sessionDir
5253
+ });
5254
+ }
5255
+ return results;
5256
+ }
5257
+
5258
+ // src/persistence/retention.ts
5259
+ import { readdirSync as readdirSync2, rmSync as rmSync2, statSync as statSync5, readFileSync as readFileSync4 } from "fs";
5260
+ import { join as join4 } from "path";
5261
+ var DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
5262
+ var DEFAULT_MAX_COUNT = 200;
5263
+ var DEFAULT_MAX_DISK_BYTES = 500 * 1024 * 1024;
5264
+ function getDirSize(dirPath) {
5265
+ let total = 0;
5266
+ try {
5267
+ for (const entry of readdirSync2(dirPath)) {
5268
+ try {
5269
+ const st = statSync5(join4(dirPath, entry));
5270
+ total += st.isFile() ? st.size : 0;
5271
+ } catch {
5272
+ }
5273
+ }
5274
+ } catch {
5275
+ }
5276
+ return total;
5277
+ }
5278
+ function pruneSessionDirs(sessionsDir, policy) {
5279
+ const maxAgeMs = policy?.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
5280
+ const maxCount = policy?.maxCount ?? DEFAULT_MAX_COUNT;
5281
+ const maxDiskBytes = policy?.maxDiskBytes ?? DEFAULT_MAX_DISK_BYTES;
5282
+ let entries;
5283
+ try {
5284
+ entries = readdirSync2(sessionsDir);
5285
+ } catch {
5286
+ return 0;
5287
+ }
5288
+ const now = Date.now();
5289
+ const dirs = [];
5290
+ for (const entry of entries) {
5291
+ const dirPath = join4(sessionsDir, entry);
5292
+ try {
5293
+ if (!statSync5(dirPath).isDirectory()) continue;
5294
+ } catch {
5295
+ continue;
5296
+ }
5297
+ let lastActiveAt = 0;
5298
+ try {
5299
+ const meta = JSON.parse(readFileSync4(join4(dirPath, "meta.json"), "utf-8"));
5300
+ lastActiveAt = new Date(meta.lastActiveAt || meta.createdAt || 0).getTime();
5301
+ } catch {
5302
+ try {
5303
+ lastActiveAt = statSync5(dirPath).mtimeMs;
5304
+ } catch {
5305
+ lastActiveAt = 0;
5306
+ }
5307
+ }
5308
+ dirs.push({
5309
+ path: dirPath,
5310
+ sessionId: entry,
5311
+ lastActiveAt,
5312
+ diskBytes: getDirSize(dirPath)
5313
+ });
5314
+ }
5315
+ dirs.sort((a, b) => a.lastActiveAt - b.lastActiveAt);
5316
+ const toRemove = /* @__PURE__ */ new Set();
5317
+ for (const dir of dirs) {
5318
+ if (now - dir.lastActiveAt > maxAgeMs) {
5319
+ toRemove.add(dir.path);
5320
+ }
5321
+ }
5322
+ const remaining = dirs.filter((d) => !toRemove.has(d.path));
5323
+ if (remaining.length > maxCount) {
5324
+ const excess = remaining.length - maxCount;
5325
+ for (let i = 0; i < excess; i++) {
5326
+ toRemove.add(remaining[i].path);
5327
+ }
5328
+ }
5329
+ const afterCountPrune = dirs.filter((d) => !toRemove.has(d.path));
5330
+ let totalSize = afterCountPrune.reduce((sum, d) => sum + d.diskBytes, 0);
5331
+ for (const dir of afterCountPrune) {
5332
+ if (totalSize <= maxDiskBytes) break;
5333
+ toRemove.add(dir.path);
5334
+ totalSize -= dir.diskBytes;
5335
+ }
5336
+ let pruned = 0;
5337
+ for (const dirPath of toRemove) {
5338
+ try {
5339
+ rmSync2(dirPath, { recursive: true, force: true });
5340
+ pruned++;
5341
+ } catch (err) {
5342
+ console.error(`[retention] Failed to remove ${dirPath}:`, err);
5343
+ }
5344
+ }
5345
+ return pruned;
5346
+ }
5347
+
5348
+ // src/session/persistence.ts
5349
+ var CRITICAL_EVENT_TYPES = /* @__PURE__ */ new Set([
5350
+ "approval_request",
5351
+ "approval_result",
5352
+ "result",
5353
+ "error"
5354
+ ]);
5355
+ function eventCriticality(type) {
5356
+ return CRITICAL_EVENT_TYPES.has(type) ? "critical" : "normal";
5357
+ }
5358
+ var SessionPersistence = class {
5359
+ stateDir;
5360
+ sessionsDir;
5361
+ releaseLock = null;
5362
+ eventLogs = /* @__PURE__ */ new Map();
5363
+ constructor(stateDir) {
5364
+ this.stateDir = stateDir ?? process.env.CODEX_MCP_STATE_DIR ?? join5(homedir2(), ".codex-mcp", "state");
5365
+ this.sessionsDir = join5(this.stateDir, "sessions");
5366
+ mkdirSync4(this.sessionsDir, { recursive: true });
5367
+ }
5368
+ /** Acquire the STATE_DIR lock. Call once at startup. */
5369
+ acquireLock() {
5370
+ this.releaseLock = acquireLock(join5(this.stateDir, ".lock"));
5371
+ }
5372
+ /** Release the lock. Call on shutdown. */
5373
+ releaseLockIfHeld() {
5374
+ if (this.releaseLock) {
5375
+ this.releaseLock();
5376
+ this.releaseLock = null;
5377
+ }
5378
+ }
5379
+ // ── Write operations ────────────────────────────────────────────
5380
+ /** Persist session metadata (called on create and status changes). */
5381
+ writeSessionMeta(session) {
5382
+ const meta = {
5383
+ schemaVersion: SCHEMA_VERSION,
5384
+ sessionId: session.sessionId,
5385
+ status: session.status,
5386
+ createdAt: session.createdAt,
5387
+ lastActiveAt: session.lastActiveAt,
5388
+ cancelledAt: session.cancelledAt,
5389
+ cancelledReason: session.cancelledReason,
5390
+ threadId: session.threadId,
5391
+ model: session.model,
5392
+ cwd: session.cwd,
5393
+ approvalPolicy: session.approvalPolicy,
5394
+ sandbox: session.sandbox,
5395
+ profile: session.profile
5396
+ };
5397
+ const dir = join5(this.sessionsDir, session.sessionId);
5398
+ atomicWriteJson(join5(dir, "meta.json"), meta);
5399
+ }
5400
+ /** Persist PID info for orphan detection. */
5401
+ writePidInfo(sessionId, pid, command) {
5402
+ const info = {
5403
+ pid,
5404
+ spawnedAt: (/* @__PURE__ */ new Date()).toISOString(),
5405
+ command
5406
+ };
5407
+ const dir = join5(this.sessionsDir, sessionId);
5408
+ mkdirSync4(dir, { recursive: true });
5409
+ atomicWriteJson(join5(dir, "pid.json"), info);
5410
+ }
5411
+ /** Append an event to the session's event log. */
5412
+ appendEvent(sessionId, type, data) {
5413
+ let log = this.eventLogs.get(sessionId);
5414
+ if (!log) {
5415
+ const dir = join5(this.sessionsDir, sessionId);
5416
+ mkdirSync4(dir, { recursive: true });
5417
+ log = new EventLog({ filePath: join5(dir, "events.jsonl") });
5418
+ this.eventLogs.set(sessionId, log);
5419
+ }
5420
+ log.append({ type, data, timestamp: (/* @__PURE__ */ new Date()).toISOString() }, eventCriticality(type));
5421
+ }
5422
+ /** Set the next sequence number for a recovered session's event log. */
5423
+ setEventLogNextSeq(sessionId, seq) {
5424
+ let log = this.eventLogs.get(sessionId);
5425
+ if (!log) {
5426
+ const dir = join5(this.sessionsDir, sessionId);
5427
+ mkdirSync4(dir, { recursive: true });
5428
+ log = new EventLog({ filePath: join5(dir, "events.jsonl") });
5429
+ this.eventLogs.set(sessionId, log);
5430
+ }
5431
+ log.setNextSeq(seq);
5432
+ }
5433
+ /** Persist the final result. */
5434
+ writeResult(sessionId, result) {
5435
+ const dir = join5(this.sessionsDir, sessionId);
5436
+ mkdirSync4(dir, { recursive: true });
5437
+ atomicWriteJson(join5(dir, "result.json"), result);
5438
+ }
5439
+ // ── Read / Recovery ─────────────────────────────────────────────
5440
+ /** Scan and recover sessions from disk. */
5441
+ recoverSessions(maxEvents) {
5442
+ return scanRecoverableSessions(this.sessionsDir, maxEvents);
5443
+ }
5444
+ /** Apply retention policy. Returns number of sessions pruned. */
5445
+ prune(policy) {
5446
+ return pruneSessionDirs(this.sessionsDir, policy);
5447
+ }
5448
+ // ── Lifecycle ───────────────────────────────────────────────────
5449
+ /** Flush all event logs synchronously (call on shutdown). */
5450
+ flushAll() {
5451
+ for (const log of this.eventLogs.values()) {
5452
+ log.flushSync();
5453
+ }
5454
+ }
5455
+ /** Destroy a single session's event log. */
5456
+ destroySessionLog(sessionId) {
5457
+ const log = this.eventLogs.get(sessionId);
5458
+ if (log) {
5459
+ log.destroy();
5460
+ this.eventLogs.delete(sessionId);
5461
+ }
5462
+ }
5463
+ /** Clean up: flush all logs, release lock. */
5464
+ destroy() {
5465
+ for (const log of this.eventLogs.values()) {
5466
+ log.destroy();
5467
+ }
5468
+ this.eventLogs.clear();
5469
+ this.releaseLockIfHeld();
5470
+ }
5471
+ /** Check if a session directory exists on disk. */
5472
+ hasSessionOnDisk(sessionId) {
5473
+ return existsSync7(join5(this.sessionsDir, sessionId, "meta.json"));
5474
+ }
5475
+ };
5476
+
5477
+ // src/session/orphan-reaper.ts
5478
+ import { execSync, spawn as spawn4 } from "child_process";
5479
+ import { readFileSync as readFileSync5 } from "fs";
5480
+ function isAlive(pid) {
5481
+ try {
5482
+ process.kill(pid, 0);
5483
+ return true;
5484
+ } catch {
5485
+ return false;
5486
+ }
5487
+ }
5488
+ function getWindowsCreationTimeMs(pid) {
5489
+ try {
5490
+ const raw = execSync(`wmic process where "ProcessId=${pid}" get CreationDate /value`, {
5491
+ stdio: ["ignore", "pipe", "ignore"],
5492
+ timeout: 5e3
5493
+ }).toString();
5494
+ const match = raw.match(/CreationDate=(\d{14})/);
5495
+ if (!match || !match[1]) return null;
5496
+ const s = match[1];
5497
+ const iso = `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(8, 10)}:${s.slice(10, 12)}:${s.slice(12, 14)}.000Z`;
5498
+ const ms = new Date(iso).getTime();
5499
+ return isNaN(ms) ? null : ms;
5500
+ } catch {
5501
+ return null;
5502
+ }
5503
+ }
5504
+ function getProcStatStartTick(pid) {
5505
+ try {
5506
+ const stat = readFileSync5(`/proc/${pid}/stat`, "utf-8");
5507
+ const afterComm = stat.slice(stat.lastIndexOf(")") + 1).trim();
5508
+ const fields = afterComm.split(" ");
5509
+ const starttime = fields[19];
5510
+ return starttime ?? null;
5511
+ } catch {
5512
+ return null;
5513
+ }
5514
+ }
5515
+ function getPsLstart(pid) {
5516
+ try {
5517
+ const output = execSync(`ps -p ${pid} -o lstart=`, {
5518
+ stdio: ["ignore", "pipe", "ignore"],
5519
+ timeout: 5e3
5520
+ }).toString().trim();
5521
+ if (!output) return null;
5522
+ const ms = new Date(output).getTime();
5523
+ return isNaN(ms) ? null : ms;
5524
+ } catch {
5525
+ return null;
5526
+ }
5527
+ }
5528
+ function isOrphan(pid, spawnedAt) {
5529
+ const storedMs = new Date(spawnedAt).getTime();
5530
+ if (isNaN(storedMs)) return false;
5531
+ if (process.platform === "win32") {
5532
+ const procMs = getWindowsCreationTimeMs(pid);
5533
+ if (procMs === null) return false;
5534
+ return Math.abs(procMs - storedMs) < 5e3;
5535
+ }
5536
+ const lstartMs = getPsLstart(pid);
5537
+ if (lstartMs !== null) {
5538
+ return Math.abs(lstartMs - storedMs) < 5e3;
5539
+ }
5540
+ const tick = getProcStatStartTick(pid);
5541
+ if (tick !== null) {
5542
+ const ageMs = Date.now() - storedMs;
5543
+ return ageMs > 0 && ageMs < 24 * 60 * 60 * 1e3;
5544
+ }
5545
+ return false;
5546
+ }
5547
+ function sendGraceful(pid) {
5548
+ try {
5549
+ if (process.platform === "win32") {
5550
+ spawn4("taskkill", ["/PID", String(pid)], {
5551
+ stdio: "ignore",
5552
+ windowsHide: true
5553
+ });
5554
+ } else {
5555
+ process.kill(pid, "SIGTERM");
5556
+ }
5557
+ } catch {
5558
+ }
5559
+ }
5560
+ function sendForce(pid) {
5561
+ try {
5562
+ if (process.platform === "win32") {
5563
+ spawn4("taskkill", ["/PID", String(pid), "/F"], {
5564
+ stdio: "ignore",
5565
+ windowsHide: true
5566
+ });
5567
+ } else {
5568
+ process.kill(pid, "SIGKILL");
5569
+ }
5570
+ } catch {
5571
+ }
5572
+ }
5573
+ function sleep(ms) {
5574
+ return new Promise((resolve) => {
5575
+ const t = setTimeout(resolve, ms);
5576
+ if (t.unref) t.unref();
5577
+ });
5578
+ }
5579
+ async function reapOrphanProcesses(recovered) {
5580
+ const summary = { reaped: 0, alreadyDead: 0, skipped: 0 };
5581
+ const candidates = recovered.filter((s) => s.pidInfo !== null);
5582
+ if (candidates.length === 0) return summary;
5583
+ const reapPromises = candidates.map(async (session) => {
5584
+ const { pid, spawnedAt } = session.pidInfo;
5585
+ if (!isAlive(pid)) {
5586
+ summary.alreadyDead++;
5587
+ return;
5588
+ }
5589
+ if (!isOrphan(pid, spawnedAt)) {
5590
+ console.error(
5591
+ `[orphan-reaper] PID ${pid} (session ${session.sessionId}) is alive but does not match stored spawn time \u2014 likely a reused PID. Skipping.`
5592
+ );
5593
+ summary.skipped++;
5594
+ return;
5595
+ }
5596
+ if (!isAlive(pid) || !isOrphan(pid, spawnedAt)) {
5597
+ summary.skipped++;
5598
+ return;
5599
+ }
5600
+ console.error(
5601
+ `[orphan-reaper] Sending graceful terminate to orphan PID ${pid} (session ${session.sessionId}).`
5602
+ );
5603
+ sendGraceful(pid);
5604
+ const deadline = Date.now() + 5e3;
5605
+ while (isAlive(pid) && Date.now() < deadline) {
5606
+ await sleep(250);
5607
+ }
5608
+ if (isAlive(pid)) {
5609
+ console.error(
5610
+ `[orphan-reaper] PID ${pid} did not exit after graceful terminate \u2014 force killing.`
5611
+ );
5612
+ sendForce(pid);
5613
+ await sleep(500);
5614
+ }
5615
+ summary.reaped++;
5616
+ });
5617
+ await Promise.all(reapPromises);
5618
+ return summary;
5619
+ }
5620
+
5621
+ // src/utils/stdin-shutdown.ts
5622
+ function decideStdinShutdown(params) {
5623
+ if (!params.stdinUnavailable) return "clear";
5624
+ if (params.isConnected) return "reschedule";
5625
+ if (!params.hasActiveSessions) return "shutdown_now";
5626
+ if (params.elapsedMs >= params.maxWaitMs) return "shutdown_timeout";
5627
+ return "reschedule";
5628
+ }
5629
+
4301
5630
  // src/index.ts
5631
+ var STDIN_SHUTDOWN_CHECK_MS = 750;
5632
+ var STDIN_SHUTDOWN_MAX_WAIT_MS = process.platform === "win32" ? 15e3 : 1e4;
4302
5633
  async function main() {
4303
5634
  const preflight = runStdioPreflight();
4304
5635
  for (const note of preflight.notes) {
@@ -4323,27 +5654,177 @@ async function main() {
4323
5654
  const clientMode = await detectClientMode(executable.command, executable.isPath);
4324
5655
  console.error(`[codex-mcp] client mode: ${clientMode} (binary: ${executable.command})`);
4325
5656
  const createClient = () => clientMode === "exec" ? new ExecClient() : new AppServerClient();
5657
+ const persistence = new SessionPersistence();
5658
+ try {
5659
+ persistence.acquireLock();
5660
+ console.error("[codex-mcp] STATE_DIR lock acquired");
5661
+ } catch (err) {
5662
+ console.error("[codex-mcp] WARNING: Failed to acquire STATE_DIR lock:", err);
5663
+ }
5664
+ const recovered = persistence.recoverSessions();
5665
+ if (recovered.length > 0) {
5666
+ console.error(`[codex-mcp] Recovered ${recovered.length} session(s) from disk`);
5667
+ }
5668
+ const pruned = persistence.prune();
5669
+ if (pruned > 0) {
5670
+ console.error(`[codex-mcp] Pruned ${pruned} old session(s)`);
5671
+ }
5672
+ const reaped = await reapOrphanProcesses(recovered);
5673
+ if (reaped.reaped > 0) console.error(`[codex-mcp] Reaped ${reaped.reaped} orphan process(es)`);
4326
5674
  const serverCwd = process.cwd();
4327
- const server = createServer(serverCwd, {
5675
+ const ctx = createServer(serverCwd, {
4328
5676
  createClient,
4329
- clientMode
5677
+ clientMode,
5678
+ persistence
4330
5679
  });
5680
+ const server = ctx.server;
5681
+ const sessionManager = ctx.sessionManager;
5682
+ if (recovered.length > 0) {
5683
+ sessionManager.ingestRecovered(recovered);
5684
+ }
4331
5685
  const transport = new StdioServerTransport();
4332
5686
  let closing = false;
4333
- const shutdown = async () => {
5687
+ let lastExitCode = 0;
5688
+ let stdinClosedAt;
5689
+ let stdinClosedReason;
5690
+ let stdinShutdownTimer;
5691
+ const onStdinEnd = () => handleStdinTerminated("end");
5692
+ const onStdinClose = () => handleStdinTerminated("close");
5693
+ const clearStdinShutdownTimer = () => {
5694
+ if (stdinShutdownTimer) {
5695
+ clearTimeout(stdinShutdownTimer);
5696
+ stdinShutdownTimer = void 0;
5697
+ }
5698
+ };
5699
+ function hasActiveSessions() {
5700
+ return sessionManager.listSessions().some((s) => s.status === "running" || s.status === "waiting_approval");
5701
+ }
5702
+ const shutdown = async (reason = "unknown") => {
4334
5703
  if (closing) return;
4335
5704
  closing = true;
5705
+ clearStdinShutdownTimer();
5706
+ if (typeof process.stdin.off === "function") {
5707
+ process.stdin.off("error", handleStdinError);
5708
+ process.stdin.off("end", onStdinEnd);
5709
+ process.stdin.off("close", onStdinClose);
5710
+ }
5711
+ const forceExitMs = process.platform === "win32" ? 1e4 : 5e3;
5712
+ const forceExitTimer = setTimeout(() => process.exit(lastExitCode), forceExitMs);
5713
+ if (forceExitTimer.unref) forceExitTimer.unref();
5714
+ const activeSessions = sessionManager.listSessions();
5715
+ const runningCount = activeSessions.filter(
5716
+ (s) => s.status === "running" || s.status === "waiting_approval"
5717
+ ).length;
5718
+ console.error(
5719
+ `[codex-mcp] shutdown triggered (reason=${reason}, activeSessions=${runningCount}, total=${activeSessions.length})`
5720
+ );
5721
+ try {
5722
+ if (server.isConnected()) {
5723
+ await server.sendLoggingMessage({
5724
+ level: "info",
5725
+ data: {
5726
+ event: "server_stopping",
5727
+ reason,
5728
+ activeSessions: runningCount,
5729
+ totalSessions: activeSessions.length
5730
+ }
5731
+ });
5732
+ }
5733
+ } catch {
5734
+ }
5735
+ try {
5736
+ persistence.flushAll();
5737
+ persistence.releaseLockIfHeld();
5738
+ } catch {
5739
+ }
4336
5740
  try {
4337
5741
  await server.close();
4338
5742
  } catch {
4339
5743
  }
4340
- process.exitCode = 0;
4341
- const exitTimer = setTimeout(() => process.exit(0), 100);
4342
- exitTimer.unref();
5744
+ persistence.destroy();
5745
+ process.exitCode = lastExitCode;
5746
+ try {
5747
+ await new Promise((resolve) => process.stderr.write("", () => resolve()));
5748
+ } catch {
5749
+ } finally {
5750
+ clearTimeout(forceExitTimer);
5751
+ }
5752
+ };
5753
+ function handleStdinError(error) {
5754
+ console.error("[codex-mcp] stdin error:", error);
5755
+ lastExitCode = 1;
5756
+ void shutdown("stdin_error");
5757
+ }
5758
+ const evaluateStdinTermination = () => {
5759
+ if (closing || stdinClosedAt === void 0) return;
5760
+ const stdinUnavailable = process.stdin.destroyed || process.stdin.readableEnded || !process.stdin.readable;
5761
+ const elapsedMs = Date.now() - stdinClosedAt;
5762
+ const active = hasActiveSessions();
5763
+ const connected = server.isConnected();
5764
+ const decision = decideStdinShutdown({
5765
+ stdinUnavailable,
5766
+ elapsedMs,
5767
+ maxWaitMs: STDIN_SHUTDOWN_MAX_WAIT_MS,
5768
+ hasActiveSessions: active,
5769
+ isConnected: connected
5770
+ });
5771
+ if (decision === "clear") {
5772
+ stdinClosedAt = void 0;
5773
+ stdinClosedReason = void 0;
5774
+ return;
5775
+ }
5776
+ if (decision === "shutdown_now") {
5777
+ console.error("[codex-mcp] stdin closed with no active sessions \u2014 shutting down");
5778
+ void shutdown(`stdin_${stdinClosedReason ?? "closed"}`);
5779
+ return;
5780
+ }
5781
+ if (decision === "shutdown_timeout") {
5782
+ console.error(
5783
+ `[codex-mcp] stdin closed and drain period (${STDIN_SHUTDOWN_MAX_WAIT_MS}ms) elapsed \u2014 forcing shutdown`
5784
+ );
5785
+ void shutdown(`stdin_${stdinClosedReason ?? "closed"}_timeout`);
5786
+ return;
5787
+ }
5788
+ if (active) {
5789
+ console.error(
5790
+ `[codex-mcp] stdin closed; ${sessionManager.getActiveSessionCount()} active session(s) \u2014 waiting up to ${STDIN_SHUTDOWN_MAX_WAIT_MS}ms (elapsed: ${elapsedMs}ms)`
5791
+ );
5792
+ }
5793
+ stdinShutdownTimer = setTimeout(evaluateStdinTermination, STDIN_SHUTDOWN_CHECK_MS);
5794
+ if (stdinShutdownTimer.unref) stdinShutdownTimer.unref();
4343
5795
  };
4344
- process.on("SIGINT", shutdown);
4345
- process.on("SIGTERM", shutdown);
4346
- process.on("SIGBREAK", shutdown);
5796
+ function handleStdinTerminated(event) {
5797
+ if (closing) return;
5798
+ if (stdinClosedAt === void 0) {
5799
+ stdinClosedAt = Date.now();
5800
+ stdinClosedReason = event;
5801
+ console.error(`[codex-mcp] stdin ${event} observed \u2014 entering guarded shutdown checks`);
5802
+ }
5803
+ clearStdinShutdownTimer();
5804
+ stdinShutdownTimer = setTimeout(evaluateStdinTermination, STDIN_SHUTDOWN_CHECK_MS);
5805
+ if (stdinShutdownTimer.unref) stdinShutdownTimer.unref();
5806
+ }
5807
+ const handleUnexpectedError = (error) => {
5808
+ console.error("[codex-mcp] Unhandled runtime error:", error);
5809
+ lastExitCode = 1;
5810
+ void shutdown("runtime_error");
5811
+ };
5812
+ process.on("SIGINT", () => void shutdown("SIGINT"));
5813
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
5814
+ process.on("SIGBREAK", () => void shutdown("SIGBREAK"));
5815
+ process.on("beforeExit", () => {
5816
+ if (!server.isConnected()) {
5817
+ void shutdown("beforeExit");
5818
+ }
5819
+ });
5820
+ process.on("uncaughtException", handleUnexpectedError);
5821
+ process.on("unhandledRejection", handleUnexpectedError);
5822
+ if (typeof process.stdin.resume === "function") {
5823
+ process.stdin.resume();
5824
+ }
5825
+ process.stdin.on("error", handleStdinError);
5826
+ process.stdin.on("end", onStdinEnd);
5827
+ process.stdin.on("close", onStdinClose);
4347
5828
  await server.connect(transport);
4348
5829
  console.error(`codex-mcp server started (cwd: ${serverCwd})`);
4349
5830
  }