Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions readme-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Set `CODEX_PATH` to run a different Codex binary; versions other than the one sp
- `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `agent`, or `agent-full-access`.
- `NO_BROWSER` - hide browser-based ChatGPT auth when set.
- `APP_SERVER_LOGS` - directory for adapter logs.
- `MCP_STARTUP_PROMPT_TIMEOUT_MS` - how long a prompt waits for the session's MCP servers to finish starting before the turn is started without them (default `30000`).

### Quick start

Expand Down
114 changes: 99 additions & 15 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ export interface SessionFailure {

const CODEX_PROCESS_EXITED_ERROR_CODE = 1001;

const DEFAULT_MCP_STARTUP_PROMPT_TIMEOUT_MS = 30_000;

function clientSupportsTypedSessionFailures(capabilities: acp.ClientCapabilities | null): boolean {
return clientSupportsAirCapability(capabilities, AIR_SESSION_FAILURE_KEY);
}
Expand All @@ -200,6 +202,8 @@ interface ActiveAuthState {
interface PendingMcpStartupSession {
requestedServers: Set<string>;
afterVersion: number;
settled: Promise<void>;
gateExpired: boolean;
}

interface PendingTurnStart {
Expand All @@ -213,6 +217,7 @@ interface ActivePrompt {
cancelSignal: Promise<null>;
signal: AbortSignal;
currentTurn: { threadId: string, turnId: string } | null;
awaitingMcpStartup: boolean;
requestCancel: () => void;
requestClose: () => void;
complete: () => void;
Expand Down Expand Up @@ -658,11 +663,7 @@ export class CodexAcpServer {

const canPublishSessionUpdates = operation !== "fork";
if (canPublishSessionUpdates && requestedMcpServers.length > 0 && mcpServerStartupVersion !== null) {
this.pendingMcpStartupSessions.set(sessionId, {
requestedServers: new Set(getRequestedMcpServerNames(requestedMcpServers)),
afterVersion: mcpServerStartupVersion,
});
this.publishMcpStartupStatusAsync(sessionId);
this.trackMcpServerStartup(sessionId, requestedMcpServers, mcpServerStartupVersion);
}

if (canPublishSessionUpdates) {
Expand Down Expand Up @@ -1708,11 +1709,7 @@ export class CodexAcpServer {
subscribed = false;

if (requestedMcpServers.length > 0 && mcpServerStartupVersion !== null) {
this.pendingMcpStartupSessions.set(sessionId, {
requestedServers: new Set(getRequestedMcpServerNames(requestedMcpServers)),
afterVersion: mcpServerStartupVersion,
});
this.publishMcpStartupStatusAsync(sessionId);
this.trackMcpServerStartup(sessionId, requestedMcpServers, mcpServerStartupVersion);
}

await this.publishAvailableCommands(sessionState, requestedSessionGeneration);
Expand Down Expand Up @@ -2152,16 +2149,73 @@ export class CodexAcpServer {
return [];
}

private publishMcpStartupStatusAsync(sessionId: string): void {
void this.doPublishMcpStartupStatus(sessionId);
private trackMcpServerStartup(
sessionId: string,
requestedMcpServers: Array<acp.McpServer>,
afterVersion: number,
): void {
const pendingStartup: PendingMcpStartupSession = {
requestedServers: new Set(getRequestedMcpServerNames(requestedMcpServers)),
afterVersion: afterVersion,
settled: Promise.resolve(),
gateExpired: false,
};
this.pendingMcpStartupSessions.set(sessionId, pendingStartup);
pendingStartup.settled = this.doPublishMcpStartupStatus(sessionId, pendingStartup);
}

private async doPublishMcpStartupStatus(sessionId: string): Promise<void> {
/**
* Waits until the session's MCP servers finished starting, so a turn never runs with tools
* Codex has not registered yet. Returns false when the prompt was cancelled while waiting.
*/
private async awaitSessionMcpStartup(sessionId: string, activePrompt: ActivePrompt): Promise<boolean> {
const pendingStartup = this.pendingMcpStartupSessions.get(sessionId);
if (!pendingStartup) {
return;
if (!pendingStartup || pendingStartup.gateExpired) {
return true;
}

const requestedServers = Array.from(pendingStartup.requestedServers);
logger.log("Waiting for MCP server startup before starting a turn", {sessionId, servers: requestedServers});
const timeoutMs = getMcpStartupPromptTimeoutMs();
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const expired = new Promise<"expired">((resolve) => {
timeoutHandle = setTimeout(() => resolve("expired"), timeoutMs);
timeoutHandle.unref?.();
});
activePrompt.awaitingMcpStartup = true;
try {
const outcome = await Promise.race([
pendingStartup.settled.then(() => "started" as const),
activePrompt.cancelSignal.then(() => "cancelled" as const),
expired,
]);
if (outcome === "cancelled") {
logger.log("Prompt cancelled while waiting for MCP server startup", {sessionId});
return false;
}
if (outcome === "expired") {
// Codex may never report a post-startup status for a server. Do not block prompts
// forever on it: run the turn without those tools and stop waiting on later prompts.
pendingStartup.gateExpired = true;
logger.log("MCP server startup timed out, starting the turn without those tools", {
sessionId,
timeoutMs,
servers: requestedServers,
});
return true;
}
logger.log("MCP server startup completed, starting the turn", {sessionId});
return true;
} finally {
activePrompt.awaitingMcpStartup = false;
clearTimeout(timeoutHandle);
}
}

private async doPublishMcpStartupStatus(
sessionId: string,
pendingStartup: PendingMcpStartupSession,
): Promise<void> {
try {
const mcpStartup = await this.runWithProcessCheck(() =>
this.codexAcpClient.awaitMcpServerStartup(
Expand Down Expand Up @@ -2228,6 +2282,7 @@ export class CodexAcpServer {
cancelSignal,
signal: abortController.signal,
currentTurn: null,
awaitingMcpStartup: false,
requestCancel: () => {
if (abortController.signal.aborted) {
return;
Expand Down Expand Up @@ -2523,6 +2578,15 @@ export class CodexAcpServer {
return cancelledPromptResponse();
}

if (this.availableCommands.startsAgentTurn(params.prompt)) {
if (!await this.awaitSessionMcpStartup(params.sessionId, activePrompt)) {
return cancelledPromptResponse();
}
if (this.sessionIsClosing(params.sessionId)) {
return cancelledPromptResponse();
}
}

const commandPromise = this.availableCommands.tryHandleCommand(params.prompt, sessionState, {
onTurnStartPending: () => {
ensurePendingTurnStart();
Expand Down Expand Up @@ -3007,6 +3071,14 @@ export class CodexAcpServer {
return;
}

// No turn exists yet while the prompt waits for MCP startup, so there is nothing to interrupt.
const activePrompt = this.activePrompts.get(params.sessionId);
if (activePrompt?.awaitingMcpStartup) {
logger.log("Cancel requested while waiting for MCP server startup", {sessionId: params.sessionId});
activePrompt.requestCancel();
return;
}

// After turnInterrupt(), Codex will send turn/completed, which naturally completes awaitTurnCompleted().
await this.interruptSessionTurn(sessionState, "Cancel", false);
}
Expand Down Expand Up @@ -3098,3 +3170,15 @@ function historyUpdateContentKey(update: UpdateSessionEvent): string | null {
function getRequestedMcpServerNames(mcpServers: Array<acp.McpServer>): Array<string> {
return Array.from(new Set(mcpServers.map(server => sanitizeMcpServerName(server.name))));
}

function getMcpStartupPromptTimeoutMs(): number {
const value = process.env["MCP_STARTUP_PROMPT_TIMEOUT_MS"]?.trim();
if (!value) {
return DEFAULT_MCP_STARTUP_PROMPT_TIMEOUT_MS;
}
const configured = Number(value);
if (!Number.isFinite(configured) || configured < 0) {
return DEFAULT_MCP_STARTUP_PROMPT_TIMEOUT_MS;
}
return configured;
}
33 changes: 33 additions & 0 deletions src/CodexCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,39 @@ export class CodexCommands {
};
}

/**
* Whether this prompt makes Codex run an agent turn that can call MCP tools.
* Keep the switch in sync with {@link tryHandleCommand}.
*/
startsAgentTurn(prompt: acp.ContentBlock[]): boolean {
const command = this.parseCommand(prompt);
if (command === null) return true;
if (command.name.startsWith("$")) return true;

switch (command.name) {
case "plan":
case "status":
case "skills":
case "mcp":
case "rename":
case "logout":
case "compact":
return false;
case "review":
case "review-branch":
case "review-commit":
return true;
// "/goal pause", "/goal clear", and "/goal" (usage) do not start a turn, but the other forms do.
case "goal": {
const arg = command.rest.trim().toLowerCase();
return !(arg.length === 0 || arg === "pause" || arg === "clear" || arg.length > 4000);
}
default:
// Unrecognized commands are forwarded to Codex as raw prompts.
return true;
}
}

async tryHandleCommand(
prompt: acp.ContentBlock[],
sessionState: SessionState,
Expand Down
Loading