Repository navigation
[bug] Team hooks on Windows still render in bash syntax and never run under Codex #1209
Description
Summary
On Windows, team hooks delivered from a team repo are still rendered with the POSIX wrapper in ~/.codex/hooks.json, so Codex (which executes command hooks with PowerShell) hits a ParserError and never runs them. #1075 fixed this for built-in hooks and #1127 explicitly left team hooks untouched ("team hooks are left as their author wrote them"), so this is the remaining half of the same problem.
Environment
- Windows 11 Pro 10.0.26200
teamai-cli@0.28.0-beta.4(contains fix(hooks): render codex built-in hooks in PowerShell on Windows (#1074) #1075 + fix(hooks): keep the Codex project hooks.json identical on every platform (#1074 follow-up) #1127)teamai-cli@0.28.0-beta.5checked: no team-hook rendering change in merged PRs since 2026-10-16- Consuming tool: Codex
- Team hooks delivered via
teamai pullfrom a team repo
What we see
~/.codex/hooks.json — every team hook entry still has the bash wrapper:
{
"command": "bash -lc \"teamai hook-dispatch session-start --tool codex 2>/dev/null\" || true",
"timeout": 15
}All 8 team dispatch entries have this form.
Symptoms
1. The hooks never run. Codex on Windows executes command hooks with PowerShell. 2>/dev/null and || true are bash syntax → ParserError. This is the exact failure mode #1075 fixed for built-in hooks.
2. Codex shows its "review hooks — modified" prompt on what looks like every session. The unparseable entries are the obvious suspect, since the built-in entries (now in the PowerShell form) do not trigger it. I can't fully rule out a different root cause for this second symptom, but it started together with the team-hook entries appearing.
Note the built-in entries in the same file are the bare PowerShell form (teamai hook-dispatch … 2>$null; exit 0), so the two rendering paths are visibly inconsistent in one file.
Why this needs its own rendering path
The team-hook dispatchers cannot simply reuse getPwshDispatchCommand — per the boundary note in #1075 and codex-team-hooks.ts, team hooks must preserve exit code 2 (block) and stderr. The fail-open ; exit 0 form would silently turn a blocking decision into success.
So a team-hook pwsh renderer needs something closer to:
pwsh -NoProfile -Command "& { teamai hook-dispatch <event> --tool codex 2>$null; exit $LASTEXITCODE }"
— i.e. /dev/null redirection kept, but the exit status passed through instead of being swallowed.
Expected
- Team hooks on Windows/Codex are rendered in a PowerShell form that keeps exit-status transparency.
- The
hooks.jsona member gets is internally consistent — no mix of bash and pwsh forms for the same tool. - Codex's hook trust state stabilizes, so the review prompt does not reappear every session.
Related
- [bug] Codex hook entries on Windows are rendered in bash syntax and silently never run #1074 — original report (built-in hooks), closed by fix(hooks): render codex built-in hooks in PowerShell on Windows (#1074) #1075
- fix(hooks): keep the Codex project hooks.json identical on every platform (#1074 follow-up) #1127 — follow-up that made project
hooks.jsonconsistent, explicitly excluded team hooks
Happy to test a beta with a team-hook pwsh renderer.
Activity
kittimzhe commentedon Oct 19, 2026
Taking this — it's the remaining half of #1075/#1127, and the mechanism is exactly where those two left it.
Verified on main: the dispatcher entry is rendered by
getTeamHookDispatchCommand(builtin-hooks.ts:253) asbash -lc "teamai hook-dispatch <E> --tool codex --team-hooks"on every platform, andreconcileCodexDispatchers(codex-team-hooks.ts:205) writes it without acommandWindows. On WindowsgetHookShellCommand()returns a quoted Git Bash path — or barebash, which resolves to the WSL launcher — and in Codex's PowerShell either form is aParserError, so the entry never runs. The built-in entries in the same file carry their PowerShell form incommandWindows(codexWindowsCommand→toCodexEntry, #1074's cross-platform byte-stability contract), which is the visible inconsistency in your hooks.json.Fix shape, following the same contract: the dispatcher entry keeps its POSIX
commandand gains acommandWindowsofdeliberately NOT the built-in fail-open
2>$null; exit 0form: the dispatcher must pass exit 2 (block) and its stderr through, so stderr is not redirected and the exit code rides$LASTEXITCODE. (The sketch in the issue has2>$null, which would discard the block message — the bash form it replaces doesn't redirect either.)One question while I implement: the entries quoted in your hooks.json (
… 2>/dev/null" || true, no--team-hooks) don't match main's dispatcher form — they look like they were written by a teamai older than the dispatcher.reconcileCodexDispatchersonly removes entries matching the dispatcher command, so those would survive a pull even after this fix. Did that file see an older teamai version, or does a fresh setup clean them? If they're stale, a one-time retirement of the old form may be worth a second look — I'd keep it out of this fix unless you want it in.kittimzhe commentedon Oct 19, 2026
Mechanism confirmed (source-level) + where the fix has to land + one byte-level question that changes the cleanup story:
Why the #1075 fix never reached team entries — the two rendering paths are genuinely separate:
commandWindows:toCodexEntry(hooks.ts:1521) callscodexWindowsCommand, whose patternBUILTIN_DISPATCH_COMMAND_RE(hooks.ts:1541) requires the built-in wrapper's swallow tail (… 2>/dev/null" || true). That's the form fix(hooks): render codex built-in hooks in PowerShell on Windows (#1074) #1075 fixed.reconcileCodexDispatchersbuilds each group as{ type: 'command', command: getTeamHookDispatchCommand(event, CODEX_TOOL_ID), timeout }(codex-team-hooks.ts:205) —commandWindowsis never set, andgetTeamHookDispatchCommand(builtin-hooks.ts:254) always renders the shell wrapper. On Windows, Codex falls back to the POSIXcommand→ the exact ParserError fix(hooks): render codex built-in hooks in PowerShell on Windows (#1074) #1075 eliminated for built-ins.A fix-template warning: the team dispatch deliberately lacks the
2>/dev/null || truetail — its docstring keeps stderr and the exit status because a team hook's blocking decision (exit 2) must survive. So the built-in pwsh form (… 2>$null; exit 0) is the wrong template for this fix: swallowing the exit status would break blocking. The Windows form should be the bare dispatch —teamai hook-dispatch <event> --tool codex --team-hooks— since the host shell is already PowerShell (same "emitted bare" reasoning asgetPwshDispatchCommand's docstring, minus the swallow).Deployment property worth noting: reconcile only rewrites when the synthesized group differs from what's on disk (the JSON equality check just above the writer). Once the writer adds
commandWindows, existing installs self-heal on the next reconcile — wanted ≠ existing, so the dispatcher entries get rewritten in place. No manual migration.One byte-level question that changes the cleanup story: the report shows the installed entries as
bash -lc "teamai hook-dispatch session-start --tool codex 2>/dev/null" || true— that's the built-in wrapper form (swallow tail, no--team-hooks). Current main generatesbash -lc "teamai hook-dispatch <event> --tool codex --team-hooks"for dispatcher entries (no tail). Could you paste one entry verbatim from your hooks.json? If your install really carries the tail form, those entries predate the current dispatcher design andisCodexTeamHookDispatcher(which requires--team-hooks) won't recognize them — reconcile would add the fixed dispatcher alongside the stale entries rather than replace them, so the fix would also want a one-time cleanup for unrecognized old-form team entries. If it's the--team-hooksform, the writer fix alone covers everything.On symptom 2: consistent with the mechanism (built-in entries parse, team entries don't, the review prompt tracks the unparseable set), but Codex's prompt trigger can't be verified from teamai's side — agreed it's plausible, not confirmed.
Disclosure: source-level analysis prepared with the help of an AI coding agent; line numbers from main at 208b97b.
natinli commentedon Oct 19, 2026
Thanks both — and you're right to ask, my report conflated two different things. Here's the verbatim data.
Your byte-level question: no
--team-hooksin my fileAll 8 teamai entries in
~/.codex/hooks.jsonare:Grepping the file for
--team-hooksreturns 0 matches. So these are not dispatcher entries — they're the built-in wrapper form (swallow tail, no--team-hooks).That means my original report was wrong on two counts:
What actually happens on this machine
teamai hooks injecton0.28.0-beta.4completes successfully (✔ Hooks injected into all AI tool settings,✔ Trusted 8 teamai hook(s) in Codex) but rewrites none of these 8 entries — they stay exactly as quoted above. So they are orphans from the inject path's point of view: neither updated to thecommandWindowsform nor removed.I don't know which teamai release wrote them; the file dates back further than my upgrade history. If it matters, I can walk the npm versions to find the last one that emitted this shape.
What this means for your fix
Your
commandWindowsplan for the dispatcher entry is the right fix for the dispatcher path, and I'd still like it — but it will not change anything on my machine: I have zero dispatcher entries, so there is nothing forreconcileCodexDispatchersto rewrite.The symptom I actually have is: 8 built-in entries in the pre-#1075 shape that
hooks injectand reconcile both leave alone, each of which is aParserErrorunder Codex's PowerShell. That's a separate cleanup question from the one you're implementing:commandWindowspath only match entries it can recognize as built-in (which these may fail, given they're the old shape)? If so, is a one-time retirement of the old built-in form needed — the same question you raised about old dispatcher entries, but for the built-in side?hooks injectreport success while leaving 8 unrecognized teamai-owned entries in place?On symptom 2 (the review prompt): since all my entries are unparseable, I can't distinguish "prompt tracks the unparseable set" from "prompt appears for any teamai hook". Your mechanism analysis is the better evidence here.
Suggested split
commandWindows— your fix, unchanged. I'll test it when it ships.hooks inject. I can open a second issue with the full before/after file if that's cleaner for you.Tell me which you'd prefer and I'll file it accordingly.
kittimzhe commentedon Oct 19, 2026
Split confirmed — please file the second issue, and include two things the commands alone can't show:
def.event === event && def.command === command), so an entry under a key a very old teamai wrote (session-startvs today'sSessionStart) is foreign by contract and left byte-identical ([bug] Delivery and local-state bugs on main found while scoping #915 #993's rule) — which also answers your question 2:hooks injectreports my writes landed, not the file holds only my entries; anything unrecognized is left alone by design, so success + untouched orphans is the expected pair once recognition fails.~/.codex/hooks.jsonwhile your current inject writes a project.codex/hooks.json— two layers, two writers, and an entry can be orphaned in the layer today's workspace no longer injects into.Worth attaching to that issue: I reproduced your exact quoted command on current main — pre-seeded
~/.codex/hooks.jsonwith the byte-identical bash form under PascalCase keys, ranteamai hooks inject, and the entries were recognized and rewritten withcommandWindows(teamai hook-dispatch session-start --tool codex 2>$null; exit 0). So "inject completes but rewrites none" does not reproduce there; whatever distinguishes your file — key casing, scope layer, or a version between beta.4 and main — the second issue's before/after file will pin it.#1210 keeps the dispatcher half and stays unchanged; test it when it ships.
natinli commentedon Oct 19, 2026
Correction — and this time I have the field-level evidence. My previous report was wrong again, and the root cause is on the Codex side, not teamai's.
What my file actually contains
I only grepped
commandlast time. Reading the full file, all 8 entries already havecommandWindows, and they are the PowerShell form:{ "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 (
SessionStart,Stop,PostToolUse,UserPromptSubmit,SubagentStart,SubagentStop) — exactly what your reproduction used. Scope is user-level (~/.codex/hooks.json); the project-levelG:\AICode\Nilo\.codex\hooks.jsonexists but holds only Nilo's own hooks, no teamai entries. So none of the hypotheses in your last comment (key casing, scope layer, version drift) is the cause: the file is already in the shape your fix produces.Why it still never runs
Codex does not know the
commandWindowsfield at all. Fromcodex-cli 0.142.5(bin/codex.exe):and a search of the binary for
commandWindowsreturns zero hits. Onlycommandis deserialized; the unknown key is dropped silently by serde's default behavior. So on Windows Codex executes the POSIXcommand— a barebash//bin/shform — regardless of what teamai wrote intocommandWindows.That is why
hooks injectreports✔ Trusted 8 teamai hook(s)while changing nothing: the entries are recognized, trusted, and rewritten — into a shape whose Windows field Codex ignores. Both halves of the "was it #1075?" question resolve the same way: #1075 fixed teamai's writer, but the reader has nocommandWindowsconcept at all, so the built-in entries have the same problem.Does that change the dispatcher fix?
For this machine, no — and the dispatcher issue (#1210) is still worth shipping as you scoped it. But the
commandWindowscontract it relies on does not currently deliver on Codex/Windows for anyone, built-in or team. If Codex is not going to add the field, the durable fix on teamai's side is to emit a platform-neutralcommand(bareteamai hook-dispatch …— Codex already runscommandthrough the host shell on every platform) and keepcommandWindowsonly for tools that read it.Split, revised
I'll skip the "old built-in entries surviving inject" issue — there is nothing to report there; my entries are current-shape, and by your #993 explanation leaving unrecognized entries alone is by design anyway. The real issue I should file is
commandWindowsis a no-op for Codex, with theHookHandlerConfig::Commandfield list and the zero-hit search as evidence. It affects #1074/#1075 and #1210 identically.Tell me if you'd rather I file it under teamai (as the writer that should adapt) or take it to the Codex side — and whether you want it filed at all before #1210 ships, since a bare-
commandchange would make #1210'scommandWindowsadd redundant.kittimzhe commentedon Oct 19, 2026
Field-level evidence — the struct dump plus the zero-hit binary search settles it:
commandWindowsnever reaches the reader, so #1074's contract is a no-op for Codex on Windows, for built-ins and dispatcher alike. Thank you for going to the binary.File it on teamai's side, and file it now — it affects shipped behavior for every Windows user, not just this PR's path, so it shouldn't wait on #1210. Two design notes worth having in that issue:
commandworks cleanly for the dispatcher (teamai hook-dispatch <E> --tool codex --team-hookshas no shell syntax; exit code and stderr pass through any host shell) — ifteamairesolves on Codex's hook PATH. That PATH assumption is why the wrappers exist at all (getWrapperDispatchCommandexists precisely because some hosts spawn hooks with a limited PATH;bash -lcsources the login profile that puts~/.teamai/binand npm globals on it). So the pivot trades the shell contract for a PATH contract — worth stating as the central risk.|| true/; exit 0) is shell syntax by nature. The deeper option is pushing fail-open intohook-dispatchitself (built-in dispatches never exit non-zero), which would make the bare form safe for them too.On #1210: I agree it's superseded the moment a bare-
commandpivot lands — I'll flag the choice to the maintainers there (merge as a stepping stone for tools that do readcommandWindows, or close in favor of the redesign) rather than push it through. Your issue is the right vehicle for the decision.natinli commentedon Oct 19, 2026
Filed: #1215 —
commandWindowsis a no-op for Codex.Included both design notes you raised:
~/.teamai/bin/teamaiandteamai.cmdareexec "<abs node>" "<abs index.js>", and their own header comments name the reason ("so hooks work in environments without PATH"). Under a restricted PATH, bareteamai --versionfails and the.cmdwrapper succeeds. So a barecommandneeds either the absolute wrapper path emitted, or a stated PATH guarantee from Codex. I put both options in the issue rather than picking one.|| true/; exit 0is shell syntax with nowhere to live in a barecommand, and that the fix is pushing fail-open intohook-dispatchfor built-in events. I verifiedhook-dispatchalready exits 0 on a non-teamai cwd, so the runtime is near that contract already; the gap is making it unconditional for built-ins.On #1210: framed it in the issue as "correct against the documented contract, still worth keeping for tools that do read the field, redundant for Codex once a portable
commandlands" — and left the merge-or-close call to you and the maintainers, as you suggested.Thanks for pushing back on the first two reports; the field dump was the thing I should have done before writing either of them.
natinli commentedon Oct 20, 2026
Correcting this report: the title and several premises turned out to be wrong. The two symptoms have different causes, and neither is the one described above.
What the report got wrong
%COMSPEC% /C— cmd.exe (command_runner.rs:119-126@rust-v0.142.5)hook: SessionStart Completed2>/dev/nulland|| true→ParserError, so they never execute"commandWindowsis what runs on Windows, notcommand(discovery.rs:470) — and the failure mode is a dialect mismatch, not a parser error incommand--team-hooksentries on this machine at all; all 8 are built-insThe last one is the important correction: the prompt is caused by the built-ins, not the team entries.
Symptom 1 — hooks that misbehave → cmd dialect
The shipped
commandWindowsis… 2>$null; exit 0, written for PowerShell. Under cmd that:$nullinstead of the null device,;,exit,0toteamaias arguments (cmd does not treat;as a separator) — so the fail-open tail never runs.Measured in a batch file:
node … a b; exit 0→ argv["a", "b;", "exit", "0"].Fix tracked in #1215 (
commandWindows→2>nul & exit 0) and #1210 (dispatcher), scope agreed with @SaulMoro.Symptom 2 — the per-session review prompt → two Codex versions
Not related to the entries being unparseable. This machine has two installs:
PATH→ 0.142.5, which teamai's automatic trust andteamai doctoruse (codex app-serverresolved from PATH)0.142.5's binary has 0 occurrences of
additionalContextLimit; 0.163 has 16. teamai sets that field on exactly two of its eight entries — SessionStart and SubagentStart — and those are the two that always show as modified. The stored hashes match the old CLI's output, so teamai keeps overwriting the desktop's "allow" with a hash the desktop rejects.Full detail and evidence in #1244.
Status of this issue
Both halves are now covered by #1215 / #1210 (dialect) and #1244 (trust loop), so this can be closed as superseded — your call.
fix(hooks): dispatcher commandWindows is the bare dispatch (Tencent#1209
fix(hooks): give the Codex team-hook dispatcher a Windows command (Te…