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
40 changes: 40 additions & 0 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,12 @@ export class CodexAcpClient {
baseUrl: gatewayConfig.config.base_url,
}
: this.getNativeProviderConfig();
logger.log("providers/list", {
providerId: OPENAI_PROVIDER_ID,
overrideActive: gatewayConfig !== null,
apiType: current.apiType,
baseUrl: current.baseUrl,
});
return [
{
providerId: OPENAI_PROVIDER_ID,
Expand Down Expand Up @@ -387,16 +393,37 @@ export class CodexAcpClient {
baseUrl: request.baseUrl,
headers: request.headers,
});
logger.log("providers/set applied", {
providerId: request.providerId,
apiType: request.apiType,
baseUrl: request.baseUrl,
headerNames: Object.keys(request.headers ?? {}),
});
}

/**
* `providers/disable`: disables the custom gateway provider. Disabling an
* unknown provider id is idempotent success (RFD behavior §7).
*/
disableProvider(request: acp.DisableProviderRequest): void {
const overrideWasActive = this.gatewayConfig !== null;
if (request.providerId === OPENAI_PROVIDER_ID) {
this.gatewayConfig = null;
}
const current = this.gatewayConfig
? {
apiType: gatewayApiTypeFromConfig(this.gatewayConfig),
baseUrl: this.gatewayConfig.config.base_url,
}
: this.getNativeProviderConfig();
logger.log("providers/disable applied", {
providerId: request.providerId,
knownProvider: request.providerId === OPENAI_PROVIDER_ID,
overrideWasActive,
overrideActive: this.gatewayConfig !== null,
restoredApiType: current.apiType,
restoredBaseUrl: current.baseUrl,
});
}

async getAccount(): Promise<GetAccountResponse> {
Expand Down Expand Up @@ -579,6 +606,19 @@ export class CodexAcpClient {
mcpServers: Array<McpServer>
): Promise<JsonObject> {
const sessionRoots = [projectPath, ...additionalDirectories];
const activeProvider = this.gatewayConfig
? {
apiType: gatewayApiTypeFromConfig(this.gatewayConfig),
baseUrl: this.gatewayConfig.config.base_url,
}
: this.getNativeProviderConfig();
logger.log("Creating session config", {
projectPath,
overrideActive: this.gatewayConfig !== null,
modelProvider: this.getModelProvider(),
apiType: activeProvider.apiType,
baseUrl: activeProvider.baseUrl,
});
const mergedConfig = {
...mergeGatewayConfig(this.config, this.gatewayConfig),
projects: Object.fromEntries(sessionRoots.map(root => [root, {
Expand Down
99 changes: 92 additions & 7 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {type CodexAuthRequest, getCodexAuthMethods, isCodexAuthRequest} from "./
import {clientSupportsUrlElicitation} from "./ElicitationCapabilities";
import {
CodexAcpClient,
OPENAI_PROVIDER_ID,
type SessionMetadata,
type SessionMetadataWithThread,
type UrlElicitationRequester
Expand Down Expand Up @@ -122,6 +123,7 @@ export interface SessionState {
authProvider: string | null;
cwd: string;
additionalDirectories: string[];
mcpServers?: Array<acp.McpServer>;
fastModeEnabled: boolean;
currentModelSupportsFast: boolean;
sessionMcpServers?: Array<string>;
Expand Down Expand Up @@ -194,20 +196,22 @@ interface ActivePrompt {
complete: () => void;
}

export type RestartCodexClient = () => Promise<CodexAcpClient>;

export class CodexAcpServer {
private static readonly MODEL_NAME_TOKEN_OVERRIDES: Record<string, string> = {
gpt: "GPT",
mini: "Mini",
codex: "Codex",
};

private readonly codexAcpClient: CodexAcpClient;
private codexAcpClient: CodexAcpClient;
private readonly connection: AcpClientConnection;
private readonly defaultAuthRequest: CodexAuthRequest | null;
private readonly getExitCode: () => number | null;
private readonly getRecentStderr: () => string;
private readonly sessionFailureEpoch: string;
private readonly availableCommands: CodexCommands;
private availableCommands: CodexCommands;
private clientInfo: acp.Implementation | null;
private clientCapabilities: acp.ClientCapabilities | null;
private terminalOutputMode: TerminalOutputMode;
Expand All @@ -222,13 +226,17 @@ export class CodexAcpServer {
private readonly sessionGenerations: Map<string, number>;
private readonly sessionOpenGenerations: Map<string, number>;
private readonly goalControlGenerations: Map<string, number>;
private readonly restartCodexClient: RestartCodexClient | null;
private initializeRequest: acp.InitializeRequest | null = null;
private providerUpdate: Promise<void> | null = null;

constructor(
connection: AcpClientConnection,
codexAcpClient: CodexAcpClient,
defaultAuthRequest?: CodexAuthRequest,
getExitCode?: () => number | null,
getRecentStderr?: () => string,
restartCodexClient?: RestartCodexClient,
) {
this.sessions = new Map();
this.pendingMcpStartupSessions = new Map();
Expand All @@ -244,14 +252,19 @@ export class CodexAcpServer {
this.defaultAuthRequest = defaultAuthRequest ?? null;
this.getExitCode = getExitCode ?? (() => null);
this.getRecentStderr = getRecentStderr ?? (() => "");
this.restartCodexClient = restartCodexClient ?? null;
this.sessionFailureEpoch = randomUUID();
this.clientInfo = null;
this.clientCapabilities = null;
this.terminalOutputMode = "terminal_output_delta";
this.booleanConfigOptionsSupported = false;
this.availableCommands = new CodexCommands(
connection,
codexAcpClient,
this.availableCommands = this.createAvailableCommands(codexAcpClient);
}

private createAvailableCommands(client: CodexAcpClient): CodexCommands {
return new CodexCommands(
this.connection,
client,
(operation) => this.runWithProcessCheck(operation),
() => this.refreshSessionsAuthState(null)
);
Expand All @@ -263,6 +276,7 @@ export class CodexAcpServer {
logger.log("Initialize request received");
this.clientInfo = _params.clientInfo ?? null;
this.clientCapabilities = _params.clientCapabilities ?? null;
this.initializeRequest = _params;
this.terminalOutputMode = resolveTerminalOutputMode(_params.clientCapabilities);
this.booleanConfigOptionsSupported = clientSupportsBooleanConfigOptions(_params.clientCapabilities);
await this.runWithProcessCheck(() => this.codexAcpClient.initialize(_params));
Expand Down Expand Up @@ -571,6 +585,7 @@ export class CodexAcpServer {
authProvider: authProvider,
cwd: request.cwd,
additionalDirectories: sessionMetadata.additionalDirectories,
mcpServers: requestedMcpServers,
fastModeEnabled: sessionMetadata.currentServiceTier === "fast",
currentModelSupportsFast: currentModelSupportsFast,
sessionMcpServers: sessionMcpServers,
Expand Down Expand Up @@ -633,6 +648,7 @@ export class CodexAcpServer {
}

async loadSession(params: acp.LoadSessionRequest): Promise<LegacyLoadSessionResponse> {
await this.waitForProviderUpdate();
logger.log("Loading session...", {sessionId: params.sessionId});
const {
sessionId,
Expand All @@ -656,6 +672,7 @@ export class CodexAcpServer {
}

async resumeSession(params: acp.ResumeSessionRequest): Promise<LegacyResumeSessionResponse> {
await this.waitForProviderUpdate();
logger.log("Resuming session...", {sessionId: params.sessionId});
const [sessionId, modelState, modeState] = await this.getOrCreateSession(params);

Expand Down Expand Up @@ -764,6 +781,7 @@ export class CodexAcpServer {
async newSession(
params: acp.NewSessionRequest,
): Promise<LegacyNewSessionResponse> {
await this.waitForProviderUpdate();
logger.log("Starting new session...");
const [sessionId, modelState, modeState] = await this.getOrCreateSession(params);

Expand Down Expand Up @@ -821,16 +839,81 @@ export class CodexAcpServer {
return { providers: this.codexAcpClient.listProviders() };
}

setProvider(params: acp.SetProviderRequest): acp.SetProviderResponse {
async setProvider(params: acp.SetProviderRequest): Promise<acp.SetProviderResponse> {
this.codexAcpClient.setProvider(params);
await this.enqueueProviderUpdate((client) => client.setProvider(params));
return { };
}

disableProvider(params: acp.DisableProviderRequest): acp.DisableProviderResponse {
async disableProvider(params: acp.DisableProviderRequest): Promise<acp.DisableProviderResponse> {
this.codexAcpClient.disableProvider(params);
if (params.providerId !== OPENAI_PROVIDER_ID) {
return { };
}
await this.enqueueProviderUpdate((client) => client.disableProvider(params));
return { };
}

private async enqueueProviderUpdate(apply: (client: CodexAcpClient) => void): Promise<void> {
const previous = this.providerUpdate?.catch(() => undefined) ?? Promise.resolve();
const update = previous.then(async () => {
if (this.sessions.size === 0 || this.restartCodexClient === null) {
return;
}

const activePrompts = [...this.activePrompts.values()].map(prompt => prompt.completion);
if (activePrompts.length > 0) {
logger.log("Waiting for active prompts before provider restart", {count: activePrompts.length});
await Promise.all(activePrompts);
}

logger.log("Restarting Codex app-server for provider update", {sessionCount: this.sessions.size});
const replacement = await this.restartCodexClient();
apply(replacement);
if (this.initializeRequest === null) {
throw new Error("Cannot restart Codex app-server before ACP initialization");
}
await replacement.initialize(this.initializeRequest);
this.codexAcpClient = replacement;
this.availableCommands = this.createAvailableCommands(replacement);

const resumeErrors: unknown[] = [];
for (const session of this.sessions.values()) {
try {
await replacement.resumeSession({
sessionId: session.sessionId,
cwd: session.cwd,
additionalDirectories: session.additionalDirectories,
mcpServers: session.mcpServers ?? [],
});
session.authProvider = replacement.getModelProvider();
logger.log("Resumed session after provider restart", {sessionId: session.sessionId});
} catch (error) {
resumeErrors.push(error);
logger.error(`Failed to resume session ${session.sessionId} after provider restart`, error);
}
}
if (resumeErrors.length > 0) {
throw new AggregateError(resumeErrors, `Failed to resume ${resumeErrors.length} session(s) after provider restart`);
}
});
this.providerUpdate = update;
try {
await update;
} finally {
if (this.providerUpdate === update) {
this.providerUpdate = null;
}
}
}

private async waitForProviderUpdate(): Promise<void> {
const update = this.providerUpdate;
if (update !== null) {
await update;
}
}

private async refreshSessionsAuthState(authProvider: string | null): Promise<void> {
if (this.sessions.size === 0) return;

Expand Down Expand Up @@ -1460,6 +1543,7 @@ export class CodexAcpServer {
authProvider: authProvider,
cwd: request.cwd,
additionalDirectories: sessionMetadata.additionalDirectories,
mcpServers: requestedMcpServers,
fastModeEnabled: sessionMetadata.currentServiceTier === "fast",
currentModelSupportsFast: currentModelSupportsFast,
sessionMcpServers: sessionMcpServers,
Expand Down Expand Up @@ -2025,6 +2109,7 @@ export class CodexAcpServer {
signal?: AbortSignal,
onTurnStarted?: () => void,
): Promise<acp.PromptResponse> {
await this.waitForProviderUpdate();
logger.log("Prompt received", {
sessionId: params.sessionId,
prompt: params.prompt,
Expand Down
3 changes: 2 additions & 1 deletion src/Logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class Logger {
try {
fs.mkdirSync(logDir, {recursive: true});
this.logFilePath = path.join(logDir, "app-server.log");
this.log("Logger initialized", {logFilePath: this.logFilePath});
} catch (ex) {
console.error("Failed to initialize logger directory", ex);
this.logFilePath = null;
Expand All @@ -32,7 +33,7 @@ class Logger {
if (!this.logFilePath) return;
try {
const timestamp = this.formatTimestamp(new Date());
const serializedContext = context ? ` ${JSON.stringify(context)}` : "";
const serializedContext = ` ${JSON.stringify({pid: process.pid, ...context})}`;

if (!message.startsWith('[')) message = `[SYS] ${message}`;
const line = `${timestamp} ${message}${serializedContext}`;
Expand Down
Loading