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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol]
- ChatGPT, API key, and client-provided custom gateway authentication.
- Model, reasoning effort, fast mode, approval, and sandbox mode configuration.
- Text prompts, embedded context, images, resource links, and additional workspace directories.
- [Cross-session references](docs/session-references.md) that use Codex thread deep links.
- 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.
- [Native ACP subagent sessions](docs/subagent-sessions.md) (after capability negotiation) with separate child histories and root-routed permissions; a legacy tool-call fallback otherwise.
- Session-scoped long-running goals through the provider-neutral [goal extension](docs/goal-extension.md).
Expand Down
19 changes: 19 additions & 0 deletions docs/session-references.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Cross-session references

The adapter recognizes an ACP `resource_link` with this URI form:

```text
acp-session://reference?sessionId=<Codex thread ID>
```

The client can add query parameters for navigation. The adapter reads only `sessionId`.

The adapter passes the thread ID and `codex://threads/<Codex thread ID>` to Codex.
It tells Codex to call `read_thread` before it uses the referenced content.

The adapter does not pass the link title. It does not copy the referenced session into the prompt.
The private MCP server reads the session only when Codex calls a thread tool.

The adapter preserves the order and number of links. It leaves other resource links unchanged.

See [`src/thread-tools-mcp/README.md`](../src/thread-tools-mcp/README.md) for the MCP server design.
271 changes: 118 additions & 153 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"license": "Apache-2.0",
"type": "module",
"devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^26.1.0",
"esbuild": "^0.28.2",
"mcp-hello-world": "^1.1.2",
Expand All @@ -65,7 +66,8 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"@openai/codex": "^0.148.0",
"@modelcontextprotocol/sdk": "^1.30.0",
"@openai/codex": "0.151.0-alpha.8",
"diff": "^9.0.0",
"open": "^11.0.1",
"vscode-jsonrpc": "^9.0.1",
Expand Down
51 changes: 33 additions & 18 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ import {CodexSubagentSubscriptions} from "./subagents/CodexSubagentSubscriptions
import {forkSession as runForkSession} from "./SessionFork";
import type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata";
export type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata";
import {toCodexSessionLinks} from "./SessionReferences";
import {CodexThreadToolsMcpServer} from "./thread-tools-mcp/server";
import {THREAD_TOOLS_MCP_NAME} from "./thread-tools-mcp/catalog";

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

Expand All @@ -124,6 +128,10 @@ export class CodexAcpClient {
this.modelProvider = modelProvider ?? null;
this.gatewayConfig = null;
this.subagents = new CodexSubagentSubscriptions(codexClient);
this.threadToolsMcpServer = new CodexThreadToolsMcpServer(
codexClient,
cwd => this.createSessionConfig(cwd, [], []),
);
}

private readonly defaultClientInfo: ClientInfo = {
Expand Down Expand Up @@ -470,12 +478,14 @@ export class CodexAcpClient {
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta);
await this.refreshSkills(request.cwd, additionalDirectories);

const config = await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []);
const response = await this.codexClient.threadResume({
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []),
config,
cwd: request.cwd,
modelProvider: await this.getResumeModelProvider(),
threadId: request.sessionId,
});
this.threadToolsMcpServer.registerThreadConfig(response.thread.id, config);
onSubscribed?.();
const codexModels = await this.fetchAvailableModels();
const currentModelId = this.createModelId(codexModels, response.model, response.reasoningEffort).toString();
Expand All @@ -492,29 +502,36 @@ export class CodexAcpClient {

async forkSession(request: acp.ForkSessionRequest): Promise<SessionMetadata> {
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta);
return await runForkSession(request, additionalDirectories, {
let forkConfig: JsonObject | null = null;
const result = await runForkSession(request, additionalDirectories, {
codexClient: this.codexClient,
refreshSkills: (cwd, directories) => this.refreshSkills(cwd, directories),
createSessionConfig: (cwd, directories, mcpServers) =>
this.createSessionConfig(cwd, directories, mcpServers),
createSessionConfig: async (cwd, directories, mcpServers) => {
forkConfig = await this.createSessionConfig(cwd, directories, mcpServers);
return forkConfig;
},
getResumeModelProvider: () => this.getResumeModelProvider(),
fetchAvailableModels: () => this.fetchAvailableModels(),
createCurrentModelId: (models, model, reasoningEffort) =>
this.createModelId(models, model, reasoningEffort).toString(),
getCollaborationMode: sessionId => this.getCollaborationMode(sessionId),
});
if (forkConfig !== null) this.threadToolsMcpServer.registerThreadConfig(result.sessionId, forkConfig);
return result;
}

async loadSession(request: acp.LoadSessionRequest, onSubscribed?: () => void): Promise<SessionMetadataWithThread> {
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta);
await this.refreshSkills(request.cwd, additionalDirectories);

const config = await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []);
const response = await this.codexClient.threadResume({
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []),
config,
cwd: request.cwd,
modelProvider: await this.getResumeModelProvider(),
threadId: request.sessionId,
});
this.threadToolsMcpServer.registerThreadConfig(response.thread.id, config);
onSubscribed?.();
const historyResponse = await this.codexClient.threadRead({
threadId: response.thread.id,
Expand Down Expand Up @@ -545,11 +562,13 @@ export class CodexAcpClient {
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta);
await this.refreshSkills(request.cwd, additionalDirectories);

const config = await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers);
const response = await this.codexClient.threadStart({
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers),
config,
modelProvider: this.getModelProvider(),
cwd: request.cwd,
});
this.threadToolsMcpServer.registerThreadConfig(response.thread.id, config);

