From b0318edb6a8a2a77e22e3cf92b0054663dee0717 Mon Sep 17 00:00:00 2001 From: Priyanshu Choudhary <57816400+Priyanchew@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:15:50 +0530 Subject: [PATCH] fix(codex): make agent-codex work on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Windows-specific gaps that combined to make every Codex spawn fail on PowerShell with "Unexpected token '-c' in expression or statement": - formatLaunchCommand(): prepend `& ` to the joined launch string when running on Windows. shellEscape quotes the resolved binary path ('C:\Users\...\codex.cmd'), and PowerShell parses a leading quoted string as an expression — without the call operator the next flag triggers a parser error before codex is ever invoked. bash treats the same string as a normal command, so the prefix is Windows-only. Applied at both getLaunchCommand and getRestoreCommand exits. - resolveCodexBinary(): add a Windows branch using `where.exe` instead of `which`. Prefers codex.cmd (npm shim) over codex.exe (Cargo build), then falls back to %APPDATA%\npm\codex.{cmd,exe} and ~\.cargo\bin for users whose PATH doesn't yet include the install dir. Lookup runs with windowsHide:true so the search itself doesn't flash a console. - sessionFileMatchesCwd(): compare paths via a canonical form (forward slashes, lowercased drive letter) so Codex JSONL rollout files can still be located when payload.cwd uses a different slash direction or drive-letter case than the workspace path AO computes via path.join. Without this, dashboard activity/cost stay empty for Codex sessions on Windows. --- packages/plugins/agent-codex/src/index.ts | 84 ++++++++++++++++++++++- 1 file changed, 81 insertions(+), 3 deletions(-) diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index 0c058e6eb..43317c67a 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -181,12 +181,24 @@ async function readJsonlPrefixLines(filePath: string, maxLines: number): Promise return lines; } +/** + * Normalize a path for cross-platform comparison. Codex's JSONL may emit + * forward-slash paths or vary drive-letter case on Windows; AO constructs + * workspace paths via path.join which yields backslashes on Windows. Compare + * via a canonical form: forward slashes throughout, lowercased drive letter. + */ +function toComparablePath(p: string): string { + const slash = p.replace(/\\/g, "/"); + return slash.replace(/^([a-zA-Z]):/, (_, d: string) => d.toLowerCase() + ":"); +} + /** * Check if the first few complete JSONL records of a session file contain a * session_meta entry matching the given workspace path. This avoids parsing a * truncated session_meta line when Codex embeds large base_instructions. */ async function sessionFileMatchesCwd(filePath: string, workspacePath: string): Promise { + const wantedCwd = toComparablePath(workspacePath); try { const lines = await readJsonlPrefixLines(filePath, SESSION_MATCH_SCAN_LINE_LIMIT); for (const line of lines) { @@ -195,7 +207,11 @@ async function sessionFileMatchesCwd(filePath: string, workspacePath: string): P if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { const entry = parsed as CodexJsonlLine; const payload = getCodexPayload(entry); - if (entry.type === "session_meta" && payload.cwd === workspacePath) { + if ( + entry.type === "session_meta" && + typeof payload.cwd === "string" && + toComparablePath(payload.cwd) === wantedCwd + ) { return true; } } @@ -350,6 +366,10 @@ async function streamCodexSessionData(filePath: string): Promise { + if (isWindows()) { + return resolveCodexBinaryWindows(); + } + // 1. Try `which codex` try { const { stdout } = await execFileAsync("which", ["codex"], { timeout: 10_000 }); @@ -381,6 +401,51 @@ export async function resolveCodexBinary(): Promise { return "codex"; } +/** + * Windows-specific binary lookup. `which` does not exist on Windows; the + * equivalent is `where.exe`, which can return multiple lines (PATHEXT + * variants). npm-installed CLIs land as `.cmd` shims, while + * Rust/Cargo installs produce `.exe`. We prefer the .cmd shim because + * it forwards to the right node binary, then fall back to .exe. + */ +async function resolveCodexBinaryWindows(): Promise { + for (const target of ["codex.cmd", "codex.exe"]) { + try { + const { stdout } = await execFileAsync("where.exe", [target], { + timeout: 10_000, + windowsHide: true, + }); + const first = stdout.split(/\r?\n/).find((line) => line.trim().length > 0); + if (first) return first.trim(); + } catch { + // Not on PATH — try next target + } + } + + // Fall back to common npm/Cargo install locations so AO works even when + // the user installed Codex into a directory not currently on PATH. + const appData = process.env["APPDATA"]; + const home = homedir(); + const candidates = [ + appData ? join(appData, "npm", "codex.cmd") : null, + appData ? join(appData, "npm", "codex.exe") : null, + join(home, ".cargo", "bin", "codex.exe"), + ].filter((p): p is string => p !== null); + + for (const candidate of candidates) { + try { + await stat(candidate); + return candidate; + } catch { + // Not at this location + } + } + + // Last resort: bare name. PowerShell will hit PATHEXT to find codex.cmd. + // Combined with the `& ` prefix from formatLaunchCommand this still works. + return "codex"; +} + // ============================================================================= // Agent Implementation // ============================================================================= @@ -445,6 +510,19 @@ async function findCodexSessionFileCached(workspacePath: string): Promise { @@ -743,7 +821,7 @@ function createCodexAgent(): Agent { // Positional threadId goes last, after all flags parts.push(shellEscape(data.threadId)); - return parts.join(" "); + return formatLaunchCommand(parts); }, async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise {