Files
NetMesh/electron/bridges/aiBridge/codexAppServer/runtime.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

989 lines
34 KiB
JavaScript

"use strict";
const {
CodexAppServerConnection,
buildCodexAppServerKey,
} = require("./connection.cjs");
const {
parseCodexModelSelection,
toCodexMcpConfig,
} = require("../sdk/codexDriver.cjs");
const INTERACTION_TIMEOUT_MS = 5 * 60 * 1000;
const INTERRUPT_REQUEST_TIMEOUT_MS = 5_000;
const INTERRUPT_GRACE_MS = 2_000;
const MAX_STREAMED_PREFIX_CHARS = 256 * 1024;
const MAX_TOOL_OUTPUT_CHARS = 1024 * 1024;
function appendStreamState(map, itemId, delta, maxPrefixChars = MAX_STREAMED_PREFIX_CHARS) {
const text = String(delta || "");
const previous = map.get(itemId) || { prefix: "", length: 0, truncated: false };
const remaining = Math.max(0, maxPrefixChars - previous.prefix.length);
const next = {
prefix: remaining > 0 ? previous.prefix + text.slice(0, remaining) : previous.prefix,
length: previous.length + text.length,
truncated: previous.truncated || text.length > remaining,
};
map.set(itemId, next);
return next;
}
function appendToolOutputState(map, itemId, delta) {
const text = String(delta || "");
const previous = map.get(itemId) || { text: "", totalLength: 0, truncated: false };
const remaining = Math.max(0, MAX_TOOL_OUTPUT_CHARS - previous.text.length);
const next = {
text: remaining > 0 ? previous.text + text.slice(0, remaining) : previous.text,
totalLength: previous.totalLength + text.length,
truncated: previous.truncated || text.length > remaining,
};
map.set(itemId, next);
return next;
}
function formatBoundedToolOutput(value, totalLength = String(value || "").length) {
const text = String(value || "");
if (text.length <= MAX_TOOL_OUTPUT_CHARS && totalLength <= MAX_TOOL_OUTPUT_CHARS) return text;
const kept = text.slice(0, MAX_TOOL_OUTPUT_CHARS);
return `${kept}\n[output truncated: ${Math.max(totalLength, text.length)} characters total]`;
}
function resolveCodexPermissionConfig(permissionMode) {
if (permissionMode === "observer") {
return {
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: "read-only",
sandboxPolicy: { type: "readOnly", networkAccess: false },
};
}
if (permissionMode === "auto") {
return {
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: "danger-full-access",
sandboxPolicy: { type: "dangerFullAccess" },
};
}
return {
approvalPolicy: "on-request",
approvalsReviewer: "user",
sandbox: "read-only",
sandboxPolicy: { type: "readOnly", networkAccess: false },
};
}
function buildThreadConfig(injectedMcpServers) {
return {
// Netcatty already applies its Observer/Confirm/Auto policy inside the MCP
// bridge. Tell Codex not to add a second MCP approval prompt: App Server
// otherwise routes the stable MCP elicitation request back to this client,
// and rejecting/omitting that duplicate prompt surfaces as
// "user rejected MCP tool call" before Netcatty's own gate can run.
mcp_servers: toCodexMcpConfig(injectedMcpServers, {
defaultToolsApprovalMode: "approve",
}),
model_reasoning_summary: "concise",
};
}
function normalizeFileChanges(changes) {
if (!Array.isArray(changes)) return [];
return changes
.filter((change) => change && typeof change.path === "string")
.map((change) => ({
path: change.path,
kind: change.kind?.type === "add"
? "add"
: change.kind?.type === "delete"
? "delete"
: "update",
}));
}
function normalizeGrantedPermissions(requested) {
const granted = {};
if (requested?.network != null) granted.network = requested.network;
if (requested?.fileSystem != null) granted.fileSystem = requested.fileSystem;
return granted;
}
function stringifyMcpContent(result) {
if (!result) return "";
const content = Array.isArray(result.content) ? result.content : [];
let text = "";
let totalLength = 0;
for (const item of content) {
const rawPart = item && typeof item === "object" && typeof item.text === "string"
? item.text
: typeof item === "string" ? item : JSON.stringify(item);
const part = typeof rawPart === "string" ? rawPart : "";
totalLength += part.length;
if (text.length < MAX_TOOL_OUTPUT_CHARS) {
text += part.slice(0, MAX_TOOL_OUTPUT_CHARS - text.length);
}
}
if (text || totalLength > 0) return formatBoundedToolOutput(text, totalLength);
if (result.structuredContent == null) return "";
return formatBoundedToolOutput(JSON.stringify(result.structuredContent));
}
function buildTurnInput(prompt, attachments) {
const input = [{ type: "text", text: String(prompt || ""), text_elements: [] }];
for (const attachment of attachments || []) {
if (!attachment?.filePath) continue;
if (!String(attachment.mediaType || "").toLowerCase().startsWith("image/")) continue;
input.push({ type: "localImage", path: attachment.filePath });
}
return input;
}
function getActiveTurnNotSteerableKind(error) {
const turnKind = error?.data?.activeTurnNotSteerable?.turnKind
?? error?.data?.codexErrorInfo?.activeTurnNotSteerable?.turnKind;
return turnKind === "review" || turnKind === "compact" ? turnKind : null;
}
function mapAppServerModels(rawModels) {
return (Array.isArray(rawModels) ? rawModels : [])
.filter((model) => model && model.id && !model.hidden)
.map((model) => ({
id: model.id,
name: model.displayName || model.id,
description: model.description || undefined,
thinkingLevels: Array.isArray(model.supportedReasoningEfforts)
? model.supportedReasoningEfforts
.map((option) => option?.reasoningEffort)
.filter(Boolean)
: [],
defaultThinkingLevel: model.defaultReasoningEffort || undefined,
isDefault: model.isDefault === true,
}));
}
function resolveAppServerModelSelection(model) {
if (!model) return null;
const defaultThinkingLevel = model.defaultThinkingLevel;
if (
defaultThinkingLevel
&& Array.isArray(model.thinkingLevels)
&& model.thinkingLevels.includes(defaultThinkingLevel)
) {
return `${model.id}/${defaultThinkingLevel}`;
}
return model.id;
}
class CodexAppServerRuntime {
constructor({
appVersion = "0.0.0",
connectionFactory,
sendInteractionRequest,
sendInteractionCleared,
interruptRequestTimeoutMs = INTERRUPT_REQUEST_TIMEOUT_MS,
interruptGraceMs = INTERRUPT_GRACE_MS,
} = {}) {
this.appVersion = appVersion;
this.connectionFactory = connectionFactory;
this.sendInteractionRequest = sendInteractionRequest;
this.sendInteractionCleared = sendInteractionCleared;
this.interruptRequestTimeoutMs = interruptRequestTimeoutMs;
this.interruptGraceMs = interruptGraceMs;
this.connections = new Map();
this.preferredConnectionKey = null;
this.activeByRequest = new Map();
this.activeByThread = new Map();
this.activeByTurn = new Map();
this.pendingInteractions = new Map();
this.interactionCounter = 0;
this.eventCounter = 0;
}
#scopedKey(connectionKey, id) {
return `${connectionKey}\u0000${String(id || "")}`;
}
#getConnection(binPath, env) {
const connectionKey = buildCodexAppServerKey(binPath, env);
this.preferredConnectionKey = connectionKey;
const existing = this.connections.get(connectionKey);
if (existing) {
this.#closeIdleConnections(connectionKey);
return { connection: existing, connectionKey };
}
this.#closeIdleConnections(connectionKey);
const factory = this.connectionFactory || ((options) => new CodexAppServerConnection(options));
const connection = factory({
binPath,
env,
appVersion: this.appVersion,
onNotification: (message) => this.#handleNotification(connectionKey, message),
onServerRequest: (message, source) => this.#handleServerRequest(connectionKey, source, message),
onFatal: (error) => this.#handleConnectionFatal(connectionKey, error),
});
this.connections.set(connectionKey, connection);
return { connection, connectionKey };
}
#closeIdleConnections(keepKey = this.preferredConnectionKey) {
const activeConnectionKeys = new Set(
Array.from(this.activeByRequest.values(), (context) => context.connectionKey),
);
for (const [connectionKey, connection] of this.connections) {
if (connectionKey === keepKey || activeConnectionKeys.has(connectionKey)) continue;
this.connections.delete(connectionKey);
try { connection.close(); } catch {}
}
}
#refreshPreferredConnectionKey() {
if (this.preferredConnectionKey && this.connections.has(this.preferredConnectionKey)) return;
const connectionKeys = Array.from(this.connections.keys());
this.preferredConnectionKey = connectionKeys.at(-1) || null;
}
async runTurn({
requestId,
chatSessionId,
prompt,
attachments,
cwd,
model,
permissionMode,
env,
binPath,
injectedMcpServers,
resumeThreadId,
emitter,
signal,
sender,
}) {
const throwIfAborted = () => {
if (!signal?.aborted) return;
const error = new Error("Codex App Server turn was interrupted before it started");
error.name = "AbortError";
throw error;
};
throwIfAborted();
const { connection, connectionKey } = this.#getConnection(binPath, env);
await connection.start();
throwIfAborted();
const permission = resolveCodexPermissionConfig(permissionMode);
const selection = parseCodexModelSelection(model);
const threadParams = {
model: selection.model || null,
cwd: cwd || process.cwd(),
approvalPolicy: permission.approvalPolicy,
approvalsReviewer: permission.approvalsReviewer,
sandbox: permission.sandbox,
config: buildThreadConfig(injectedMcpServers),
};
const threadResult = resumeThreadId
? await connection.request("thread/resume", { threadId: resumeThreadId, ...threadParams })
: await connection.request("thread/start", threadParams);
throwIfAborted();
const threadId = threadResult?.thread?.id || resumeThreadId;
if (!threadId) throw new Error("Codex App Server did not return a thread id");
emitter.sessionId(threadId);
const context = {
requestId,
chatSessionId,
connection,
connectionKey,
threadId,
turnId: null,
emitter,
signal,
sender,
lastError: null,
settled: false,
cancelRequested: false,
interruptPromise: null,
steerPromise: null,
reasoningOpen: false,
streamedTextByItem: new Map(),
streamedReasoningByItem: new Map(),
commandOutputByItem: new Map(),
emittedToolCalls: new Set(),
emittedToolResults: new Set(),
forceCancelTimer: null,
abortListener: null,
};
this.activeByRequest.set(requestId, context);
this.activeByThread.set(this.#scopedKey(connectionKey, threadId), context);
const completion = new Promise((resolve, reject) => {
context.resolve = resolve;
context.reject = reject;
});
if (signal) {
context.abortListener = () => { void this.cancelTurn(requestId); };
signal.addEventListener("abort", context.abortListener, { once: true });
if (signal.aborted) context.abortListener();
}
try {
const turnResult = await connection.request("turn/start", {
threadId,
input: buildTurnInput(prompt, attachments),
cwd: cwd || process.cwd(),
approvalPolicy: permission.approvalPolicy,
approvalsReviewer: permission.approvalsReviewer,
sandboxPolicy: permission.sandboxPolicy,
model: selection.model || null,
effort: selection.effort || null,
summary: "concise",
});
const turnId = turnResult?.turn?.id;
if (turnId) this.#assignTurnId(context, turnId);
await completion;
return { threadId, turnId: context.turnId };
} finally {
this.#removeContext(context);
}
}
async listModels({ binPath, env }) {
const { connection } = this.#getConnection(binPath, env);
await connection.start();
const all = [];
let cursor = null;
do {
const response = await connection.request("model/list", {
cursor,
limit: 100,
}, 10_000);
all.push(...(response?.data || []));
cursor = response?.nextCursor || null;
} while (cursor);
const models = mapAppServerModels(all);
const defaultModel = models.find((model) => model.isDefault);
return {
currentModelId: resolveAppServerModelSelection(defaultModel),
models,
};
}
async steerTurn(requestId, {
chatSessionId,
prompt,
attachments,
clientUserMessageId,
} = {}) {
const context = this.activeByRequest.get(requestId);
if (!context || context.settled || context.chatSessionId !== chatSessionId) {
return { status: "inactive" };
}
if (context.cancelRequested || context.signal?.aborted) {
return { status: "cancelled" };
}
if (!context.turnId) {
return { status: "busy", message: "Codex turn is still starting" };
}
if (context.steerPromise) {
return { status: "busy", message: "A Codex instruction is already being sent" };
}
const steerPromise = (async () => {
try {
const response = await context.connection.request("turn/steer", {
threadId: context.threadId,
expectedTurnId: context.turnId,
input: buildTurnInput(prompt, attachments),
clientUserMessageId: clientUserMessageId || null,
});
if (context.cancelRequested || context.signal?.aborted || context.settled) {
return { status: "cancelled" };
}
if (response?.turnId && response.turnId !== context.turnId) {
return {
status: "failed",
message: "Codex App Server returned a different turn id while steering",
};
}
return { status: "accepted" };
} catch (error) {
const turnKind = getActiveTurnNotSteerableKind(error);
if (turnKind) {
return {
status: "not-steerable",
turnKind,
message: error?.message || "The active Codex turn cannot be steered",
};
}
if (context.cancelRequested || context.signal?.aborted || context.settled) {
return { status: "cancelled" };
}
return {
status: "failed",
message: error?.message || String(error),
};
}
})();
context.steerPromise = steerPromise;
try {
return await steerPromise;
} finally {
if (context.steerPromise === steerPromise) context.steerPromise = null;
}
}
#assignTurnId(context, turnId) {
if (!turnId || context.turnId === turnId) return;
if (context.turnId) {
this.activeByTurn.delete(this.#scopedKey(context.connectionKey, context.turnId));
}
context.turnId = turnId;
this.activeByTurn.set(this.#scopedKey(context.connectionKey, turnId), context);
if (context.cancelRequested) void this.#interruptAndSchedule(context);
}
#interruptContext(context) {
if (!context.turnId) return Promise.resolve(false);
if (context.interruptPromise) return context.interruptPromise;
let timeout;
const request = Promise.resolve().then(() => context.connection.request("turn/interrupt", {
threadId: context.threadId,
turnId: context.turnId,
}, this.interruptRequestTimeoutMs)).then(() => true).catch(() => false);
const deadline = new Promise((resolve) => {
timeout = setTimeout(() => resolve(false), this.interruptRequestTimeoutMs);
timeout.unref?.();
});
context.interruptPromise = Promise.race([request, deadline])
.finally(() => clearTimeout(timeout));
return context.interruptPromise;
}
#scheduleForcedCancellation(context, delayMs) {
if (context.settled) return;
clearTimeout(context.forceCancelTimer);
context.forceCancelTimer = setTimeout(() => {
this.#forceCancelContext(context, "Codex App Server did not complete the interrupted turn");
}, Math.max(0, delayMs));
context.forceCancelTimer.unref?.();
}
async #interruptAndSchedule(context) {
if (context.settled) return;
if (!context.turnId) {
this.#scheduleForcedCancellation(
context,
this.interruptRequestTimeoutMs + this.interruptGraceMs,
);
return;
}
const interrupted = await this.#interruptContext(context);
if (context.settled) return;
if (!interrupted) {
this.#forceCancelContext(context, "Codex App Server could not interrupt the turn");
return;
}
this.#scheduleForcedCancellation(context, this.interruptGraceMs);
}
#forceCancelContext(context, reason) {
if (context.settled) return;
context.settled = true;
clearTimeout(context.forceCancelTimer);
context.forceCancelTimer = null;
this.#closeReasoning(context);
this.#clearInteractionsForContext(context, "cancel");
context.emitter.emitDone();
context.resolve();
const connection = this.connections.get(context.connectionKey);
if (connection === context.connection) {
this.connections.delete(context.connectionKey);
try { connection.close(); } catch {}
this.#refreshPreferredConnectionKey();
}
const error = new Error(reason);
for (const candidate of this.activeByRequest.values()) {
if (candidate === context || candidate.connectionKey !== context.connectionKey || candidate.settled) continue;
candidate.settled = true;
clearTimeout(candidate.forceCancelTimer);
candidate.forceCancelTimer = null;
this.#clearInteractionsForContext(candidate, "cancel");
candidate.reject(error);
}
}
#findContext(connectionKey, params) {
if (params?.turnId) {
const byTurn = this.activeByTurn.get(this.#scopedKey(connectionKey, params.turnId));
if (byTurn) return byTurn;
}
if (params?.threadId) {
return this.activeByThread.get(this.#scopedKey(connectionKey, params.threadId)) || null;
}
return null;
}
#handleNotification(connectionKey, message) {
const params = message.params || {};
const context = this.#findContext(connectionKey, params);
if (!context) {
if (message.method === "warning") {
const contexts = Array.from(this.activeByRequest.values())
.filter((candidate) => candidate.connectionKey === connectionKey);
for (const candidate of contexts) {
candidate.emitter.warning(
`codex-warning:connection:${++this.eventCounter}`,
params.message || "Codex warning",
);
}
}
return;
}
const emitter = context.emitter;
switch (message.method) {
case "turn/started":
this.#assignTurnId(context, params.turn?.id);
return;
case "item/agentMessage/delta": {
appendStreamState(context.streamedTextByItem, params.itemId, params.delta);
emitter.text(params.delta || "");
return;
}
case "item/reasoning/summaryTextDelta": {
appendStreamState(context.streamedReasoningByItem, params.itemId, params.delta);
emitter.reasoning(params.delta || "");
context.reasoningOpen = true;
return;
}
case "item/commandExecution/outputDelta": {
appendToolOutputState(context.commandOutputByItem, params.itemId, params.delta);
return;
}
case "item/started":
this.#handleItem(context, params.item, false);
return;
case "item/completed":
this.#handleItem(context, params.item, true);
return;
case "turn/plan/updated":
emitter.planUpdate(
`codex-plan:${params.turnId}`,
(params.plan || []).map((item) => ({
text: item.step || "",
completed: item.status === "completed",
})),
(params.plan || []).every((item) => item.status === "completed") ? "completed" : "running",
);
return;
case "thread/tokenUsage/updated": {
const usage = params.tokenUsage?.last;
if (usage) {
emitter.usage({
inputTokens: Number(usage.inputTokens) || 0,
cachedInputTokens: Number(usage.cachedInputTokens) || 0,
outputTokens: Number(usage.outputTokens) || 0,
reasoningTokens: Number(usage.reasoningOutputTokens) || 0,
totalTokens: Number(usage.totalTokens) || 0,
});
}
return;
}
case "warning":
emitter.warning(
`codex-warning:${params.turnId || context.turnId}:${++this.eventCounter}`,
params.message || "Codex warning",
);
return;
case "error":
context.lastError = params.error?.message || "Codex App Server error";
emitter.warning(
`codex-error:${params.turnId || context.turnId}:${++this.eventCounter}`,
params.willRetry ? `${context.lastError} (retrying)` : context.lastError,
);
return;
case "turn/completed":
this.#completeTurn(context, params.turn);
return;
default:
return;
}
}
#closeReasoning(context) {
if (!context.reasoningOpen) return;
context.emitter.reasoningEnd();
context.reasoningOpen = false;
}
#emitToolCallOnce(context, item, name, args) {
if (!item?.id || context.emittedToolCalls.has(item.id)) return;
context.emittedToolCalls.add(item.id);
this.#closeReasoning(context);
context.emitter.toolCall(name, args || {}, item.id);
}
#emitToolResultOnce(context, item, output, name) {
if (!item?.id || context.emittedToolResults.has(item.id)) return;
context.emittedToolResults.add(item.id);
context.emitter.toolResult(item.id, output || "", name);
}
#handleItem(context, item, completed) {
if (!item || typeof item !== "object") return;
const emitter = context.emitter;
switch (item.type) {
case "agentMessage": {
if (!completed) return;
this.#closeReasoning(context);
const streamed = context.streamedTextByItem.get(item.id);
context.streamedTextByItem.delete(item.id);
if (item.text && streamed && item.text.startsWith(streamed.prefix)) {
if (item.text.length > streamed.length) emitter.text(item.text.slice(streamed.length));
} else if (item.text && !streamed) emitter.text(item.text);
return;
}
case "reasoning": {
if (!completed) return;
const finalText = Array.isArray(item.summary) ? item.summary.join("\n") : "";
const streamed = context.streamedReasoningByItem.get(item.id);
context.streamedReasoningByItem.delete(item.id);
if (finalText && streamed && finalText.startsWith(streamed.prefix)) {
if (finalText.length > streamed.length) emitter.reasoning(finalText.slice(streamed.length));
} else if (finalText && !streamed) emitter.reasoning(finalText);
context.reasoningOpen = true;
this.#closeReasoning(context);
return;
}
case "commandExecution": {
const toolName = "codex.command";
this.#emitToolCallOnce(context, item, toolName, { command: item.command, cwd: item.cwd });
if (completed) {
const streamedOutput = context.commandOutputByItem.get(item.id);
context.commandOutputByItem.delete(item.id);
const output = item.aggregatedOutput == null
? formatBoundedToolOutput(
streamedOutput?.text || "",
streamedOutput?.totalLength || 0,
)
: formatBoundedToolOutput(item.aggregatedOutput);
const suffix = item.exitCode == null ? "" : `\n[exit code: ${item.exitCode}]`;
this.#emitToolResultOnce(context, item, `${output}${suffix}`, toolName);
}
return;
}
case "mcpToolCall": {
const toolName = `${item.server || "mcp"}.${item.tool || "tool"}`;
this.#emitToolCallOnce(context, item, toolName, item.arguments || {});
if (completed) {
const output = item.error?.message || stringifyMcpContent(item.result);
this.#emitToolResultOnce(context, item, output, toolName);
}
return;
}
case "fileChange":
if (completed) {
emitter.fileChange(
item.id,
normalizeFileChanges(item.changes),
item.status === "completed" ? "completed" : "failed",
);
}
return;
case "webSearch":
emitter.webSearch(item.id, item.query || "", completed ? "completed" : "running");
return;
default:
return;
}
}
#completeTurn(context, turn) {
if (context.settled) return;
context.settled = true;
clearTimeout(context.forceCancelTimer);
context.forceCancelTimer = null;
this.#closeReasoning(context);
this.#clearInteractionsForContext(context, "cancel");
if (turn?.status === "failed") {
context.reject(new Error(turn.error?.message || context.lastError || "Codex turn failed"));
return;
}
context.emitter.emitDone();
context.resolve();
}
async #handleServerRequest(connectionKey, connection, message) {
const params = message.params || {};
const context = this.#findContext(connectionKey, params);
const supported = new Map([
["item/commandExecution/requestApproval", "command"],
["item/fileChange/requestApproval", "file-change"],
["item/permissions/requestApproval", "permissions"],
["item/tool/requestUserInput", "user-input"],
]);
const kind = supported.get(message.method);
if (!kind) {
connection.respondError(message.id, -32601, `Unsupported Codex App Server request: ${message.method}`);
context?.emitter.warning(
`codex-unsupported-request:${++this.eventCounter}`,
`Unsupported Codex request: ${message.method}`,
);
return;
}
if (!context) {
connection.respond(message.id, this.#safeInteractionResponse(kind, params, "reject"));
return;
}
const interactionId = `codex_interaction_${++this.interactionCounter}_${Date.now()}`;
const timeoutMs = kind === "user-input" && Number(params.autoResolutionMs) > 0
? Number(params.autoResolutionMs)
: INTERACTION_TIMEOUT_MS;
// Hard ceiling from creation — review can cancel the idle timer but must
// re-arm the absolute remainder (Catty/MCP pattern; never unbounded).
const absoluteExpiresAt = Date.now() + timeoutMs;
const armTimer = (ms) => {
const pending = this.pendingInteractions.get(interactionId);
if (!pending) return;
if (pending.timer) {
clearTimeout(pending.timer);
pending.timer = null;
}
if (ms <= 0) {
this.#resolveInteraction(
interactionId,
kind === "user-input" ? { answers: {} } : { decision: "reject" },
);
return;
}
pending.timer = setTimeout(() => {
this.#resolveInteraction(
interactionId,
kind === "user-input" ? { answers: {} } : { decision: "reject" },
);
}, ms);
};
this.pendingInteractions.set(interactionId, {
interactionId,
connection,
rpcId: message.id,
kind,
params,
context,
timer: null,
absoluteExpiresAt,
idleCancelled: false,
});
armTimer(timeoutMs);
const payload = {
interactionId,
source: "codex-app-server",
kind,
requestId: context.requestId,
chatSessionId: context.chatSessionId,
itemId: params.itemId,
toolName: kind === "command"
? "codex.command"
: kind === "file-change"
? "codex.file_change"
: kind === "permissions"
? "codex.permissions"
: undefined,
args: kind === "command"
? {
command: params.command,
cwd: params.cwd,
reason: params.reason,
commandActions: params.commandActions,
}
: kind === "file-change"
? { reason: params.reason, grantRoot: params.grantRoot, itemId: params.itemId }
: kind === "permissions"
? { cwd: params.cwd, reason: params.reason, permissions: params.permissions }
: undefined,
availableDecisions: kind === "command" && Array.isArray(params.availableDecisions)
? params.availableDecisions
: undefined,
questions: kind === "user-input" ? params.questions || [] : undefined,
autoResolutionMs: kind === "user-input" ? params.autoResolutionMs : undefined,
};
let delivered = false;
try {
delivered = typeof this.sendInteractionRequest === "function"
&& this.sendInteractionRequest(payload, context) !== false;
} catch {
delivered = false;
}
if (!delivered) {
this.#resolveInteraction(interactionId, kind === "user-input" ? { answers: {} } : { decision: "reject" });
}
}
#safeInteractionResponse(kind, params, decision) {
if (kind === "user-input") return { answers: {} };
if (kind === "permissions") {
const granted = decision === "once" || decision === "session"
? normalizeGrantedPermissions(params.permissions)
: {};
return { permissions: granted, scope: decision === "session" ? "session" : "turn" };
}
const mapped = decision === "once"
? "accept"
: decision === "session"
? "acceptForSession"
: decision === "cancel"
? "cancel"
: "decline";
return { decision: mapped };
}
#resolveInteraction(interactionId, response) {
const pending = this.pendingInteractions.get(interactionId);
if (!pending) return false;
this.pendingInteractions.delete(interactionId);
clearTimeout(pending.timer);
try {
const result = pending.kind === "user-input"
? { answers: response?.answers || {} }
: this.#safeInteractionResponse(pending.kind, pending.params, response?.decision || "reject");
try { pending.connection.respond(pending.rpcId, result); } catch {}
} finally {
this.sendInteractionCleared?.({
interactionIds: [interactionId],
chatSessionId: pending.context.chatSessionId,
}, pending.context);
}
return true;
}
respondInteraction(interactionId, response, sender) {
const pending = this.pendingInteractions.get(interactionId);
if (sender && pending?.context?.sender && pending.context.sender !== sender) return false;
return this.#resolveInteraction(interactionId, response);
}
/**
* Drop the idle auto-reject timer after the user starts reviewing an approval card.
* Re-arms the absolute creation deadline so a late approve cannot outlive the
* original timeout window (matches Catty/MCP approval cancel semantics).
*/
cancelInteractionTimeout(interactionId, sender) {
const pending = this.pendingInteractions.get(interactionId);
if (!pending || pending.idleCancelled) return false;
if (sender && pending.context?.sender && pending.context.sender !== sender) return false;
pending.idleCancelled = true;
if (pending.timer) {
clearTimeout(pending.timer);
pending.timer = null;
}
const remainingMs = Math.max(0, (pending.absoluteExpiresAt ?? 0) - Date.now());
if (remainingMs <= 0) {
this.#resolveInteraction(
interactionId,
pending.kind === "user-input" ? { answers: {} } : { decision: "reject" },
);
return true;
}
pending.timer = setTimeout(() => {
this.#resolveInteraction(
interactionId,
pending.kind === "user-input" ? { answers: {} } : { decision: "reject" },
);
}, remainingMs);
return true;
}
#clearInteractionsForContext(context, decision) {
for (const [interactionId, pending] of Array.from(this.pendingInteractions)) {
if (pending.context === context) {
this.#resolveInteraction(
interactionId,
pending.kind === "user-input" ? { answers: {} } : { decision },
);
}
}
}
async cancelTurn(requestId) {
const context = this.activeByRequest.get(requestId);
if (!context) return false;
context.cancelRequested = true;
this.#clearInteractionsForContext(context, "cancel");
await this.#interruptAndSchedule(context);
return true;
}
async cleanupChatSession(chatSessionId) {
const contexts = Array.from(this.activeByRequest.values())
.filter((context) => context.chatSessionId === chatSessionId);
await Promise.all(contexts.map((context) => this.cancelTurn(context.requestId)));
}
#handleConnectionFatal(connectionKey, error) {
const connection = this.connections.get(connectionKey);
if (connection) {
this.connections.delete(connectionKey);
this.#refreshPreferredConnectionKey();
}
const contexts = Array.from(this.activeByRequest.values())
.filter((context) => context.connectionKey === connectionKey);
for (const context of contexts) {
if (context.settled) continue;
context.settled = true;
clearTimeout(context.forceCancelTimer);
context.forceCancelTimer = null;
this.#clearInteractionsForContext(context, "cancel");
context.reject(error);
}
}
#removeContext(context) {
clearTimeout(context.forceCancelTimer);
context.forceCancelTimer = null;
if (context.abortListener && context.signal) {
context.signal.removeEventListener("abort", context.abortListener);
context.abortListener = null;
}
this.activeByRequest.delete(context.requestId);
this.activeByThread.delete(this.#scopedKey(context.connectionKey, context.threadId));
if (context.turnId) this.activeByTurn.delete(this.#scopedKey(context.connectionKey, context.turnId));
this.#closeIdleConnections();
}
close() {
for (const interactionId of Array.from(this.pendingInteractions.keys())) {
this.#resolveInteraction(interactionId, { decision: "cancel", answers: {} });
}
for (const [, connection] of this.connections) connection.close();
this.connections.clear();
this.preferredConnectionKey = null;
for (const context of this.activeByRequest.values()) {
if (!context.settled) {
context.settled = true;
clearTimeout(context.forceCancelTimer);
context.forceCancelTimer = null;
context.reject(new Error("Codex App Server shut down"));
}
}
this.activeByRequest.clear();
this.activeByThread.clear();
this.activeByTurn.clear();
}
}
module.exports = {
CodexAppServerRuntime,
INTERACTION_TIMEOUT_MS,
buildThreadConfig,
buildTurnInput,
getActiveTurnNotSteerableKind,
mapAppServerModels,
normalizeFileChanges,
normalizeGrantedPermissions,
resolveAppServerModelSelection,
resolveCodexPermissionConfig,
stringifyMcpContent,
};