const codexModels = await this.fetchAvailableModels();
if (codexModels.length === 0) {
Expand All @@ -573,6 +592,7 @@ export class CodexAcpClient {
} finally {
this.codexClient.clearThreadHandlers(sessionId);
this.subagents.clear(sessionId);
this.threadToolsMcpServer.forgetThreadConfig(sessionId);
}
}

Expand Down Expand Up @@ -685,27 +705,22 @@ export class CodexAcpClient {
}])),
};
const configWithWorkspaceRoots = mergeSandboxWorkspaceWriteRoots(mergedConfig, additionalDirectories);
if (mcpServers.length === 0) {
return configWithWorkspaceRoots;
}

const requestedServers = mcpServers.map(mcp => ({
name: sanitizeMcpServerName(mcp.name),
server: mcp,
}));
let serversToConfigure = requestedServers;
if (shouldDeduplicateMcpConflicts()) {
if (requestedServers.length > 0 && shouldDeduplicateMcpConflicts()) {
// Prevents Codex from deep-merging incompatible field types, such as url and stdio schemas.
const existingNames = await this.getConfigMcpServerNames(projectPath);
serversToConfigure = requestedServers.filter(mcp => !existingNames.has(mcp.name));
}
if (serversToConfigure.length === 0) {
return configWithWorkspaceRoots;
}

return {
...configWithWorkspaceRoots,
"mcp_servers": Object.fromEntries(serversToConfigure.map(mcp => [mcp.name, this.createMcpSeverConfig(mcp.server)])),
"mcp_servers": {
...Object.fromEntries(serversToConfigure.map(mcp => [mcp.name, this.createMcpSeverConfig(mcp.server)])),
[THREAD_TOOLS_MCP_NAME]: await this.threadToolsMcpServer.config(),
},
};
}

