Skip to content

Codex: built-in commandWindows uses PowerShell syntax, but Codex runs Windows hooks under cmd.exe #1215

Closed

Description

@natinli

Summary

commandWindows never reaches Codex. On Windows, Codex therefore executes the POSIX command for every teamai hook — built-in and team dispatcher alike — which is a ParserError under Codex's PowerShell. #1074/#1075's cross-platform contract is a no-op for this host, so the fix it shipped never took effect on Windows.

Confirmed against codex-cli 0.142.5.

Evidence

1. Codex's hook struct has no such field. From the binary's type metadata:

struct variant HookHandlerConfig::Command with 4 elements
  command  timeout  async  statusMessage        (+ agent)

2. The string does not exist in the binary. grep -a commandWindows codex.exe → 0 hits.

Codex is Rust + serde with the default (non-deny_unknown_fields) behavior, so the key is dropped silently at deserialization. Nothing warns, nothing fails — the entry looks correct on disk and is simply ignored.

3. The installed file is already the correct shape. On a machine running teamai-cli@0.28.0-beta.4:

{
  "type": "command",
  "command": "bash -lc \"teamai hook-dispatch session-start --tool codex 2>/dev/null\" || true",
  "commandWindows": "teamai hook-dispatch session-start --tool codex 2>$null; exit 0"
}

Event keys are PascalCase, the scope is user-level, and teamai hooks inject reports ✔ Trusted 8 teamai hook(s). The file matches what the current writer produces — it is the reader that has nowhere to put the field.

4. Both commands run fine when invoked directly — bash -lc "…" and the PowerShell form both exit 0. So this is purely a "which one does the host pick" problem, not a broken command.

Observable symptoms

  • Hooks never execute on Codex/Windows (no session-start context, no team hook dispatch).
  • Codex shows its hook-review prompt every session, because the command it tries to run fails to parse.

teamai hooks inject reporting success is correct behavior, not a bug: its writes landed. They land in a field the host ignores.

Proposed direction: put a portable form in command

Codex runs command through the host shell on every platform (that is why bash -lc works at all on POSIX and why the same field is a ParserError on Windows PowerShell). So a shell-syntax-free command is platform-neutral by construction:

teamai hook-dispatch <event> --tool codex [--team-hooks]

Exit code and stderr pass through unchanged, which is exactly what the team dispatcher needs — it has no swallow tail today and must keep exit 2 meaningful.

