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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol]
- Model, reasoning effort, fast mode, approval, and sandbox mode configuration.
- Text prompts, embedded context, images, resource links, and additional workspace directories.
- Shell command, file change, permission request, MCP tool call, terminal output, reasoning, plan, web search, image generation, image view, token usage, and review events.
- Subagent launches as standard ACP tool calls, with Codex thread identity and activity details in namespaced `_meta.codex.subagent` metadata.
- Native ACP subagent sessions with separate child histories and root-routed permissions.
- Session-scoped long-running goals through the provider-neutral [goal extension](docs/goal-extension.md).
- 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.
Expand Down Expand Up @@ -75,6 +75,15 @@ npm run bundle:all

See [readme-dev.md](readme-dev.md) for local client configuration, binary packaging, and Codex type regeneration.

### Subagent sessions

Subagents are exposed only after bilateral capability negotiation. Until the released ACP SDKs
preserve the draft `clientCapabilities.subagents` field, a supporting client may advertise
`nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`; the adapter mirrors the capability
in its initialize response. The canonical field remains supported and takes precedence once it is
available. Without either client signal, subagent lifecycle and child output stay hidden while
child permission requests continue to be handled on the root session.

## License

By contributing, you agree that your contributions will be licensed under the Apache 2.0 License.
15 changes: 9 additions & 6 deletions src/ACPSessionConnection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import * as acp from "@agentclientprotocol/sdk";
import type {SessionNotification} from "@agentclientprotocol/sdk";
import {
type AcpSessionUpdate,
asSdkSessionNotification,
} from "./subagents/AcpSubagents";

export type AcpClientConnection = Pick<acp.AgentContext, "notify" | "request">;

Expand All @@ -12,12 +15,12 @@ export class ACPSessionConnection {
this.sessionId = sessionId;
}

async update(update: UpdateSessionEvent) {
await this.connection.notify(acp.methods.client.session.update, {
sessionId: this.sessionId,
async update(update: UpdateSessionEvent, sessionId: string = this.sessionId) {
await this.connection.notify(acp.methods.client.session.update, asSdkSessionNotification({
sessionId,
update: update
});
}));
}
}

export type UpdateSessionEvent = SessionNotification["update"];
export type UpdateSessionEvent = AcpSessionUpdate;
17 changes: 17 additions & 0 deletions src/AirExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,22 @@ export const AIR_EXTENSION_VERSION_KEY = "version";
export const AIR_EXTENSION_CAPABILITIES_KEY = "capabilities";
export const AIR_SESSION_FAILURE_KEY = "sessionFailure";
export const AIR_AGENT_FILE_CHANGE_REPORT_KEY = "agentFileChangeReport";
export const AIR_NATIVE_SUBAGENT_SESSIONS_KEY = "nativeSubagentSessions";
export const AIR_AGENT_FILE_CHANGE_REPORT_REQUEST_KEY = "agentFileChangeReportRequest";
export const AIR_EXTENSION_VERSION = 1;

export function clientSupportsAirCapability(
capabilities: ClientCapabilities | null | undefined,
capability: string,
): boolean {
const jetbrains = capabilities?._meta?.[JETBRAINS_META_KEY] as Record<string, unknown> | undefined;
const air = jetbrains?.[AIR_META_KEY] as Record<string, unknown> | undefined;
const version = air?.[AIR_EXTENSION_VERSION_KEY];
const supported = air?.[AIR_EXTENSION_CAPABILITIES_KEY];
return typeof version === "number"
&& Number.isInteger(version)
&& version >= AIR_EXTENSION_VERSION
&& Array.isArray(supported)
&& supported.includes(capability);
}
import type {ClientCapabilities} from "@agentclientprotocol/sdk";
41 changes: 15 additions & 26 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
createReportedAgentFileChangeReport,
createUnavailableAgentFileChangeReport,
} from "./AgentFileChangeReport";
import {CodexSubagentSubscriptions} from "./subagents/CodexSubagentSubscriptions";