Expand Down Expand Up @@ -874,7 +889,7 @@ export class CodexAcpClient {
onTurnStarted?: (turnId: string) => void,
shouldCancel?: () => boolean,
): Promise<TurnCompletedNotification | null> {
const input = buildPromptItems(request.prompt);
const input = buildPromptItems(toCodexSessionLinks(request.prompt));
const effort = modelId.effort as ReasoningEffort | null; //TODO remove unsafe conversion
await this.refreshSkills(cwd, additionalDirectories);
if (shouldCancel?.()) {
Expand Down Expand Up @@ -1141,7 +1156,7 @@ export class CodexAcpClient {
return await this.codexClient.turnSteer({
threadId: params.threadId,
expectedTurnId: params.turnId,
input: buildPromptItems(params.prompt),
input: buildPromptItems(toCodexSessionLinks(params.prompt)),
});
}

Expand Down
16 changes: 16 additions & 0 deletions src/CodexAppServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,15 @@ import type {
ThreadReadResponse,
ThreadResumeParams,
ThreadResumeResponse,
ThreadSetNameParams,
ThreadSetNameResponse,
ThreadSettings,
ThreadStartParams,
ThreadStartResponse,
ThreadUnsubscribeParams,
ThreadUnsubscribeResponse,
ThreadUnarchiveParams,
ThreadUnarchiveResponse,
ToolRequestUserInputParams,
ToolRequestUserInputResponse,
TurnCompletedNotification,
Expand Down Expand Up @@ -560,6 +564,18 @@ export class CodexAppServerClient {
return await this.sendRequest({ method: "thread/archive", params: params });
}

async threadUnarchive(params: ThreadUnarchiveParams): Promise<ThreadUnarchiveResponse> {
return await this.sendRequest({ method: "thread/unarchive", params });
}

async threadSetName(params: ThreadSetNameParams): Promise<ThreadSetNameResponse> {
return await this.sendRequest({ method: "thread/name/set", params });
}

onThreadStatus(threadId: string, handler: (status: ThreadStatus) => void): () => void {
return this.captureThreadStatuses(threadId, handler);
}

async threadUnsubscribe(params: ThreadUnsubscribeParams): Promise<ThreadUnsubscribeResponse> {
return await this.sendRequest({ method: "thread/unsubscribe", params: params });
}
Expand Down
27 changes: 27 additions & 0 deletions src/SessionReferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type {ContentBlock} from "@agentclientprotocol/sdk";

export function toCodexSessionLinks(prompt: ContentBlock[]): ContentBlock[] {
return prompt.map((block): ContentBlock => {
if (block.type !== "resource_link") return block;
const sessionId = acpSessionId(block.uri);
if (sessionId === null) return block;
return {
type: "text",
text: [
"Referenced Codex task. Call `read_thread` before relying on its contents.",
JSON.stringify({threadId: sessionId}),
`codex://threads/${sessionId}`,
].join("\n"),
};
});
}

function acpSessionId(uri: string): string | null {
try {
const parsed = new URL(uri);
if (parsed.protocol !== "acp-session:" || parsed.hostname !== "reference") return null;
return parsed.searchParams.get("sessionId")?.trim() || null;
} catch {
return null;
}
}
51 changes: 50 additions & 1 deletion src/__tests__/CodexACPAgent/CodexAcpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ describe('ACP server test', { timeout: 40_000 }, () => {
expect(newSessionResponse.sessionId).toBeDefined();

const transportEvents = keyFixture.getCodexConnectionEvents([...ignoredFields, "upgrade"]);
const transportMethods = transportEvents.flatMap(event => "method" in event ? [event.method] : []);
const transportMethods = transportEvents
.flatMap(event => "method" in event ? [event.method] : [])
.filter(method => method !== "mcpServer/startupStatus/updated");
const loginRequest = transportEvents.find(event =>
event.eventType === "request" &&
"method" in event &&
Expand Down Expand Up @@ -918,6 +920,16 @@ describe('ACP server test', { timeout: 40_000 }, () => {

const threadStartRequest = threadStartSpy.mock.calls[0]![0];
expect(threadStartRequest.config?.["mcp_servers"]).toEqual({
codex_tui: {
url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/),
http_headers: {Authorization: expect.stringMatching(/^Bearer /)},
default_tools_approval_mode: "approve",
tools: {
create_thread: {approval_mode: "prompt"},
send_message_to_thread: {approval_mode: "prompt"},
fork_thread: {approval_mode: "prompt"},
},
},
stdio_server_one: {
command: "npx",
args: ["stdio"],
Expand Down Expand Up @@ -1531,6 +1543,43 @@ describe('ACP server test', { timeout: 40_000 }, () => {
await expect(mockFixture.getCodexConnectionDump(ignoredFields)).toMatchFileSnapshot("data/send-attachments-turn-start.json");
});

it('converts ACP session links to readable Codex task references', async () => {
const {mockFixture, turnStartSpy} = setupPromptFixture();
const threadRead = vi.spyOn(mockFixture.getCodexAppServerClient(), "threadRead");

await mockFixture.getCodexAcpAgent().prompt({
sessionId: "session-id",
prompt: [
{
type: "resource_link",
name: "Source chat",
uri: "acp-session://reference?sessionId=source-session",
},
{
type: "resource_link",
name: "Duplicate source chat",
uri: "acp-session://reference?sessionId=source-session",
},
],
});

expect(threadRead).not.toHaveBeenCalled();
expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({
input: [
{
type: "text",
text: "Referenced Codex task. Call `read_thread` before relying on its contents.\n{\"threadId\":\"source-session\"}\ncodex://threads/source-session",
text_elements: [],
},
{
type: "text",
text: "Referenced Codex task. Call `read_thread` before relying on its contents.\n{\"threadId\":\"source-session\"}\ncodex://threads/source-session",
text_elements: [],
},
],
}));
});

it('should fail on wrong sessionId', async () => {
const sessionId = "not-existing-session";

Expand Down
Loading