The central risk (as raised in #1209): this trades a shell contract for a PATH contract

The wrappers exist because some hosts spawn hooks with a limited PATH. Verified on this machine — ~/.teamai/bin/ contains:

  • teamai — #!/bin/sh, exec "<abs node>" "<abs index.js>" "$@", with a header comment saying it exists "so hooks work in environments without PATH (e.g. WorkBuddy GUI subprocess)"
  • teamai.cmd — same, for "CodeBuddy IDE hook subprocess"

Measured under a restricted PATH (C:\Windows\System32 only):

Invocation Result
bare teamai --version teamai is not recognized
%USERPROFILE%\.teamai\bin\teamai.cmd --version 0.28.0-beta.4

So a bare command is only safe if Codex's hook PATH includes the wrapper directory or npm globals. Two ways to keep the guarantee:

  • emit the absolute wrapper path (~/.teamai/bin/teamai.cmd on Windows) instead of the bare name, or
  • confirm and document the PATH contract Codex hooks actually run under, and rely on it.

The second is cheaper but needs a stated guarantee from Codex; the first is self-contained and needs no assumptions.

Built-ins need fail-open moved, not just the shell form

Built-in advisories fail open via shell syntax (|| true, ; exit 0). A bare command has nowhere to put that, so:

  • push the fail-open into hook-dispatch itself for built-in events (never exit non-zero), making the bare form safe, or
  • keep a deliberate wrapper for built-ins only and accept that they remain shell-syntax-dependent.

Verified that hook-dispatch already exits 0 on a non-teamai cwd, so the runtime side is close to this contract already — the gap is making it unconditional for built-in events.

Relationship to #1210

#1210 adds commandWindows to the dispatcher entry. It is correct against the documented contract and still worth keeping for any tool that does read the field — but it cannot fix Codex/Windows, because Codex has no reader for it. If the bare-command pivot lands, #1210's commandWindows becomes redundant for Codex (the bare form is already portable) and #1210 can be closed in favor of the redesign. That choice belongs to the maintainers; this issue is the vehicle for the decision.

Scope note

This is shipped behavior affecting every Windows user of teamai's Codex integration, including installs that never touch team hooks — hence filed separately from #1210 rather than folded into it.


Evidence gathered on teamai-cli@0.28.0-beta.4 / codex-cli 0.142.5 / Windows 11.

Activity

  1. kittimzhe commented on Oct 20, 2026

    @kittimzhe
    Contributor

    Taking this — the #1209 analysis that shaped the design notes is mine, and the fix direction is clear: the self-contained option.

    Scope I'd implement:

    1. Dispatcher entries: command becomes the absolute wrapper path ("<home>/.teamai/bin/teamai[.cmd]" hook-dispatch <E> --tool codex --team-hooks) — double-quoted for spaces, portable across PowerShell and POSIX sh, no PATH assumptions. The removal/rewrite match recognizes both the legacy bare form and the new absolute form, so existing installs migrate on the next hooks inject.
    2. Built-ins: fail-open moves into hook-dispatch (never exit non-zero for built-in events — the non-teamai-cwd exit-0 behavior already verified in the report, made unconditional), and built-in commands drop the shell tails.
    3. Red-first tests for both, plus real-CLI records (hooks inject on a seeded ~/.codex/hooks.json).

    The documented-PATH-contract variant stays open as a follow-up if Codex ever states the guarantee — the absolute-path form needs no assumptions and can land now.

  2. natinli commented on Oct 20, 2026

    @natinli
    Author

    Implementation plan looks right — one blocker on the dispatcher entry, from testing your exact candidate string.

    "<home>/.teamai/bin/teamai[.cmd]" hook-dispatch … does not run in PowerShell

    PowerShell does not execute a double-quoted path in command position; it parses it as a string expression and chokes on the arguments that follow. Measured on Windows 11 / PowerShell 5.1:

    Command Result
    "C:\Users\natinli\.teamai\bin\teamai.cmd" --version ParserError — unexpected token '--version'
    & "C:\Users\natinli\.teamai\bin\teamai.cmd" --version 0.28.0-beta.4
    C:\Users\natinli\.teamai\bin\teamai.cmd --version (unquoted) 0.28.0-beta.4

    And without quotes the form is only safe while the path has no spaces. With the home directory containing one:

    C:\Users\natinli\Temp Test Dir\t.cmd  →  "'C:\Users\natinli\Temp" is not recognized…"
    & "C:\Users\natinli\Temp Test Dir\t.cmd"  →  0.28.0-beta.4
    

    So the two shells want incompatible things: PowerShell needs & in command position, POSIX sh has no & operator (it would background the command) and does not need one.

    The awkward part: there is no single string that satisfies both

    • & "…" → correct in PowerShell, wrong in sh.
    • "…" → correct in sh, ParserError in PowerShell.
    • unquoted → works in both, only if the path has no spaces.

    & is a control operator in POSIX shells, so it cannot be smuggled into a portable string. call and cmd /c are Windows-only.

    This puts the absolute-path plan in tension with its own goal: the wrapper was chosen to need no assumptions, but a path string that works under both host shells does need one (no spaces in the home path).

    Three ways out, your call

    1. Emit the platform-appropriate string — & "<abs>" on Windows, "<abs>" on POSIX. Codex reads command on both platforms, so this only works if teamai can tell which host shell will run it. It can for our own writer (we know the platform), but the same string is then not portable by construction — fine if command is genuinely consumed per-platform, which is what the current design already assumes.
    2. Drop the .cmd wrapper for a .exe-free absolute call — invoke node directly ("<abs node>" "<abs index.js>" hook-dispatch …), which is exactly what the wrapper does internally. Same quoting problem, so this only helps if (1) is applied.
    3. Ship the documented-PATH variant now — trust that Codex hook subprocesses inherit a PATH that resolves the wrapper. This is the thing we could not verify from teamai's side, and it reintroduces the original assumption, so I'd only take it if Codex confirms it.

    My read: option 1, and it is cheaper than it looks — the built-in entries already have this shape (2>$null; exit 0 on Windows vs 2>/dev/null || true on POSIX), so per-platform strings are already what the writer emits. The wrapper's PATH-independence is preserved; only the quoting becomes platform-specific.

    If you agree, the match/rewrite logic in your point 1 should recognize all four legacy shapes (bare + bash -lc wrapper, and the three quoting variants above) so existing installs migrate in one hooks inject.

    Happy to test a build when you have one — I have the failing setup and the byte-level records.

  3. kittimzhe commented on Oct 20, 2026

    @kittimzhe
    Contributor

    Agreed — option 1, and thank you for catching it before Windows users did. Pushed as 5f46081 on #1231:

    • getTeamHookDispatchCommand now emits & "<abs teamai.cmd>" hook-dispatch <E> --tool codex --team-hooks on win32 and "<abs teamai>" hook-dispatch … --team-hooks elsewhere. The fallback when ensureTeamaiWrapper() fails stays the bare name, which both shells resolve from PATH.
    • Your measurement table is now encoded as a test: the win32 form must start with the call operator, quote the wrapper with the .cmd suffix, and keep the shell-free dispatch tail (emits the PowerShell call-operator form on Windows in codex-team-hooks.test.ts, with process.platform mocked). The recognition test covers the & "…" form alongside the legacy bare/bash -lc/Git-Bash shapes, so all four migrate on the next inject.
    • Re-verified on the real CLI (POSIX side): re-inject rewrites the seeded legacy dispatcher to "<home>/.teamai/bin/teamai" hook-dispatch Stop --tool codex --team-hooks and a second inject is byte-stable.

    Your offer to test a build would be the decisive record — the branch is fix/portable-hook-command-1215 on my fork, latest commit 5f46081. The two things worth measuring on your setup: the dispatcher entry's & "…" form under Codex's PowerShell hook runner, and whether the bare built-in teamai hook-dispatch <E> --tool codex resolves from the PATH Codex actually spawns hooks with (the documented-PATH question from the report — if it doesn't, the built-ins need their own answer).

  4. natinli commented on Oct 20, 2026

    @natinli
    Author

    Tested your branch (5f46081) on the failing setup. The dispatcher half works. The built-in half has one open risk I could not settle from outside Codex.

    Setup

    Cloned kittimzhe/teamai-cli@fix/portable-hook-command-1215, npm install && npm run build, then ran everything against a seeded isolated HOME so my real install was untouched:

    • ~/.codex/hooks.json seeded with the exact legacy shapes from the report: a bash dispatcher under Stop, a bash built-in with commandWindows under SessionStart, plus a user-owned echo user hook.
    • teamai init --self --scope workspace in a throwaway git repo, then hooks inject twice.

    Verified working

    Built-ins are now shell-free on disk. Project-level .codex/hooks.json after inject:

    {"type": "command", "command": "teamai hook-dispatch session-start --tool codex", "timeout": 15}

    No bash -lc, no 2>/dev/null, no commandWindows field at all. That is the #1215 contract as you described it.

    The dispatcher is written with the absolute wrapper and the call operator. User-level ~/.codex/hooks.json:

    "command": "& \"G:\AICode\_teamai-pr1231\.testhome\.teamai\bin\teamai.cmd\" hook-dispatch Stop --tool codex --team-hooks"
    • The wrapper file exists at exactly that path after inject (.teamai/bin/teamai.cmd and the POSIX teamai sibling, both created in the same run).
    • Executing the string verbatim in PowerShell: exit=0.
    • The user-owned echo user hook entry is byte-identical, and the dispatcher was inserted under Stop alongside it.
    • A second hooks inject is byte-stable.

    Legacy user-level entries are left alone, by design. The seeded legacy built-in under SessionStart in ~/.codex/hooks.json was not rewritten — only the project-level file gets built-ins. Consistent with #993 and with your description; noting it because it means a machine like mine keeps its old user-level entries until something removes them.

    The one thing I could not settle: bare teamai under Codex's actual PATH

    This is the documented-PATH question. Measured in PowerShell:

    PATH teamai hook-dispatch stop --tool codex
    normal exit=0
    C:\Windows\System32 only CommandNotFoundException; $LASTEXITCODE comes back empty, not a number

    So the bare built-in form depends entirely on Codex spawning hooks with an inherited PATH. Two consequences worth deciding on before merge:

    1. If Codex uses a restricted PATH, every built-in hook throws CommandNotFoundException on every Windows session. That is not a silent no-op — it is a visible error, and depending on how Codex treats a failed hook it could surface as a warning or a block.
    2. $LASTEXITCODE being empty (not 0) is the part I would want the fail-open argument to cover. Your PR says fail-open now lives in hook-dispatch's unconditional process.exit(0) — but if the command never resolves, hook-dispatch never runs, so that guarantee does not apply. The old 2>$null; exit 0 tail did cover this case; the bare form does not.

    Suggestion, in order of cost:

    • Cheapest: keep the bare form but emit the absolute wrapper for built-ins too (same & "<abs>" / "<abs>" split you already implemented for the dispatcher). It removes the PATH dependency entirely and makes both halves consistent. The only cost is losing cross-platform byte-stability for built-ins in the user-level file — which the dispatcher already gave up.
    • If byte-stability for project-level files matters: keep bare command there (a repo committed on Linux then pulled on Windows still reads the same) and accept that built-ins need a working PATH. That is a real tradeoff and probably fine, but it should be stated as the contract rather than implied.
    • Cheapest of all, if you can get it: ask the Codex maintainers whether hook subprocesses inherit the parent PATH. If yes, the current form is fine and this whole branch is done.

    My test machine resolves teamai from %APPDATA%\npm (365-day-old npm global), so it will not tell you what a fresh install looks like — this needs the Codex-side answer, not another measurement here.

    Everything else in the PR checks out on my side. Happy to re-test after you pick a direction.

  5. kittimzhe commented on Oct 20, 2026

    @kittimzhe
    Contributor

    The `` finding settles it — thank you. That's the case my fail-open argument silently dropped: process.exit(0) covers every failure inside hook-dispatch, but a command that never resolves never reaches it. And it reframes the regression precisely: the PATH dependency itself isn't new (the old bash form needed `teamai` on PATH too) — what changed is not-found going from silent fail-open (the tail) to a visible error with an empty exit code.

    Proposed direction, splitting by scope since the writer knows it:

    • User-level built-ins → the absolute wrapper, same per-platform & "…" / "…" split as the dispatcher. The user file is per-machine and hooks inject ran on it, so the wrapper exists and the hooks actually run — no PATH dependency where the file is already machine-local.
    • Project-level built-ins → bare + ; exit 0: byte-stable across platforms (both shells parse ; exit 0), and it restores the old not-found fail-open — PowerShell's CommandNotFoundException is non-terminating, so exit 0 still runs and `` comes back 0; POSIX sh continues after a not-found and exits 0 the same way. Project files are committed by teams that ran teamai init (teamai resolves for them); a cloner without teamai gets the old silent behavior back instead of a new error. The PATH requirement gets stated as the contract for project files.

    The tail's not-found behavior is a 30-second measurement on your box if you want to confirm the PowerShell half before I land it. Otherwise I'll implement the split on #1231 now — the writers already know the scope, so it's contained to the same files.

  6. natinli commented on Oct 20, 2026

    @natinli
    Author

    Both halves of the split measured. The proposal holds.

    Project-level: bare … ; exit 0 restores fail-open on both shells

    Restricted PATH (C:\Windows\System32 only) / POSIX PATH=/nonexistent:

    Form PowerShell POSIX sh
    bare, no tail CommandNotFoundException, exit code empty command not found, exit 127
    bare + ; exit 0 CommandNotFoundException printed, process exit 0 command not found, exit 0

    So your reasoning is right on both sides: PowerShell's CommandNotFoundException is non-terminating, exit 0 still runs; sh continues after a not-found and reaches exit 0 too. And ; exit 0 parses identically in both, which keeps the byte-stability contract for committed project files.

    One honest note on the visible surface: the PowerShell error text is still printed to stderr before exit 0 runs. Exit code is clean, but if Codex surfaces hook stderr anywhere the not-found message would show up. The old bash form suppressed it with 2>$null. If you want the old silence too, 2>$null; exit 0 is the exact equivalent — still byte-stable across shells only in the sense that the same string is written everywhere (POSIX sh would treat 2>$null as a redirect to a file literally named $null, which is harmless but not the same thing). So ; exit 0 is the better cross-platform choice and I'd keep it; just flagging that "silent" is not part of what it restores.

    User-level: the absolute wrapper runs with no PATH at all

    Restricted PATH, & "<abs>\.teamai\bin\teamai.cmd" hook-dispatch stop --tool codex; exit 0 → exit 0, no PATH dependency, no error output. Also verified unquoted (works only while the path has no spaces, so the quoted + call-operator form stays correct).

    That is the important asymmetry and I think it justifies the split cleanly: the user-level file is machine-local and hooks inject just ran on it, so the wrapper is guaranteed present — emitting its absolute path costs nothing and removes the last assumption. The project-level file is shared, so it correctly keeps the portable bare form.

    Suggested detail while you're in there

    isCodexTeamHookDispatcher / the match-and-rewrite set will need ; exit 0 recognized on the built-in side too, not just the dispatcher — i.e. the legacy bash -lc "… 2>/dev/null" || true and the interim bare form both have to be replaced by the new project-level string in one inject, or the migration leaves the old shape behind. Same shape as the four dispatcher variants we already enumerated.

    Nothing else from my side. I'll re-test the branch after you land the split — the two things I'll check are the user-level file now carrying & "…" and the project-level file carrying … ; exit 0.

  7. SaulMoro commented on Oct 20, 2026

    @SaulMoro
    Collaborator

    @kittimzhe @natinli Codex does read commandWindows (codex-rs/config/src/hook_config.rs:144 and codex-rs/hooks/src/engine/discovery.rs:470 at rust-v0.142.5, added in openai/codex#22159), so the premise of #1215 does not hold and the binary grep was a false negative. Since the 8 entries already carried commandWindows, the Windows failure has another cause. Could you reproduce it and capture Codex's actual hook error before we go further here? If it turns out to be PATH, the absolute wrapper path may still be worth a smaller, separate change.

  8. kittimzhe commented on Oct 20, 2026

    @kittimzhe
    Contributor

    Found it — the shell is the answer, and it's neither of the two we were measuring.

    codex-rs/hooks/src/engine/command_runner.rs (rust-v0.142.5), default_shell_command():

    #[cfg(windows)]
    {
        let comspec = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string());
        let mut command = Command::new(comspec);
        command.arg("/C");
        command
    }

    Windows hooks run under cmd.exe (%COMSPEC% /C), not PowerShell — no powershell/pwsh string appears anywhere in the hooks engine. And openai/codex#33926 (July) wraps the command string in an extra quote layer via raw_arg when the shell is cmd, so a quoted program path inside the string resolves correctly.

    That explains every observation in this thread:

    • The original Windows failure: the old commandWindows content was PowerShell syntax fed to cmd.exe — 2>$null creates a file named $null, and ; is not a cmd separator (it's an argument delimiter), so ; exit 0 arrived as junk arguments to teamai hook-dispatch. The hooks never ran correctly because the string was written for a shell Codex never uses.
    • My PowerShell measurements were answering the wrong shell — the call-operator analysis doesn't apply; I apologize for the detour. The plain quoted form "<abs>" hook-dispatch … (no &) is in fact correct for cmd.exe and sh.
    • The ; exit 0 tail I landed on fix(hooks): make Codex hook commands portable shell-free argv (#1215) #1231 (22e3732) is cmd-hostile (junk args, same mechanism) — I'll revert it.

    The minimal true fix for the original bug is two lines: the old design (command + commandWindows pair) was right, but the Windows field needs cmd syntax — 2>$null; exit 0 → 2>nul & exit 0. On builds with commandWindows support that restores fail-open under the actual hook shell; on older builds the bare command (POSIX) runs as today. I'll rework #1231 along these lines — dispatcher back to the plain quoted absolute path (no &), built-ins bare with a cmd-syntax commandWindows — unless the maintainers prefer bare-only.

  9. natinli commented on Oct 20, 2026

    @natinli
    Author

    You're both right, and I was wrong twice. The binary I inspected is not the binary that runs, and my PowerShell measurements were testing a shell Codex never invokes.

    My grep was a false negative — wrong file

    @openai/codex@0.142.5 resolves through bin/codex.js to the platform package @openai/codex-win32-x64, i.e. …/OpenAI/Codex/bin/896a0166b774037a/codex.exe (341 MB). I had grepped …/Codex/bin/codex.exe (245 MB) — a different, older build still sitting in the same tree. Re-running against the right file:

    commandWindows  11 hits
    HookHandlerConfig        (context: … local in_memory commandWindows timeout async
                             statusMessage additionalContextLimit mcp_tool …)
    struct variant HookHandlerConfig::Command with 6 elements
    

    So commandWindows is real, Command has 6 elements, and @SaulMoro's pointer to hook_config.rs:144 / discovery.rs:470 is correct. The with 4 elements line I quoted came from the stale binary. I should have resolved the npm entry point before drawing any conclusion — that's on me.

    The shell is cmd.exe — measured

    Testing under real cmd /C, which is what default_shell_command() selects on Windows:

    command command exists restricted PATH
    bare 0 —
    bare 2>nul & exit 0 0 0
    bare 2>$null; exit 0 (the old shape) 0 1

    The last row is the actual bug: under cmd, 2>$null redirects to a literal file named $null and ; is an argument delimiter, so ; exit 0 never runs as a command — the tail becomes junk argv and the exit status is whatever the failed dispatch left, 1. That is why hooks never ran and why the review prompt appeared, and it is exactly the mechanism you described.

    So the shape is: command + a cmd-syntax commandWindows

    • command stays the portable POSIX form (what a Linux/macOS Codex reads).
    • commandWindows carries … 2>nul & exit 0.
    • Dispatcher keeps the plain quoted absolute path, no & — cmd resolves "<abs>" fine, and my call-operator detour was answering PowerShell.

    One caveat on the dispatcher form

    While testing I hit a separate cmd behavior worth knowing (it does not affect Codex's cmd /C single-line invocation, only nested batch files): calling a .cmd from another batch file without call transfers control and never returns. Codex runs one command string per hook, so this is not in your path — but if any of these commands ever get embedded in a generated .bat, it will bite. Just flagging it so it does not surprise you later.

    Nothing to re-test from me until the rework lands; I'll check the cmd tail and the dispatcher's "<abs>" hook-dispatch … form the same way. And sorry for the two false leads — the first report and this one both cost you time.

  10. natinli commented on Oct 20, 2026

    @natinli
    Author

    One more thing I should state plainly, because it matters for the rework.

    My wrong diagnosis sent the PR in a worse direction

    Two rounds ago I reported "Codex does not read commandWindows" and proposed dropping the field in favor of a bare command. That proposal is what made #1231 remove the field and go bare — and bare is what lost the built-in fail-open, which then produced the whole PATH discussion we spent a night on.

    Had the original diagnosis been right, none of that would have been necessary. The correct fix was always the one you landed on: keep the command/commandWindows pair, fix the Windows content to cmd syntax. The field was never the problem; the dialect inside it was.

    So: the PATH tradeoff, the ; exit 0 tail, the absolute-wrapper split — those were all responses to a problem that did not exist. You can go straight back to the pre-#1231 design with 2>nul & exit 0 substituted in, and the whole detour disappears.

    What I got wrong, concretely

    • Grepped bin/codex.exe (245 MB, a stale build in the same tree) instead of the platform package binary npm actually executes (896a0166b774037a/codex.exe, 341 MB, where commandWindows appears 11 times).
    • I had a grep result and treated it as proof, without ever asking "is this the file that runs?"
    • Separately, I measured everything in PowerShell because the original issue title said PowerShell. Nobody in this thread had verified which shell Codex uses — including me, after I had taken over the investigation.

    What I'll do differently

    Before reporting a field/flag as unsupported, resolve the actual execution path first (bin entry in package.json → platform package → real binary) and say in the report which file was inspected. And before reasoning about shell syntax, read the host's shell-selection code.

    Nothing else from me — your rework direction is right and I have nothing to add to it. I'll re-test the two forms when it lands.

  11. SaulMoro commented on Oct 20, 2026

    @SaulMoro
    Collaborator

    Thanks both. The cmd.exe finding is right. At rust-v0.142.5, default_shell_command() runs %COMSPEC% /C on Windows (codex-rs/hooks/src/engine/command_runner.rs:119-126). Two points need correcting before the rework.

    1. openai/codex#33926 is not in 0.142.5. It first ships in rust-v0.145.0 (2026-07-21). At 0.142.5, build_command passes the hook string with Command::arg. That call escapes inner quotes as \", and cmd.exe does not read \", so a command with a quoted executable path fails. #33926 describes exactly this failure. This has three consequences:

    • The dispatcher form "<abs>\teamai.cmd" hook-dispatch … only works on Codex 0.145.0 and later. On 0.142.5, the version in this report, it would fail.
    • Running cmd /C "…" by hand cannot catch this, because it skips Rust's argument quoting. The measurement has to go through Codex.
    • It probably also explains [bug] Codex hook entries on Windows are rendered in bash syntax and silently never run #1074. The old Git Bash entry starts with a quoted path that contains a space ("C:/Program Files/Git/bin/bash.exe"). The PowerShell form that "fixed" it has no quotes.

    2. The original symptom is still not reproduced. In @natinli's cmd table, the shipped … 2>$null; exit 0 form exits 0 when teamai resolves, and it resolves on that machine. So the tail explains losing fail-open when teamai is missing. It does not explain hooks never running, or the review prompt on every session. Before #1231 changes shape again, please run Codex itself on Windows with the shipped entries. Paste what Codex reports for each hook: run status, exit code, stderr and trust status. Ideally do it on 0.142.5 and on a current release, because the hook runner changed between them.

    Suggested scope. For the built-ins, a cmd-syntax commandWindows (… 2>nul & exit 0) is the smallest change that fits what we know, and I would land it on its own. It has no inner quotes, so it works on both sides of #33926. Two more pieces belong in the same change:

    • codexWindowsCommand in src/hooks.ts documents Codex as PowerShell. That comment needs updating.
    • A new commandWindows changes the trust hash Codex computes, so the e2e record should show hooks inject trusting the rewritten entries.

    The absolute-path dispatcher depends on the Codex version (point 1), so it should wait for the record from a real Codex run.

  12. natinli commented on Oct 20, 2026

    @natinli
    Author

    Ran Codex itself on Windows with the shipped entries, as asked. The original symptom reproduces, and the mechanism is broader than the commandWindows tail.

    Setups

    codex-cli 0.142.5, codex exec in a real project (G:\AICode\Nilo), hooks enabled in config.toml, project at trust_level = "trusted".

    Codex's own output for one run:

    hook: SessionStart
    hook: SessionStart
    hook: SessionStart
    hook: SessionStart Failed        ← one of the three
    hook: SessionStart Completed
    hook: SessionStart Completed
    

    The three SessionStart entries at that moment were two project-level (Nilo's own) and one user-level (~/.codex/hooks.json), the shipped teamai built-in.

    Reproducing the execution path by hand

    Codex runs each entry as %COMSPEC% /C <command> with the string passed through Command::arg, so I reproduced that shape under real cmd. All three entries break:

    Entry Result under cmd /C Exit
    pwsh -NoLogo -NoProfile -File scripts/hooks/check-startup-health.ps1; exit $LASTEXITCODE PowerShell errors out (garbled args) 0
    python -X utf8 .codex/hooks/session-start.py; exit $LASTEXITCODE can't open file '...session-start.py;' — the ; is swallowed into the filename 0
    bash -lc "teamai hook-dispatch session-start --tool codex 2>/dev/null" || true bash: -c: line 1: unexpected EOF while looking for matching '"' — cmd eats the inner quotes 0

    Two things fall out of this:

    1. ; is not a cmd separator — it becomes part of the preceding argument. That is why the ; exit $LASTEXITCODE / ; exit 0 tails do nothing, and why fail-open silently never happens.
    2. Inner double quotes are not preserved through cmd /C in this invocation shape — the bash -lc "…" form reaches bash with unbalanced quotes. So the shipped command (the POSIX bash -lc wrapper) genuinely fails on Windows, independent of commandWindows.

    Every one of them exits 0, which is why Codex reports Failed for one entry and gives no stderr, and why the failure is invisible in normal use.

    On point 2 specifically

    You were right that my earlier cmd table did not explain the symptom. It still points the same way, though: I measured teamai resolving and the tail exiting 0, but I measured the tail, never the whole bash -lc "…" string through cmd. Doing that shows the command itself is the breakage, not only the fail-open.

    This also matches your #33926 note from the other direction — the quotes are being lost before bash sees them, which is the same class of failure. I cannot separate "cmd ate the quotes" from "Rust's \" escaping" from my measurements alone; both produce this exact unexpected EOF. That distinction determines whether a quoted "<abs>" dispatcher can work on 0.142.5, so your point 1 stands and I would not land the absolute-path dispatcher before that is settled.

    Suggested scope, revised

    • Built-ins: cmd-syntax commandWindows (… 2>nul & exit 0) — agreed, and it is the piece that survives either quote story, since it has no inner quotes.
    • But the command side also needs reconsidering: on 0.142.5 the bash -lc "…" string does not survive cmd. Whether commandWindows covering it is enough is a Codex-behaviour question I cannot answer from here.
    • Dispatcher absolute path: still blocked on point 1, agreed.

    I did not modify any teamai config in this run — probes were in a throwaway repo and in a backed-up copy of ~/.codex/hooks.json, now restored. Happy to run the same measurement against 0.145+ if you want the comparison.

  13. SaulMoro commented on Oct 20, 2026

    @SaulMoro
    Collaborator

    This was generated by AI during triage.

    Thanks @natinli, the Failed line from Codex itself is the first real reproduction. Two parts of the reading don't hold up, though, so the scope shouldn't move yet.

    1. The table can't be the shape Codex ran. Codex marks a SessionStart run Failed in four cases: a spawn error or timeout, a nonzero exit, no exit code, or stdout that looks like JSON but doesn't parse (codex-rs/hooks/src/events/session_start.rs:228-312 at rust-v0.142.5). An exit of 0 with plain stdout is Completed. So "every entry exits 0" can't produce the Failed line. The exit codes are odd on their own terms, too: python exits 2 when it can't open a file, and true doesn't exist in cmd. My guess is the probes ran from a PowerShell prompt, which splits cmd /C …; exit $LASTEXITCODE at the ; before cmd ever sees it.

    2. The bash -lc row tests a string Codex doesn't run. teamai's built-ins carry commandWindows, and on Windows Codex uses that field whenever it is present (discovery.rs:470). For teamai's entries, command only matters on Linux and macOS. The unbalanced-quote failure doesn't apply here, and it doesn't call for changes on the command side.

    What would settle it: which of the three entries is the Failed one, and the reason Codex gives. Two cheap ways to find out:

    • Run codex exec in a throwaway project with no .codex/hooks.json, so only teamai's user-level entry runs, and report whether it shows Completed or Failed.
    • The TUI hook panel shows each entry's message, such as hook exited with code N or hook returned invalid session start JSON output. That message is the root cause.

    One side check helps too: a file named $null in the hook's working directory confirms that Codex ran teamai's commandWindows under cmd.

    The 0.145+ comparison is only needed for the quoted dispatcher, so it can wait until this one is settled.

  14. natinli commented on Oct 20, 2026

    @natinli
    Author

    Your two corrections both land. I re-ran both and here is what survives.

    1. My exit codes were fabricated by my own harness

    $? after a pipe reports the pipe's status, not cmd's — and I had | tail/| grep on every measurement. Measured properly (> /dev/null 2>&1, no pipe):

    case real exit code
    python on a missing file 2 (not 0)
    unknown command 1 (not 0)
    true not a cmd builtin — your catch was right

    So the "everything exits 0" column in my earlier table was an artifact. The Failed line is not explained by that table.

    2. The bash -lc row was testing a string Codex never runs

    Agreed — teamai's built-ins carry commandWindows, and discovery.rs:470 prefers it on Windows. That row is irrelevant to teamai's entries. I withdraw it.

    What I did get, measured properly

    Isolated project, no .codex/hooks.json, so only teamai's user-level entry runs. codex exec output:

    hook: SessionStart
    hook: SessionStart Completed
    

    Completed, not Failed — and no $null file appeared. Two readings are possible and I could not separate them from outside: either Codex skipped the entry (untrusted), or it ran commandWindows and $null landed somewhere other than the cwd. Either way, the teamai entry alone does not reproduce the failure, which matches your point that the built-ins are not the broken part on this machine.

    The one thing I can state cleanly

    Under real cmd (batch file, so nothing is lost to Git Bash argument handling):

    cmd> echo hi 2>$null
          → creates a file literally named $null        ← confirmed, `if exist "$null"` is true
    cmd> node -e "..." a b; exit 0
          argv → ["a", "b;", "exit", "0"]
    

    So 2>$null; exit 0 does two wrong things at once: it creates a stray file, and it passes ;, exit, 0 as arguments to teamai. The fail-open tail is not a tail, it is three junk argv entries — exactly your "accepted and ignored" reading, now measured rather than inferred.

    What is still open

    The Failed line came from a run in G:\AICode\Nilo, where three SessionStart entries exist (two of Nilo's own plus teamai's). I isolated teamai's alone and got Completed; I have not isolated which of the other two produces Failed. I tried and hit a harness problem of my own making (a bad edit to a live config, since restored from backup and verified — 2 project entries, 8 user entries).

    I will not claim the Failed belongs to any particular entry until I isolate it. Tell me if you want that isolation next, or if the $null + argv evidence is enough to proceed with the cmd-syntax commandWindows change on its own.

  15. SaulMoro commented on Oct 20, 2026

    @SaulMoro
    Collaborator

    This was generated by AI during triage.

    Thanks @natinli. That's enough to proceed, and nothing more needs isolating on the teamai side.

    What the runs established:

    • On its own, teamai's user-level entry ends Completed under Codex 0.142.5. Codex only runs trusted hooks (discovery.rs:527-545), and it printed a run, so the entry was trusted and it ran. "Hooks never run on Windows" does not reproduce for teamai.
    • The Failed line is probably one of Nilo's own entries. Your argv probe (b; stays one token) suggests cmd hands session-start.py; to python, which exits 2, and Codex reports that as Failed. That is an inference from your measurement, and it is a problem for that project, not for teamai.
    • teamai's real defect is the dialect. Codex runs commandWindows under cmd.exe, so the shipped … 2>$null; exit 0 loses fail-open when teamai is missing, sends ;, exit and 0 to teamai as junk arguments, and sends stderr to a stray file. The missing $null doesn't change this; cmd may have named the file $null;.

    So the fix is the scope on #1231: built-ins only, with commandWindows in cmd syntax (… 2>nul & exit 0). The dispatcher's commandWindows is #1210's change, in cmd syntax too. Neither needs the absolute-path wrapper or changes to command.

    I'll retitle this issue to match the actual defect. If the hook-review prompt still shows up every session, please open a separate issue with the Codex message, since nothing here explains it.

  16. changed the title [-]commandWindows is a no-op for Codex: hooks never run on Windows[/-] [+]Codex: built-in `commandWindows` uses PowerShell syntax, but Codex runs Windows hooks under cmd.exe[/+] on Oct 20, 2026
  17. natinli commented on Oct 20, 2026

    @natinli
    Author

    Filed the follow-up you asked for: #1244 — the hook-review prompt that returns every session.

    It is a trust-hash split, not a hook-execution problem. Short version: teamai spawns a bare codex app-server, so it trusts through whatever codex resolves to on PATH (0.142.5 here); the desktop app runs 0.163.0-alpha.10.2. The two builds differ on additionalContextLimit (0 occurrences in 0.142.5's binary, 16 in 0.163), and teamai writes that field on exactly two of its eight entries — SessionStart and SubagentStart — which are the two that keep showing as modified. The stored hashes match the old CLI's output, so the last writer was 0.142.5.

    teamai doctor stays green because it asks the same 0.142.5 binary — it confirms the old CLI agrees with itself, not that the desktop does.

    So, for the record in this issue: nothing here changes the #1215 conclusion. The cmd-syntax defect (2>$null; exit 0 under cmd.exe) is real and independent; it just is not what produces the per-session prompt.

    Thanks for keeping the two apart — that is what made the second one findable.

  18. added a commit that references this issue on Oct 22, 2026
    611f467
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions