Skip to content
Closed
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
2 changes: 2 additions & 0 deletions src/AirExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ 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_ASYNC_TASKS_KEY = "asyncTasks";
export const AIR_COMMAND_PRESENTATION_KEY = "commandPresentation";
export const AIR_TOOL_PRESENTATION_KEY = "toolPresentation";
export const AIR_ASYNC_TASKS_BACKGROUNDED_KEY = "backgrounded";
export const AIR_AGENT_FILE_CHANGE_REPORT_REQUEST_KEY = "agentFileChangeReportRequest";
export const AIR_EXTENSION_VERSION = 1;
Expand Down
42 changes: 40 additions & 2 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,14 @@ import {once} from "node:events";
import {
AIR_AGENT_FILE_CHANGE_REPORT_KEY,
AIR_ASYNC_TASKS_KEY,
AIR_COMMAND_PRESENTATION_KEY,
AIR_NATIVE_SUBAGENT_SESSIONS_KEY,
AIR_EXTENSION_CAPABILITIES_KEY,
AIR_EXTENSION_VERSION,
AIR_EXTENSION_VERSION_KEY,
AIR_META_KEY,
AIR_SESSION_FAILURE_KEY,
AIR_TOOL_PRESENTATION_KEY,
clientSupportsAirCapability,
JETBRAINS_META_KEY,
} from "./AirExtension";
Expand Down Expand Up @@ -215,6 +217,14 @@ function clientSupportsAgentFileChangeReports(capabilities: acp.ClientCapabiliti
return clientSupportsAirCapability(capabilities, AIR_AGENT_FILE_CHANGE_REPORT_KEY);
}

function clientSupportsCommandPresentation(capabilities: acp.ClientCapabilities | null): boolean {
return clientSupportsAirCapability(capabilities, AIR_COMMAND_PRESENTATION_KEY);
}

function clientSupportsToolPresentation(capabilities: acp.ClientCapabilities | null): boolean {
return clientSupportsAirCapability(capabilities, AIR_TOOL_PRESENTATION_KEY);
}

