@leo000001/codex-mcp 2.1.4 → 2.1.6
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/CHANGELOG.md +11 -0
- package/README.md +74 -45
- package/dist/index.js +1613 -169
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -243,7 +243,7 @@ function getPathExtensions(env) {
|
|
|
243
243
|
}
|
|
244
244
|
return Array.from(new Set(merged));
|
|
245
245
|
}
|
|
246
|
-
function
|
|
246
|
+
function resolveCommandFromPath(command, env) {
|
|
247
247
|
const dirs = getPathEntries(env);
|
|
248
248
|
const ext = process.platform === "win32" ? path2.extname(command) : "";
|
|
249
249
|
const names = process.platform === "win32" ? Array.from(
|
|
@@ -254,10 +254,10 @@ function commandExistsOnPath(command, env) {
|
|
|
254
254
|
for (const dir of dirs) {
|
|
255
255
|
for (const name of names) {
|
|
256
256
|
const candidate = path2.join(dir, name);
|
|
257
|
-
if (isExecutableFile(candidate)) return
|
|
257
|
+
if (isExecutableFile(candidate)) return path2.normalize(candidate);
|
|
258
258
|
}
|
|
259
259
|
}
|
|
260
|
-
return
|
|
260
|
+
return void 0;
|
|
261
261
|
}
|
|
262
262
|
function looksLikePath(value) {
|
|
263
263
|
return value.includes("/") || value.includes("\\");
|
|
@@ -288,14 +288,16 @@ function resolveDefaultCodexExecutable(env = process.env) {
|
|
|
288
288
|
`${CODEX_MCP_COMMAND}="${envCommand}" looks like a path. Use ${CODEX_MCP_PATH} for filesystem paths.`
|
|
289
289
|
);
|
|
290
290
|
}
|
|
291
|
-
|
|
291
|
+
const resolved = resolveCommandFromPath(envCommand, env);
|
|
292
|
+
if (!resolved) {
|
|
292
293
|
throw new Error(`${CODEX_MCP_COMMAND}="${envCommand}" was not found in PATH.`);
|
|
293
294
|
}
|
|
294
|
-
return { command:
|
|
295
|
+
return { command: resolved, isPath: true, source: "env_command" };
|
|
295
296
|
}
|
|
296
297
|
for (const candidate of AUTO_CODEX_COMMANDS) {
|
|
297
|
-
|
|
298
|
-
|
|
298
|
+
const resolved = resolveCommandFromPath(candidate, env);
|
|
299
|
+
if (resolved) {
|
|
300
|
+
return { command: resolved, isPath: true, source: "auto_detect" };
|
|
299
301
|
}
|
|
300
302
|
}
|
|
301
303
|
return { command: "codex", isPath: false, source: "default" };
|
|
@@ -308,16 +310,15 @@ function getDefaultCodexExecutable() {
|
|
|
308
310
|
}
|
|
309
311
|
function checkDefaultCodexExecutableAvailability() {
|
|
310
312
|
const info = getDefaultCodexExecutable();
|
|
311
|
-
const label = info.isPath ? "path" : "command";
|
|
312
313
|
switch (info.source) {
|
|
313
314
|
case "env_path":
|
|
314
315
|
console.error(`[codex-executable] Using ${CODEX_MCP_PATH}: ${info.command}`);
|
|
315
316
|
break;
|
|
316
317
|
case "env_command":
|
|
317
|
-
console.error(`[codex-executable] Using ${CODEX_MCP_COMMAND}: ${info.command}`);
|
|
318
|
+
console.error(`[codex-executable] Using ${CODEX_MCP_COMMAND}: resolved to ${info.command}`);
|
|
318
319
|
break;
|
|
319
320
|
case "auto_detect":
|
|
320
|
-
console.error(`[codex-executable] Auto-detected
|
|
321
|
+
console.error(`[codex-executable] Auto-detected executable: ${info.command}`);
|
|
321
322
|
break;
|
|
322
323
|
case "default":
|
|
323
324
|
console.error(
|
|
@@ -392,7 +393,7 @@ var DEFAULT_TERMINAL_CLEANUP_MS = 5 * 60 * 1e3;
|
|
|
392
393
|
var CLEANUP_INTERVAL_MS = 6e4;
|
|
393
394
|
|
|
394
395
|
// src/app-server/client.ts
|
|
395
|
-
var CLIENT_VERSION = true ? "2.1.
|
|
396
|
+
var CLIENT_VERSION = true ? "2.1.6" : "0.0.0-dev";
|
|
396
397
|
var DEFAULT_REQUEST_TIMEOUT = 3e4;
|
|
397
398
|
var STARTUP_REQUEST_TIMEOUT = 9e4;
|
|
398
399
|
var MAX_WRITE_QUEUE_BYTES = 5 * 1024 * 1024;
|
|
@@ -417,6 +418,9 @@ var AppServerClient = class extends EventEmitter {
|
|
|
417
418
|
get supportsTurnOverrides() {
|
|
418
419
|
return true;
|
|
419
420
|
}
|
|
421
|
+
get childPid() {
|
|
422
|
+
return this.process?.pid ?? void 0;
|
|
423
|
+
}
|
|
420
424
|
/**
|
|
421
425
|
* Spawn codex app-server and perform initialization handshake.
|
|
422
426
|
*/
|
|
@@ -877,19 +881,97 @@ function stripShellNoise(delta) {
|
|
|
877
881
|
if (cleaned.length === 0) return "";
|
|
878
882
|
return cleaned.join("\n");
|
|
879
883
|
}
|
|
884
|
+
var MAX_WAITERS_PER_SESSION = 4;
|
|
885
|
+
var MAX_WAIT_MS = 12e4;
|
|
880
886
|
var SessionManager = class {
|
|
881
887
|
sessions = /* @__PURE__ */ new Map();
|
|
882
888
|
clients = /* @__PURE__ */ new Map();
|
|
883
889
|
cancellationInFlight = /* @__PURE__ */ new Map();
|
|
884
890
|
cleanupTimer = null;
|
|
885
891
|
createClient;
|
|
892
|
+
/** Optional disk persistence adapter. */
|
|
893
|
+
persistence;
|
|
894
|
+
/** Track last persisted status to avoid redundant writes. */
|
|
895
|
+
lastPersistedStatus = /* @__PURE__ */ new Map();
|
|
896
|
+
/** Sessions for which a TTL warning event has already been emitted this cycle. */
|
|
897
|
+
ttlWarningEmitted = /* @__PURE__ */ new Set();
|
|
898
|
+
/** Long-poll notifiers: set of resolve callbacks waiting for any change in a session. */
|
|
899
|
+
sessionNotifiers = /* @__PURE__ */ new Map();
|
|
886
900
|
constructor(options = {}) {
|
|
887
901
|
this.createClient = options.createClient ?? (() => new AppServerClient());
|
|
902
|
+
this.persistence = options.persistence ?? null;
|
|
888
903
|
if (!options.disableCleanup) {
|
|
889
904
|
this.cleanupTimer = setInterval(() => this.cleanupSessions(), CLEANUP_INTERVAL_MS);
|
|
890
905
|
if (this.cleanupTimer.unref) this.cleanupTimer.unref();
|
|
891
906
|
}
|
|
892
907
|
}
|
|
908
|
+
/**
|
|
909
|
+
* Ingest recovered sessions from disk into the in-memory session store.
|
|
910
|
+
* Marks previously-running sessions as error, preserves completed results.
|
|
911
|
+
*/
|
|
912
|
+
ingestRecovered(recovered) {
|
|
913
|
+
for (const rec of recovered) {
|
|
914
|
+
if (this.sessions.has(rec.sessionId)) continue;
|
|
915
|
+
const VALID_STATUSES = /* @__PURE__ */ new Set([
|
|
916
|
+
"running",
|
|
917
|
+
"idle",
|
|
918
|
+
"waiting_approval",
|
|
919
|
+
"error",
|
|
920
|
+
"cancelled"
|
|
921
|
+
]);
|
|
922
|
+
const wasActive = rec.meta.status === "running" || rec.meta.status === "waiting_approval";
|
|
923
|
+
const resolvedStatus = wasActive ? "error" : VALID_STATUSES.has(rec.meta.status) ? rec.meta.status : "error";
|
|
924
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
925
|
+
const session = {
|
|
926
|
+
sessionId: rec.meta.sessionId,
|
|
927
|
+
threadId: rec.meta.threadId,
|
|
928
|
+
status: resolvedStatus,
|
|
929
|
+
lastEventCursor: 0,
|
|
930
|
+
createdAt: rec.meta.createdAt,
|
|
931
|
+
lastActiveAt: now,
|
|
932
|
+
cancelledAt: rec.meta.cancelledAt,
|
|
933
|
+
cancelledReason: wasActive ? "Server restarted while session was active" : rec.meta.cancelledReason,
|
|
934
|
+
cwd: rec.meta.cwd ?? ".",
|
|
935
|
+
model: rec.meta.model,
|
|
936
|
+
approvalPolicy: rec.meta.approvalPolicy,
|
|
937
|
+
sandbox: rec.meta.sandbox,
|
|
938
|
+
eventBuffer: createEventBuffer(),
|
|
939
|
+
pendingRequests: /* @__PURE__ */ new Map(),
|
|
940
|
+
lastResult: rec.result
|
|
941
|
+
};
|
|
942
|
+
this.sessions.set(rec.sessionId, session);
|
|
943
|
+
if (rec.lastSeq >= 0) {
|
|
944
|
+
this.persistence?.setEventLogNextSeq(rec.sessionId, rec.lastSeq + 1);
|
|
945
|
+
}
|
|
946
|
+
if (wasActive) {
|
|
947
|
+
this.persistSessionIfChanged(session);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Best-effort persist session metadata to disk when status changes.
|
|
953
|
+
* Deduplicates writes if status hasn't changed since last persist.
|
|
954
|
+
*/
|
|
955
|
+
persistSessionIfChanged(session) {
|
|
956
|
+
if (!this.persistence) return;
|
|
957
|
+
const lastStatus = this.lastPersistedStatus.get(session.sessionId);
|
|
958
|
+
if (lastStatus === session.status) return;
|
|
959
|
+
try {
|
|
960
|
+
this.persistence.writeSessionMeta(session);
|
|
961
|
+
this.lastPersistedStatus.set(session.sessionId, session.status);
|
|
962
|
+
} catch {
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Best-effort persist result to disk.
|
|
967
|
+
*/
|
|
968
|
+
persistResult(session) {
|
|
969
|
+
if (!this.persistence || !session.lastResult) return;
|
|
970
|
+
try {
|
|
971
|
+
this.persistence.writeResult(session.sessionId, session.lastResult);
|
|
972
|
+
} catch {
|
|
973
|
+
}
|
|
974
|
+
}
|
|
893
975
|
// ── Session Creation ─────────────────────────────────────────────
|
|
894
976
|
async createSession(prompt, cwd, spawnOpts, effort, advanced) {
|
|
895
977
|
const sessionId = `sess_${randomUUID().slice(0, 12)}`;
|
|
@@ -915,9 +997,19 @@ var SessionManager = class {
|
|
|
915
997
|
};
|
|
916
998
|
this.sessions.set(sessionId, session);
|
|
917
999
|
this.clients.set(sessionId, client);
|
|
1000
|
+
try {
|
|
1001
|
+
this.persistence?.writeSessionMeta(session);
|
|
1002
|
+
} catch {
|
|
1003
|
+
}
|
|
918
1004
|
try {
|
|
919
1005
|
this.registerHandlers(sessionId, client, approvalTimeoutMs);
|
|
920
1006
|
await client.start(spawnOpts);
|
|
1007
|
+
if (client.childPid !== void 0) {
|
|
1008
|
+
try {
|
|
1009
|
+
this.persistence?.writePidInfo(sessionId, client.childPid, spawnOpts.model);
|
|
1010
|
+
} catch {
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
921
1013
|
const threadStartResult = await client.threadStart({
|
|
922
1014
|
cwd,
|
|
923
1015
|
model: spawnOpts.model,
|
|
@@ -985,6 +1077,7 @@ var SessionManager = class {
|
|
|
985
1077
|
clearTerminalEvents(session.eventBuffer);
|
|
986
1078
|
session.status = "running";
|
|
987
1079
|
session.lastActiveAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1080
|
+
this.persistSessionIfChanged(session);
|
|
988
1081
|
const input = [{ type: "text", text: prompt }];
|
|
989
1082
|
const resolvedCwd = overrides?.cwd ? resolveAndValidateCwd(overrides.cwd, session.cwd) : void 0;
|
|
990
1083
|
const turnParams = {
|
|
@@ -1070,6 +1163,18 @@ var SessionManager = class {
|
|
|
1070
1163
|
const session = this.getSessionOrThrow(sessionId);
|
|
1071
1164
|
return includeSensitive ? toSensitiveInfo(session) : toPublicInfo(session);
|
|
1072
1165
|
}
|
|
1166
|
+
getLastResult(sessionId) {
|
|
1167
|
+
return this.getSessionOrThrow(sessionId).lastResult;
|
|
1168
|
+
}
|
|
1169
|
+
getPendingActionTypes(sessionId) {
|
|
1170
|
+
const session = this.getSessionOrThrow(sessionId);
|
|
1171
|
+
const actionTypes = /* @__PURE__ */ new Set();
|
|
1172
|
+
for (const req of session.pendingRequests.values()) {
|
|
1173
|
+
if (req.resolved) continue;
|
|
1174
|
+
actionTypes.add(req.kind === "user_input" ? "user_input" : "approval");
|
|
1175
|
+
}
|
|
1176
|
+
return Array.from(actionTypes);
|
|
1177
|
+
}
|
|
1073
1178
|
async cancelSession(sessionId, reason) {
|
|
1074
1179
|
const existing = this.cancellationInFlight.get(sessionId);
|
|
1075
1180
|
if (existing) {
|
|
@@ -1093,6 +1198,7 @@ var SessionManager = class {
|
|
|
1093
1198
|
session.cancelledAt = now;
|
|
1094
1199
|
session.lastActiveAt = now;
|
|
1095
1200
|
session.cancelledReason = reason ?? "Cancelled by user";
|
|
1201
|
+
this.persistSessionIfChanged(session);
|
|
1096
1202
|
for (const [reqId, req] of session.pendingRequests) {
|
|
1097
1203
|
if (req.timeoutHandle) clearTimeout(req.timeoutHandle);
|
|
1098
1204
|
if (!req.resolved && req.respond) {
|
|
@@ -1129,6 +1235,7 @@ var SessionManager = class {
|
|
|
1129
1235
|
{ status: "cancelled", reason: session.cancelledReason, turnId: cancelledTurnId },
|
|
1130
1236
|
true
|
|
1131
1237
|
);
|
|
1238
|
+
this.notifyWaiters(sessionId);
|
|
1132
1239
|
if (client) {
|
|
1133
1240
|
await client.destroy();
|
|
1134
1241
|
this.clients.delete(sessionId);
|
|
@@ -1244,6 +1351,57 @@ var SessionManager = class {
|
|
|
1244
1351
|
);
|
|
1245
1352
|
}
|
|
1246
1353
|
}
|
|
1354
|
+
// ── Long-poll support ────────────────────────────────────────────
|
|
1355
|
+
/**
|
|
1356
|
+
* Wait until a new event is pushed, a new pending request arrives, a status
|
|
1357
|
+
* change occurs, or `timeoutMs` elapses (whichever comes first).
|
|
1358
|
+
*
|
|
1359
|
+
* Rejects with an error when more than MAX_WAITERS_PER_SESSION concurrent
|
|
1360
|
+
* waiters are already queued for the same session.
|
|
1361
|
+
*/
|
|
1362
|
+
waitForChange(sessionId, timeoutMs, signal) {
|
|
1363
|
+
return new Promise((resolve, reject) => {
|
|
1364
|
+
if (signal?.aborted) {
|
|
1365
|
+
resolve();
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
let notifiers = this.sessionNotifiers.get(sessionId);
|
|
1369
|
+
if (!notifiers) {
|
|
1370
|
+
notifiers = /* @__PURE__ */ new Set();
|
|
1371
|
+
this.sessionNotifiers.set(sessionId, notifiers);
|
|
1372
|
+
}
|
|
1373
|
+
if (notifiers.size >= MAX_WAITERS_PER_SESSION) {
|
|
1374
|
+
reject(
|
|
1375
|
+
new Error(
|
|
1376
|
+
`[codex-mcp] Too many concurrent long-poll waiters for session '${sessionId}' (max ${MAX_WAITERS_PER_SESSION})`
|
|
1377
|
+
)
|
|
1378
|
+
);
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
const clampedMs = Math.min(Math.max(0, timeoutMs), MAX_WAIT_MS);
|
|
1382
|
+
const done = () => {
|
|
1383
|
+
notifiers.delete(notifyFn);
|
|
1384
|
+
if (notifiers.size === 0) this.sessionNotifiers.delete(sessionId);
|
|
1385
|
+
clearTimeout(timer);
|
|
1386
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
1387
|
+
resolve();
|
|
1388
|
+
};
|
|
1389
|
+
const notifyFn = done;
|
|
1390
|
+
const timer = setTimeout(done, clampedMs);
|
|
1391
|
+
if (timer.unref) timer.unref();
|
|
1392
|
+
const onAbort = () => done();
|
|
1393
|
+
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
|
1394
|
+
notifiers.add(notifyFn);
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
/** Resolve all waiters for a session immediately (called on any state change). */
|
|
1398
|
+
notifyWaiters(sessionId) {
|
|
1399
|
+
const notifiers = this.sessionNotifiers.get(sessionId);
|
|
1400
|
+
if (!notifiers || notifiers.size === 0) return;
|
|
1401
|
+
for (const fn of Array.from(notifiers)) {
|
|
1402
|
+
fn();
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1247
1405
|
// ── Event Polling ────────────────────────────────────────────────
|
|
1248
1406
|
pollEvents(sessionId, cursor, maxEvents = DEFAULT_MAX_EVENTS, options = {}) {
|
|
1249
1407
|
const session = this.getSessionOrThrow(sessionId);
|
|
@@ -1466,8 +1624,18 @@ var SessionManager = class {
|
|
|
1466
1624
|
}
|
|
1467
1625
|
req.resolved = true;
|
|
1468
1626
|
req.decision = decision;
|
|
1627
|
+
try {
|
|
1628
|
+
sendPendingRequestResponseOrThrow(req, response, sessionId, requestId);
|
|
1629
|
+
} catch (error) {
|
|
1630
|
+
req.resolved = false;
|
|
1631
|
+
req.decision = void 0;
|
|
1632
|
+
session.pendingRequests.set(requestId, req);
|
|
1633
|
+
if (session.status !== "cancelled") {
|
|
1634
|
+
session.status = "waiting_approval";
|
|
1635
|
+
}
|
|
1636
|
+
throw error;
|
|
1637
|
+
}
|
|
1469
1638
|
if (req.timeoutHandle) clearTimeout(req.timeoutHandle);
|
|
1470
|
-
sendPendingRequestResponseOrThrow(req, response, sessionId, requestId);
|
|
1471
1639
|
pushEvent(
|
|
1472
1640
|
session.eventBuffer,
|
|
1473
1641
|
"approval_result",
|
|
@@ -1484,6 +1652,7 @@ var SessionManager = class {
|
|
|
1484
1652
|
if (session.pendingRequests.size === 0 && session.status === "waiting_approval") {
|
|
1485
1653
|
session.status = "running";
|
|
1486
1654
|
}
|
|
1655
|
+
this.notifyWaiters(sessionId);
|
|
1487
1656
|
}
|
|
1488
1657
|
// ── User Input Response ──────────────────────────────────────────
|
|
1489
1658
|
resolveUserInput(sessionId, requestId, answers) {
|
|
@@ -1495,13 +1664,22 @@ var SessionManager = class {
|
|
|
1495
1664
|
);
|
|
1496
1665
|
}
|
|
1497
1666
|
req.resolved = true;
|
|
1667
|
+
try {
|
|
1668
|
+
sendPendingRequestResponseOrThrow(
|
|
1669
|
+
req,
|
|
1670
|
+
{ answers },
|
|
1671
|
+
sessionId,
|
|
1672
|
+
requestId
|
|
1673
|
+
);
|
|
1674
|
+
} catch (error) {
|
|
1675
|
+
req.resolved = false;
|
|
1676
|
+
session.pendingRequests.set(requestId, req);
|
|
1677
|
+
if (session.status !== "cancelled") {
|
|
1678
|
+
session.status = "waiting_approval";
|
|
1679
|
+
}
|
|
1680
|
+
throw error;
|
|
1681
|
+
}
|
|
1498
1682
|
if (req.timeoutHandle) clearTimeout(req.timeoutHandle);
|
|
1499
|
-
sendPendingRequestResponseOrThrow(
|
|
1500
|
-
req,
|
|
1501
|
-
{ answers },
|
|
1502
|
-
sessionId,
|
|
1503
|
-
requestId
|
|
1504
|
-
);
|
|
1505
1683
|
pushEvent(
|
|
1506
1684
|
session.eventBuffer,
|
|
1507
1685
|
"approval_result",
|
|
@@ -1517,6 +1695,7 @@ var SessionManager = class {
|
|
|
1517
1695
|
if (session.pendingRequests.size === 0 && session.status === "waiting_approval") {
|
|
1518
1696
|
session.status = "running";
|
|
1519
1697
|
}
|
|
1698
|
+
this.notifyWaiters(sessionId);
|
|
1520
1699
|
}
|
|
1521
1700
|
// ── Cleanup ──────────────────────────────────────────────────────
|
|
1522
1701
|
destroy() {
|
|
@@ -1625,6 +1804,8 @@ var SessionManager = class {
|
|
|
1625
1804
|
},
|
|
1626
1805
|
true
|
|
1627
1806
|
);
|
|
1807
|
+
this.persistSessionIfChanged(session);
|
|
1808
|
+
this.persistResult(session);
|
|
1628
1809
|
break;
|
|
1629
1810
|
}
|
|
1630
1811
|
case Methods.ERROR: {
|
|
@@ -1651,6 +1832,7 @@ var SessionManager = class {
|
|
|
1651
1832
|
);
|
|
1652
1833
|
} else {
|
|
1653
1834
|
pushEvent(session.eventBuffer, "error", data, true);
|
|
1835
|
+
this.persistSessionIfChanged(session);
|
|
1654
1836
|
}
|
|
1655
1837
|
}
|
|
1656
1838
|
break;
|
|
@@ -1699,6 +1881,7 @@ var SessionManager = class {
|
|
|
1699
1881
|
default:
|
|
1700
1882
|
break;
|
|
1701
1883
|
}
|
|
1884
|
+
this.notifyWaiters(sessionId);
|
|
1702
1885
|
});
|
|
1703
1886
|
client.onServerRequest((id, method, params) => {
|
|
1704
1887
|
if (session.status === "cancelled" || session.status === "error") {
|
|
@@ -1769,6 +1952,7 @@ var SessionManager = class {
|
|
|
1769
1952
|
if (session.pendingRequests.size === 0 && session.status === "waiting_approval") {
|
|
1770
1953
|
session.status = "running";
|
|
1771
1954
|
}
|
|
1955
|
+
this.notifyWaiters(sessionId);
|
|
1772
1956
|
}
|
|
1773
1957
|
}, approvalTimeoutMs);
|
|
1774
1958
|
session.pendingRequests.set(requestId, pending);
|
|
@@ -1836,6 +2020,7 @@ var SessionManager = class {
|
|
|
1836
2020
|
if (session.pendingRequests.size === 0 && session.status === "waiting_approval") {
|
|
1837
2021
|
session.status = "running";
|
|
1838
2022
|
}
|
|
2023
|
+
this.notifyWaiters(sessionId);
|
|
1839
2024
|
}
|
|
1840
2025
|
}, approvalTimeoutMs);
|
|
1841
2026
|
session.pendingRequests.set(requestId, pending);
|
|
@@ -1890,6 +2075,7 @@ var SessionManager = class {
|
|
|
1890
2075
|
if (session.pendingRequests.size === 0 && session.status === "waiting_approval") {
|
|
1891
2076
|
session.status = "running";
|
|
1892
2077
|
}
|
|
2078
|
+
this.notifyWaiters(sessionId);
|
|
1893
2079
|
}
|
|
1894
2080
|
}, approvalTimeoutMs);
|
|
1895
2081
|
session.pendingRequests.set(requestId, pending);
|
|
@@ -1928,6 +2114,7 @@ var SessionManager = class {
|
|
|
1928
2114
|
client.respondErrorToServer(id, -32601, `Unhandled server request: ${method}`);
|
|
1929
2115
|
break;
|
|
1930
2116
|
}
|
|
2117
|
+
this.notifyWaiters(sessionId);
|
|
1931
2118
|
});
|
|
1932
2119
|
client.on("exit", (code) => {
|
|
1933
2120
|
clearSessionPendingRequests(session);
|
|
@@ -1943,6 +2130,9 @@ var SessionManager = class {
|
|
|
1943
2130
|
},
|
|
1944
2131
|
true
|
|
1945
2132
|
);
|
|
2133
|
+
this.persistSessionIfChanged(session);
|
|
2134
|
+
this.persistResult(session);
|
|
2135
|
+
this.notifyWaiters(sessionId);
|
|
1946
2136
|
}
|
|
1947
2137
|
});
|
|
1948
2138
|
client.on("error", (err) => {
|
|
@@ -1959,25 +2149,34 @@ var SessionManager = class {
|
|
|
1959
2149
|
},
|
|
1960
2150
|
true
|
|
1961
2151
|
);
|
|
2152
|
+
this.persistSessionIfChanged(session);
|
|
2153
|
+
this.persistResult(session);
|
|
2154
|
+
this.notifyWaiters(sessionId);
|
|
1962
2155
|
}
|
|
1963
2156
|
});
|
|
1964
2157
|
}
|
|
1965
2158
|
cleanupSessions() {
|
|
1966
2159
|
const now = Date.now();
|
|
2160
|
+
const TTL_WARNING_THRESHOLD_MS = 6e4;
|
|
1967
2161
|
for (const [id, session] of this.sessions) {
|
|
1968
2162
|
const lastActive = new Date(session.lastActiveAt).getTime();
|
|
1969
2163
|
if (Number.isNaN(lastActive)) {
|
|
2164
|
+
this.ttlWarningEmitted.delete(id);
|
|
1970
2165
|
this.requestCancellation(id, "Invalid timestamp");
|
|
1971
2166
|
continue;
|
|
1972
2167
|
}
|
|
1973
2168
|
const age = now - lastActive;
|
|
1974
2169
|
if (session.status === "idle" && age > DEFAULT_IDLE_CLEANUP_MS) {
|
|
2170
|
+
this.ttlWarningEmitted.delete(id);
|
|
1975
2171
|
this.requestCancellation(id, "Idle timeout");
|
|
1976
2172
|
} else if (session.status === "waiting_approval" && age > DEFAULT_RUNNING_CLEANUP_MS) {
|
|
2173
|
+
this.ttlWarningEmitted.delete(id);
|
|
1977
2174
|
this.requestCancellation(id, "Approval timeout");
|
|
1978
2175
|
} else if (session.status === "running" && age > DEFAULT_RUNNING_CLEANUP_MS) {
|
|
2176
|
+
this.ttlWarningEmitted.delete(id);
|
|
1979
2177
|
this.requestCancellation(id, "Running timeout");
|
|
1980
2178
|
} else if ((session.status === "cancelled" || session.status === "error") && age > DEFAULT_TERMINAL_CLEANUP_MS) {
|
|
2179
|
+
this.ttlWarningEmitted.delete(id);
|
|
1981
2180
|
this.clients.get(id)?.destroy().catch((err) => {
|
|
1982
2181
|
console.error(
|
|
1983
2182
|
`[codex-mcp] Failed to destroy app-server client during cleanup: session=${id} error=${err instanceof Error ? err.message : String(err)}`
|
|
@@ -1985,6 +2184,27 @@ var SessionManager = class {
|
|
|
1985
2184
|
});
|
|
1986
2185
|
this.clients.delete(id);
|
|
1987
2186
|
this.sessions.delete(id);
|
|
2187
|
+
this.lastPersistedStatus.delete(id);
|
|
2188
|
+
this.ttlWarningEmitted.delete(id);
|
|
2189
|
+
} else {
|
|
2190
|
+
let ttlMs;
|
|
2191
|
+
if (session.status === "idle") {
|
|
2192
|
+
ttlMs = DEFAULT_IDLE_CLEANUP_MS;
|
|
2193
|
+
} else if (session.status === "running" || session.status === "waiting_approval") {
|
|
2194
|
+
ttlMs = DEFAULT_RUNNING_CLEANUP_MS;
|
|
2195
|
+
}
|
|
2196
|
+
if (ttlMs !== void 0 && !this.ttlWarningEmitted.has(id)) {
|
|
2197
|
+
const timeUntilExpiry = ttlMs - age;
|
|
2198
|
+
if (timeUntilExpiry <= TTL_WARNING_THRESHOLD_MS && timeUntilExpiry > 0) {
|
|
2199
|
+
this.ttlWarningEmitted.add(id);
|
|
2200
|
+
pushEvent(session.eventBuffer, "progress", {
|
|
2201
|
+
method: "codex-mcp/ttl_warning",
|
|
2202
|
+
type: "ttl_warning",
|
|
2203
|
+
ttlRemainingMs: timeUntilExpiry,
|
|
2204
|
+
sessionId: id
|
|
2205
|
+
});
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
1988
2208
|
}
|
|
1989
2209
|
}
|
|
1990
2210
|
}
|
|
@@ -2474,17 +2694,117 @@ function extractSpawnOptions(params) {
|
|
|
2474
2694
|
};
|
|
2475
2695
|
}
|
|
2476
2696
|
|
|
2697
|
+
// src/utils/execution.ts
|
|
2698
|
+
var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["idle", "error", "cancelled"]);
|
|
2699
|
+
function interactionStateForStatus(status) {
|
|
2700
|
+
if (status === "waiting_approval") return "waiting_input";
|
|
2701
|
+
if (TERMINAL_STATUSES.has(status)) return "finished";
|
|
2702
|
+
return "working";
|
|
2703
|
+
}
|
|
2704
|
+
function recommendedNextActionForStatus(status, actionTypes = []) {
|
|
2705
|
+
if (status === "waiting_approval") {
|
|
2706
|
+
if (actionTypes.includes("user_input")) return "respond_user_input";
|
|
2707
|
+
if (actionTypes.includes("approval")) return "respond_permission";
|
|
2708
|
+
}
|
|
2709
|
+
if (TERMINAL_STATUSES.has(status)) return "none";
|
|
2710
|
+
return "poll";
|
|
2711
|
+
}
|
|
2712
|
+
function buildExecutionInfo(waitForResultMs, status, fallbackReason) {
|
|
2713
|
+
const requested = waitForResultMs && waitForResultMs > 0 ? "foreground" : "background";
|
|
2714
|
+
const effective = requested === "foreground" && TERMINAL_STATUSES.has(status) ? "foreground" : "background";
|
|
2715
|
+
return {
|
|
2716
|
+
requested,
|
|
2717
|
+
effective,
|
|
2718
|
+
waitForResultMs: waitForResultMs && waitForResultMs > 0 ? waitForResultMs : void 0,
|
|
2719
|
+
fallbackReason: effective === "background" ? fallbackReason : void 0
|
|
2720
|
+
};
|
|
2721
|
+
}
|
|
2722
|
+
async function waitForCodexSessionForegroundResult(sessionManager, sessionId, waitForResultMs, signal) {
|
|
2723
|
+
const deadline = Date.now() + Math.min(waitForResultMs, 3e5);
|
|
2724
|
+
while (Date.now() < deadline) {
|
|
2725
|
+
let status2;
|
|
2726
|
+
try {
|
|
2727
|
+
status2 = sessionManager.getSession(sessionId).status;
|
|
2728
|
+
} catch {
|
|
2729
|
+
status2 = "error";
|
|
2730
|
+
}
|
|
2731
|
+
if (TERMINAL_STATUSES.has(status2)) {
|
|
2732
|
+
const finalResult = sessionManager.getLastResult(sessionId);
|
|
2733
|
+
return {
|
|
2734
|
+
status: status2,
|
|
2735
|
+
result: finalResult,
|
|
2736
|
+
completedAt: finalResult?.completedAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
2737
|
+
};
|
|
2738
|
+
}
|
|
2739
|
+
if (status2 === "waiting_approval") {
|
|
2740
|
+
return {
|
|
2741
|
+
status: status2,
|
|
2742
|
+
pendingActionTypes: sessionManager.getPendingActionTypes(sessionId),
|
|
2743
|
+
fallbackReason: "interactive_poll_required"
|
|
2744
|
+
};
|
|
2745
|
+
}
|
|
2746
|
+
const remainingMs = Math.min(deadline - Date.now(), 5e3);
|
|
2747
|
+
if (remainingMs <= 0) break;
|
|
2748
|
+
try {
|
|
2749
|
+
await sessionManager.waitForChange(sessionId, remainingMs, signal);
|
|
2750
|
+
} catch {
|
|
2751
|
+
break;
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
let status = "running";
|
|
2755
|
+
try {
|
|
2756
|
+
status = sessionManager.getSession(sessionId).status;
|
|
2757
|
+
} catch {
|
|
2758
|
+
status = "error";
|
|
2759
|
+
}
|
|
2760
|
+
return { status, fallbackReason: "wait_for_result_timeout" };
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2477
2763
|
// src/tools/codex.ts
|
|
2478
|
-
async function executeCodex(args, sessionManager, serverCwd) {
|
|
2764
|
+
async function executeCodex(args, sessionManager, serverCwd, requestSignal) {
|
|
2479
2765
|
const cwd = resolveAndValidateCwd(args.cwd, serverCwd);
|
|
2480
2766
|
const spawnOpts = extractSpawnOptions(args);
|
|
2481
2767
|
const effort = args.effort ?? DEFAULT_EFFORT_LEVEL;
|
|
2482
|
-
|
|
2768
|
+
const startResult = await sessionManager.createSession(
|
|
2769
|
+
args.prompt,
|
|
2770
|
+
cwd,
|
|
2771
|
+
spawnOpts,
|
|
2772
|
+
effort,
|
|
2773
|
+
args.advanced
|
|
2774
|
+
);
|
|
2775
|
+
const waitMs = args.advanced?.waitForResult;
|
|
2776
|
+
const baseResult = {
|
|
2777
|
+
...startResult,
|
|
2778
|
+
execution: buildExecutionInfo(waitMs, "running"),
|
|
2779
|
+
interactionState: interactionStateForStatus("running"),
|
|
2780
|
+
recommendedNextAction: recommendedNextActionForStatus("running")
|
|
2781
|
+
};
|
|
2782
|
+
if (!waitMs || waitMs <= 0) return baseResult;
|
|
2783
|
+
const foreground = await waitForCodexSessionForegroundResult(
|
|
2784
|
+
sessionManager,
|
|
2785
|
+
startResult.sessionId,
|
|
2786
|
+
waitMs,
|
|
2787
|
+
requestSignal
|
|
2788
|
+
);
|
|
2789
|
+
return {
|
|
2790
|
+
sessionId: startResult.sessionId,
|
|
2791
|
+
threadId: startResult.threadId,
|
|
2792
|
+
result: foreground.result,
|
|
2793
|
+
status: foreground.status,
|
|
2794
|
+
completedAt: foreground.completedAt,
|
|
2795
|
+
pollInterval: foreground.status === "waiting_approval" ? WAITING_APPROVAL_POLL_INTERVAL : foreground.status === "running" ? startResult.pollInterval : void 0,
|
|
2796
|
+
execution: buildExecutionInfo(waitMs, foreground.status, foreground.fallbackReason),
|
|
2797
|
+
interactionState: interactionStateForStatus(foreground.status),
|
|
2798
|
+
recommendedNextAction: recommendedNextActionForStatus(
|
|
2799
|
+
foreground.status,
|
|
2800
|
+
foreground.pendingActionTypes ?? []
|
|
2801
|
+
)
|
|
2802
|
+
};
|
|
2483
2803
|
}
|
|
2484
2804
|
|
|
2485
2805
|
// src/tools/codex-reply.ts
|
|
2486
|
-
async function executeCodexReply(args, sessionManager) {
|
|
2487
|
-
|
|
2806
|
+
async function executeCodexReply(args, sessionManager, requestSignal) {
|
|
2807
|
+
const startResult = await sessionManager.replyToSession(args.sessionId, args.prompt, {
|
|
2488
2808
|
model: args.model,
|
|
2489
2809
|
approvalPolicy: args.approvalPolicy,
|
|
2490
2810
|
effort: args.effort,
|
|
@@ -2494,6 +2814,36 @@ async function executeCodexReply(args, sessionManager) {
|
|
|
2494
2814
|
cwd: args.cwd,
|
|
2495
2815
|
outputSchema: args.outputSchema
|
|
2496
2816
|
});
|
|
2817
|
+
const waitMs = args.waitForResult;
|
|
2818
|
+
const baseResult = {
|
|
2819
|
+
...startResult,
|
|
2820
|
+
execution: buildExecutionInfo(waitMs, "running"),
|
|
2821
|
+
interactionState: interactionStateForStatus("running"),
|
|
2822
|
+
recommendedNextAction: recommendedNextActionForStatus("running")
|
|
2823
|
+
};
|
|
2824
|
+
if (!waitMs || waitMs <= 0) {
|
|
2825
|
+
return baseResult;
|
|
2826
|
+
}
|
|
2827
|
+
const foreground = await waitForCodexSessionForegroundResult(
|
|
2828
|
+
sessionManager,
|
|
2829
|
+
startResult.sessionId,
|
|
2830
|
+
waitMs,
|
|
2831
|
+
requestSignal
|
|
2832
|
+
);
|
|
2833
|
+
return {
|
|
2834
|
+
sessionId: startResult.sessionId,
|
|
2835
|
+
threadId: startResult.threadId,
|
|
2836
|
+
status: foreground.status,
|
|
2837
|
+
pollInterval: foreground.status === "waiting_approval" ? WAITING_APPROVAL_POLL_INTERVAL : foreground.status === "running" ? startResult.pollInterval : void 0,
|
|
2838
|
+
result: foreground.result,
|
|
2839
|
+
completedAt: foreground.completedAt,
|
|
2840
|
+
execution: buildExecutionInfo(waitMs, foreground.status, foreground.fallbackReason),
|
|
2841
|
+
interactionState: interactionStateForStatus(foreground.status),
|
|
2842
|
+
recommendedNextAction: recommendedNextActionForStatus(
|
|
2843
|
+
foreground.status,
|
|
2844
|
+
foreground.pendingActionTypes ?? []
|
|
2845
|
+
)
|
|
2846
|
+
};
|
|
2497
2847
|
}
|
|
2498
2848
|
|
|
2499
2849
|
// src/tools/codex-session.ts
|
|
@@ -2556,7 +2906,7 @@ async function executeCodexSession(args, sessionManager) {
|
|
|
2556
2906
|
}
|
|
2557
2907
|
|
|
2558
2908
|
// src/tools/codex-check.ts
|
|
2559
|
-
function executeCodexCheck(args, sessionManager) {
|
|
2909
|
+
function executeCodexCheck(args, sessionManager, requestSignal) {
|
|
2560
2910
|
const responseMode = args.responseMode ?? "minimal";
|
|
2561
2911
|
const pollOptions = args.pollOptions;
|
|
2562
2912
|
switch (args.action) {
|
|
@@ -2568,10 +2918,34 @@ function executeCodexCheck(args, sessionManager) {
|
|
|
2568
2918
|
};
|
|
2569
2919
|
}
|
|
2570
2920
|
const maxEvents = typeof args.maxEvents === "number" ? Math.max(POLL_MIN_MAX_EVENTS, Math.floor(args.maxEvents)) : POLL_DEFAULT_MAX_EVENTS;
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2921
|
+
const waitMs = pollOptions?.waitMs;
|
|
2922
|
+
if (typeof waitMs === "number" && waitMs > 0) {
|
|
2923
|
+
const firstCheck = sessionManager.pollEvents(args.sessionId, args.cursor, maxEvents, {
|
|
2924
|
+
responseMode,
|
|
2925
|
+
pollOptions
|
|
2926
|
+
});
|
|
2927
|
+
const hasData = firstCheck.events.length > 0 || firstCheck.actions !== void 0 && firstCheck.actions.length > 0 || firstCheck.result !== void 0;
|
|
2928
|
+
if (!hasData) {
|
|
2929
|
+
const clampedWait = Math.min(waitMs, 12e4);
|
|
2930
|
+
return sessionManager.waitForChange(args.sessionId, clampedWait, requestSignal).catch(() => void 0).then(
|
|
2931
|
+
() => enrichCheckResult(
|
|
2932
|
+
sessionManager,
|
|
2933
|
+
sessionManager.pollEvents(args.sessionId, args.cursor, maxEvents, {
|
|
2934
|
+
responseMode,
|
|
2935
|
+
pollOptions
|
|
2936
|
+
})
|
|
2937
|
+
)
|
|
2938
|
+
);
|
|
2939
|
+
}
|
|
2940
|
+
return enrichCheckResult(sessionManager, firstCheck);
|
|
2941
|
+
}
|
|
2942
|
+
return enrichCheckResult(
|
|
2943
|
+
sessionManager,
|
|
2944
|
+
sessionManager.pollEvents(args.sessionId, args.cursor, maxEvents, {
|
|
2945
|
+
responseMode,
|
|
2946
|
+
pollOptions
|
|
2947
|
+
})
|
|
2948
|
+
);
|
|
2575
2949
|
}
|
|
2576
2950
|
case "respond_permission": {
|
|
2577
2951
|
if (!args.requestId || !args.decision) {
|
|
@@ -2641,10 +3015,13 @@ function executeCodexCheck(args, sessionManager) {
|
|
|
2641
3015
|
return { error: message, isError: true };
|
|
2642
3016
|
}
|
|
2643
3017
|
const maxEvents = typeof args.maxEvents === "number" ? Math.max(0, Math.floor(args.maxEvents)) : RESPOND_DEFAULT_MAX_EVENTS;
|
|
2644
|
-
return
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
3018
|
+
return enrichCheckResult(
|
|
3019
|
+
sessionManager,
|
|
3020
|
+
sessionManager.pollEventsMonotonic(args.sessionId, args.cursor, maxEvents, {
|
|
3021
|
+
responseMode,
|
|
3022
|
+
pollOptions
|
|
3023
|
+
})
|
|
3024
|
+
);
|
|
2648
3025
|
}
|
|
2649
3026
|
case "respond_user_input": {
|
|
2650
3027
|
if (!args.requestId || !args.answers) {
|
|
@@ -2666,10 +3043,13 @@ function executeCodexCheck(args, sessionManager) {
|
|
|
2666
3043
|
return { error: message, isError: true };
|
|
2667
3044
|
}
|
|
2668
3045
|
const maxEvents = typeof args.maxEvents === "number" ? Math.max(0, Math.floor(args.maxEvents)) : RESPOND_DEFAULT_MAX_EVENTS;
|
|
2669
|
-
return
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
3046
|
+
return enrichCheckResult(
|
|
3047
|
+
sessionManager,
|
|
3048
|
+
sessionManager.pollEventsMonotonic(args.sessionId, args.cursor, maxEvents, {
|
|
3049
|
+
responseMode,
|
|
3050
|
+
pollOptions
|
|
3051
|
+
})
|
|
3052
|
+
);
|
|
2673
3053
|
}
|
|
2674
3054
|
default:
|
|
2675
3055
|
return {
|
|
@@ -2678,9 +3058,231 @@ function executeCodexCheck(args, sessionManager) {
|
|
|
2678
3058
|
};
|
|
2679
3059
|
}
|
|
2680
3060
|
}
|
|
3061
|
+
function enrichCheckResult(sessionManager, result) {
|
|
3062
|
+
const actionTypes = result.status === "waiting_approval" ? sessionManager.getPendingActionTypes(result.sessionId) : [];
|
|
3063
|
+
return {
|
|
3064
|
+
...result,
|
|
3065
|
+
interactionState: interactionStateForStatus(result.status),
|
|
3066
|
+
recommendedNextAction: recommendedNextActionForStatus(result.status, actionTypes)
|
|
3067
|
+
};
|
|
3068
|
+
}
|
|
2681
3069
|
|
|
2682
|
-
// src/
|
|
3070
|
+
// src/tools/codex-setup.ts
|
|
2683
3071
|
import { spawnSync } from "child_process";
|
|
3072
|
+
import { existsSync as existsSync5 } from "fs";
|
|
3073
|
+
import { homedir } from "os";
|
|
3074
|
+
import path5 from "path";
|
|
3075
|
+
|
|
3076
|
+
// src/app-server/detect.ts
|
|
3077
|
+
import { spawn as spawn2 } from "child_process";
|
|
3078
|
+
var DETECTION_TIMEOUT_MS = 5e3;
|
|
3079
|
+
async function detectClientMode(codexCommand, codexIsPath = false, env = process.env) {
|
|
3080
|
+
const override = env.CODEX_MCP_MODE;
|
|
3081
|
+
if (override === "app-server" || override === "exec") {
|
|
3082
|
+
return override;
|
|
3083
|
+
}
|
|
3084
|
+
try {
|
|
3085
|
+
const supported = await probeAppServer(codexCommand, codexIsPath, env);
|
|
3086
|
+
return supported ? "app-server" : "exec";
|
|
3087
|
+
} catch {
|
|
3088
|
+
return "exec";
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
function probeAppServer(codexCommand, codexIsPath, env) {
|
|
3092
|
+
return new Promise((resolve) => {
|
|
3093
|
+
const invocation = resolveCodexInvocation(["app-server", "--help"], {
|
|
3094
|
+
env,
|
|
3095
|
+
codexCommand,
|
|
3096
|
+
codexIsPath
|
|
3097
|
+
});
|
|
3098
|
+
let settled = false;
|
|
3099
|
+
const settle = (result) => {
|
|
3100
|
+
if (settled) return;
|
|
3101
|
+
settled = true;
|
|
3102
|
+
clearTimeout(timer);
|
|
3103
|
+
resolve(result);
|
|
3104
|
+
};
|
|
3105
|
+
let stdout = "";
|
|
3106
|
+
let stderr = "";
|
|
3107
|
+
const proc = spawn2(invocation.cmd, invocation.args, {
|
|
3108
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3109
|
+
env,
|
|
3110
|
+
windowsHide: true
|
|
3111
|
+
});
|
|
3112
|
+
proc.stdout?.on("data", (chunk) => {
|
|
3113
|
+
stdout += chunk.toString();
|
|
3114
|
+
});
|
|
3115
|
+
proc.stderr?.on("data", (chunk) => {
|
|
3116
|
+
stderr += chunk.toString();
|
|
3117
|
+
});
|
|
3118
|
+
proc.on("error", () => {
|
|
3119
|
+
settle(false);
|
|
3120
|
+
});
|
|
3121
|
+
proc.on("exit", (code) => {
|
|
3122
|
+
if (code === 0) {
|
|
3123
|
+
settle(true);
|
|
3124
|
+
} else {
|
|
3125
|
+
const combined = (stdout + stderr).toLowerCase();
|
|
3126
|
+
const isUnknown = combined.includes("unknown") || combined.includes("unrecognized") || combined.includes("not found") || combined.includes("no such subcommand");
|
|
3127
|
+
settle(!isUnknown && combined.includes("app-server"));
|
|
3128
|
+
}
|
|
3129
|
+
});
|
|
3130
|
+
const timer = setTimeout(() => {
|
|
3131
|
+
try {
|
|
3132
|
+
proc.kill("SIGTERM");
|
|
3133
|
+
} catch {
|
|
3134
|
+
}
|
|
3135
|
+
const forceKill = setTimeout(() => {
|
|
3136
|
+
try {
|
|
3137
|
+
if (!proc.killed && proc.exitCode === null) {
|
|
3138
|
+
proc.kill("SIGKILL");
|
|
3139
|
+
}
|
|
3140
|
+
} catch {
|
|
3141
|
+
}
|
|
3142
|
+
}, 2e3);
|
|
3143
|
+
if (forceKill.unref) forceKill.unref();
|
|
3144
|
+
settle(false);
|
|
3145
|
+
}, DETECTION_TIMEOUT_MS);
|
|
3146
|
+
if (timer.unref) timer.unref();
|
|
3147
|
+
});
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
// src/tools/codex-setup.ts
|
|
3151
|
+
function isCodexInternalExecutable(info) {
|
|
3152
|
+
const ext = path5.extname(info.command);
|
|
3153
|
+
return path5.basename(info.command, ext).toLowerCase() === "codex-internal";
|
|
3154
|
+
}
|
|
3155
|
+
function classifyAuthResult(status, combined) {
|
|
3156
|
+
if (status === 0) return "authenticated";
|
|
3157
|
+
if (/(not (logged|authenticated)|login required|run\s+codex\s+login)/i.test(combined)) {
|
|
3158
|
+
return "unauthenticated";
|
|
3159
|
+
}
|
|
3160
|
+
return "unknown";
|
|
3161
|
+
}
|
|
3162
|
+
function resolveCodexStateDir() {
|
|
3163
|
+
const configured = process.env.CODEX_MCP_STATE_DIR?.trim();
|
|
3164
|
+
return configured && configured !== "" ? configured : path5.join(homedir(), ".codex-mcp", "state");
|
|
3165
|
+
}
|
|
3166
|
+
function probeCodexAuth(info) {
|
|
3167
|
+
if (isCodexInternalExecutable(info)) {
|
|
3168
|
+
return {
|
|
3169
|
+
ok: true,
|
|
3170
|
+
state: "unknown",
|
|
3171
|
+
detail: "Using a codex-internal executable; auth/login readiness is not probed and does not block setup readiness."
|
|
3172
|
+
};
|
|
3173
|
+
}
|
|
3174
|
+
const invocation = resolveCodexInvocation(["login", "status"], {
|
|
3175
|
+
codexCommand: info.command,
|
|
3176
|
+
codexIsPath: info.isPath
|
|
3177
|
+
});
|
|
3178
|
+
const run = spawnSync(invocation.cmd, invocation.args, {
|
|
3179
|
+
encoding: "utf8",
|
|
3180
|
+
timeout: 5e3,
|
|
3181
|
+
windowsHide: true
|
|
3182
|
+
});
|
|
3183
|
+
const combined = `${run.stdout ?? ""}
|
|
3184
|
+
${run.stderr ?? ""}`.trim();
|
|
3185
|
+
if (run.error) {
|
|
3186
|
+
return {
|
|
3187
|
+
ok: false,
|
|
3188
|
+
state: "unknown",
|
|
3189
|
+
detail: `Failed to probe auth status: ${run.error.message}`
|
|
3190
|
+
};
|
|
3191
|
+
}
|
|
3192
|
+
const state = classifyAuthResult(run.status, combined);
|
|
3193
|
+
return {
|
|
3194
|
+
ok: state !== "unauthenticated",
|
|
3195
|
+
state,
|
|
3196
|
+
detail: combined || (state === "authenticated" ? "Authenticated." : "Auth status unknown.")
|
|
3197
|
+
};
|
|
3198
|
+
}
|
|
3199
|
+
async function executeCodexSetup(input, serverCwd) {
|
|
3200
|
+
const cwd = input?.cwd && input.cwd.trim() !== "" ? input.cwd : serverCwd;
|
|
3201
|
+
const warnings = [];
|
|
3202
|
+
const nextSteps = [];
|
|
3203
|
+
let executable;
|
|
3204
|
+
let auth;
|
|
3205
|
+
let clientMode;
|
|
3206
|
+
let internalExecutable = false;
|
|
3207
|
+
try {
|
|
3208
|
+
const info = resolveDefaultCodexExecutable();
|
|
3209
|
+
const available = info.source !== "default";
|
|
3210
|
+
internalExecutable = isCodexInternalExecutable(info);
|
|
3211
|
+
executable = {
|
|
3212
|
+
ok: available,
|
|
3213
|
+
source: info.source,
|
|
3214
|
+
command: info.command,
|
|
3215
|
+
isPath: info.isPath,
|
|
3216
|
+
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}.`
|
|
3217
|
+
};
|
|
3218
|
+
if (available) {
|
|
3219
|
+
auth = probeCodexAuth(info);
|
|
3220
|
+
clientMode = await detectClientMode(info.command, info.isPath);
|
|
3221
|
+
} else {
|
|
3222
|
+
auth = {
|
|
3223
|
+
ok: false,
|
|
3224
|
+
state: "unknown",
|
|
3225
|
+
detail: "Auth status not checked because no codex executable was detected."
|
|
3226
|
+
};
|
|
3227
|
+
}
|
|
3228
|
+
} catch (err) {
|
|
3229
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3230
|
+
executable = {
|
|
3231
|
+
ok: false,
|
|
3232
|
+
source: "error",
|
|
3233
|
+
detail: message
|
|
3234
|
+
};
|
|
3235
|
+
auth = {
|
|
3236
|
+
ok: false,
|
|
3237
|
+
state: "unknown",
|
|
3238
|
+
detail: "Auth status not checked because executable resolution failed."
|
|
3239
|
+
};
|
|
3240
|
+
}
|
|
3241
|
+
const projectContext = {
|
|
3242
|
+
hasUserConfig: existsSync5(path5.join(homedir(), ".codex", "config.toml")),
|
|
3243
|
+
hasProjectConfig: existsSync5(path5.join(cwd, ".codex", "config.toml"))
|
|
3244
|
+
};
|
|
3245
|
+
if (!executable.ok) {
|
|
3246
|
+
warnings.push(executable.detail);
|
|
3247
|
+
nextSteps.push(
|
|
3248
|
+
"Install Codex or fix CODEX_MCP_COMMAND / CODEX_MCP_PATH so the executable can be resolved."
|
|
3249
|
+
);
|
|
3250
|
+
}
|
|
3251
|
+
if (auth.state === "unauthenticated") {
|
|
3252
|
+
warnings.push(auth.detail);
|
|
3253
|
+
nextSteps.push("Run `codex login` and rerun `codex_setup`.");
|
|
3254
|
+
} else if (auth.state === "unknown" && !internalExecutable) {
|
|
3255
|
+
warnings.push(auth.detail);
|
|
3256
|
+
nextSteps.push(
|
|
3257
|
+
"Verify Codex authentication explicitly (for example with `codex login status`) before relying on this environment."
|
|
3258
|
+
);
|
|
3259
|
+
}
|
|
3260
|
+
if (!projectContext.hasUserConfig && !projectContext.hasProjectConfig) {
|
|
3261
|
+
warnings.push("No Codex config.toml was found in ~/.codex or this project.");
|
|
3262
|
+
}
|
|
3263
|
+
if (clientMode === "exec") {
|
|
3264
|
+
warnings.push(
|
|
3265
|
+
"Codex app-server support was not detected; codex-mcp would run in exec fallback mode with fewer capabilities."
|
|
3266
|
+
);
|
|
3267
|
+
}
|
|
3268
|
+
return {
|
|
3269
|
+
ready: executable.ok && auth.ok,
|
|
3270
|
+
cwd,
|
|
3271
|
+
executable,
|
|
3272
|
+
auth,
|
|
3273
|
+
runtime: {
|
|
3274
|
+
sameMachineRequired: true,
|
|
3275
|
+
clientMode,
|
|
3276
|
+
stateDir: resolveCodexStateDir()
|
|
3277
|
+
},
|
|
3278
|
+
projectContext,
|
|
3279
|
+
warnings,
|
|
3280
|
+
nextSteps
|
|
3281
|
+
};
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3284
|
+
// src/resources/register-resources.ts
|
|
3285
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
2684
3286
|
|
|
2685
3287
|
// src/utils/stdio-guard.ts
|
|
2686
3288
|
var STDIO_MODES = ["auto", "strict", "off"];
|
|
@@ -2779,7 +3381,8 @@ var RESOURCE_URIS = {
|
|
|
2779
3381
|
config: `${RESOURCE_SCHEME}:///config`,
|
|
2780
3382
|
gotchas: `${RESOURCE_SCHEME}:///gotchas`,
|
|
2781
3383
|
quickstart: `${RESOURCE_SCHEME}:///quickstart`,
|
|
2782
|
-
errors: `${RESOURCE_SCHEME}:///errors
|
|
3384
|
+
errors: `${RESOURCE_SCHEME}:///errors`,
|
|
3385
|
+
delegationGuide: `${RESOURCE_SCHEME}:///delegation-guide`
|
|
2783
3386
|
};
|
|
2784
3387
|
var RESOURCE_CATALOG = [
|
|
2785
3388
|
{
|
|
@@ -2823,6 +3426,13 @@ var RESOURCE_CATALOG = [
|
|
|
2823
3426
|
title: "Errors",
|
|
2824
3427
|
description: "Error code reference and recovery hints",
|
|
2825
3428
|
mimeType: "text/markdown"
|
|
3429
|
+
},
|
|
3430
|
+
{
|
|
3431
|
+
key: "delegationGuide",
|
|
3432
|
+
name: "delegation_guide",
|
|
3433
|
+
title: "Delegation Guide",
|
|
3434
|
+
description: "Best practices for delegating tasks to Codex",
|
|
3435
|
+
mimeType: "text/markdown"
|
|
2826
3436
|
}
|
|
2827
3437
|
];
|
|
2828
3438
|
var ERROR_CODE_HINTS = {
|
|
@@ -2854,7 +3464,7 @@ function asTextResource(uri, text, mimeType) {
|
|
|
2854
3464
|
function detectCodexCliVersion(timeoutMs = 1500) {
|
|
2855
3465
|
try {
|
|
2856
3466
|
const executable = getDefaultCodexExecutable();
|
|
2857
|
-
const run =
|
|
3467
|
+
const run = spawnSync2(executable.command, ["--version"], {
|
|
2858
3468
|
encoding: "utf8",
|
|
2859
3469
|
timeout: timeoutMs,
|
|
2860
3470
|
windowsHide: true
|
|
@@ -2996,6 +3606,8 @@ function buildQuickstartText() {
|
|
|
2996
3606
|
return [
|
|
2997
3607
|
"## Minimal flow",
|
|
2998
3608
|
"",
|
|
3609
|
+
"0. Optional but recommended: run `codex_setup` first to verify the local Codex CLI, login state, and backend mode.",
|
|
3610
|
+
"",
|
|
2999
3611
|
"1. Start session (`codex`)",
|
|
3000
3612
|
"",
|
|
3001
3613
|
"```json",
|
|
@@ -3094,25 +3706,83 @@ function buildErrorsText() {
|
|
|
3094
3706
|
lines.push("");
|
|
3095
3707
|
return lines.join("\n");
|
|
3096
3708
|
}
|
|
3097
|
-
function
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3709
|
+
function buildDelegationGuideText() {
|
|
3710
|
+
return [
|
|
3711
|
+
"# Codex Delegation Guide",
|
|
3712
|
+
"",
|
|
3713
|
+
"## When to delegate",
|
|
3714
|
+
"- Bug investigation or fix that benefits from a second opinion",
|
|
3715
|
+
"- Code review (use read-only sandbox)",
|
|
3716
|
+
"- Refactoring or migration tasks with clear scope",
|
|
3717
|
+
"- Tasks where the calling agent is stuck or wants parallel work",
|
|
3718
|
+
"",
|
|
3719
|
+
"## Permission combinations by task type",
|
|
3720
|
+
"",
|
|
3721
|
+
"| Task | approvalPolicy | sandbox | Notes |",
|
|
3722
|
+
"|------|---------------|---------|-------|",
|
|
3723
|
+
"| Code review / analysis | `never` | `read-only` | Safe: sandbox blocks writes, no approval needed |",
|
|
3724
|
+
"| Quick bug fix | `on-failure` | `workspace-write` | Auto-approves unless error; good with `waitForResult` |",
|
|
3725
|
+
"| Feature implementation | `on-failure` | `workspace-write` | Async mode recommended for longer tasks |",
|
|
3726
|
+
"| Sensitive refactor | `on-request` | `workspace-write` | Codex asks before each action; requires active polling |",
|
|
3727
|
+
"| Full autonomy | `never` | `workspace-write` | No guardrails \u2014 only for well-scoped, trusted tasks |",
|
|
3728
|
+
"| Network access needed | `on-failure` | `danger-full-access` | Rare; avoid unless genuinely required |",
|
|
3729
|
+
"",
|
|
3730
|
+
"**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.",
|
|
3731
|
+
"",
|
|
3732
|
+
"## Quick mode: `waitForResult`",
|
|
3733
|
+
"For short tasks (< 2 min), set `advanced.waitForResult` to get the final result in a single tool call:",
|
|
3734
|
+
"```json",
|
|
3735
|
+
'{ "prompt": "Fix the null check in auth.ts", "approvalPolicy": "on-failure", "sandbox": "workspace-write", "effort": "medium", "advanced": { "waitForResult": 120000 } }',
|
|
3736
|
+
"```",
|
|
3737
|
+
"If the task finishes within the timeout, the result is returned directly. Otherwise the response falls back to polling metadata (including `sessionId` and `threadId`).",
|
|
3738
|
+
"",
|
|
3739
|
+
"**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'`.",
|
|
3740
|
+
"",
|
|
3741
|
+
"## Async mode: poll loop",
|
|
3742
|
+
'For long tasks, omit `waitForResult`. Use `codex_check(action="poll", pollOptions={ waitMs: 30000 })` with long-polling to avoid empty polls.',
|
|
3743
|
+
"",
|
|
3744
|
+
"## Effort selection",
|
|
3745
|
+
"- `low` (default): quick questions, lookups, simple edits",
|
|
3746
|
+
"- `medium`: multi-file changes, moderate reasoning",
|
|
3747
|
+
"- `high`/`xhigh`: complex architecture decisions, large refactors",
|
|
3748
|
+
"",
|
|
3749
|
+
"## Troubleshooting",
|
|
3750
|
+
"",
|
|
3751
|
+
"**Tool call hangs:** Likely `waitForResult` + approval conflict; cancel and retry with `on-failure`/`never`. See `codex-mcp:///gotchas`.",
|
|
3752
|
+
"",
|
|
3753
|
+
"**Empty polls:** Use `pollOptions.waitMs` for long-polling; stop when status is terminal. See `codex-mcp:///gotchas`.",
|
|
3754
|
+
"",
|
|
3755
|
+
"**Session not found after restart:** Previously-running sessions surface as `status: 'error'` with restart reason. See `codex-mcp:///gotchas`.",
|
|
3756
|
+
"",
|
|
3757
|
+
"**Approval timeout:** Default is 60s; infrequent polling causes silent auto-decline. See `codex-mcp:///gotchas`.",
|
|
3758
|
+
"",
|
|
3759
|
+
"## Security notes",
|
|
3760
|
+
"- `sandbox: 'read-only'` is the strongest isolation \u2014 blocks all writes regardless of approval policy",
|
|
3761
|
+
"- `approvalPolicy: 'never'` + `sandbox: 'workspace-write'` gives the agent full write access with no human oversight \u2014 use only for well-defined, low-risk tasks",
|
|
3762
|
+
"- `danger-full-access` allows network and system access \u2014 treat as root-equivalent",
|
|
3763
|
+
"- Persisted session data (events, results) may contain code snippets and file paths \u2014 stored in `~/.codex-mcp/state/`",
|
|
3764
|
+
""
|
|
3765
|
+
].join("\n");
|
|
3766
|
+
}
|
|
3767
|
+
function buildCompatReport(deps, codexCliVersion) {
|
|
3768
|
+
const runtimeWarnings = [];
|
|
3769
|
+
if (!codexCliVersion) {
|
|
3770
|
+
runtimeWarnings.push("Unable to detect local codex CLI version from PATH.");
|
|
3771
|
+
}
|
|
3772
|
+
return JSON.stringify(
|
|
3773
|
+
{
|
|
3774
|
+
schemaVersion: "1.0.0",
|
|
3775
|
+
features: {
|
|
3776
|
+
respondPermission: true,
|
|
3777
|
+
respondApprovalAlias: false,
|
|
3778
|
+
respondUserInput: true,
|
|
3779
|
+
sessionInterrupt: true,
|
|
3780
|
+
responseModeMinimal: true,
|
|
3781
|
+
responseModeDeltaCompact: true,
|
|
3782
|
+
responseModeFull: true,
|
|
3783
|
+
pollOptionsBase: true,
|
|
3784
|
+
maxBytesTruncation: true,
|
|
3785
|
+
compatWarnings: true,
|
|
3116
3786
|
diskResume: false,
|
|
3117
3787
|
dynamicTools: false,
|
|
3118
3788
|
toolPermissionControl: false
|
|
@@ -3128,7 +3798,7 @@ function buildCompatReport(deps, codexCliVersion) {
|
|
|
3128
3798
|
}
|
|
3129
3799
|
},
|
|
3130
3800
|
toolCounts: {
|
|
3131
|
-
core:
|
|
3801
|
+
core: 5
|
|
3132
3802
|
},
|
|
3133
3803
|
runtimeWarnings,
|
|
3134
3804
|
detectedMismatches: [],
|
|
@@ -3258,10 +3928,22 @@ function registerResources(server, deps) {
|
|
|
3258
3928
|
},
|
|
3259
3929
|
() => asTextResource(errorsUri, buildErrorsText(), "text/markdown")
|
|
3260
3930
|
);
|
|
3931
|
+
const delegationGuideMeta = byKey.get("delegationGuide");
|
|
3932
|
+
const delegationGuideUri = new URL(RESOURCE_URIS.delegationGuide);
|
|
3933
|
+
server.registerResource(
|
|
3934
|
+
delegationGuideMeta.name,
|
|
3935
|
+
delegationGuideUri.toString(),
|
|
3936
|
+
{
|
|
3937
|
+
title: delegationGuideMeta.title,
|
|
3938
|
+
description: delegationGuideMeta.description,
|
|
3939
|
+
mimeType: delegationGuideMeta.mimeType
|
|
3940
|
+
},
|
|
3941
|
+
() => asTextResource(delegationGuideUri, buildDelegationGuideText(), "text/markdown")
|
|
3942
|
+
);
|
|
3261
3943
|
}
|
|
3262
3944
|
|
|
3263
3945
|
// src/server.ts
|
|
3264
|
-
var SERVER_VERSION = true ? "2.1.
|
|
3946
|
+
var SERVER_VERSION = true ? "2.1.6" : "0.0.0-dev";
|
|
3265
3947
|
function formatErrorMessage(err) {
|
|
3266
3948
|
const message = err instanceof Error ? err.message : String(err);
|
|
3267
3949
|
const m = /^Error \[([A-Z_]+)\]:\s*(.*)$/.exec(message);
|
|
@@ -3307,20 +3989,63 @@ function createServer(serverCwd, options) {
|
|
|
3307
3989
|
error: z.string().optional(),
|
|
3308
3990
|
isError: z.boolean().optional()
|
|
3309
3991
|
};
|
|
3992
|
+
const executionInfoSchema = z.object({
|
|
3993
|
+
requested: z.enum(["background", "foreground"]),
|
|
3994
|
+
effective: z.enum(["background", "foreground"]),
|
|
3995
|
+
waitForResultMs: z.number().int().positive().optional(),
|
|
3996
|
+
fallbackReason: z.enum(["wait_for_result_timeout", "interactive_poll_required"]).optional()
|
|
3997
|
+
});
|
|
3998
|
+
const interactionStateSchema = z.enum(["working", "waiting_input", "finished"]);
|
|
3999
|
+
const nextActionSchema = z.enum(["poll", "respond_permission", "respond_user_input", "none"]);
|
|
4000
|
+
const setupResultShape = {
|
|
4001
|
+
ready: z.boolean(),
|
|
4002
|
+
cwd: z.string(),
|
|
4003
|
+
executable: z.object({
|
|
4004
|
+
ok: z.boolean(),
|
|
4005
|
+
source: z.string(),
|
|
4006
|
+
command: z.string().optional(),
|
|
4007
|
+
isPath: z.boolean().optional(),
|
|
4008
|
+
detail: z.string()
|
|
4009
|
+
}),
|
|
4010
|
+
auth: z.object({
|
|
4011
|
+
ok: z.boolean(),
|
|
4012
|
+
state: z.enum(["authenticated", "unauthenticated", "unknown"]),
|
|
4013
|
+
detail: z.string()
|
|
4014
|
+
}),
|
|
4015
|
+
runtime: z.object({
|
|
4016
|
+
sameMachineRequired: z.boolean(),
|
|
4017
|
+
clientMode: z.enum(["app-server", "exec"]).optional(),
|
|
4018
|
+
stateDir: z.string()
|
|
4019
|
+
}),
|
|
4020
|
+
projectContext: z.object({
|
|
4021
|
+
hasUserConfig: z.boolean(),
|
|
4022
|
+
hasProjectConfig: z.boolean()
|
|
4023
|
+
}),
|
|
4024
|
+
warnings: z.array(z.string()),
|
|
4025
|
+
nextSteps: z.array(z.string())
|
|
4026
|
+
};
|
|
3310
4027
|
const sessionStartOutputShape = {
|
|
3311
4028
|
sessionId: z.string().optional(),
|
|
3312
4029
|
threadId: z.string().optional(),
|
|
3313
|
-
status: z.enum(["running", "idle"]).optional(),
|
|
4030
|
+
status: z.enum(["running", "waiting_approval", "idle", "error", "cancelled"]).optional(),
|
|
3314
4031
|
pollInterval: z.number().int().optional().describe(
|
|
3315
4032
|
"Recommended minimum delay before next poll (ms): running >=120000, waiting_approval ~=1000."
|
|
3316
4033
|
),
|
|
4034
|
+
result: z.unknown().optional().describe("Final result when waitForResult is set and session completed."),
|
|
4035
|
+
completedAt: z.string().optional().describe("ISO timestamp when the session completed (only when waitForResult succeeded)."),
|
|
4036
|
+
execution: executionInfoSchema.optional(),
|
|
4037
|
+
interactionState: interactionStateSchema.optional(),
|
|
4038
|
+
recommendedNextAction: nextActionSchema.optional(),
|
|
3317
4039
|
...errorOutputShape
|
|
3318
4040
|
};
|
|
3319
4041
|
const codexCheckPollOptionsSchema = z.object({
|
|
3320
4042
|
includeEvents: z.boolean().optional().describe("Default: true. Include events[] in response."),
|
|
3321
4043
|
includeActions: z.boolean().optional().describe("Default: true. Include actions[] in response."),
|
|
3322
4044
|
includeResult: z.boolean().optional().describe("Default: true. Include result in response."),
|
|
3323
|
-
maxBytes: z.number().int().positive().optional().describe("Default: unlimited. Best-effort response payload cap in bytes.")
|
|
4045
|
+
maxBytes: z.number().int().positive().optional().describe("Default: unlimited. Best-effort response payload cap in bytes."),
|
|
4046
|
+
waitMs: z.number().int().nonnegative().optional().describe(
|
|
4047
|
+
"Long-poll: block up to this many ms for new events (max 120000). Omit or 0 for immediate return."
|
|
4048
|
+
)
|
|
3324
4049
|
}).optional().describe("Optional poll shaping controls.");
|
|
3325
4050
|
const codexCheckInputSchema = z.object({
|
|
3326
4051
|
action: z.enum(CHECK_ACTIONS),
|
|
@@ -3350,10 +4075,10 @@ function createServer(serverCwd, options) {
|
|
|
3350
4075
|
})
|
|
3351
4076
|
).optional().describe("question-id -> answers map (id from actions[] user_input request).")
|
|
3352
4077
|
}).superRefine((value, ctx) => {
|
|
3353
|
-
const addIssue = (
|
|
4078
|
+
const addIssue = (path6, message) => {
|
|
3354
4079
|
ctx.addIssue({
|
|
3355
4080
|
code: z.ZodIssueCode.custom,
|
|
3356
|
-
path: [
|
|
4081
|
+
path: [path6],
|
|
3357
4082
|
message
|
|
3358
4083
|
});
|
|
3359
4084
|
};
|
|
@@ -3480,7 +4205,10 @@ function createServer(serverCwd, options) {
|
|
|
3480
4205
|
ephemeral: z.boolean().optional().describe("Do not persist thread (default: false)."),
|
|
3481
4206
|
outputSchema: z.record(z.string(), z.unknown()).optional().describe("Structured output schema."),
|
|
3482
4207
|
images: z.array(z.string()).optional().describe("Local image paths."),
|
|
3483
|
-
approvalTimeoutMs: z.number().int().positive().default(DEFAULT_APPROVAL_TIMEOUT_MS).optional().describe(`Auto-decline timeout in ms (default: ${DEFAULT_APPROVAL_TIMEOUT_MS})`)
|
|
4208
|
+
approvalTimeoutMs: z.number().int().positive().default(DEFAULT_APPROVAL_TIMEOUT_MS).optional().describe(`Auto-decline timeout in ms (default: ${DEFAULT_APPROVAL_TIMEOUT_MS})`),
|
|
4209
|
+
waitForResult: z.number().int().positive().max(3e5).optional().describe(
|
|
4210
|
+
"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."
|
|
4211
|
+
)
|
|
3484
4212
|
}).optional().describe("Advanced settings.")
|
|
3485
4213
|
},
|
|
3486
4214
|
outputSchema: sessionStartOutputShape,
|
|
@@ -3492,9 +4220,9 @@ function createServer(serverCwd, options) {
|
|
|
3492
4220
|
openWorldHint: true
|
|
3493
4221
|
}
|
|
3494
4222
|
},
|
|
3495
|
-
async (args) => {
|
|
4223
|
+
async (args, extra) => {
|
|
3496
4224
|
try {
|
|
3497
|
-
const result = await executeCodex(args, sessionManager, serverCwd);
|
|
4225
|
+
const result = await executeCodex(args, sessionManager, serverCwd, extra.signal);
|
|
3498
4226
|
return {
|
|
3499
4227
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
3500
4228
|
structuredContent: toStructuredContent(result),
|
|
@@ -3525,7 +4253,10 @@ function createServer(serverCwd, options) {
|
|
|
3525
4253
|
personality: z.enum(PERSONALITIES).optional().describe("Override personality."),
|
|
3526
4254
|
sandbox: z.enum(SANDBOX_MODES).optional().describe("Override sandbox."),
|
|
3527
4255
|
cwd: z.string().optional().describe("Override cwd."),
|
|
3528
|
-
outputSchema: z.record(z.string(), z.unknown()).optional().describe("Structured output schema override (top-level in codex_reply).")
|
|
4256
|
+
outputSchema: z.record(z.string(), z.unknown()).optional().describe("Structured output schema override (top-level in codex_reply)."),
|
|
4257
|
+
waitForResult: z.number().int().positive().max(3e5).optional().describe(
|
|
4258
|
+
"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."
|
|
4259
|
+
)
|
|
3529
4260
|
},
|
|
3530
4261
|
outputSchema: sessionStartOutputShape,
|
|
3531
4262
|
annotations: {
|
|
@@ -3536,9 +4267,9 @@ function createServer(serverCwd, options) {
|
|
|
3536
4267
|
openWorldHint: true
|
|
3537
4268
|
}
|
|
3538
4269
|
},
|
|
3539
|
-
async (args) => {
|
|
4270
|
+
async (args, extra) => {
|
|
3540
4271
|
try {
|
|
3541
|
-
const result = await executeCodexReply(args, sessionManager);
|
|
4272
|
+
const result = await executeCodexReply(args, sessionManager, extra.signal);
|
|
3542
4273
|
return {
|
|
3543
4274
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
3544
4275
|
structuredContent: toStructuredContent(result),
|
|
@@ -3554,6 +4285,32 @@ function createServer(serverCwd, options) {
|
|
|
3554
4285
|
}
|
|
3555
4286
|
}
|
|
3556
4287
|
);
|
|
4288
|
+
server.registerTool(
|
|
4289
|
+
"codex_setup",
|
|
4290
|
+
{
|
|
4291
|
+
title: "Codex Setup",
|
|
4292
|
+
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.",
|
|
4293
|
+
inputSchema: {
|
|
4294
|
+
cwd: z.string().optional().describe("Optional cwd to inspect for project-local Codex config. Default: server cwd.")
|
|
4295
|
+
},
|
|
4296
|
+
outputSchema: setupResultShape,
|
|
4297
|
+
annotations: {
|
|
4298
|
+
title: "Codex Setup",
|
|
4299
|
+
readOnlyHint: true,
|
|
4300
|
+
destructiveHint: false,
|
|
4301
|
+
idempotentHint: true,
|
|
4302
|
+
openWorldHint: false
|
|
4303
|
+
}
|
|
4304
|
+
},
|
|
4305
|
+
async (args) => {
|
|
4306
|
+
const result = await executeCodexSetup(args, serverCwd);
|
|
4307
|
+
return {
|
|
4308
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
4309
|
+
structuredContent: toStructuredContent(result),
|
|
4310
|
+
isError: false
|
|
4311
|
+
};
|
|
4312
|
+
}
|
|
4313
|
+
);
|
|
3557
4314
|
server.registerTool(
|
|
3558
4315
|
"codex_session",
|
|
3559
4316
|
{
|
|
@@ -3625,23 +4382,11 @@ function createServer(serverCwd, options) {
|
|
|
3625
4382
|
"codex_check",
|
|
3626
4383
|
{
|
|
3627
4384
|
title: "Poll & Respond",
|
|
3628
|
-
description: `Poll session for events or respond to approval/input requests.
|
|
3629
|
-
|
|
3630
|
-
POLLING FREQUENCY: Do NOT poll every turn. Codex tasks take minutes, not seconds.
|
|
3631
|
-
- Treat pollInterval as a minimum hint, not a fixed schedule.
|
|
3632
|
-
- "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.
|
|
3633
|
-
- "waiting_approval": poll about every 1000ms and respond quickly to actions[].
|
|
3634
|
-
- When status is "idle"/"error"/"cancelled": stop polling, the session is done.
|
|
3635
|
-
- Adapt interval based on task complexity and whether the previous poll returned new events.
|
|
4385
|
+
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.
|
|
3636
4386
|
|
|
3637
4387
|
poll: events since cursor. Default maxEvents=${POLL_DEFAULT_MAX_EVENTS}.
|
|
3638
|
-
|
|
3639
4388
|
respond_permission: approval decision. Default maxEvents=${RESPOND_DEFAULT_MAX_EVENTS} (compact ACK).
|
|
3640
|
-
|
|
3641
|
-
respond_user_input: user-input answers. Default maxEvents=${RESPOND_DEFAULT_MAX_EVENTS} (compact ACK).
|
|
3642
|
-
|
|
3643
|
-
events[].type is coarse-grained; details are in events[].data.method.
|
|
3644
|
-
cursor omitted => use session last cursor. cursorResetTo => reset and continue.`,
|
|
4389
|
+
respond_user_input: user-input answers. Default maxEvents=${RESPOND_DEFAULT_MAX_EVENTS} (compact ACK).`,
|
|
3645
4390
|
inputSchema: codexCheckInputSchema,
|
|
3646
4391
|
outputSchema: {
|
|
3647
4392
|
sessionId: z.string().optional(),
|
|
@@ -3649,6 +4394,8 @@ cursor omitted => use session last cursor. cursorResetTo => reset and continue.`
|
|
|
3649
4394
|
pollInterval: z.number().int().optional().describe(
|
|
3650
4395
|
"Recommended minimum delay before next poll (ms): running >=120000, waiting_approval ~=1000."
|
|
3651
4396
|
),
|
|
4397
|
+
interactionState: interactionStateSchema.optional(),
|
|
4398
|
+
recommendedNextAction: nextActionSchema.optional(),
|
|
3652
4399
|
events: z.array(
|
|
3653
4400
|
z.object({
|
|
3654
4401
|
id: z.number().int(),
|
|
@@ -3703,9 +4450,9 @@ cursor omitted => use session last cursor. cursorResetTo => reset and continue.`
|
|
|
3703
4450
|
openWorldHint: false
|
|
3704
4451
|
}
|
|
3705
4452
|
},
|
|
3706
|
-
async (args) => {
|
|
4453
|
+
async (args, extra) => {
|
|
3707
4454
|
try {
|
|
3708
|
-
const result = executeCodexCheck(args, sessionManager);
|
|
4455
|
+
const result = await executeCodexCheck(args, sessionManager, extra.signal);
|
|
3709
4456
|
const isError = typeof result.isError === "boolean" ? result.isError : false;
|
|
3710
4457
|
return {
|
|
3711
4458
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
@@ -3727,81 +4474,7 @@ cursor omitted => use session last cursor. cursorResetTo => reset and continue.`
|
|
|
3727
4474
|
sessionManager.destroy();
|
|
3728
4475
|
await originalClose();
|
|
3729
4476
|
};
|
|
3730
|
-
return server;
|
|
3731
|
-
}
|
|
3732
|
-
|
|
3733
|
-
// src/app-server/detect.ts
|
|
3734
|
-
import { spawn as spawn2 } from "child_process";
|
|
3735
|
-
var DETECTION_TIMEOUT_MS = 5e3;
|
|
3736
|
-
async function detectClientMode(codexCommand, codexIsPath = false, env = process.env) {
|
|
3737
|
-
const override = env.CODEX_MCP_MODE;
|
|
3738
|
-
if (override === "app-server" || override === "exec") {
|
|
3739
|
-
return override;
|
|
3740
|
-
}
|
|
3741
|
-
try {
|
|
3742
|
-
const supported = await probeAppServer(codexCommand, codexIsPath, env);
|
|
3743
|
-
return supported ? "app-server" : "exec";
|
|
3744
|
-
} catch {
|
|
3745
|
-
return "exec";
|
|
3746
|
-
}
|
|
3747
|
-
}
|
|
3748
|
-
function probeAppServer(codexCommand, codexIsPath, env) {
|
|
3749
|
-
return new Promise((resolve) => {
|
|
3750
|
-
const invocation = resolveCodexInvocation(["app-server", "--help"], {
|
|
3751
|
-
env,
|
|
3752
|
-
codexCommand,
|
|
3753
|
-
codexIsPath
|
|
3754
|
-
});
|
|
3755
|
-
let settled = false;
|
|
3756
|
-
const settle = (result) => {
|
|
3757
|
-
if (settled) return;
|
|
3758
|
-
settled = true;
|
|
3759
|
-
clearTimeout(timer);
|
|
3760
|
-
resolve(result);
|
|
3761
|
-
};
|
|
3762
|
-
let stdout = "";
|
|
3763
|
-
let stderr = "";
|
|
3764
|
-
const proc = spawn2(invocation.cmd, invocation.args, {
|
|
3765
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
3766
|
-
env,
|
|
3767
|
-
windowsHide: true
|
|
3768
|
-
});
|
|
3769
|
-
proc.stdout?.on("data", (chunk) => {
|
|
3770
|
-
stdout += chunk.toString();
|
|
3771
|
-
});
|
|
3772
|
-
proc.stderr?.on("data", (chunk) => {
|
|
3773
|
-
stderr += chunk.toString();
|
|
3774
|
-
});
|
|
3775
|
-
proc.on("error", () => {
|
|
3776
|
-
settle(false);
|
|
3777
|
-
});
|
|
3778
|
-
proc.on("exit", (code) => {
|
|
3779
|
-
if (code === 0) {
|
|
3780
|
-
settle(true);
|
|
3781
|
-
} else {
|
|
3782
|
-
const combined = (stdout + stderr).toLowerCase();
|
|
3783
|
-
const isUnknown = combined.includes("unknown") || combined.includes("unrecognized") || combined.includes("not found") || combined.includes("no such subcommand");
|
|
3784
|
-
settle(!isUnknown && combined.includes("app-server"));
|
|
3785
|
-
}
|
|
3786
|
-
});
|
|
3787
|
-
const timer = setTimeout(() => {
|
|
3788
|
-
try {
|
|
3789
|
-
proc.kill("SIGTERM");
|
|
3790
|
-
} catch {
|
|
3791
|
-
}
|
|
3792
|
-
const forceKill = setTimeout(() => {
|
|
3793
|
-
try {
|
|
3794
|
-
if (!proc.killed && proc.exitCode === null) {
|
|
3795
|
-
proc.kill("SIGKILL");
|
|
3796
|
-
}
|
|
3797
|
-
} catch {
|
|
3798
|
-
}
|
|
3799
|
-
}, 2e3);
|
|
3800
|
-
if (forceKill.unref) forceKill.unref();
|
|
3801
|
-
settle(false);
|
|
3802
|
-
}, DETECTION_TIMEOUT_MS);
|
|
3803
|
-
if (timer.unref) timer.unref();
|
|
3804
|
-
});
|
|
4477
|
+
return { server, sessionManager };
|
|
3805
4478
|
}
|
|
3806
4479
|
|
|
3807
4480
|
// src/app-server/exec-client.ts
|
|
@@ -3916,6 +4589,9 @@ var ExecClient = class extends EventEmitter2 {
|
|
|
3916
4589
|
get supportsTurnOverrides() {
|
|
3917
4590
|
return this.turnCount <= 1 || this.realThreadId == null;
|
|
3918
4591
|
}
|
|
4592
|
+
get childPid() {
|
|
4593
|
+
return this.process?.pid ?? void 0;
|
|
4594
|
+
}
|
|
3919
4595
|
async start(opts) {
|
|
3920
4596
|
if (this._destroyed) throw new Error("Client destroyed");
|
|
3921
4597
|
this.spawnOpts = opts;
|
|
@@ -4349,7 +5025,625 @@ var ExecClient = class extends EventEmitter2 {
|
|
|
4349
5025
|
}
|
|
4350
5026
|
};
|
|
4351
5027
|
|
|
5028
|
+
// src/session/persistence.ts
|
|
5029
|
+
import { join as join5 } from "path";
|
|
5030
|
+
import { mkdirSync as mkdirSync4, existsSync as existsSync7 } from "fs";
|
|
5031
|
+
import { homedir as homedir2 } from "os";
|
|
5032
|
+
|
|
5033
|
+
// src/persistence/atomic-writer.ts
|
|
5034
|
+
import { writeFileSync as writeFileSync2, renameSync, mkdirSync, unlinkSync } from "fs";
|
|
5035
|
+
import { dirname, join as join2 } from "path";
|
|
5036
|
+
import { randomBytes } from "crypto";
|
|
5037
|
+
function atomicWriteJson(filePath, data) {
|
|
5038
|
+
const dir = dirname(filePath);
|
|
5039
|
+
mkdirSync(dir, { recursive: true });
|
|
5040
|
+
const tmpPath = join2(dir, `.tmp-${randomBytes(6).toString("hex")}`);
|
|
5041
|
+
try {
|
|
5042
|
+
writeFileSync2(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
5043
|
+
renameSync(tmpPath, filePath);
|
|
5044
|
+
} catch (err) {
|
|
5045
|
+
try {
|
|
5046
|
+
unlinkSync(tmpPath);
|
|
5047
|
+
} catch {
|
|
5048
|
+
}
|
|
5049
|
+
throw err;
|
|
5050
|
+
}
|
|
5051
|
+
}
|
|
5052
|
+
|
|
5053
|
+
// src/persistence/lockfile.ts
|
|
5054
|
+
import {
|
|
5055
|
+
openSync,
|
|
5056
|
+
closeSync,
|
|
5057
|
+
readFileSync as readFileSync2,
|
|
5058
|
+
writeFileSync as writeFileSync3,
|
|
5059
|
+
unlinkSync as unlinkSync2,
|
|
5060
|
+
mkdirSync as mkdirSync2,
|
|
5061
|
+
constants as constants2
|
|
5062
|
+
} from "fs";
|
|
5063
|
+
import { dirname as dirname2 } from "path";
|
|
5064
|
+
function isPidAlive(pid) {
|
|
5065
|
+
try {
|
|
5066
|
+
process.kill(pid, 0);
|
|
5067
|
+
return true;
|
|
5068
|
+
} catch {
|
|
5069
|
+
return false;
|
|
5070
|
+
}
|
|
5071
|
+
}
|
|
5072
|
+
function acquireLock(lockPath) {
|
|
5073
|
+
mkdirSync2(dirname2(lockPath), { recursive: true });
|
|
5074
|
+
try {
|
|
5075
|
+
const raw = readFileSync2(lockPath, "utf-8");
|
|
5076
|
+
const existing = JSON.parse(raw);
|
|
5077
|
+
if (isPidAlive(existing.pid) && existing.pid !== process.pid) {
|
|
5078
|
+
throw new Error(
|
|
5079
|
+
`STATE_DIR is locked by another process (pid=${existing.pid}, started=${existing.startedAt}). If this is stale, delete ${lockPath}`
|
|
5080
|
+
);
|
|
5081
|
+
}
|
|
5082
|
+
unlinkSync2(lockPath);
|
|
5083
|
+
} catch (err) {
|
|
5084
|
+
if (err.code !== "ENOENT") {
|
|
5085
|
+
if (err.message?.includes("STATE_DIR is locked")) throw err;
|
|
5086
|
+
console.error(
|
|
5087
|
+
`[lockfile] Existing lock at ${lockPath} is unreadable \u2014 will attempt O_EXCL create`
|
|
5088
|
+
);
|
|
5089
|
+
}
|
|
5090
|
+
}
|
|
5091
|
+
const content = {
|
|
5092
|
+
pid: process.pid,
|
|
5093
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5094
|
+
};
|
|
5095
|
+
let fd;
|
|
5096
|
+
try {
|
|
5097
|
+
fd = openSync(lockPath, constants2.O_WRONLY | constants2.O_CREAT | constants2.O_EXCL);
|
|
5098
|
+
writeFileSync3(fd, JSON.stringify(content) + "\n", "utf-8");
|
|
5099
|
+
closeSync(fd);
|
|
5100
|
+
fd = void 0;
|
|
5101
|
+
} catch (err) {
|
|
5102
|
+
if (fd !== void 0) {
|
|
5103
|
+
try {
|
|
5104
|
+
closeSync(fd);
|
|
5105
|
+
} catch {
|
|
5106
|
+
}
|
|
5107
|
+
}
|
|
5108
|
+
if (err.code === "EEXIST") {
|
|
5109
|
+
throw new Error(
|
|
5110
|
+
`Failed to acquire STATE_DIR lock at ${lockPath} \u2014 race with another process`
|
|
5111
|
+
);
|
|
5112
|
+
}
|
|
5113
|
+
try {
|
|
5114
|
+
unlinkSync2(lockPath);
|
|
5115
|
+
} catch {
|
|
5116
|
+
}
|
|
5117
|
+
throw err;
|
|
5118
|
+
}
|
|
5119
|
+
let released = false;
|
|
5120
|
+
return () => {
|
|
5121
|
+
if (released) return;
|
|
5122
|
+
released = true;
|
|
5123
|
+
try {
|
|
5124
|
+
unlinkSync2(lockPath);
|
|
5125
|
+
} catch {
|
|
5126
|
+
}
|
|
5127
|
+
};
|
|
5128
|
+
}
|
|
5129
|
+
|
|
5130
|
+
// src/persistence/event-log.ts
|
|
5131
|
+
import { appendFileSync, mkdirSync as mkdirSync3 } from "fs";
|
|
5132
|
+
import { dirname as dirname3 } from "path";
|
|
5133
|
+
var EventLog = class {
|
|
5134
|
+
filePath;
|
|
5135
|
+
batchIntervalMs;
|
|
5136
|
+
buffer = [];
|
|
5137
|
+
flushTimer = null;
|
|
5138
|
+
nextSeq = 0;
|
|
5139
|
+
destroyed = false;
|
|
5140
|
+
constructor(opts) {
|
|
5141
|
+
this.filePath = opts.filePath;
|
|
5142
|
+
this.batchIntervalMs = opts.batchIntervalMs ?? 100;
|
|
5143
|
+
mkdirSync3(dirname3(this.filePath), { recursive: true });
|
|
5144
|
+
}
|
|
5145
|
+
/**
|
|
5146
|
+
* Append an event to the log.
|
|
5147
|
+
* @param event - Arbitrary JSON-serializable event data
|
|
5148
|
+
* @param criticality - "critical" forces immediate flush; "normal" batches
|
|
5149
|
+
*/
|
|
5150
|
+
append(event, criticality = "normal") {
|
|
5151
|
+
if (this.destroyed) return -1;
|
|
5152
|
+
const seq = this.nextSeq++;
|
|
5153
|
+
const line = JSON.stringify({ seq, ...event }) + "\n";
|
|
5154
|
+
this.buffer.push({ line });
|
|
5155
|
+
if (criticality === "critical") {
|
|
5156
|
+
this.flushSync();
|
|
5157
|
+
} else if (!this.flushTimer) {
|
|
5158
|
+
this.flushTimer = setTimeout(() => {
|
|
5159
|
+
this.flushTimer = null;
|
|
5160
|
+
this.flushSync();
|
|
5161
|
+
}, this.batchIntervalMs);
|
|
5162
|
+
if (this.flushTimer.unref) this.flushTimer.unref();
|
|
5163
|
+
}
|
|
5164
|
+
return seq;
|
|
5165
|
+
}
|
|
5166
|
+
/** Set the next sequence number (used during recovery to continue from last known seq). */
|
|
5167
|
+
setNextSeq(seq) {
|
|
5168
|
+
this.nextSeq = seq;
|
|
5169
|
+
}
|
|
5170
|
+
/** Synchronously flush all buffered writes to disk. */
|
|
5171
|
+
flushSync() {
|
|
5172
|
+
if (this.buffer.length === 0) return;
|
|
5173
|
+
const chunk = this.buffer.map((w) => w.line).join("");
|
|
5174
|
+
this.buffer = [];
|
|
5175
|
+
if (this.flushTimer) {
|
|
5176
|
+
clearTimeout(this.flushTimer);
|
|
5177
|
+
this.flushTimer = null;
|
|
5178
|
+
}
|
|
5179
|
+
try {
|
|
5180
|
+
appendFileSync(this.filePath, chunk, "utf-8");
|
|
5181
|
+
} catch (err) {
|
|
5182
|
+
console.error(`[event-log] Failed to flush to ${this.filePath}:`, err);
|
|
5183
|
+
}
|
|
5184
|
+
}
|
|
5185
|
+
/** Destroy the log, flushing any remaining events. */
|
|
5186
|
+
destroy() {
|
|
5187
|
+
if (this.destroyed) return;
|
|
5188
|
+
this.destroyed = true;
|
|
5189
|
+
this.flushSync();
|
|
5190
|
+
if (this.flushTimer) {
|
|
5191
|
+
clearTimeout(this.flushTimer);
|
|
5192
|
+
this.flushTimer = null;
|
|
5193
|
+
}
|
|
5194
|
+
}
|
|
5195
|
+
};
|
|
5196
|
+
|
|
5197
|
+
// src/persistence/recovery-scanner.ts
|
|
5198
|
+
import { readdirSync, readFileSync as readFileSync3, existsSync as existsSync6, statSync as statSync4 } from "fs";
|
|
5199
|
+
import { join as join3 } from "path";
|
|
5200
|
+
var SCHEMA_VERSION = 1;
|
|
5201
|
+
function parseEventsJsonl(filePath) {
|
|
5202
|
+
if (!existsSync6(filePath)) return [];
|
|
5203
|
+
const raw = readFileSync3(filePath, "utf-8");
|
|
5204
|
+
const lines = raw.split("\n");
|
|
5205
|
+
const events = [];
|
|
5206
|
+
for (const line of lines) {
|
|
5207
|
+
const trimmed = line.trim();
|
|
5208
|
+
if (!trimmed) continue;
|
|
5209
|
+
try {
|
|
5210
|
+
const parsed = JSON.parse(trimmed);
|
|
5211
|
+
if (typeof parsed.seq === "number") {
|
|
5212
|
+
events.push(parsed);
|
|
5213
|
+
}
|
|
5214
|
+
} catch {
|
|
5215
|
+
break;
|
|
5216
|
+
}
|
|
5217
|
+
}
|
|
5218
|
+
return events.sort((a, b) => a.seq - b.seq);
|
|
5219
|
+
}
|
|
5220
|
+
function readJsonSafe(filePath) {
|
|
5221
|
+
if (!existsSync6(filePath)) return null;
|
|
5222
|
+
try {
|
|
5223
|
+
return JSON.parse(readFileSync3(filePath, "utf-8"));
|
|
5224
|
+
} catch {
|
|
5225
|
+
return null;
|
|
5226
|
+
}
|
|
5227
|
+
}
|
|
5228
|
+
function scanRecoverableSessions(sessionsDir, maxEvents = 500) {
|
|
5229
|
+
if (!existsSync6(sessionsDir)) return [];
|
|
5230
|
+
const results = [];
|
|
5231
|
+
let entries;
|
|
5232
|
+
try {
|
|
5233
|
+
entries = readdirSync(sessionsDir);
|
|
5234
|
+
} catch {
|
|
5235
|
+
return [];
|
|
5236
|
+
}
|
|
5237
|
+
for (const entry of entries) {
|
|
5238
|
+
const sessionDir = join3(sessionsDir, entry);
|
|
5239
|
+
try {
|
|
5240
|
+
if (!statSync4(sessionDir).isDirectory()) continue;
|
|
5241
|
+
} catch {
|
|
5242
|
+
continue;
|
|
5243
|
+
}
|
|
5244
|
+
const meta = readJsonSafe(join3(sessionDir, "meta.json"));
|
|
5245
|
+
if (!meta || !meta.sessionId) continue;
|
|
5246
|
+
if (meta.schemaVersion !== void 0 && meta.schemaVersion > SCHEMA_VERSION) {
|
|
5247
|
+
console.error(
|
|
5248
|
+
`[recovery] Skipping session ${meta.sessionId}: schema version ${meta.schemaVersion} > ${SCHEMA_VERSION}`
|
|
5249
|
+
);
|
|
5250
|
+
continue;
|
|
5251
|
+
}
|
|
5252
|
+
let events = parseEventsJsonl(join3(sessionDir, "events.jsonl"));
|
|
5253
|
+
if (events.length > maxEvents) {
|
|
5254
|
+
events = events.slice(-maxEvents);
|
|
5255
|
+
}
|
|
5256
|
+
const lastSeq = events.length > 0 ? events[events.length - 1].seq : -1;
|
|
5257
|
+
const result = readJsonSafe(join3(sessionDir, "result.json"));
|
|
5258
|
+
const pidInfo = readJsonSafe(join3(sessionDir, "pid.json"));
|
|
5259
|
+
results.push({
|
|
5260
|
+
sessionId: meta.sessionId,
|
|
5261
|
+
meta,
|
|
5262
|
+
events,
|
|
5263
|
+
lastSeq,
|
|
5264
|
+
result,
|
|
5265
|
+
pidInfo,
|
|
5266
|
+
sessionDir
|
|
5267
|
+
});
|
|
5268
|
+
}
|
|
5269
|
+
return results;
|
|
5270
|
+
}
|
|
5271
|
+
|
|
5272
|
+
// src/persistence/retention.ts
|
|
5273
|
+
import { readdirSync as readdirSync2, rmSync as rmSync2, statSync as statSync5, readFileSync as readFileSync4 } from "fs";
|
|
5274
|
+
import { join as join4 } from "path";
|
|
5275
|
+
var DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
5276
|
+
var DEFAULT_MAX_COUNT = 200;
|
|
5277
|
+
var DEFAULT_MAX_DISK_BYTES = 500 * 1024 * 1024;
|
|
5278
|
+
function getDirSize(dirPath) {
|
|
5279
|
+
let total = 0;
|
|
5280
|
+
try {
|
|
5281
|
+
for (const entry of readdirSync2(dirPath)) {
|
|
5282
|
+
try {
|
|
5283
|
+
const st = statSync5(join4(dirPath, entry));
|
|
5284
|
+
total += st.isFile() ? st.size : 0;
|
|
5285
|
+
} catch {
|
|
5286
|
+
}
|
|
5287
|
+
}
|
|
5288
|
+
} catch {
|
|
5289
|
+
}
|
|
5290
|
+
return total;
|
|
5291
|
+
}
|
|
5292
|
+
function pruneSessionDirs(sessionsDir, policy) {
|
|
5293
|
+
const maxAgeMs = policy?.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
5294
|
+
const maxCount = policy?.maxCount ?? DEFAULT_MAX_COUNT;
|
|
5295
|
+
const maxDiskBytes = policy?.maxDiskBytes ?? DEFAULT_MAX_DISK_BYTES;
|
|
5296
|
+
let entries;
|
|
5297
|
+
try {
|
|
5298
|
+
entries = readdirSync2(sessionsDir);
|
|
5299
|
+
} catch {
|
|
5300
|
+
return 0;
|
|
5301
|
+
}
|
|
5302
|
+
const now = Date.now();
|
|
5303
|
+
const dirs = [];
|
|
5304
|
+
for (const entry of entries) {
|
|
5305
|
+
const dirPath = join4(sessionsDir, entry);
|
|
5306
|
+
try {
|
|
5307
|
+
if (!statSync5(dirPath).isDirectory()) continue;
|
|
5308
|
+
} catch {
|
|
5309
|
+
continue;
|
|
5310
|
+
}
|
|
5311
|
+
let lastActiveAt = 0;
|
|
5312
|
+
try {
|
|
5313
|
+
const meta = JSON.parse(readFileSync4(join4(dirPath, "meta.json"), "utf-8"));
|
|
5314
|
+
lastActiveAt = new Date(meta.lastActiveAt || meta.createdAt || 0).getTime();
|
|
5315
|
+
} catch {
|
|
5316
|
+
try {
|
|
5317
|
+
lastActiveAt = statSync5(dirPath).mtimeMs;
|
|
5318
|
+
} catch {
|
|
5319
|
+
lastActiveAt = 0;
|
|
5320
|
+
}
|
|
5321
|
+
}
|
|
5322
|
+
dirs.push({
|
|
5323
|
+
path: dirPath,
|
|
5324
|
+
sessionId: entry,
|
|
5325
|
+
lastActiveAt,
|
|
5326
|
+
diskBytes: getDirSize(dirPath)
|
|
5327
|
+
});
|
|
5328
|
+
}
|
|
5329
|
+
dirs.sort((a, b) => a.lastActiveAt - b.lastActiveAt);
|
|
5330
|
+
const toRemove = /* @__PURE__ */ new Set();
|
|
5331
|
+
for (const dir of dirs) {
|
|
5332
|
+
if (now - dir.lastActiveAt > maxAgeMs) {
|
|
5333
|
+
toRemove.add(dir.path);
|
|
5334
|
+
}
|
|
5335
|
+
}
|
|
5336
|
+
const remaining = dirs.filter((d) => !toRemove.has(d.path));
|
|
5337
|
+
if (remaining.length > maxCount) {
|
|
5338
|
+
const excess = remaining.length - maxCount;
|
|
5339
|
+
for (let i = 0; i < excess; i++) {
|
|
5340
|
+
toRemove.add(remaining[i].path);
|
|
5341
|
+
}
|
|
5342
|
+
}
|
|
5343
|
+
const afterCountPrune = dirs.filter((d) => !toRemove.has(d.path));
|
|
5344
|
+
let totalSize = afterCountPrune.reduce((sum, d) => sum + d.diskBytes, 0);
|
|
5345
|
+
for (const dir of afterCountPrune) {
|
|
5346
|
+
if (totalSize <= maxDiskBytes) break;
|
|
5347
|
+
toRemove.add(dir.path);
|
|
5348
|
+
totalSize -= dir.diskBytes;
|
|
5349
|
+
}
|
|
5350
|
+
let pruned = 0;
|
|
5351
|
+
for (const dirPath of toRemove) {
|
|
5352
|
+
try {
|
|
5353
|
+
rmSync2(dirPath, { recursive: true, force: true });
|
|
5354
|
+
pruned++;
|
|
5355
|
+
} catch (err) {
|
|
5356
|
+
console.error(`[retention] Failed to remove ${dirPath}:`, err);
|
|
5357
|
+
}
|
|
5358
|
+
}
|
|
5359
|
+
return pruned;
|
|
5360
|
+
}
|
|
5361
|
+
|
|
5362
|
+
// src/session/persistence.ts
|
|
5363
|
+
var CRITICAL_EVENT_TYPES = /* @__PURE__ */ new Set([
|
|
5364
|
+
"approval_request",
|
|
5365
|
+
"approval_result",
|
|
5366
|
+
"result",
|
|
5367
|
+
"error"
|
|
5368
|
+
]);
|
|
5369
|
+
function eventCriticality(type) {
|
|
5370
|
+
return CRITICAL_EVENT_TYPES.has(type) ? "critical" : "normal";
|
|
5371
|
+
}
|
|
5372
|
+
var SessionPersistence = class {
|
|
5373
|
+
stateDir;
|
|
5374
|
+
sessionsDir;
|
|
5375
|
+
releaseLock = null;
|
|
5376
|
+
eventLogs = /* @__PURE__ */ new Map();
|
|
5377
|
+
constructor(stateDir) {
|
|
5378
|
+
this.stateDir = stateDir ?? process.env.CODEX_MCP_STATE_DIR ?? join5(homedir2(), ".codex-mcp", "state");
|
|
5379
|
+
this.sessionsDir = join5(this.stateDir, "sessions");
|
|
5380
|
+
mkdirSync4(this.sessionsDir, { recursive: true });
|
|
5381
|
+
}
|
|
5382
|
+
/** Acquire the STATE_DIR lock. Call once at startup. */
|
|
5383
|
+
acquireLock() {
|
|
5384
|
+
this.releaseLock = acquireLock(join5(this.stateDir, ".lock"));
|
|
5385
|
+
}
|
|
5386
|
+
/** Release the lock. Call on shutdown. */
|
|
5387
|
+
releaseLockIfHeld() {
|
|
5388
|
+
if (this.releaseLock) {
|
|
5389
|
+
this.releaseLock();
|
|
5390
|
+
this.releaseLock = null;
|
|
5391
|
+
}
|
|
5392
|
+
}
|
|
5393
|
+
// ── Write operations ────────────────────────────────────────────
|
|
5394
|
+
/** Persist session metadata (called on create and status changes). */
|
|
5395
|
+
writeSessionMeta(session) {
|
|
5396
|
+
const meta = {
|
|
5397
|
+
schemaVersion: SCHEMA_VERSION,
|
|
5398
|
+
sessionId: session.sessionId,
|
|
5399
|
+
status: session.status,
|
|
5400
|
+
createdAt: session.createdAt,
|
|
5401
|
+
lastActiveAt: session.lastActiveAt,
|
|
5402
|
+
cancelledAt: session.cancelledAt,
|
|
5403
|
+
cancelledReason: session.cancelledReason,
|
|
5404
|
+
threadId: session.threadId,
|
|
5405
|
+
model: session.model,
|
|
5406
|
+
cwd: session.cwd,
|
|
5407
|
+
approvalPolicy: session.approvalPolicy,
|
|
5408
|
+
sandbox: session.sandbox,
|
|
5409
|
+
profile: session.profile
|
|
5410
|
+
};
|
|
5411
|
+
const dir = join5(this.sessionsDir, session.sessionId);
|
|
5412
|
+
atomicWriteJson(join5(dir, "meta.json"), meta);
|
|
5413
|
+
}
|
|
5414
|
+
/** Persist PID info for orphan detection. */
|
|
5415
|
+
writePidInfo(sessionId, pid, command) {
|
|
5416
|
+
const info = {
|
|
5417
|
+
pid,
|
|
5418
|
+
spawnedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5419
|
+
command
|
|
5420
|
+
};
|
|
5421
|
+
const dir = join5(this.sessionsDir, sessionId);
|
|
5422
|
+
mkdirSync4(dir, { recursive: true });
|
|
5423
|
+
atomicWriteJson(join5(dir, "pid.json"), info);
|
|
5424
|
+
}
|
|
5425
|
+
/** Append an event to the session's event log. */
|
|
5426
|
+
appendEvent(sessionId, type, data) {
|
|
5427
|
+
let log = this.eventLogs.get(sessionId);
|
|
5428
|
+
if (!log) {
|
|
5429
|
+
const dir = join5(this.sessionsDir, sessionId);
|
|
5430
|
+
mkdirSync4(dir, { recursive: true });
|
|
5431
|
+
log = new EventLog({ filePath: join5(dir, "events.jsonl") });
|
|
5432
|
+
this.eventLogs.set(sessionId, log);
|
|
5433
|
+
}
|
|
5434
|
+
log.append({ type, data, timestamp: (/* @__PURE__ */ new Date()).toISOString() }, eventCriticality(type));
|
|
5435
|
+
}
|
|
5436
|
+
/** Set the next sequence number for a recovered session's event log. */
|
|
5437
|
+
setEventLogNextSeq(sessionId, seq) {
|
|
5438
|
+
let log = this.eventLogs.get(sessionId);
|
|
5439
|
+
if (!log) {
|
|
5440
|
+
const dir = join5(this.sessionsDir, sessionId);
|
|
5441
|
+
mkdirSync4(dir, { recursive: true });
|
|
5442
|
+
log = new EventLog({ filePath: join5(dir, "events.jsonl") });
|
|
5443
|
+
this.eventLogs.set(sessionId, log);
|
|
5444
|
+
}
|
|
5445
|
+
log.setNextSeq(seq);
|
|
5446
|
+
}
|
|
5447
|
+
/** Persist the final result. */
|
|
5448
|
+
writeResult(sessionId, result) {
|
|
5449
|
+
const dir = join5(this.sessionsDir, sessionId);
|
|
5450
|
+
mkdirSync4(dir, { recursive: true });
|
|
5451
|
+
atomicWriteJson(join5(dir, "result.json"), result);
|
|
5452
|
+
}
|
|
5453
|
+
// ── Read / Recovery ─────────────────────────────────────────────
|
|
5454
|
+
/** Scan and recover sessions from disk. */
|
|
5455
|
+
recoverSessions(maxEvents) {
|
|
5456
|
+
return scanRecoverableSessions(this.sessionsDir, maxEvents);
|
|
5457
|
+
}
|
|
5458
|
+
/** Apply retention policy. Returns number of sessions pruned. */
|
|
5459
|
+
prune(policy) {
|
|
5460
|
+
return pruneSessionDirs(this.sessionsDir, policy);
|
|
5461
|
+
}
|
|
5462
|
+
// ── Lifecycle ───────────────────────────────────────────────────
|
|
5463
|
+
/** Flush all event logs synchronously (call on shutdown). */
|
|
5464
|
+
flushAll() {
|
|
5465
|
+
for (const log of this.eventLogs.values()) {
|
|
5466
|
+
log.flushSync();
|
|
5467
|
+
}
|
|
5468
|
+
}
|
|
5469
|
+
/** Destroy a single session's event log. */
|
|
5470
|
+
destroySessionLog(sessionId) {
|
|
5471
|
+
const log = this.eventLogs.get(sessionId);
|
|
5472
|
+
if (log) {
|
|
5473
|
+
log.destroy();
|
|
5474
|
+
this.eventLogs.delete(sessionId);
|
|
5475
|
+
}
|
|
5476
|
+
}
|
|
5477
|
+
/** Clean up: flush all logs, release lock. */
|
|
5478
|
+
destroy() {
|
|
5479
|
+
for (const log of this.eventLogs.values()) {
|
|
5480
|
+
log.destroy();
|
|
5481
|
+
}
|
|
5482
|
+
this.eventLogs.clear();
|
|
5483
|
+
this.releaseLockIfHeld();
|
|
5484
|
+
}
|
|
5485
|
+
/** Check if a session directory exists on disk. */
|
|
5486
|
+
hasSessionOnDisk(sessionId) {
|
|
5487
|
+
return existsSync7(join5(this.sessionsDir, sessionId, "meta.json"));
|
|
5488
|
+
}
|
|
5489
|
+
};
|
|
5490
|
+
|
|
5491
|
+
// src/session/orphan-reaper.ts
|
|
5492
|
+
import { execSync, spawn as spawn4 } from "child_process";
|
|
5493
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
5494
|
+
function isAlive(pid) {
|
|
5495
|
+
try {
|
|
5496
|
+
process.kill(pid, 0);
|
|
5497
|
+
return true;
|
|
5498
|
+
} catch {
|
|
5499
|
+
return false;
|
|
5500
|
+
}
|
|
5501
|
+
}
|
|
5502
|
+
function getWindowsCreationTimeMs(pid) {
|
|
5503
|
+
try {
|
|
5504
|
+
const raw = execSync(`wmic process where "ProcessId=${pid}" get CreationDate /value`, {
|
|
5505
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
5506
|
+
timeout: 5e3
|
|
5507
|
+
}).toString();
|
|
5508
|
+
const match = raw.match(/CreationDate=(\d{14})/);
|
|
5509
|
+
if (!match || !match[1]) return null;
|
|
5510
|
+
const s = match[1];
|
|
5511
|
+
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`;
|
|
5512
|
+
const ms = new Date(iso).getTime();
|
|
5513
|
+
return isNaN(ms) ? null : ms;
|
|
5514
|
+
} catch {
|
|
5515
|
+
return null;
|
|
5516
|
+
}
|
|
5517
|
+
}
|
|
5518
|
+
function getProcStatStartTick(pid) {
|
|
5519
|
+
try {
|
|
5520
|
+
const stat = readFileSync5(`/proc/${pid}/stat`, "utf-8");
|
|
5521
|
+
const afterComm = stat.slice(stat.lastIndexOf(")") + 1).trim();
|
|
5522
|
+
const fields = afterComm.split(" ");
|
|
5523
|
+
const starttime = fields[19];
|
|
5524
|
+
return starttime ?? null;
|
|
5525
|
+
} catch {
|
|
5526
|
+
return null;
|
|
5527
|
+
}
|
|
5528
|
+
}
|
|
5529
|
+
function getPsLstart(pid) {
|
|
5530
|
+
try {
|
|
5531
|
+
const output = execSync(`ps -p ${pid} -o lstart=`, {
|
|
5532
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
5533
|
+
timeout: 5e3
|
|
5534
|
+
}).toString().trim();
|
|
5535
|
+
if (!output) return null;
|
|
5536
|
+
const ms = new Date(output).getTime();
|
|
5537
|
+
return isNaN(ms) ? null : ms;
|
|
5538
|
+
} catch {
|
|
5539
|
+
return null;
|
|
5540
|
+
}
|
|
5541
|
+
}
|
|
5542
|
+
function isOrphan(pid, spawnedAt) {
|
|
5543
|
+
const storedMs = new Date(spawnedAt).getTime();
|
|
5544
|
+
if (isNaN(storedMs)) return false;
|
|
5545
|
+
if (process.platform === "win32") {
|
|
5546
|
+
const procMs = getWindowsCreationTimeMs(pid);
|
|
5547
|
+
if (procMs === null) return false;
|
|
5548
|
+
return Math.abs(procMs - storedMs) < 5e3;
|
|
5549
|
+
}
|
|
5550
|
+
const lstartMs = getPsLstart(pid);
|
|
5551
|
+
if (lstartMs !== null) {
|
|
5552
|
+
return Math.abs(lstartMs - storedMs) < 5e3;
|
|
5553
|
+
}
|
|
5554
|
+
const tick = getProcStatStartTick(pid);
|
|
5555
|
+
if (tick !== null) {
|
|
5556
|
+
const ageMs = Date.now() - storedMs;
|
|
5557
|
+
return ageMs > 0 && ageMs < 24 * 60 * 60 * 1e3;
|
|
5558
|
+
}
|
|
5559
|
+
return false;
|
|
5560
|
+
}
|
|
5561
|
+
function sendGraceful(pid) {
|
|
5562
|
+
try {
|
|
5563
|
+
if (process.platform === "win32") {
|
|
5564
|
+
spawn4("taskkill", ["/PID", String(pid)], {
|
|
5565
|
+
stdio: "ignore",
|
|
5566
|
+
windowsHide: true
|
|
5567
|
+
});
|
|
5568
|
+
} else {
|
|
5569
|
+
process.kill(pid, "SIGTERM");
|
|
5570
|
+
}
|
|
5571
|
+
} catch {
|
|
5572
|
+
}
|
|
5573
|
+
}
|
|
5574
|
+
function sendForce(pid) {
|
|
5575
|
+
try {
|
|
5576
|
+
if (process.platform === "win32") {
|
|
5577
|
+
spawn4("taskkill", ["/PID", String(pid), "/F"], {
|
|
5578
|
+
stdio: "ignore",
|
|
5579
|
+
windowsHide: true
|
|
5580
|
+
});
|
|
5581
|
+
} else {
|
|
5582
|
+
process.kill(pid, "SIGKILL");
|
|
5583
|
+
}
|
|
5584
|
+
} catch {
|
|
5585
|
+
}
|
|
5586
|
+
}
|
|
5587
|
+
function sleep(ms) {
|
|
5588
|
+
return new Promise((resolve) => {
|
|
5589
|
+
const t = setTimeout(resolve, ms);
|
|
5590
|
+
if (t.unref) t.unref();
|
|
5591
|
+
});
|
|
5592
|
+
}
|
|
5593
|
+
async function reapOrphanProcesses(recovered) {
|
|
5594
|
+
const summary = { reaped: 0, alreadyDead: 0, skipped: 0 };
|
|
5595
|
+
const candidates = recovered.filter((s) => s.pidInfo !== null);
|
|
5596
|
+
if (candidates.length === 0) return summary;
|
|
5597
|
+
const reapPromises = candidates.map(async (session) => {
|
|
5598
|
+
const { pid, spawnedAt } = session.pidInfo;
|
|
5599
|
+
if (!isAlive(pid)) {
|
|
5600
|
+
summary.alreadyDead++;
|
|
5601
|
+
return;
|
|
5602
|
+
}
|
|
5603
|
+
if (!isOrphan(pid, spawnedAt)) {
|
|
5604
|
+
console.error(
|
|
5605
|
+
`[orphan-reaper] PID ${pid} (session ${session.sessionId}) is alive but does not match stored spawn time \u2014 likely a reused PID. Skipping.`
|
|
5606
|
+
);
|
|
5607
|
+
summary.skipped++;
|
|
5608
|
+
return;
|
|
5609
|
+
}
|
|
5610
|
+
if (!isAlive(pid) || !isOrphan(pid, spawnedAt)) {
|
|
5611
|
+
summary.skipped++;
|
|
5612
|
+
return;
|
|
5613
|
+
}
|
|
5614
|
+
console.error(
|
|
5615
|
+
`[orphan-reaper] Sending graceful terminate to orphan PID ${pid} (session ${session.sessionId}).`
|
|
5616
|
+
);
|
|
5617
|
+
sendGraceful(pid);
|
|
5618
|
+
const deadline = Date.now() + 5e3;
|
|
5619
|
+
while (isAlive(pid) && Date.now() < deadline) {
|
|
5620
|
+
await sleep(250);
|
|
5621
|
+
}
|
|
5622
|
+
if (isAlive(pid)) {
|
|
5623
|
+
console.error(
|
|
5624
|
+
`[orphan-reaper] PID ${pid} did not exit after graceful terminate \u2014 force killing.`
|
|
5625
|
+
);
|
|
5626
|
+
sendForce(pid);
|
|
5627
|
+
await sleep(500);
|
|
5628
|
+
}
|
|
5629
|
+
summary.reaped++;
|
|
5630
|
+
});
|
|
5631
|
+
await Promise.all(reapPromises);
|
|
5632
|
+
return summary;
|
|
5633
|
+
}
|
|
5634
|
+
|
|
5635
|
+
// src/utils/stdin-shutdown.ts
|
|
5636
|
+
function decideStdinShutdown(params) {
|
|
5637
|
+
if (!params.stdinUnavailable) return "clear";
|
|
5638
|
+
if (params.isConnected) return "reschedule";
|
|
5639
|
+
if (!params.hasActiveSessions) return "shutdown_now";
|
|
5640
|
+
if (params.elapsedMs >= params.maxWaitMs) return "shutdown_timeout";
|
|
5641
|
+
return "reschedule";
|
|
5642
|
+
}
|
|
5643
|
+
|
|
4352
5644
|
// src/index.ts
|
|
5645
|
+
var STDIN_SHUTDOWN_CHECK_MS = 750;
|
|
5646
|
+
var STDIN_SHUTDOWN_MAX_WAIT_MS = process.platform === "win32" ? 15e3 : 1e4;
|
|
4353
5647
|
async function main() {
|
|
4354
5648
|
const preflight = runStdioPreflight();
|
|
4355
5649
|
for (const note of preflight.notes) {
|
|
@@ -4374,27 +5668,177 @@ async function main() {
|
|
|
4374
5668
|
const clientMode = await detectClientMode(executable.command, executable.isPath);
|
|
4375
5669
|
console.error(`[codex-mcp] client mode: ${clientMode} (binary: ${executable.command})`);
|
|
4376
5670
|
const createClient = () => clientMode === "exec" ? new ExecClient() : new AppServerClient();
|
|
5671
|
+
const persistence = new SessionPersistence();
|
|
5672
|
+
try {
|
|
5673
|
+
persistence.acquireLock();
|
|
5674
|
+
console.error("[codex-mcp] STATE_DIR lock acquired");
|
|
5675
|
+
} catch (err) {
|
|
5676
|
+
console.error("[codex-mcp] WARNING: Failed to acquire STATE_DIR lock:", err);
|
|
5677
|
+
}
|
|
5678
|
+
const recovered = persistence.recoverSessions();
|
|
5679
|
+
if (recovered.length > 0) {
|
|
5680
|
+
console.error(`[codex-mcp] Recovered ${recovered.length} session(s) from disk`);
|
|
5681
|
+
}
|
|
5682
|
+
const pruned = persistence.prune();
|
|
5683
|
+
if (pruned > 0) {
|
|
5684
|
+
console.error(`[codex-mcp] Pruned ${pruned} old session(s)`);
|
|
5685
|
+
}
|
|
5686
|
+
const reaped = await reapOrphanProcesses(recovered);
|
|
5687
|
+
if (reaped.reaped > 0) console.error(`[codex-mcp] Reaped ${reaped.reaped} orphan process(es)`);
|
|
4377
5688
|
const serverCwd = process.cwd();
|
|
4378
|
-
const
|
|
5689
|
+
const ctx = createServer(serverCwd, {
|
|
4379
5690
|
createClient,
|
|
4380
|
-
clientMode
|
|
5691
|
+
clientMode,
|
|
5692
|
+
persistence
|
|
4381
5693
|
});
|
|
5694
|
+
const server = ctx.server;
|
|
5695
|
+
const sessionManager = ctx.sessionManager;
|
|
5696
|
+
if (recovered.length > 0) {
|
|
5697
|
+
sessionManager.ingestRecovered(recovered);
|
|
5698
|
+
}
|
|
4382
5699
|
const transport = new StdioServerTransport();
|
|
4383
5700
|
let closing = false;
|
|
4384
|
-
|
|
5701
|
+
let lastExitCode = 0;
|
|
5702
|
+
let stdinClosedAt;
|
|
5703
|
+
let stdinClosedReason;
|
|
5704
|
+
let stdinShutdownTimer;
|
|
5705
|
+
const onStdinEnd = () => handleStdinTerminated("end");
|
|
5706
|
+
const onStdinClose = () => handleStdinTerminated("close");
|
|
5707
|
+
const clearStdinShutdownTimer = () => {
|
|
5708
|
+
if (stdinShutdownTimer) {
|
|
5709
|
+
clearTimeout(stdinShutdownTimer);
|
|
5710
|
+
stdinShutdownTimer = void 0;
|
|
5711
|
+
}
|
|
5712
|
+
};
|
|
5713
|
+
function hasActiveSessions() {
|
|
5714
|
+
return sessionManager.listSessions().some((s) => s.status === "running" || s.status === "waiting_approval");
|
|
5715
|
+
}
|
|
5716
|
+
const shutdown = async (reason = "unknown") => {
|
|
4385
5717
|
if (closing) return;
|
|
4386
5718
|
closing = true;
|
|
5719
|
+
clearStdinShutdownTimer();
|
|
5720
|
+
if (typeof process.stdin.off === "function") {
|
|
5721
|
+
process.stdin.off("error", handleStdinError);
|
|
5722
|
+
process.stdin.off("end", onStdinEnd);
|
|
5723
|
+
process.stdin.off("close", onStdinClose);
|
|
5724
|
+
}
|
|
5725
|
+
const forceExitMs = process.platform === "win32" ? 1e4 : 5e3;
|
|
5726
|
+
const forceExitTimer = setTimeout(() => process.exit(lastExitCode), forceExitMs);
|
|
5727
|
+
if (forceExitTimer.unref) forceExitTimer.unref();
|
|
5728
|
+
const activeSessions = sessionManager.listSessions();
|
|
5729
|
+
const runningCount = activeSessions.filter(
|
|
5730
|
+
(s) => s.status === "running" || s.status === "waiting_approval"
|
|
5731
|
+
).length;
|
|
5732
|
+
console.error(
|
|
5733
|
+
`[codex-mcp] shutdown triggered (reason=${reason}, activeSessions=${runningCount}, total=${activeSessions.length})`
|
|
5734
|
+
);
|
|
5735
|
+
try {
|
|
5736
|
+
if (server.isConnected()) {
|
|
5737
|
+
await server.sendLoggingMessage({
|
|
5738
|
+
level: "info",
|
|
5739
|
+
data: {
|
|
5740
|
+
event: "server_stopping",
|
|
5741
|
+
reason,
|
|
5742
|
+
activeSessions: runningCount,
|
|
5743
|
+
totalSessions: activeSessions.length
|
|
5744
|
+
}
|
|
5745
|
+
});
|
|
5746
|
+
}
|
|
5747
|
+
} catch {
|
|
5748
|
+
}
|
|
5749
|
+
try {
|
|
5750
|
+
persistence.flushAll();
|
|
5751
|
+
persistence.releaseLockIfHeld();
|
|
5752
|
+
} catch {
|
|
5753
|
+
}
|
|
4387
5754
|
try {
|
|
4388
5755
|
await server.close();
|
|
4389
5756
|
} catch {
|
|
4390
5757
|
}
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
5758
|
+
persistence.destroy();
|
|
5759
|
+
process.exitCode = lastExitCode;
|
|
5760
|
+
try {
|
|
5761
|
+
await new Promise((resolve) => process.stderr.write("", () => resolve()));
|
|
5762
|
+
} catch {
|
|
5763
|
+
} finally {
|
|
5764
|
+
clearTimeout(forceExitTimer);
|
|
5765
|
+
}
|
|
5766
|
+
};
|
|
5767
|
+
function handleStdinError(error) {
|
|
5768
|
+
console.error("[codex-mcp] stdin error:", error);
|
|
5769
|
+
lastExitCode = 1;
|
|
5770
|
+
void shutdown("stdin_error");
|
|
5771
|
+
}
|
|
5772
|
+
const evaluateStdinTermination = () => {
|
|
5773
|
+
if (closing || stdinClosedAt === void 0) return;
|
|
5774
|
+
const stdinUnavailable = process.stdin.destroyed || process.stdin.readableEnded || !process.stdin.readable;
|
|
5775
|
+
const elapsedMs = Date.now() - stdinClosedAt;
|
|
5776
|
+
const active = hasActiveSessions();
|
|
5777
|
+
const connected = server.isConnected();
|
|
5778
|
+
const decision = decideStdinShutdown({
|
|
5779
|
+
stdinUnavailable,
|
|
5780
|
+
elapsedMs,
|
|
5781
|
+
maxWaitMs: STDIN_SHUTDOWN_MAX_WAIT_MS,
|
|
5782
|
+
hasActiveSessions: active,
|
|
5783
|
+
isConnected: connected
|
|
5784
|
+
});
|
|
5785
|
+
if (decision === "clear") {
|
|
5786
|
+
stdinClosedAt = void 0;
|
|
5787
|
+
stdinClosedReason = void 0;
|
|
5788
|
+
return;
|
|
5789
|
+
}
|
|
5790
|
+
if (decision === "shutdown_now") {
|
|
5791
|
+
console.error("[codex-mcp] stdin closed with no active sessions \u2014 shutting down");
|
|
5792
|
+
void shutdown(`stdin_${stdinClosedReason ?? "closed"}`);
|
|
5793
|
+
return;
|
|
5794
|
+
}
|
|
5795
|
+
if (decision === "shutdown_timeout") {
|
|
5796
|
+
console.error(
|
|
5797
|
+
`[codex-mcp] stdin closed and drain period (${STDIN_SHUTDOWN_MAX_WAIT_MS}ms) elapsed \u2014 forcing shutdown`
|
|
5798
|
+
);
|
|
5799
|
+
void shutdown(`stdin_${stdinClosedReason ?? "closed"}_timeout`);
|
|
5800
|
+
return;
|
|
5801
|
+
}
|
|
5802
|
+
if (active) {
|
|
5803
|
+
console.error(
|
|
5804
|
+
`[codex-mcp] stdin closed; ${sessionManager.getActiveSessionCount()} active session(s) \u2014 waiting up to ${STDIN_SHUTDOWN_MAX_WAIT_MS}ms (elapsed: ${elapsedMs}ms)`
|
|
5805
|
+
);
|
|
5806
|
+
}
|
|
5807
|
+
stdinShutdownTimer = setTimeout(evaluateStdinTermination, STDIN_SHUTDOWN_CHECK_MS);
|
|
5808
|
+
if (stdinShutdownTimer.unref) stdinShutdownTimer.unref();
|
|
5809
|
+
};
|
|
5810
|
+
function handleStdinTerminated(event) {
|
|
5811
|
+
if (closing) return;
|
|
5812
|
+
if (stdinClosedAt === void 0) {
|
|
5813
|
+
stdinClosedAt = Date.now();
|
|
5814
|
+
stdinClosedReason = event;
|
|
5815
|
+
console.error(`[codex-mcp] stdin ${event} observed \u2014 entering guarded shutdown checks`);
|
|
5816
|
+
}
|
|
5817
|
+
clearStdinShutdownTimer();
|
|
5818
|
+
stdinShutdownTimer = setTimeout(evaluateStdinTermination, STDIN_SHUTDOWN_CHECK_MS);
|
|
5819
|
+
if (stdinShutdownTimer.unref) stdinShutdownTimer.unref();
|
|
5820
|
+
}
|
|
5821
|
+
const handleUnexpectedError = (error) => {
|
|
5822
|
+
console.error("[codex-mcp] Unhandled runtime error:", error);
|
|
5823
|
+
lastExitCode = 1;
|
|
5824
|
+
void shutdown("runtime_error");
|
|
4394
5825
|
};
|
|
4395
|
-
process.on("SIGINT", shutdown);
|
|
4396
|
-
process.on("SIGTERM", shutdown);
|
|
4397
|
-
process.on("SIGBREAK", shutdown);
|
|
5826
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
5827
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
5828
|
+
process.on("SIGBREAK", () => void shutdown("SIGBREAK"));
|
|
5829
|
+
process.on("beforeExit", () => {
|
|
5830
|
+
if (!server.isConnected()) {
|
|
5831
|
+
void shutdown("beforeExit");
|
|
5832
|
+
}
|
|
5833
|
+
});
|
|
5834
|
+
process.on("uncaughtException", handleUnexpectedError);
|
|
5835
|
+
process.on("unhandledRejection", handleUnexpectedError);
|
|
5836
|
+
if (typeof process.stdin.resume === "function") {
|
|
5837
|
+
process.stdin.resume();
|
|
5838
|
+
}
|
|
5839
|
+
process.stdin.on("error", handleStdinError);
|
|
5840
|
+
process.stdin.on("end", onStdinEnd);
|
|
5841
|
+
process.stdin.on("close", onStdinClose);
|
|
4398
5842
|
await server.connect(transport);
|
|
4399
5843
|
console.error(`codex-mcp server started (cwd: ${serverCwd})`);
|
|
4400
5844
|
}
|