Skip to content
Merged
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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
- Codex app-server usage: see https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md when touching protocol/transport details, adding or consuming JSON-RPC methods, handling approvals/turn events, or updating generated schema/clients.
- App-server events: prefer `thread/*`, `turn/*`, and `item/*` event surfaces; avoid the deprecated `codex/event/*` API (planned removal). Keep implementations aligned with generated types in `src/app-server` (including `v2` exports).
- Steer uses app-server `turn/steer` on the tracked active turn. Correlate `clientUserMessageId` and acknowledge only the matching `item/completed(userMessage)`; never emulate steer with a second `turn/start`.
- Session fork uses app-server `thread/fork` and installs the returned child as an independent ACP session. Never emulate fork by replaying source history.
- Codex reasoning summaries can echo trailing empty HTML comments from model instructions. Keep
that provider-specific cleanup in `src/ReasoningText.ts` across live deltas and history replay;
do not filter assistant text, raw reasoning, or HTML globally in the client renderer.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol]
- 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.
- Client-provided MCP servers over command-based stdio config and HTTP transport.
- Native ACP session forking through Codex App Server `thread/fork`.
- Acknowledged steering of an active Codex turn through app-server `turn/steer`.
- Slash commands: `/status`, `/mcp`, `/skills`, `/review`, `/review-branch`, `/review-commit`, `/compact`, and `/logout`, as well as configured skills.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "1.2.1",
"version": "1.3.0",
"description": "An ACP-compatible coding agent powered by Codex",
"main": "dist/index.js",
"bin": {
Expand Down
39 changes: 36 additions & 3 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,10 @@ export class CodexAcpClient {
return this.codexClient.accountRateLimitsRead();
}

async resumeSession(request: acp.ResumeSessionRequest, onSubscribed?: () => void): Promise<SessionMetadata> {
async resumeSession(
request: acp.ResumeSessionRequest,
onSubscribed?: (sessionId?: string) => void,
): Promise<SessionMetadata> {
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta);
await this.refreshSkills(request.cwd, additionalDirectories);

Expand All @@ -333,7 +336,7 @@ export class CodexAcpClient {
modelProvider: await this.getResumeModelProvider(),
threadId: request.sessionId,
});
onSubscribed?.();
onSubscribed?.(request.sessionId);
const codexModels = await this.fetchAvailableModels();
const currentModelId = this.createModelId(codexModels, response.model, response.reasoningEffort).toString();
return {
Expand All @@ -346,6 +349,32 @@ export class CodexAcpClient {
}
}

async forkSession(
request: acp.ForkSessionRequest,
onSubscribed?: (sessionId?: string) => void,
): Promise<SessionMetadata> {
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta);
await this.refreshSkills(request.cwd, additionalDirectories);

const response = await this.codexClient.threadFork({
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []),
cwd: request.cwd,
modelProvider: await this.getResumeModelProvider(),
threadId: request.sessionId,
});
onSubscribed?.(response.thread.id);
const codexModels = await this.fetchAvailableModels();
const currentModelId = this.createModelId(codexModels, response.model, response.reasoningEffort).toString();
return {
sessionId: response.thread.id,
currentModelId,
models: codexModels,
modelProvider: response.modelProvider,
currentServiceTier: response.serviceTier as ServiceTier ?? null,
additionalDirectories,
};
}

async loadSession(request: acp.LoadSessionRequest, onSubscribed?: () => void): Promise<SessionMetadataWithThread> {
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta);
await this.refreshSkills(request.cwd, additionalDirectories);
Expand Down Expand Up @@ -374,7 +403,10 @@ export class CodexAcpClient {
};
}

async newSession(request: acp.NewSessionRequest): Promise<SessionMetadata> {
async newSession(
request: acp.NewSessionRequest,
onSubscribed?: (sessionId?: string) => void,
): Promise<SessionMetadata> {
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta);
await this.refreshSkills(request.cwd, additionalDirectories);

Expand All @@ -383,6 +415,7 @@ export class CodexAcpClient {
modelProvider: this.getModelProvider(),
cwd: request.cwd,
});
onSubscribed?.(response.thread.id);

