Repository navigation
Codex: built-in commandWindows uses PowerShell syntax, but Codex runs Windows hooks under cmd.exe #1215
Description
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
commandit 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.cmdon 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-dispatchitself 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
kittimzhe commentedon Oct 20, 2026
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:
commandbecomes 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 nexthooks inject.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.hooks injecton 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.
natinli commentedon Oct 20, 2026
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 PowerShellPowerShell 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:
"C:\Users\natinli\.teamai\bin\teamai.cmd" --versionParserError— unexpected token '--version'& "C:\Users\natinli\.teamai\bin\teamai.cmd" --version0.28.0-beta.4C:\Users\natinli\.teamai\bin\teamai.cmd --version(unquoted)0.28.0-beta.4And without quotes the form is only safe while the path has no spaces. With the home directory containing one:
So the two shells want incompatible things: PowerShell needs
&in command position, POSIXshhas 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 insh."…"→ correct insh,ParserErrorin PowerShell.&is a control operator in POSIX shells, so it cannot be smuggled into a portable string.callandcmd /care 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
& "<abs>"on Windows,"<abs>"on POSIX. Codex readscommandon 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 ifcommandis genuinely consumed per-platform, which is what the current design already assumes..cmdwrapper 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.My read: option 1, and it is cheaper than it looks — the built-in entries already have this shape (
2>$null; exit 0on Windows vs2>/dev/null || trueon 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 -lcwrapper, and the three quoting variants above) so existing installs migrate in onehooks inject.Happy to test a build when you have one — I have the failing setup and the byte-level records.
fix(hooks): quote the dispatcher wrapper per host shell (Tencent#1215)
kittimzhe commentedon Oct 20, 2026
Agreed — option 1, and thank you for catching it before Windows users did. Pushed as 5f46081 on #1231:
getTeamHookDispatchCommandnow emits& "<abs teamai.cmd>" hook-dispatch <E> --tool codex --team-hookson win32 and"<abs teamai>" hook-dispatch … --team-hookselsewhere. The fallback whenensureTeamaiWrapper()fails stays the bare name, which both shells resolve from PATH..cmdsuffix, and keep the shell-free dispatch tail (emits the PowerShell call-operator form on Windowsincodex-team-hooks.test.ts, withprocess.platformmocked). The recognition test covers the& "…"form alongside the legacy bare/bash -lc/Git-Bash shapes, so all four migrate on the next inject."<home>/.teamai/bin/teamai" hook-dispatch Stop --tool codex --team-hooksand a second inject is byte-stable.Your offer to test a build would be the decisive record — the branch is
fix/portable-hook-command-1215on 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-inteamai hook-dispatch <E> --tool codexresolves 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).natinli commentedon Oct 20, 2026
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 isolatedHOMEso my real install was untouched:~/.codex/hooks.jsonseeded with the exact legacy shapes from the report: a bash dispatcher underStop, a bash built-in withcommandWindowsunderSessionStart, plus a user-ownedecho user hook.teamai init --self --scope workspacein a throwaway git repo, thenhooks injecttwice.Verified working
Built-ins are now shell-free on disk. Project-level
.codex/hooks.jsonafter inject:{"type": "command", "command": "teamai hook-dispatch session-start --tool codex", "timeout": 15}No
bash -lc, no2>/dev/null, nocommandWindowsfield 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:.teamai/bin/teamai.cmdand the POSIXteamaisibling, both created in the same run).exit=0.echo user hookentry is byte-identical, and the dispatcher was inserted underStopalongside it.hooks injectis byte-stable.Legacy user-level entries are left alone, by design. The seeded legacy built-in under
SessionStartin~/.codex/hooks.jsonwas 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
teamaiunder Codex's actual PATHThis is the documented-PATH question. Measured in PowerShell:
teamai hook-dispatch stop --tool codexexit=0C:\Windows\System32onlyCommandNotFoundException;$LASTEXITCODEcomes back empty, not a numberSo the bare built-in form depends entirely on Codex spawning hooks with an inherited PATH. Two consequences worth deciding on before merge:
CommandNotFoundExceptionon 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.$LASTEXITCODEbeing empty (not0) is the part I would want the fail-open argument to cover. Your PR says fail-open now lives inhook-dispatch's unconditionalprocess.exit(0)— but if the command never resolves,hook-dispatchnever runs, so that guarantee does not apply. The old2>$null; exit 0tail did cover this case; the bare form does not.Suggestion, in order of cost:
& "<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.commandthere (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.My test machine resolves
teamaifrom%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.
kittimzhe commentedon Oct 20, 2026
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:
& "…"/"…"split as the dispatcher. The user file is per-machine andhooks injectran on it, so the wrapper exists and the hooks actually run — no PATH dependency where the file is already machine-local.; 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, soexit 0still 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 ranteamai 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.
fix(hooks): restore the not-found fail-open on Codex built-ins (Tence…
natinli commentedon Oct 20, 2026
Both halves of the split measured. The proposal holds.
Project-level:
bare … ; exit 0restores fail-open on both shellsRestricted PATH (
C:\Windows\System32only) / POSIXPATH=/nonexistent:CommandNotFoundException, exit code emptycommand not found, exit 127; exit 0CommandNotFoundExceptionprinted, process exit 0command not found, exit 0So your reasoning is right on both sides: PowerShell's
CommandNotFoundExceptionis non-terminating,exit 0still runs;shcontinues after a not-found and reachesexit 0too. And; exit 0parses 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 0runs. Exit code is clean, but if Codex surfaces hook stderr anywhere the not-found message would show up. The old bash form suppressed it with2>$null. If you want the old silence too,2>$null; exit 0is the exact equivalent — still byte-stable across shells only in the sense that the same string is written everywhere (POSIXshwould treat2>$nullas a redirect to a file literally named$null, which is harmless but not the same thing). So; exit 0is 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 injectjust 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 0recognized on the built-in side too, not just the dispatcher — i.e. the legacybash -lc "… 2>/dev/null" || trueand 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.SaulMoro commentedon Oct 20, 2026
@kittimzhe @natinli Codex does read
commandWindows(codex-rs/config/src/hook_config.rs:144andcodex-rs/hooks/src/engine/discovery.rs:470atrust-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 carriedcommandWindows, 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.kittimzhe commentedon Oct 20, 2026
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():Windows hooks run under cmd.exe (
%COMSPEC% /C), not PowerShell — nopowershell/pwshstring appears anywhere in the hooks engine. And openai/codex#33926 (July) wraps the command string in an extra quote layer viaraw_argwhen the shell is cmd, so a quoted program path inside the string resolves correctly.That explains every observation in this thread:
commandWindowscontent was PowerShell syntax fed to cmd.exe —2>$nullcreates a file named$null, and;is not a cmd separator (it's an argument delimiter), so; exit 0arrived as junk arguments toteamai hook-dispatch. The hooks never ran correctly because the string was written for a shell Codex never uses."<abs>" hook-dispatch …(no&) is in fact correct for cmd.exe and sh.; exit 0tail 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 barecommand(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-syntaxcommandWindows— unless the maintainers prefer bare-only.natinli commentedon Oct 20, 2026
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.5resolves throughbin/codex.jsto 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:So
commandWindowsis real,Commandhas 6 elements, and@SaulMoro's pointer tohook_config.rs:144/discovery.rs:470is correct. Thewith 4 elementsline 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 whatdefault_shell_command()selects on Windows:command2>nul & exit 02>$null; exit 0(the old shape)The last row is the actual bug: under cmd,
2>$nullredirects to a literal file named$nulland;is an argument delimiter, so; exit 0never 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-syntaxcommandWindowscommandstays the portable POSIX form (what a Linux/macOS Codex reads).commandWindowscarries… 2>nul & exit 0.&— 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 /Csingle-line invocation, only nested batch files): calling a.cmdfrom another batch file withoutcalltransfers 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.natinli commentedon Oct 20, 2026
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 barecommand. 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/commandWindowspair, fix the Windows content to cmd syntax. The field was never the problem; the dialect inside it was.So: the PATH tradeoff, the
; exit 0tail, 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 with2>nul & exit 0substituted in, and the whole detour disappears.What I got wrong, concretely
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, wherecommandWindowsappears 11 times).grepresult and treated it as proof, without ever asking "is this the file that runs?"What I'll do differently
Before reporting a field/flag as unsupported, resolve the actual execution path first (
binentry 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.
SaulMoro commentedon Oct 20, 2026
Thanks both. The cmd.exe finding is right. At
rust-v0.142.5,default_shell_command()runs%COMSPEC% /Con 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_commandpasses the hook string withCommand::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:"<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.cmd /C "…"by hand cannot catch this, because it skips Rust's argument quoting. The measurement has to go through Codex."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 0form exits 0 whenteamairesolves, and it resolves on that machine. So the tail explains losing fail-open whenteamaiis 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:codexWindowsCommandinsrc/hooks.tsdocuments Codex as PowerShell. That comment needs updating.commandWindowschanges the trust hash Codex computes, so the e2e record should showhooks injecttrusting 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.
natinli commentedon Oct 20, 2026
Ran Codex itself on Windows with the shipped entries, as asked. The original symptom reproduces, and the mechanism is broader than the
commandWindowstail.Setups
codex-cli 0.142.5,codex execin a real project (G:\AICode\Nilo), hooks enabled inconfig.toml, project attrust_level = "trusted".Codex's own output for one run:
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 throughCommand::arg, so I reproduced that shape under realcmd. All three entries break:cmd /Cpwsh -NoLogo -NoProfile -File scripts/hooks/check-startup-health.ps1; exit $LASTEXITCODEpython -X utf8 .codex/hooks/session-start.py; exit $LASTEXITCODEcan't open file '...session-start.py;'— the;is swallowed into the filenamebash -lc "teamai hook-dispatch session-start --tool codex 2>/dev/null" || truebash: -c: line 1: unexpected EOF while looking for matching '"'— cmd eats the inner quotesTwo things fall out of this:
;is not a cmd separator — it becomes part of the preceding argument. That is why the; exit $LASTEXITCODE/; exit 0tails do nothing, and why fail-open silently never happens.cmd /Cin this invocation shape — thebash -lc "…"form reaches bash with unbalanced quotes. So the shippedcommand(the POSIXbash -lcwrapper) genuinely fails on Windows, independent ofcommandWindows.Every one of them exits 0, which is why Codex reports
Failedfor 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
teamairesolving and the tail exiting 0, but I measured the tail, never the wholebash -lc "…"string throughcmd. Doing that shows thecommanditself 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 exactunexpected 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
commandWindows(… 2>nul & exit 0) — agreed, and it is the piece that survives either quote story, since it has no inner quotes.commandside also needs reconsidering: on 0.142.5 thebash -lc "…"string does not survivecmd. WhethercommandWindowscovering it is enough is a Codex-behaviour question I cannot answer from here.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.SaulMoro commentedon Oct 20, 2026
Thanks @natinli, the
Failedline 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
Failedin 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-312atrust-v0.142.5). An exit of 0 with plain stdout isCompleted. So "every entry exits 0" can't produce theFailedline. The exit codes are odd on their own terms, too:pythonexits 2 when it can't open a file, andtruedoesn't exist in cmd. My guess is the probes ran from a PowerShell prompt, which splitscmd /C …; exit $LASTEXITCODEat the;before cmd ever sees it.2. The
bash -lcrow tests a string Codex doesn't run. teamai's built-ins carrycommandWindows, and on Windows Codex uses that field whenever it is present (discovery.rs:470). For teamai's entries,commandonly matters on Linux and macOS. The unbalanced-quote failure doesn't apply here, and it doesn't call for changes on thecommandside.What would settle it: which of the three entries is the
Failedone, and the reason Codex gives. Two cheap ways to find out:codex execin a throwaway project with no.codex/hooks.json, so only teamai's user-level entry runs, and report whether it showsCompletedorFailed.hook exited with code Norhook returned invalid session start JSON output. That message is the root cause.One side check helps too: a file named
$nullin the hook's working directory confirms that Codex ran teamai'scommandWindowsunder cmd.The 0.145+ comparison is only needed for the quoted dispatcher, so it can wait until this one is settled.
natinli commentedon Oct 20, 2026
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, notcmd's — and I had| tail/| grepon every measurement. Measured properly (> /dev/null 2>&1, no pipe):pythonon a missing filetrueSo the "everything exits 0" column in my earlier table was an artifact. The
Failedline is not explained by that table.2. The
bash -lcrow was testing a string Codex never runsAgreed — teamai's built-ins carry
commandWindows, anddiscovery.rs:470prefers 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 execoutput:Completed, notFailed— and no$nullfile appeared. Two readings are possible and I could not separate them from outside: either Codex skipped the entry (untrusted), or it rancommandWindowsand$nulllanded 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):So
2>$null; exit 0does two wrong things at once: it creates a stray file, and it passes;,exit,0as arguments toteamai. 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
Failedline came from a run inG:\AICode\Nilo, where three SessionStart entries exist (two of Nilo's own plus teamai's). I isolated teamai's alone and gotCompleted; I have not isolated which of the other two producesFailed. 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
Failedbelongs 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-syntaxcommandWindowschange on its own.SaulMoro commentedon Oct 20, 2026
Thanks @natinli. That's enough to proceed, and nothing more needs isolating on the teamai side.
What the runs established:
Completedunder 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.Failedline is probably one of Nilo's own entries. Your argv probe (b;stays one token) suggests cmd handssession-start.py;topython, which exits 2, and Codex reports that asFailed. That is an inference from your measurement, and it is a problem for that project, not for teamai.commandWindowsunder cmd.exe, so the shipped… 2>$null; exit 0loses fail-open whenteamaiis missing, sends;,exitand0toteamaias junk arguments, and sends stderr to a stray file. The missing$nulldoesn't change this; cmd may have named the file$null;.So the fix is the scope on #1231: built-ins only, with
commandWindowsin cmd syntax (… 2>nul & exit 0). The dispatcher'scommandWindowsis #1210's change, in cmd syntax too. Neither needs the absolute-path wrapper or changes tocommand.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.
[-]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[/+]natinli commentedon Oct 20, 2026
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 whatevercodexresolves to on PATH (0.142.5 here); the desktop app runs 0.163.0-alpha.10.2. The two builds differ onadditionalContextLimit(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 doctorstays 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 0under 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.
fix(hooks): carry a cmd.exe commandWindows on Codex built-ins (Tencen…
fix(hooks): dispatcher commandWindows is the bare dispatch (Tencent#1209
fix(hooks): restore the POSIX not-found fail-open on Codex built-ins (T…
fix(hooks): scope Tencent#1231 to the commandWindows syntax fix (Tenc…
fix(hooks): give built-in Codex hooks a cmd.exe commandWindows (Tence…
test(hooks): cover upgrading Windows Codex hooks written by the fork
test(hooks): cover upgrading Windows Codex hooks written by the fork
test(hooks): cover upgrading Windows Codex hooks written by the fork