fix(codex): make agent-codex work on Windows

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.
This commit is contained in:
Priyanshu Choudhary 2026-04-25 20:15:50 +05:30
parent 42a519f91e
commit b0318edb6a
1 changed files with 81 additions and 3 deletions

View File

@ -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<boolean> {
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<CodexSessionDat
* Returns "codex" as final fallback (let the shell resolve it at runtime).
*/
export async function resolveCodexBinary(): Promise<string> {
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<string> {
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 `<name>.cmd` shims, while
* Rust/Cargo installs produce `<name>.exe`. We prefer the .cmd shim because
* it forwards to the right node binary, then fall back to .exe.
*/
async function resolveCodexBinaryWindows(): Promise<string> {
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<string
return result;
}
/**
* Format a launch command for the host shell. On Windows the resolved binary
* path is single-quoted by shellEscape (e.g. `'C:\Users\...\codex.cmd'`), and
* PowerShell parses a leading quoted string as an expression `'codex' -c …`
* fails with "Unexpected token '-c' in expression or statement". Prepending
* the call operator `& ` tells PowerShell to *invoke* the string as a command.
* On Unix the prefix is unnecessary; bash treats `'codex' -c …` as a command.
*/
function formatLaunchCommand(parts: string[]): string {
const cmd = parts.join(" ");
return isWindows() ? `& ${cmd}` : cmd;
}
function createCodexAgent(): Agent {
/** Cached resolved binary path (populated by init or first getLaunchCommand) */
let resolvedBinary: string | null = null;
@ -477,7 +555,7 @@ function createCodexAgent(): Agent {
parts.push("--", shellEscape(config.prompt));
}
return parts.join(" ");
return formatLaunchCommand(parts);
},
getEnvironment(config: AgentLaunchConfig): Record<string, string> {
@ -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<void> {