const codexModels = await this.fetchAvailableModels();
if (codexModels.length === 0) {
Expand Down
149 changes: 119 additions & 30 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,18 @@ interface ActiveAuthState {
authConfigured: boolean;
}

type SessionOpenOperation =
| { kind: "new"; request: acp.NewSessionRequest }
| { kind: "resume"; request: acp.ResumeSessionRequest }
| { kind: "fork"; request: acp.ForkSessionRequest };

type SessionOpenResult = [
SessionId,
LegacySessionModelState,
SessionModeState,
acp.AvailableCommand[],
];

interface PendingMcpStartupSession {
requestedServers: Set<string>;
afterVersion: number;
Expand Down Expand Up @@ -239,6 +251,7 @@ export class CodexAcpServer {
},
sessionCapabilities: {
resume: { },
fork: { },
list: { },
close: { },
delete: { },
Expand Down Expand Up @@ -293,7 +306,7 @@ export class CodexAcpServer {
}
}

async getOrCreateSession(request: acp.NewSessionRequest | acp.ResumeSessionRequest): Promise<[SessionId, LegacySessionModelState, SessionModeState, acp.AvailableCommand[]]> {
async getOrCreateSession(request: acp.NewSessionRequest | acp.ResumeSessionRequest): Promise<SessionOpenResult> {
try {
return await this.tryCreateSession(request);
} catch (e) {
Expand All @@ -303,6 +316,16 @@ export class CodexAcpServer {
}
}

private async getOrForkSession(request: acp.ForkSessionRequest): Promise<SessionOpenResult> {
try {
return await this.tryOpenSession({kind: "fork", request});
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
await this.handleError(error);
throw e;
}
}

async handleError(e: Error){
if (e.message.includes("log out") || e.message.includes("cloud requirements")) {
await this.runWithProcessCheck(() => this.codexAcpClient.logout());
Expand Down Expand Up @@ -374,54 +397,104 @@ export class CodexAcpServer {
return generation;
}

async tryCreateSession(request: acp.NewSessionRequest | acp.ResumeSessionRequest): Promise<[SessionId, LegacySessionModelState, SessionModeState, acp.AvailableCommand[]]> {
const requestedSessionGeneration = "sessionId" in request
? this.beginSessionOpen(request.sessionId)
async tryCreateSession(request: acp.NewSessionRequest | acp.ResumeSessionRequest): Promise<SessionOpenResult> {
return await this.tryOpenSession("sessionId" in request
? {kind: "resume", request}
: {kind: "new", request});
}

private async tryOpenSession(operation: SessionOpenOperation): Promise<SessionOpenResult> {
const {request} = operation;
let openedSession = operation.kind === "resume"
? {
sessionId: operation.request.sessionId,
generation: this.beginSessionOpen(operation.request.sessionId),
}
: null;
let subscribed = false;
const onSubscribed = (reportedSessionId?: string): void => {
const sessionId = reportedSessionId
?? (operation.kind === "resume" ? operation.request.sessionId : null);
if (!sessionId) {
throw RequestError.internalError("Codex subscribed without reporting a session id");
}
subscribed = true;
if (!openedSession) {
openedSession = {
sessionId,
generation: this.beginSessionOpen(sessionId),
};
}
};
await this.checkAuthorization();
const requestedMcpServers = request.mcpServers ?? [];
const mcpServerStartupVersion = requestedMcpServers.length > 0
? this.codexAcpClient.getMcpServerStartupVersion()
: null;

let sessionMetadata: SessionMetadata;
let resumeSubscribed = false;
if ("sessionId" in request) {
logger.log(`Resume existing session: ${request.sessionId}...`);
try {
sessionMetadata = await this.runWithProcessCheck(() =>
this.codexAcpClient.resumeSession(request, () => {
resumeSubscribed = true;
})
);
} catch (err) {
if (resumeSubscribed && requestedSessionGeneration !== null) {
await this.cleanupStaleSessionOpen(request.sessionId, requestedSessionGeneration);
}
throw err;
try {
switch (operation.kind) {
case "new":
logger.log("Create new session...");
sessionMetadata = await this.runWithProcessCheck(() =>
this.codexAcpClient.newSession(operation.request, onSubscribed)
);
break;
case "resume":
logger.log(`Resume existing session: ${operation.request.sessionId}...`);
sessionMetadata = await this.runWithProcessCheck(() =>
this.codexAcpClient.resumeSession(operation.request, onSubscribed)
);
break;
case "fork":
logger.log(`Fork existing session: ${operation.request.sessionId}...`);
sessionMetadata = await this.runWithProcessCheck(() =>
this.codexAcpClient.forkSession(operation.request, onSubscribed)
);
break;
}
} else {
logger.log(`Create new session...`);
sessionMetadata = await this.runWithProcessCheck(() => this.codexAcpClient.newSession(request));
} catch (err) {
if (subscribed && openedSession) {
await this.cleanupStaleSessionOpen(openedSession.sessionId, openedSession.generation);
}
throw err;
}

const {sessionId, currentModelId, models} = sessionMetadata;
if (!openedSession) {
openedSession = {
sessionId,
generation: this.beginSessionOpen(sessionId),
};
} else if (openedSession.sessionId !== sessionId) {
if (subscribed) {
await this.cleanupStaleSessionOpen(openedSession.sessionId, openedSession.generation);
}
throw RequestError.internalError(
{expectedSessionId: openedSession?.sessionId, actualSessionId: sessionId},
"Codex opened a different session than it reported",
);
}
subscribed = true;
const authProvider = sessionMetadata.modelProvider ?? this.codexAcpClient.getModelProvider();
let authState: ActiveAuthState;
try {
authState = await this.getAuthStateForProvider(authProvider);
} catch (err) {
if (resumeSubscribed && requestedSessionGeneration !== null) {
await this.cleanupStaleSessionOpen(sessionId, requestedSessionGeneration);
if (subscribed) {
await this.cleanupStaleSessionOpen(sessionId, openedSession.generation);
}
throw err;
}
const sessionGeneration = requestedSessionGeneration ?? this.beginSessionOpen(sessionId);
if (!this.sessionOpenCanInstall(sessionId, sessionGeneration)) {
resumeSubscribed = false;
await this.closeStaleSessionOpen(sessionId, sessionGeneration);
if (!this.sessionOpenCanInstall(sessionId, openedSession.generation)) {
subscribed = false;
await this.closeStaleSessionOpen(sessionId, openedSession.generation);
}
const sessionMcpServers = this.resolveSessionMcpServers(requestedMcpServers, "sessionId" in request);
const sessionMcpServers = this.resolveSessionMcpServers(
requestedMcpServers,
operation.kind !== "new",
);
const currentModel = this.findCurrentModel(models, currentModelId);
const currentModelSupportsFast = modelSupportsFast(currentModel);
const sessionState: SessionState = {
Expand All @@ -448,11 +521,11 @@ export class CodexAcpServer {
sessionMcpServers: sessionMcpServers,
terminalOutputMode: this.terminalOutputMode,
sessionTitle: null,
sessionTitleSource: "sessionId" in request ? "unknown" : "unset",
sessionTitleSource: operation.kind === "new" ? "unset" : "unknown",
};
this.sessions.set(sessionId, sessionState);
this.publishRateLimitsAsync(sessionState);
resumeSubscribed = false;
subscribed = false;

if (requestedMcpServers.length > 0 && mcpServerStartupVersion !== null) {
this.pendingMcpStartupSessions.set(sessionId, {
Expand Down Expand Up @@ -576,6 +649,22 @@ export class CodexAcpServer {
};
}

async unstable_forkSession(params: acp.ForkSessionRequest): Promise<acp.ForkSessionResponse> {
logger.log("Forking session...", {sessionId: params.sessionId});
const [sessionId, , modeState, availableCommands] = await this.getOrForkSession(params);
this.publishAvailableCommandsAsync(sessionId, availableCommands);

logger.log("Session forked", {
sourceSessionId: params.sessionId,
sessionId,
});
return {
sessionId,
modes: modeState,
...this.createSessionConfigOptionsResponse(this.getSessionState(sessionId)),
};
}

async listSessions(params: acp.ListSessionsRequest): Promise<acp.ListSessionsResponse> {
logger.log("Listing sessions...", {cwd: params.cwd, cursor: params.cursor});
await this.checkAuthorization();
Expand Down
6 changes: 6 additions & 0 deletions src/CodexAppServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import type {
ThreadGoalClearResponse,
ThreadGoalSetParams,
ThreadGoalSetResponse,
ThreadForkParams,
ThreadForkResponse,
ThreadLoadedListParams,
ThreadLoadedListResponse,
ThreadListParams,
Expand Down Expand Up @@ -520,6 +522,10 @@ export class CodexAppServerClient {
return await this.sendRequest({ method: "thread/resume", params: params });
}

async threadFork(params: ThreadForkParams): Promise<ThreadForkResponse> {
return await this.sendRequest({ method: "thread/fork", params: params });
}

async threadList(params: ThreadListParams): Promise<ThreadListResponse> {
return await this.sendRequest({ method: "thread/list", params: params });
}
Expand Down
27 changes: 27 additions & 0 deletions src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,33 @@ describeE2E("E2E tests", () => {
});
});

it("forks a session and continues from the copied Codex history", async () => {
fixture = await createAuthenticatedFixture();
const source = await fixture.createSession();
await fixture.expectPromptText(
source.sessionId,
"Remember the exact marker fork-history-ok and reply with exactly remembered.",
(text) => {
expect(text.toLowerCase()).toContain("remembered");
},
);

const forked = await fixture.connection.unstable_forkSession({
sessionId: source.sessionId,
cwd: fixture.workspaceDir,
mcpServers: [],
});

expect(forked.sessionId).not.toBe(source.sessionId);
await fixture.expectPromptText(
forked.sessionId,
"Reply with only the exact marker I asked you to remember.",
(text) => {
expect(text.toLowerCase()).toContain("fork-history-ok");
},
);
});

it("returns model response when authenticated via gateway", async () => {
const apiKey = requireLiveApiKey();
fixture = await createGatewayFixture("https://api.openai.com/v1", {Authorization: `Bearer ${apiKey}`});
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/CodexACPAgent/initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ describe('CodexACPAgent - initialize', () => {
},
sessionCapabilities: {
resume: {},
fork: {},
list: {},
close: {},
delete: {},
Expand Down
Loading