Skip to content

[bug] Team hooks on Windows still render in bash syntax and never run under Codex #1209

Closed

Description

@natinli

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

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

  1. Team hooks on Windows/Codex are rendered in a PowerShell form that keeps exit-status transparency.
  2. The hooks.json a member gets is internally consistent — no mix of bash and pwsh forms for the same tool.
  3. Codex's hook trust state stabilizes, so the review prompt does not reappear every session.

Related

Happy to test a beta with a team-hook pwsh renderer.

Activity

  1. kittimzhe commented on Oct 19, 2026

    @kittimzhe
    Contributor

    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) as bash -lc "teamai hook-dispatch <E> --tool codex --team-hooks" on every platform, and reconcileCodexDispatchers (codex-team-hooks.ts:205) writes it without a commandWindows. On Windows getHookShellCommand() returns a quoted Git Bash path — or bare bash, which resolves to the WSL launcher — and in Codex's PowerShell either form is a ParserError, so the entry never runs. The built-in entries in the same file carry their PowerShell form in commandWindows (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 command and gains a commandWindows of

    teamai hook-dispatch <E> --tool codex --team-hooks; exit $LASTEXITCODE
    

    deliberately NOT the built-in fail-open 2>$null; exit 0 form: 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 has 2>$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. reconcileCodexDispatchers only 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.

  2. kittimzhe commented on Oct 19, 2026

    @kittimzhe
    Contributor

    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:

    • Built-in entries get their Windows form through commandWindows: toCodexEntry (hooks.ts:1521) calls codexWindowsCommand, whose pattern BUILTIN_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.
    • Team dispatcher entries are synthesized by a different writer: reconcileCodexDispatchers builds each group as { type: 'command', command: getTeamHookDispatchCommand(event, CODEX_TOOL_ID), timeout } (codex-team-hooks.ts:205) — commandWindows is never set, and getTeamHookDispatchCommand (builtin-hooks.ts:254) always renders the shell wrapper. On Windows, Codex falls back to the POSIX command → 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 || true tail — 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 as getPwshDispatchCommand'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 generates bash -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 and isCodexTeamHookDispatcher (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-hooks form, 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.

  3. natinli commented on Oct 19, 2026

    @natinli
    Author

    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-hooks in my file

    All 8 teamai entries in ~/.codex/hooks.json are:

    "command": "bash -lc \"teamai hook-dispatch session-start --tool codex 2>/dev/null\" || true"

    Grepping the file for --team-hooks returns 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:

    • I called them "team hooks". They are built-in entries.
    • I said "the built-in entries in the same file are already PowerShell". They are not — every teamai entry in my file is this bash form, so there is no inconsistency to show. Sorry for the misleading evidence.

    What actually happens on this machine

    teamai hooks inject on 0.28.0-beta.4 completes 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 the commandWindows form 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 commandWindows plan 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 for reconcileCodexDispatchers to rewrite.

    The symptom I actually have is: 8 built-in entries in the pre-#1075 shape that hooks inject and reconcile both leave alone, each of which is a ParserError under Codex's PowerShell. That's a separate cleanup question from the one you're implementing:

    1. Does fix(hooks): render codex built-in hooks in PowerShell on Windows (#1074) #1075's commandWindows path 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?
    2. Why does hooks inject report 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

    Tell me which you'd prefer and I'll file it accordingly.

  4. kittimzhe commented on Oct 19, 2026

    @kittimzhe
    Contributor

    Split confirmed — please file the second issue, and include two things the commands alone can't show:

    1. The event keys the 8 entries sit under. Recognition compares the key plus the command against the current built-in defs (def.event === event && def.command === command), so an entry under a key a very old teamai wrote (session-start vs today's SessionStart) 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 inject reports 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.
    2. Whether the workspace is user-scope or project-scope, and whether the file you quoted is ~/.codex/hooks.json while 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.json with the byte-identical bash form under PascalCase keys, ran teamai hooks inject, and the entries were recognized and rewritten with commandWindows (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.

  5. natinli commented on Oct 19, 2026

    @natinli
    Author

    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 command last time. Reading the full file, all 8 entries already have commandWindows, 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-level G:\AICode\Nilo\.codex\hooks.json exists 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 commandWindows field at all. From codex-cli 0.142.5 (bin/codex.exe):

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

    and a search of the binary for commandWindows returns zero hits. Only command is deserialized; the unknown key is dropped silently by serde's default behavior. So on Windows Codex executes the POSIX command — a bare bash//bin/sh form — regardless of what teamai wrote into commandWindows.

    That is why hooks inject reports ✔ 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 no commandWindows concept 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 commandWindows contract 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-neutral command (bare teamai hook-dispatch … — Codex already runs command through the host shell on every platform) and keep commandWindows only 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 commandWindows is a no-op for Codex, with the HookHandlerConfig::Command field 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-command change would make #1210's commandWindows add redundant.

  6. kittimzhe commented on Oct 19, 2026

    @kittimzhe
    Contributor

    Field-level evidence — the struct dump plus the zero-hit binary search settles it: commandWindows never 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:

    1. Bare command works cleanly for the dispatcher (teamai hook-dispatch <E> --tool codex --team-hooks has no shell syntax; exit code and stderr pass through any host shell) — if teamai resolves on Codex's hook PATH. That PATH assumption is why the wrappers exist at all (getWrapperDispatchCommand exists precisely because some hosts spawn hooks with a limited PATH; bash -lc sources the login profile that puts ~/.teamai/bin and npm globals on it). So the pivot trades the shell contract for a PATH contract — worth stating as the central risk.
    2. Built-ins can't go bare without moving fail-open somewhere: their advisory contract (|| true / ; exit 0) is shell syntax by nature. The deeper option is pushing fail-open into hook-dispatch itself (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-command pivot lands — I'll flag the choice to the maintainers there (merge as a stepping stone for tools that do read commandWindows, or close in favor of the redesign) rather than push it through. Your issue is the right vehicle for the decision.

  7. natinli commented on Oct 19, 2026

    @natinli
    Author

    Filed: #1215 — commandWindows is a no-op for Codex.

    Included both design notes you raised:

    • PATH contract as the central risk, with measurements. I checked whether the wrappers were really doing PATH work: ~/.teamai/bin/teamai and teamai.cmd are exec "<abs node>" "<abs index.js>", and their own header comments name the reason ("so hooks work in environments without PATH"). Under a restricted PATH, bare teamai --version fails and the .cmd wrapper succeeds. So a bare command needs either the absolute wrapper path emitted, or a stated PATH guarantee from Codex. I put both options in the issue rather than picking one.
    • Built-in fail-open — noted that || true / ; exit 0 is shell syntax with nowhere to live in a bare command, and that the fix is pushing fail-open into hook-dispatch for built-in events. I verified hook-dispatch already 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 command lands" — 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.

  8. natinli commented on Oct 20, 2026

    @natinli
    Author

    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

    Claim in the report Actual
    "Codex executes command hooks with PowerShell" It runs %COMSPEC% /C — cmd.exe (command_runner.rs:119-126 @ rust-v0.142.5)
    "The hooks never run" They run. Isolating teamai's user-level entry on its own gives hook: SessionStart Completed
    "2>/dev/null and || true → ParserError, so they never execute" commandWindows is what runs on Windows, not command (discovery.rs:470) — and the failure mode is a dialect mismatch, not a parser error in command
    "All 8 team dispatch entries have this form" There are no --team-hooks entries on this machine at all; all 8 are built-ins
    "built-in entries do not trigger the prompt" They are the trigger — see below

    The 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 commandWindows is … 2>$null; exit 0, written for PowerShell. Under cmd that:

    • redirects stderr to a file named $null instead of the null device,
    • passes ;, exit, 0 to teamai as 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 and teamai doctor use (codex app-server resolved from PATH)
    • desktop runtime → 0.163.0-alpha.10.2

    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.

  9. added a commit that references this issue on Oct 21, 2026
    0da2be7
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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions