Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
97b6bf5
feat: show live MCP server status and reconnect from /mcp
nikita-ashihmin Oct 1, 2026
eee8863
feat: send the SKILL.md path of a skill command to AIR
nikita-ashihmin Oct 1, 2026
b8bbdf5
fix: end the compaction wait when Codex exits
nikita-ashihmin Oct 1, 2026
ecca58d
refactor: move the MCP session startup report to src/mcp/
nikita-ashihmin Oct 1, 2026
2a3f2f2
docs: list the skillPath key of a skill command for AIR
nikita-ashihmin Oct 1, 2026
cea9521
fix: keep more of the MCP error text and limit the cleanup time
nikita-ashihmin Oct 1, 2026
4771d51
refactor: simplify the MCP session startup wait
nikita-ashihmin Oct 1, 2026
9dda6ae
test: kill the fake Codex process after the compact exit test
nikita-ashihmin Oct 1, 2026
ff13488
test: use a SKILL.md path in the skill command fixture
nikita-ashihmin Oct 1, 2026
acec35e
perf: read the next history page while the adapter sends a page
nikita-ashihmin Oct 2, 2026
410ffbd
fix: cut a session title to 256 characters with an ellipsis
nikita-ashihmin Oct 2, 2026
d8ffe81
perf: skip the large fields of a finished tool call report
nikita-ashihmin Oct 2, 2026
f9e89fc
perf: write the ACP stream to stdout as strings
nikita-ashihmin Oct 2, 2026
96def94
perf: read the account while session/load resumes the thread
nikita-ashihmin Oct 2, 2026
faeccfb
fix: open a session when the ChatGPT backend is not reachable
nikita-ashihmin Oct 2, 2026
d851f26
fix: report a late auth failure of the account read
nikita-ashihmin Oct 2, 2026
eb1e005
fix: cut a session title to 160 characters for AIR
nikita-ashihmin Oct 2, 2026
37d998b
fix: count an MCP startup event without a thread id as global
nikita-ashihmin Oct 2, 2026
0c45e68
revert: keep the session title limit at 256 characters
nikita-ashihmin Oct 2, 2026
2f2721f
refactor: remove the server argument of /mcp reconnect
nikita-ashihmin Oct 2, 2026
32eb679
refactor: simplify the MCP error text cleanup
nikita-ashihmin Oct 2, 2026
536f627
refactor: use one helper to wait for a promise with a timeout
nikita-ashihmin Oct 2, 2026
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol]
- All AIR extensions, capabilities, and `_meta` keys: [AIR extensions](docs/air-extensions.md).
- A per-turn [agent file-change report](docs/air-extensions.md#agent-file-change-report) after capability negotiation.
- Client-provided MCP servers over command-based stdio config and HTTP transport.
- Slash commands: `/status`, `/mcp`, `/skills`, `/goal`, `/review`, `/review-branch`, `/review-commit`, `/compact`, and `/logout`, as well as configured skills.
- Slash commands: `/status`, `/mcp` (with `/mcp reconnect`), `/skills`, `/goal`, `/review`, `/review-branch`, `/review-commit`, `/compact`, and `/logout`, as well as configured skills.

## Installation

Expand Down
5 changes: 5 additions & 0 deletions docs/air-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Only these differences are allowed:
ACP defines no merge for `_meta` keys, so the `_meta` of each report keeps every key that the adapter sent before.
- Bug fixes: a unique MCP startup tool call id, the result of a dynamic tool in `content`,
no output after a tool call ended, and a terminal status for a replayed image generation.
- Feature changes for every client: the `/mcp` command has a new description and an input hint.
- The client gets no AIR-only key.
That is no `_meta.jetbrains.air` key and none of the earlier keys in [Removed keys](#removed-keys).
- The output of a command arrives once, in the chunks or in `content`, as [Zed conventions](#zed-conventions) describe.
Expand Down Expand Up @@ -186,6 +187,7 @@ The adapter sends these keys only to AIR. "AIR" in the gate column means that th
| `phase` | `agent_message_chunk._meta.jetbrains.air` | Codex message phase string | AIR |
| `kind` | session mode `_meta.jetbrains.air` and `mode` config option value `_meta.jetbrains.air` | `standard`, `auto_review`, or `full_access` | AIR |
| `commandAction` | available command `_meta.jetbrains.air` | command action object | AIR |
| `skillPath` | available command `_meta.jetbrains.air` of a `$skill` command | OS-native absolute path of the skill's `SKILL.md`, not a URI | AIR |
| `customAnswer` | `request_user_input` note field of an elicitation schema, `_meta.jetbrains.air` | `true` | AIR. The same field also carries the root key `_meta._askUserQuestionCustomAnswer: true`, because released AIR versions read only that key. |

## JetBrains shared keys
Expand Down Expand Up @@ -991,6 +993,9 @@ The adapter sends these keys only to AIR:
- An available command can carry `_meta.jetbrains.air.commandAction`:
- `/plan` has `{kind: "setConfigOption", configId, value, resetValue, presentation: "state"}`. It switches the collaboration mode to plan.
- `/goal` has `{kind: "prefixPrompt", presentation: "state"}`.
- A `$skill` command carries `_meta.jetbrains.air.skillPath`, the absolute path of its `SKILL.md` from `skills/list`.
The path is an OS-native absolute path, such as `C:\repo\SKILL.md` on Windows. It is not a URI.
AIR opens this file on a click on the skill chip.

## Removed keys

Expand Down
17 changes: 13 additions & 4 deletions docs/mcp-startup-await-timeout.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,19 @@ There is no built-in default timeout: an omitted field means do not wait

## Behavior after the request completes

The adapter never blocks `session/new` or `session/resume` on MCP startup: the request completes as soon as the session
is created, before any requested MCP server reaches a terminal state. Startup itself is never cancelled or interrupted;
the adapter keeps tracking every requested server in the background and still publishes `session_info_update` MCP status
notifications for `session/new` and `session/resume` sessions as servers finish starting.
Without the field, the adapter does not block `session/new` or `session/resume` on MCP startup. The request completes
as soon as the session is created. With the field, the wait ends when the startup completes or at the timeout,
whichever comes first. The adapter never cancels the startup.
It keeps tracking every requested server in the background.

When the requested servers reach a terminal state, the adapter reports each of them that failed or was cancelled.
Each report is a `session/update` with a new `tool_call` in the `failed` status and the title `mcp__<server>__startup`.
A server that starts successfully gets no report. When a server fails because it needs authentication and the client
supports URL elicitation, the adapter first starts the MCP OAuth sign-in. It reports the server only if the sign-in fails.
The `session/new`, `session/resume`, and `session/load` sessions get these reports. The `session/fork` sessions do not.

To see the live status of every MCP server, send the `/mcp` command. `/mcp reconnect` reloads the MCP configuration and
restarts the servers that failed, stopped, or changed.

## Compatibility

Expand Down
1 change: 1 addition & 0 deletions src/AirExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const AIR_MESSAGE_PHASE_KEY = "phase";
export const AIR_GOAL_KEY = "goal";
export const AIR_KIND_KEY = "kind";
export const AIR_COMMAND_ACTION_KEY = "commandAction";
export const AIR_SKILL_PATH_KEY = "skillPath";
export const AIR_PERMISSION_KEY = "permission";
export const AIR_CONTEXT_COMPACTION_KEY = "contextCompaction";
export const AIR_RAW_INPUT_RENDERING_KEY = "rawInputRendering";
Expand Down
27 changes: 27 additions & 0 deletions src/AuthStatusMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,33 @@ export function fromAccountUpdated(
}
}

/**
* Maps the `account/updated` push onto the last known account of the agent. A logout gives `null`.
* The push carries no email, so a ChatGPT account keeps the email of `previous` when `previous` is a
* ChatGPT account too. The push cannot fill the other account shapes, so the other modes keep `previous`
* until the next account read.
*/
export function accountFromUpdated(notification: AccountUpdatedNotification, previous: Account | null): Account | null {
switch (notification.authMode) {
case null:
return null;
case "chatgpt":
case "chatgptAuthTokens": {
const previousChatGpt = previous?.type === "chatgpt" ? previous : null;
return {
type: "chatgpt",
email: previousChatGpt?.email ?? null,
planType: notification.planType ?? previousChatGpt?.planType ?? "unknown",
};
}
case "apikey":
case "personalAccessToken":
return {type: "apiKey"};
default:
return previous;
}
}

/**
* Do two payloads carry the same information? Compares every rendered field,
* so a richer read of the same login is NOT the same. Callers use it to drop a
Expand Down
77 changes: 61 additions & 16 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import type {
ApprovalHandler,
CodexAppServerClient,
ElicitationHandler,
McpStartupResult,
} from "./CodexAppServerClient";
import type {McpServerStartupWaitOptions, McpStartupResult} from "./mcp/McpStartupTracker";
import open from "open";
import type {Disposable} from "vscode-jsonrpc";
import type {
Expand All @@ -28,12 +28,15 @@ import {ModelId} from "./ModelId";
import {AgentMode} from "./AgentMode";
import path from "node:path";
import {logger} from "./Logger";
import {isAccountReadAuthFailureError, isAccountReadUnavailableError} from "./CodexThreadErrors";
import {sanitizeMcpServerName} from "./McpServerName";
import {normalizeSessionTitle} from "./SessionTitle";
import type {
AccountLoginCompletedNotification,
AccountUpdatedNotification,
GetAccountRateLimitsResponse,
GetAccountResponse,
ListMcpServerStatusParams,
ListMcpServerStatusResponse,
McpServerOauthLoginCompletedNotification,
McpServerOauthLoginParams,
Expand Down Expand Up @@ -105,6 +108,14 @@ export type CreateUrlElicitationRequest = Extract<acp.CreateElicitationRequest,
*/
export type UrlElicitationRequest = Omit<CreateUrlElicitationRequest, "mode" | "requestId">;

/** The answer of {@link CodexAcpClient.readAuthRequirement}. */
export interface AuthRequirement {
/** Whether the agent needs a login before it opens a session. */
required: boolean;
/** The account read that gave the answer, or `null` when the adapter did not read the account. */
account: GetAccountResponse | null;
}

export interface UrlElicitationRequester {
elicitUrl(request: UrlElicitationRequest): Promise<acp.CreateElicitationResponse>;
completeElicitation(): Promise<void>;
Expand Down Expand Up @@ -210,8 +221,7 @@ export class CodexAcpClient {
}

private async authenticateWithChatGpt(): Promise<Boolean> {
const accountResponse = await this.codexClient.accountRead({refreshToken: true});
if (accountResponse.account?.type === "chatgpt") {
if (await this.hasWorkingChatGptLogin()) {
return true;
}
const loginCompletedPromise = this.awaitNextLoginCompleted();
Expand All @@ -223,9 +233,33 @@ export class CodexAcpClient {
return result.success;
}

/**
* Reads with a token refresh whether the agent has a ChatGPT login that a new login does not need to replace.
*
* An unavailable read answers `true`: only a ChatGPT login runs the routing discovery that failed, see
* {@link isAccountReadUnavailableError}, and a new login cannot run without the network either.
* A read that failed because the login does not work answers `false`, so a new login replaces it, see
* {@link isAccountReadAuthFailureError}. Another error rejects.
*/
private async hasWorkingChatGptLogin(): Promise<boolean> {
try {
const accountResponse = await this.codexClient.accountRead({refreshToken: true});
return accountResponse.account?.type === "chatgpt";
} catch (error) {
if (isAccountReadUnavailableError(error)) {
logger.log("Account read unavailable, so the stored ChatGPT login stays", {error: String(error)});
return true;
}
if (isAccountReadAuthFailureError(error)) {
logger.log("The stored ChatGPT login does not work, so a new login starts", {error: String(error)});
return false;
}
throw error;
}
}

private async authenticateWithChatGptDeviceCode(urlElicitationRequester?: UrlElicitationRequester): Promise<Boolean> {
const accountResponse = await this.codexClient.accountRead({refreshToken: true});
if (accountResponse.account?.type === "chatgpt") {
if (await this.hasWorkingChatGptLogin()) {
return true;
}
if (!urlElicitationRequester) {
Expand Down Expand Up @@ -349,17 +383,18 @@ export class CodexAcpClient {
await accountUpdatedPromise;
}

async authRequired(): Promise<Boolean> {
/** Reads whether the agent needs a login, with the account read that gave the answer. */
async readAuthRequirement(): Promise<AuthRequirement> {
if (this.gatewayConfig != null) {
// The authentication is already in progress:
// the gateway config is set during the authentication request processing.
// We assume that custom model providers will handle authentication themselves,
// so Codex will not need to require it.
return false;
return {required: false, account: null};
}

const response = await this.codexClient.accountRead({refreshToken: false})
return response.requiresOpenaiAuth && !response.account;
const response = await this.codexClient.accountRead({refreshToken: false});
return {required: response.requiresOpenaiAuth && !response.account, account: response};
}

/**
Expand Down Expand Up @@ -837,12 +872,16 @@ export class CodexAcpClient {
await this.codexClient.runGoalClear({threadId: sessionId});
}

async awaitMcpServerStartup(serverNames: Array<string>, afterVersion: number): Promise<McpStartupResult> {
return await this.codexClient.awaitMcpServerStartup(serverNames, afterVersion);
async awaitMcpServerStartup(
serverNames: Array<string>,
afterVersion: number,
options: McpServerStartupWaitOptions,
): Promise<McpStartupResult> {
return await this.codexClient.mcpStartup.await(serverNames, afterVersion, options);
}

getMcpServerStartupVersion(): number {
return this.codexClient.getMcpServerStartupVersion();
return this.codexClient.mcpStartup.version();
}

private async createSessionConfig(
Expand Down Expand Up @@ -1142,8 +1181,13 @@ export class CodexAcpClient {
});
}

async listMcpServers(): Promise<ListMcpServerStatusResponse> {
return this.codexClient.listMcpServerStatus({});
async listMcpServers(params: ListMcpServerStatusParams): Promise<ListMcpServerStatusResponse> {
return this.codexClient.listMcpServerStatus(params);
}

/** Reloads the MCP configuration. Codex reconnects the servers of every loaded thread that failed, closed, or changed. */
async reloadMcpServers(): Promise<void> {
await this.codexClient.mcpServerReload();
}

async mcpServerOauthLogin(
Expand All @@ -1155,8 +1199,9 @@ export class CodexAcpClient {
async awaitMcpServerOauthLoginCompleted(
name: string,
threadId: string,
signal?: AbortSignal,
): Promise<McpServerOauthLoginCompletedNotification> {
return await this.codexClient.awaitMcpServerOauthLoginCompleted(name, threadId);
return await this.codexClient.mcpOauthCompletions.await(name, threadId, signal);
}

async listSessions(request: acp.ListSessionsRequest): Promise<acp.ListSessionsResponse> {
Expand Down Expand Up @@ -1190,7 +1235,7 @@ export class CodexAcpClient {
const mapThreadToSession = (thread: Thread) => ({
sessionId: thread.id,
cwd: thread.cwd,
title: (thread.name ?? thread.preview) || null,
title: normalizeSessionTitle(thread.name ?? thread.preview),
updatedAt: new Date(thread.updatedAt * 1000).toISOString(),
});

Expand Down
Loading
Loading