Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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;
}
30 changes: 30 additions & 0 deletions src/CodexCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,36 @@ 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":
// "/goal pause" and "/goal clear" do not start a turn, but the other forms do.
case "goal":
return true;
Comment thread
bgeisberger marked this conversation as resolved.
default:
// Unrecognized commands are forwarded to Codex as raw prompts.
return true;
}
}

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