/**
* Well-known provider id for the client-configurable custom LLM gateway.
Expand Down Expand Up @@ -108,6 +109,7 @@ export class CodexAcpClient {
private pendingLoginCompleted: Promise<AccountLoginCompletedNotification> | null = null;
private pendingAccountUpdated: Promise<AccountUpdatedNotification> | null = null;
private readonly sessionNotificationQueues = new Map<string, Promise<void>>();
private readonly subagents: CodexSubagentSubscriptions;
private skillExtraRoots: string[] = [];
private configPath: string | null = null;

Expand All @@ -117,6 +119,7 @@ export class CodexAcpClient {
this.config = codexConfig ?? {};
this.modelProvider = modelProvider ?? null;
this.gatewayConfig = null;
this.subagents = new CodexSubagentSubscriptions(codexClient);
}

private readonly defaultClientInfo: ClientInfo = {
Expand Down Expand Up @@ -525,6 +528,7 @@ export class CodexAcpClient {
await this.codexClient.threadUnsubscribe({threadId: sessionId});
} finally {
this.codexClient.clearThreadHandlers(sessionId);
this.subagents.clear(sessionId);
}
}

Expand Down Expand Up @@ -763,34 +767,19 @@ export class CodexAcpClient {
sessionId: string,
eventHandler: (result: ServerNotification) => void | Promise<void>,
approvalHandler: ApprovalHandler,
elicitationHandler: ElicitationHandler
elicitationHandler: ElicitationHandler,
supportsSubagents: boolean,
) {
this.codexClient.onServerNotification(sessionId, (event) => {
const dispatch = (event: ServerNotification) => {
this.enqueueSessionNotification(sessionId, () => eventHandler(event));
});
this.codexClient.onApprovalRequest(sessionId, {
handleCommandExecution: async (params) => {
await this.waitForSessionNotifications(sessionId);
return await approvalHandler.handleCommandExecution(params);
},
handleFileChange: async (params) => {
await this.waitForSessionNotifications(sessionId);
return await approvalHandler.handleFileChange(params);
},
handlePermissionsRequest: async (params) => {
await this.waitForSessionNotifications(sessionId);
return await approvalHandler.handlePermissionsRequest(params);
},
});
this.codexClient.onElicitationRequest(sessionId, {
handleElicitation: async (params) => {
await this.waitForSessionNotifications(sessionId);
return await elicitationHandler.handleElicitation(params);
},
handleUserInput: async (params) => {
await this.waitForSessionNotifications(sessionId);
return await elicitationHandler.handleUserInput(params);
},
};
this.subagents.subscribe({
rootSessionId: sessionId,
supportsSubagents,
dispatch,
approvalHandler,
elicitationHandler,
waitForRootNotifications: () => this.waitForSessionNotifications(sessionId),
});
}

Expand Down
72 changes: 47 additions & 25 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,22 @@ import {
createUserMessageChunk,
} from "./ContentChunks";
import {sameThreadGoalSnapshot, type ThreadGoalSnapshot, toThreadGoalSnapshot,} from "./ThreadGoalSnapshot";
import {
clientSupportsSubagents,
type SubagentAwareSessionCapabilities,
} from "./subagents/AcpSubagents";
import {CodexSubagentEventRouter} from "./subagents/CodexSubagentEventRouter";
import {randomUUID} from "node:crypto";
import {once} from "node:events";
import {
AIR_AGENT_FILE_CHANGE_REPORT_KEY,
AIR_NATIVE_SUBAGENT_SESSIONS_KEY,
AIR_EXTENSION_CAPABILITIES_KEY,
AIR_EXTENSION_VERSION,
AIR_EXTENSION_VERSION_KEY,
AIR_META_KEY,
AIR_SESSION_FAILURE_KEY,
clientSupportsAirCapability,
JETBRAINS_META_KEY,
} from "./AirExtension";
import {
Expand Down Expand Up @@ -144,6 +151,7 @@ export interface SessionState {
sessionTitle: string | null;
sessionTitleSource: "unset" | "fallback" | "explicit" | "unknown";
sessionFailure?: SessionFailure;
subagents: CodexSubagentEventRouter;
}

export type SessionFailureCategory =
Expand All @@ -169,21 +177,6 @@ export interface SessionFailure {

const CODEX_PROCESS_EXITED_ERROR_CODE = 1001;

function clientSupportsAirCapability(
capabilities: acp.ClientCapabilities | null,
capability: string,
): boolean {
const jetbrains = capabilities?._meta?.[JETBRAINS_META_KEY] as Record<string, unknown> | undefined;
const air = jetbrains?.[AIR_META_KEY] as Record<string, unknown> | undefined;
const version = air?.[AIR_EXTENSION_VERSION_KEY];
const supported = air?.[AIR_EXTENSION_CAPABILITIES_KEY];
return typeof version === "number"
&& Number.isInteger(version)
&& version >= AIR_EXTENSION_VERSION
&& Array.isArray(supported)
&& supported.includes(capability);
}

function clientSupportsTypedSessionFailures(capabilities: acp.ClientCapabilities | null): boolean {
return clientSupportsAirCapability(capabilities, AIR_SESSION_FAILURE_KEY);
}
Expand Down Expand Up @@ -310,6 +303,14 @@ export class CodexAcpServer {
this.terminalOutputMode = resolveTerminalOutputMode(_params.clientCapabilities);
this.booleanConfigOptionsSupported = clientSupportsBooleanConfigOptions(_params.clientCapabilities);
await this.runWithProcessCheck(() => this.codexAcpClient.initialize(_params));
const sessionCapabilities: SubagentAwareSessionCapabilities = {
resume: { },
list: { },
close: { },
delete: { },
additionalDirectories: {},
...(clientSupportsSubagents(_params.clientCapabilities) ? {subagents: {}} : {}),
};
return {
protocolVersion: acp.PROTOCOL_VERSION,
agentInfo: {
Expand All @@ -327,13 +328,7 @@ export class CodexAcpServer {
embeddedContext: true,
image: true
},
sessionCapabilities: {
resume: { },
list: { },
close: { },
delete: { },
additionalDirectories: {},
},
sessionCapabilities,
mcpCapabilities: {
acp: false,
http: true,
Expand All @@ -356,6 +351,7 @@ export class CodexAcpServer {
[AIR_EXTENSION_CAPABILITIES_KEY]: [
AIR_SESSION_FAILURE_KEY,
AIR_AGENT_FILE_CHANGE_REPORT_KEY,
AIR_NATIVE_SUBAGENT_SESSIONS_KEY,
],
},
},
Expand Down Expand Up @@ -626,6 +622,11 @@ export class CodexAcpServer {
goalRevision: 0,
sessionTitle: null,
sessionTitleSource: "sessionId" in request ? "unknown" : "unset",
subagents: new CodexSubagentEventRouter(
sessionId,
clientSupportsSubagents(this.clientCapabilities),
new ACPSessionConnection(this.connection, sessionId),
),
};
this.sessions.set(sessionId, sessionState);
resumeSubscribed = false;
Expand Down Expand Up @@ -1624,6 +1625,11 @@ export class CodexAcpServer {
goalRevision: 0,
sessionTitle: null,
sessionTitleSource: "unset",
subagents: new CodexSubagentEventRouter(
sessionId,
clientSupportsSubagents(this.clientCapabilities),
new ACPSessionConnection(this.connection, sessionId),
),
};
this.sessions.set(sessionId, sessionState);
subscribed = false;
Expand Down Expand Up @@ -2240,6 +2246,7 @@ export class CodexAcpServer {
: null;
let agentFileChangeReportTurnId: string | null = null;
let agentFileChangeReportUnavailableReason: AgentFileChangeReportUnavailableReason = "providerError";
let promptWasCancelled = false;
let recoverableSessionFailure = sessionState.sessionFailure;
sessionState.currentTurnId = null;
sessionState.lastTokenUsage = null;
Expand All @@ -2266,6 +2273,7 @@ export class CodexAcpServer {
}
};
const cancelledPromptResponse = (): acp.PromptResponse => {
promptWasCancelled = true;
agentFileChangeReportTurnId = null;
agentFileChangeReportUnavailableReason = "cancelled";
return this.cancelledPromptResponse(sessionState);
Expand All @@ -2278,12 +2286,12 @@ export class CodexAcpServer {
clientSupportsPlanUpdates(this.clientCapabilities),
clientSupportsTypedSessionFailures(this.clientCapabilities),
this.sessionFailureEpoch,
sessionState.subagents,
);
eventHandler = promptEventHandler;
const approvalHandler = new CodexApprovalHandler(this.connection, sessionState, activePrompt.signal);
const approvalHandler = new CodexApprovalHandler(this.connection, activePrompt.signal);
const elicitationHandler = new CodexElicitationHandler(
this.connection,
sessionState,
this.clientCapabilities,
activePrompt.signal,
);
Expand All @@ -2304,7 +2312,8 @@ export class CodexAcpServer {
}
},
approvalHandler,
elicitationHandler);
elicitationHandler,
clientSupportsSubagents(this.clientCapabilities));

if (activePrompt.signal.aborted) {
return cancelledPromptResponse();
Expand Down Expand Up @@ -2455,6 +2464,8 @@ export class CodexAcpServer {
return cancelledPromptResponse();
}

await this.codexAcpClient.waitForSessionNotifications(params.sessionId);
await eventHandler.waitForNativeSubagents(activePrompt.signal);
await this.codexAcpClient.waitForSessionNotifications(params.sessionId);
await eventHandler.flushPendingErrors();
await eventHandler.handleFailedTurn(turnCompleted.turn);
Expand Down Expand Up @@ -2548,6 +2559,8 @@ export class CodexAcpServer {
return cancelledPromptResponse();
}

await this.codexAcpClient.waitForSessionNotifications(params.sessionId);
await eventHandler.waitForNativeSubagents(activePrompt.signal);
await this.codexAcpClient.waitForSessionNotifications(params.sessionId);
await eventHandler.flushPendingErrors();
await eventHandler.handleFailedTurn(turnCompleted.turn);
Expand Down Expand Up @@ -2619,6 +2632,15 @@ export class CodexAcpServer {
// The app-server subscription is session-scoped and outlives this prompt. Flip routing before
// awaiting disposal so queued late notifications cannot enter prompt-local buffers.
promptNotificationsActive = false;
try {
await eventHandler?.finishOutstandingNativeSubagents(
promptWasCancelled || activePrompt.signal.aborted || this.sessionIsClosing(params.sessionId)
? "cancelled"
: "failed",
);
} catch (error) {
logger.error("Failed to publish terminal subagent state during prompt cleanup", error);
}
if (agentFileChangeReportRequest !== null) {
await this.publishAgentFileChangeReport(
sessionState,
Expand Down
10 changes: 3 additions & 7 deletions src/CodexApprovalHandler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as acp from "@agentclientprotocol/sdk";
import type {SessionState} from "./CodexAcpServer";
import type {ApprovalHandler} from "./CodexAppServerClient";
import type {
CommandExecutionApprovalDecision,
Expand Down Expand Up @@ -56,24 +55,21 @@ function permissionOption(

export class CodexApprovalHandler implements ApprovalHandler {
private readonly connection: AcpClientConnection;
private readonly sessionState: SessionState;
private readonly cancellationSignal: AbortSignal | undefined;

constructor(
connection: AcpClientConnection,
sessionState: SessionState,
cancellationSignal?: AbortSignal,
) {
this.connection = connection;
this.sessionState = sessionState;
this.cancellationSignal = cancellationSignal;
}

async handleCommandExecution(
params: CommandExecutionRequestApprovalParams
): Promise<CommandExecutionRequestApprovalResponse> {
try {
const sessionId = this.sessionState.sessionId;
const sessionId = params.threadId;
const acpRequest = this.buildCommandPermissionRequest(sessionId, params);
const response = await this.connection.request(
acp.methods.client.session.requestPermission,
Expand All @@ -91,7 +87,7 @@ export class CodexApprovalHandler implements ApprovalHandler {
params: FileChangeRequestApprovalParams
): Promise<FileChangeRequestApprovalResponse> {
try {
const sessionId = this.sessionState.sessionId;
const sessionId = params.threadId;
const acpRequest = this.buildFileChangePermissionRequest(sessionId, params);
const response = await this.connection.request(
acp.methods.client.session.requestPermission,
Expand All @@ -109,7 +105,7 @@ export class CodexApprovalHandler implements ApprovalHandler {
params: PermissionsRequestApprovalParams
): Promise<PermissionsRequestApprovalResponse> {
try {
const sessionId = this.sessionState.sessionId;
const sessionId = params.threadId;
const acpRequest = this.buildPermissionsRequest(sessionId, params);
const response = await this.connection.request(
acp.methods.client.session.requestPermission,
Expand Down
Loading