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:
parent
42a519f91e
commit
b0318edb6a
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue