Skip to content
12 changes: 11 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](docs/permission-extension.md), 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,16 @@ 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 retains its legacy ordinary ACP
tool-call representation, while child permission and elicitation requests are 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;
18 changes: 18 additions & 0 deletions src/AirExtension.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type {ClientCapabilities} from "@agentclientprotocol/sdk";

/**
* Wire names for the versioned JetBrains AIR ACP extension.
*
Expand All @@ -12,5 +14,21 @@ 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);
}
55 changes: 30 additions & 25 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 @@ -109,6 +110,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 @@ -118,6 +120,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 @@ -513,6 +516,13 @@ export class CodexAcpClient {
};
}

async readSessionThread(sessionId: string): Promise<Thread> {
return (await this.codexClient.threadRead({
threadId: sessionId,
includeTurns: true,
})).thread;
}

async newSession(request: acp.NewSessionRequest): Promise<SessionMetadata> {
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta);
await this.refreshSkills(request.cwd, additionalDirectories);
Expand Down Expand Up @@ -544,6 +554,7 @@ export class CodexAcpClient {
await this.codexClient.threadUnsubscribe({threadId: sessionId});
} finally {
this.codexClient.clearThreadHandlers(sessionId);
this.subagents.clear(sessionId);
}
}

Expand Down Expand Up @@ -782,34 +793,28 @@ export class CodexAcpClient {
sessionId: string,
eventHandler: (result: ServerNotification) => void | Promise<void>,
approvalHandler: ApprovalHandler,
elicitationHandler: ElicitationHandler
elicitationHandler: ElicitationHandler,
supportsSubagents: boolean,
observeInteraction: (result: ServerNotification) => void | Promise<void>,
waitForChildSession: (childThreadId: string) => Promise<string | null>,
) {
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,
enqueueInteraction: (event) => {
// Child observation uses the same serialized, error-reporting queue
// as ordinary session notifications; callers intentionally do not
// await the callback registered with app-server.
this.enqueueSessionNotification(sessionId, () => observeInteraction(event));
},
approvalHandler,
elicitationHandler,
waitForRootNotifications: () => this.waitForSessionNotifications(sessionId),
waitForChildSession,
});
}

Expand Down
Loading