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 @@ -12,6 +12,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol]
- Model, reasoning effort, fast mode, approval, sandbox mode, and configured [Codex permission profile](docs/permission-profiles.md) selection.
- Concrete recommended model and reasoning-effort values through the opt-in [AIR recommended config values](docs/air-extensions.md#recommended-config-values) capability.
- Text prompts, embedded context, images, resource links, and additional workspace directories.
- A per-turn working directory override on `session/prompt` through [`_meta.cwd`](docs/prompt-cwd.md).
- Shell command, file change, [permission request](docs/air-extensions.md#permission-presentation), MCP tool call, terminal output, reasoning, plan, web search, image generation, image view, token usage, and review events.
- Compact file changes through the negotiated [AIR diff patch extension](docs/air-extensions.md#diff-patch).
- For AIR, one tool call shape with each fact in one field, as the [tool call contract](docs/air-extensions.md#tool-call-contract) defines. Other clients keep the earlier fields.
Expand Down
45 changes: 45 additions & 0 deletions docs/prompt-cwd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Per-turn working directory

ACP `session/prompt` carries no working directory of its own: a session keeps the `cwd` it was
created or last loaded with, and neither ACP v1 nor v2 has an in-session primitive to move a live
session to another directory. A `session/resume` with a different `cwd` can be rejected with
`invalid_params`. Clients that let the user navigate folders between turns (a GUI file tree, a
workspace switcher) therefore cannot move a running session with the standard methods.

## Request

On `session/prompt`, a client may pass a per-turn working directory override through `_meta.cwd`:

```json
{
"sessionId": "thread-1",
"prompt": [{"type": "text", "text": "Where are the tests?"}],
"_meta": {"cwd": "/Users/me/other-project"}
}
```

Both ACP v1 and v2 accept it. A prompt without `_meta`, with `_meta: null`, or with any other
`_meta` keys behaves exactly as before; `_meta.cwd: null` counts as absent.

## Validation

The adapter validates `_meta.cwd` before the prompt takes its turn-start slot, so an invalid value
rejects the prompt with the standard `invalid_params` error (`-32602`) and nothing is sent to
Codex:

- the value must be a non-empty string,
- an absolute POSIX or Windows path (never a file URI),
- an existing directory.

## Effect

The validated directory is sent as the `cwd` of the Codex `turn/start` request, which overrides
the working directory for this turn and subsequent turns. Once the turn starts, the adapter
updates the session's working directory, so the following turns, file citations, and turn diffs
resolve against it. `session/list` reflects the new directory through Codex's own thread cwd.

## Client support

Older adapters ignore unknown `_meta` keys, so sending `_meta.cwd` to one is harmless but has no
effect: the turn still runs in the session's original directory. A client that needs the override
should gate on the adapter version or detect the effect before relying on it.
3 changes: 3 additions & 0 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1405,6 +1405,9 @@ export class CodexAcpClient {
return await this.codexClient.runTurn({
threadId: request.sessionId,
input: input,
// Re-pin the thread working directory like the other overrides below; it applies to
// this turn and subsequent turns, so Codex follows the session's cwd.
cwd: cwd,
approvalPolicy: agentMode.approvalPolicy,
approvalsReviewer: agentMode.approvalsReviewer,
...(permissionProfileId
Expand Down
46 changes: 43 additions & 3 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ import {
} from "./ModelConfigOption";
import type {TokenCount} from "./TokenCount";
import {PromptTokenUsage, toPromptUsage, ZERO_TOKEN_COUNT} from "./TokenCount";
import {statSync} from "node:fs";
import {isAbsoluteProtocolPath} from "./PathUtils";
import {CodexCommands} from "./CodexCommands";
import {SteeringQueue} from "./SteeringQueue";
import type {QuotaMeta} from "./QuotaMeta";
Expand Down Expand Up @@ -387,6 +389,34 @@ function stopReasonForUnownedTurn(status: TurnStatus): acp.StopReason {
}
}

/** The `_meta.cwd` per-turn working directory override on `session/prompt`. */
function readMetaTurnCwd(meta?: Record<string, unknown> | null): string | undefined {
const rawCwd = meta?.["cwd"];
// The ACP type allows `_meta: null`; treat it and a null `cwd` like an absent override.
if (rawCwd === undefined || rawCwd === null) {
return undefined;
}
if (typeof rawCwd !== "string" || rawCwd.trim().length === 0) {
throw RequestError.invalidParams(undefined, "_meta.cwd must be a non-empty string");
}
const turnCwd = rawCwd.trim();
if (!isAbsoluteProtocolPath(turnCwd)) {
throw RequestError.invalidParams(undefined, "_meta.cwd must be an absolute path");
}
// Unlike `session/new`, a prompt must not fail after it was queued or inserted, so the
// directory is checked here instead of being left to Codex's turn-start error.
let stats;
try {
stats = statSync(turnCwd);
} catch {
throw RequestError.invalidParams(undefined, `_meta.cwd is not an existing directory: ${turnCwd}`);
}
if (!stats.isDirectory()) {
throw RequestError.invalidParams(undefined, `_meta.cwd is not a directory: ${turnCwd}`);
}
return turnCwd;
}

/**
* Approval/elicitation handlers for the baseline Codex turn tracker installed before any prompt
* has run. They answer exactly as app-server already defaults to for a thread with no
Expand Down Expand Up @@ -4857,6 +4887,9 @@ export class CodexAcpServer {
reservation?: TurnStartReservation,
): Promise<acp.PromptResponse> {
assertPromptContentSupported(params);
// Validated before the turn-start slot is taken, so a bad `_meta.cwd` rejects the prompt
// without touching the session.
const turnCwd = readMetaTurnCwd(params._meta);
this.assertInstructionsReady(params.sessionId);
// Callers that need to gate additional checks (closing, canStart) atomically with the
// turn-start slot acquire their own reservation and pass it in; otherwise this call is
Expand All @@ -4867,7 +4900,7 @@ export class CodexAcpServer {
await activeReservation.wait;
}
try {
return await this.promptAfterReservation(params, signal, onTurnStarted, insertion);
return await this.promptAfterReservation(params, signal, onTurnStarted, insertion, turnCwd);
} finally {
if (ownsReservation) {
activeReservation.release();
Expand All @@ -4880,6 +4913,7 @@ export class CodexAcpServer {
signal?: AbortSignal,
onTurnStarted?: () => void,
insertion?: UserMessageInsertion,
turnCwd?: string,
): Promise<acp.PromptResponse> {
if (this.providerUpdate !== null) {
await this.waitForProviderUpdate();
Expand All @@ -4889,6 +4923,9 @@ export class CodexAcpServer {
prompt: params.prompt,
});
const sessionState = this.getSessionState(params.sessionId);
// The directory this prompt's turn runs in: the per-turn `_meta.cwd` override, or the
// session's cwd. Codex adopts it for subsequent turns too, so the session state follows.
const turnWorkingDirectory = turnCwd ?? sessionState.cwd;
// Awaited only when a clear is sent, so a prompt with no held suggestion starts in the same tick.
const clearing = sessionState.promptSuggestions?.clear();
if (clearing) await clearing;
Expand All @@ -4897,7 +4934,7 @@ export class CodexAcpServer {
: null;
const agentFileChangeWorkspace = agentFileChangeReportRequest === null
? null
: captureAgentFileChangeWorkspace(sessionState.cwd, sessionState.additionalDirectories);
: captureAgentFileChangeWorkspace(turnWorkingDirectory, sessionState.additionalDirectories);
let agentFileChangeReportTurnId: string | null = null;
let agentFileChangeReportUnavailableReason: AgentFileChangeReportUnavailableReason = "providerError";
let promptWasCancelled = false;
Expand Down Expand Up @@ -5207,9 +5244,12 @@ export class CodexAcpServer {
modelId,
serviceTier,
disableSummary,
sessionState.cwd,
turnWorkingDirectory,
sessionState.additionalDirectories,
(turnId) => {
// The turn/start request carried the turn working directory, so Codex runs
// this turn and subsequent turns there; follow it once the turn is real.
sessionState.cwd = turnWorkingDirectory;
const turn = {threadId: params.sessionId, turnId};
activePrompt.currentTurn = turn;
insertion?.onTurnStarted?.(turn);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"text_elements": []
}
],
"cwd": "/workspace",
"approvalPolicy": "on-request",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"text_elements": []
}
],
"cwd": "/workspace",
"approvalPolicy": "on-request",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {
Expand Down Expand Up @@ -212,6 +213,7 @@
"text_elements": []
}
],
"cwd": "/workspace",
"approvalPolicy": "on-request",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/CodexACPAgent/data/prompt-v2-inserted.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"text_elements": []
}
],
"cwd": "/workspace",
"approvalPolicy": "on-request",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"text_elements": []
}
],
"cwd": "/workspace",
"approvalPolicy": "on-request",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"text_elements": []
}
],
"cwd": "/workspace",
"approvalPolicy": "on-request",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"text_elements": []
}
],
"cwd": "/workspace",
"approvalPolicy": "on-request",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {
Expand Down Expand Up @@ -169,6 +170,7 @@
"text_elements": []
}
],
"cwd": "/workspace",
"approvalPolicy": "on-request",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"text_elements": []
}
],
"cwd": "/workspace",
"approvalPolicy": "on-request",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {
Expand Down Expand Up @@ -102,6 +103,7 @@
"text_elements": []
}
],
"cwd": "/workspace",
"approvalPolicy": "on-request",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"text_elements": []
}
],
"cwd": "/workspace",
"approvalPolicy": "on-request",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {
Expand Down Expand Up @@ -108,6 +109,7 @@
"text_elements": []
}
],
"cwd": "/workspace",
"approvalPolicy": "on-request",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"text_elements": []
}
],
"cwd": "/workspace",
"approvalPolicy": "on-request",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {
Expand Down Expand Up @@ -101,6 +102,7 @@
"text_elements": []
}
],
"cwd": "/workspace",
"approvalPolicy": "on-request",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"text_elements": []
}
],
"cwd": "/workspace",
"approvalPolicy": "on-request",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"text_elements": []
}
],
"cwd": "/workspace",
"approvalPolicy": "on-request",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"text_elements": []
}
],
"cwd": "/workspace",
"approvalPolicy": "on-request",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"text_elements": []
}
],
"cwd": "cwd",
"approvalPolicy": "on-request",
"approvalsReviewer": "approvalsReviewer",
"sandboxPolicy": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"text_elements": []
}
],
"cwd": "/workspace",
"approvalPolicy": "on-request",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"text_elements": []
}
],
"cwd": "/workspace",
"approvalPolicy": "on-request",
"approvalsReviewer": "auto_review",
"sandboxPolicy": {
Expand Down
Loading