interface ActiveAuthState {
account: Account | null;
authConfigured: boolean;
Expand Down Expand Up @@ -285,6 +295,7 @@ export class CodexAcpServer {
private codexProcessGeneration = 0;
private initializeRequest: acp.InitializeRequest | null = null;
private providerUpdate: Promise<void> | null = null;
private catalogNotificationSubscription: {dispose(): void};

constructor(
connection: AcpClientConnection,
Expand Down Expand Up @@ -318,6 +329,7 @@ export class CodexAcpServer {
this.booleanConfigOptionsSupported = false;
this.currentAuthStatus = null;
this.availableCommands = this.createAvailableCommands(codexAcpClient);
this.catalogNotificationSubscription = this.observeCatalogChanges(codexAcpClient);
this.observeCodexProcess();
}

Expand All @@ -326,10 +338,27 @@ export class CodexAcpServer {
this.connection,
client,
(operation) => this.runWithProcessCheck(operation),
() => this.refreshAuthState(null)
() => this.refreshAuthState(null),
() => clientSupportsCommandPresentation(this.clientCapabilities),
);
}

private observeCatalogChanges(client: CodexAcpClient): {dispose(): void} {
return client.appServerClient.onGlobalServerNotification((notification) => {
if (notification.method !== "skills/changed"
&& notification.method !== "app/list/updated"
&& notification.method !== "externalAgentConfig/import/completed") {
return;
}
for (const sessionState of this.sessions.values()) {
this.publishAvailableCommandsAsync(
sessionState,
this.getSessionGeneration(sessionState.sessionId),
);
}
});
}

async initialize(
_params: acp.InitializeRequest,
): Promise<acp.InitializeResponse> {
Expand Down Expand Up @@ -397,6 +426,8 @@ export class CodexAcpServer {
AIR_AGENT_FILE_CHANGE_REPORT_KEY,
AIR_NATIVE_SUBAGENT_SESSIONS_KEY,
AIR_ASYNC_TASKS_KEY,
AIR_COMMAND_PRESENTATION_KEY,
AIR_TOOL_PRESENTATION_KEY,
],
},
},
Expand Down Expand Up @@ -889,6 +920,7 @@ export class CodexAcpServer {
this.activePrompts.delete(params.sessionId);
this.steeringQueues.delete(params.sessionId);
this.goalControlGenerations.delete(params.sessionId);
this.availableCommands.clear(params.sessionId);
}
this.endSessionCloseFence(params.sessionId);
}
Expand Down Expand Up @@ -1047,6 +1079,8 @@ export class CodexAcpServer {
await replacement.initialize(this.initializeRequest);
this.codexAcpClient = replacement;
this.availableCommands = this.createAvailableCommands(replacement);
this.catalogNotificationSubscription.dispose();
this.catalogNotificationSubscription = this.observeCatalogChanges(replacement);

const resumeErrors: unknown[] = [];
for (const session of this.sessions.values()) {
Expand Down Expand Up @@ -2258,7 +2292,10 @@ export class CodexAcpServer {
return updates;
}
case "mcpToolCall":
return [await createMcpToolCallUpdate(item)];
return [await createMcpToolCallUpdate(
item,
clientSupportsToolPresentation(this.clientCapabilities),
)];
case "dynamicToolCall":
return [await createDynamicToolCallUpdate(item)];
case "collabAgentToolCall":
Expand Down Expand Up @@ -2790,6 +2827,7 @@ export class CodexAcpServer {
this.sessionFailureEpoch,
sessionState.subagents,
(accountUpdated) => this.handleAccountUpdated(accountUpdated),
clientSupportsToolPresentation(this.clientCapabilities),
);
eventHandler = promptEventHandler;
const permissionLifecycle = this.permissionLifecycleContext(sessionState);
Expand Down
10 changes: 10 additions & 0 deletions src/CodexAppServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export class CodexAppServerClient {
private readonly threadGoalClearedCaptures = new Map<string, Set<() => void>>();
private readonly threadSettings = new Map<string, ThreadSettings>();
private readonly staleTurnIds = new Map<string, Set<string>>();
private readonly globalNotificationHandlers = new Set<(event: ServerNotification) => void>();

constructor(connection: MessageConnection) {
this.connection = connection;
Expand Down Expand Up @@ -735,13 +736,22 @@ export class CodexAppServerClient {
this.notificationHandlers.set(sessionId, callback);
}

/** Observes process-wide notifications exactly once, before they are fanned out to sessions. */
onGlobalServerNotification(callback: (event: ServerNotification) => void): {dispose(): void} {
this.globalNotificationHandlers.add(callback);
return {dispose: () => this.globalNotificationHandlers.delete(callback)};
}

private codexEventHandlers: Array<(event: CodexConnectionEvent) => void> = [];
onClientTransportEvent(callback: (event: CodexConnectionEvent) => void){
this.codexEventHandlers.push(callback);
}

private notificationHandlers = new Map<string, (event: ServerNotification) => void>();
private notify(notification: ServerNotification) {
for (const handler of this.globalNotificationHandlers) {
handler(notification);
}
const threadId = extractThreadId(notification);
if (threadId !== null) {
const handler = this.notificationHandlers.get(threadId);
Expand Down
60 changes: 46 additions & 14 deletions src/CodexCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ import {
DEFAULT_COLLABORATION_MODE,
PLAN_COLLABORATION_MODE,
} from "./CollaborationModeConfig";
import {
AIR_COMMAND_PRESENTATION_KEY,
AIR_EXTENSION_VERSION,
AIR_EXTENSION_VERSION_KEY,
AIR_META_KEY,
JETBRAINS_META_KEY,
} from "./AirExtension";

type ParsedSlashCommand = {
name: string;
Expand Down Expand Up @@ -41,27 +48,42 @@ export class CodexCommands {
private readonly codexAcpClient: CodexAcpClient;
private readonly runWithProcessCheck: <T>(operation: () => Promise<T>) => Promise<T>;
private readonly onLogout: LogoutHandler;
private readonly supportsCommandPresentation: () => boolean;
private readonly publishVersions = new Map<string, number>();
private readonly lastPublishedCommands = new Map<string, string>();

constructor(
connection: AcpClientConnection,
codexAcpClient: CodexAcpClient,
runWithProcessCheck: <T>(operation: () => Promise<T>) => Promise<T>,
onLogout: LogoutHandler = () => {}
onLogout: LogoutHandler = () => {},
supportsCommandPresentation: () => boolean = () => false,
) {
this.connection = connection;
this.codexAcpClient = codexAcpClient;
this.runWithProcessCheck = runWithProcessCheck;
this.onLogout = onLogout;
this.supportsCommandPresentation = supportsCommandPresentation;
}

async publish(sessionState: SessionState, shouldPublish: () => boolean = () => true): Promise<void> {
async publish(
sessionState: SessionState,
shouldPublish: () => boolean = () => true,
force = false,
): Promise<void> {
const publishVersion = (this.publishVersions.get(sessionState.sessionId) ?? 0) + 1;
this.publishVersions.set(sessionState.sessionId, publishVersion);
try {
if (!shouldPublish()) {
return;
}
const skillsResponse = await this.runWithProcessCheck(() => this.codexAcpClient.listSkills(this.createSkillsListParams(sessionState)));
const availableCommands = this.buildAvailableCommands(skillsResponse?.data ?? []);
if (availableCommands.length === 0 || !shouldPublish()) {
const serializedCommands = JSON.stringify(availableCommands);
if (availableCommands.length === 0
|| !shouldPublish()
|| this.publishVersions.get(sessionState.sessionId) !== publishVersion
|| !force && this.lastPublishedCommands.get(sessionState.sessionId) === serializedCommands) {
return;
}

Expand All @@ -70,13 +92,21 @@ export class CodexCommands {
sessionUpdate: "available_commands_update",
availableCommands
});
if (this.publishVersions.get(sessionState.sessionId) === publishVersion) {
this.lastPublishedCommands.set(sessionState.sessionId, serializedCommands);
}
} catch (err) {
if (shouldPublish()) {
logger.error(`Failed to publish available commands for session ${sessionState.sessionId}`, err);
}
}
}

clear(sessionId: string): void {
this.publishVersions.delete(sessionId);
this.lastPublishedCommands.delete(sessionId);
}

private createSkillsListParams(sessionState: SessionState): SkillsListParams {
return {
cwds: [sessionState.cwd, ...sessionState.additionalDirectories],
Expand All @@ -99,6 +129,18 @@ export class CodexCommands {
name,
description,
input: null,
...(this.supportsCommandPresentation() ? {
_meta: {
[JETBRAINS_META_KEY]: {
[AIR_META_KEY]: {
[AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION,
[AIR_COMMAND_PRESENTATION_KEY]: skill.pluginId === null
? {source: "skill"}
: {source: "plugin", pluginId: skill.pluginId},
},
},
},
} : {}),
});
}
}
Expand Down Expand Up @@ -283,17 +325,7 @@ export class CodexCommands {
return { handled: true };
}
case "skills": {
const response = await this.runWithProcessCheck(() => this.codexAcpClient.listSkills(this.createSkillsListParams(sessionState)));
const skills = (response?.data ?? []).flatMap(entry => entry.skills);
const lines = skills.map(skill => {
const description = skill.shortDescription ?? skill.description ?? "";
return description ? `- ${skill.name}: ${description}` : `- ${skill.name}`;
});
const text = lines.length > 0
? ["Available skills:", ...lines].join("\n")
: "No skills configured.";
const session = new ACPSessionConnection(this.connection, sessionId);
await session.update(createAgentTextMessageChunk(text));
await this.publish(sessionState, () => true, true);
return { handled: true };
}
case "mcp": {
Expand Down
5 changes: 4 additions & 1 deletion src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ export class CodexEventHandler {
private readonly subagents: CodexSubagentEventRouter;
/** Connection-level `authStatus` sink; the app-server account push feeds it. */
private readonly onAccountUpdated: ((notification: AccountUpdatedNotification) => void) | undefined;
private readonly supportsToolPresentation: boolean;

constructor(
connection: AcpClientConnection,
Expand All @@ -242,6 +243,7 @@ export class CodexEventHandler {
new ACPSessionConnection(connection, sessionState.sessionId),
),
onAccountUpdated?: (notification: AccountUpdatedNotification) => void,
supportsToolPresentation = false,
) {
this.onAccountUpdated = onAccountUpdated;
this.sessionState = sessionState;
Expand All @@ -250,6 +252,7 @@ export class CodexEventHandler {
this.sessionFailureEpoch = sessionFailureEpoch;
this.session = new ACPSessionConnection(connection, sessionState.sessionId);
this.subagents = subagents;
this.supportsToolPresentation = supportsToolPresentation;
if (sessionState.sessionFailure !== undefined) {
this.failuresById.set(sessionState.sessionFailure.id, sessionState.sessionFailure);
}
Expand Down Expand Up @@ -730,7 +733,7 @@ export class CodexEventHandler {
return await createCommandExecutionUpdate(event.item);
}
case "mcpToolCall":
return await createMcpToolCallUpdate(event.item);
return await createMcpToolCallUpdate(event.item, this.supportsToolPresentation);
case "dynamicToolCall":
return await createDynamicToolCallUpdate(event.item);
case "webSearch":
Expand Down
31 changes: 29 additions & 2 deletions src/CodexToolCallMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ import {
type TerminalOutputMode,
} from "./TerminalOutputMode";
import {createContextCompactionMeta} from "./ContextCompactionMeta";
import {
AIR_EXTENSION_VERSION,
AIR_EXTENSION_VERSION_KEY,
AIR_META_KEY,
AIR_TOOL_PRESENTATION_KEY,
JETBRAINS_META_KEY,
} from "./AirExtension";

type CodexItemStatus = CommandExecutionStatus | PatchApplyStatus | McpToolCallStatus | DynamicToolCallStatus | CollabAgentToolCallStatus;
type AcpToolCallStatus = "pending" | "in_progress" | "completed" | "failed";
Expand Down Expand Up @@ -141,16 +148,36 @@ export function createCommandExecutionCompleteUpdate(
}

export async function createMcpToolCallUpdate(
item: ThreadItem & { type: "mcpToolCall" }
item: ThreadItem & { type: "mcpToolCall" },
supportsToolPresentation = false,
): Promise<UpdateSessionEvent> {
const presentation = item.appContext !== null
? {
source: "app",
appId: item.appContext.connectorId,
...(item.appContext.appName === null ? {} : {appName: item.appContext.appName}),
}
: item.pluginId === null
? null
: {source: "plugin", pluginId: item.pluginId};
return {
...await createExecuteToolCallUpdate(
item,
`mcp.${item.server}.${item.tool}`,
createMcpRawInput(item.server, item.tool, item.arguments),
createMcpRawOutput(item.result, item.error),
),
_meta: { is_mcp_tool_call: true },
_meta: {
is_mcp_tool_call: true,
...(supportsToolPresentation && presentation !== null ? {
[JETBRAINS_META_KEY]: {
[AIR_META_KEY]: {
[AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION,
[AIR_TOOL_PRESENTATION_KEY]: presentation,
},
},
} : {}),
},
};
}

Expand Down
Loading