Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# cmd.exe reads a batch file line by line; keep the CRLF endings it expects.
*.cmd text eol=crlf
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **A Codex session on Windows reports again.** Every hook failed there —
`hook exited with code 1` on each event, and `invalid stop hook JSON output`
on top of it, because cmd.exe writes its own error to stdout and Codex reads
a Stop hook's stdout as a decision. Two causes, both on this side. Codex
substitutes `${KEY}` and nothing else before handing the line to a shell, so
the bare `$CLAUDE_PLUGIN_ROOT` the Codex registration used only ever worked
because the Unix shell expanded what Codex had left alone — `cmd.exe /C` does
not, and the hook ran a path that does not exist. The registration now spells
the braced form, the one Claude Code's has always used. And cmd.exe cannot
execute a `.sh` at all, so each hook gained a `commandWindows` handing the
same script and the same argument to `hooks/win-run.cmd`, a wrapper that
finds Git Bash — on `PATH`, then at its default install — and exits 0
whether or not it does. Codex prefers `commandWindows` on Windows and ignores
it everywhere else, so one registration still serves both, and nothing about
a Unix run changes.

## [0.9.2] - 2026-08-12

### Fixed
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ hooks/hooks.json # hook registration, Claude Code
hooks/codex-hooks.json # hook registration, Codex
hooks/crush-hooks.json # hook registration, Crush (hand-merged into crush.json)
hooks/ # hook scripts ($CLAUDE_PLUGIN_ROOT/hooks/<script>, all three)
hooks/win-run.cmd # runs one of those scripts on Windows (Codex)
opencode/lich.js # opencode client: a module, not a command
docs/ # lich ⇄ plugin communication contracts
skills/ # skills (skills/<name>/SKILL.md, all)
Expand Down
12 changes: 12 additions & 0 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ This repository is one plugin, packaged for each harness it supports:
| `hooks/codex-hooks.json` | Codex | hook registration |
| `hooks/crush-hooks.json` | Crush | hook registration, merged by hand |
| `hooks/*.sh` | the three | the reports themselves |
| `hooks/win-run.cmd` | Codex | runs a script on Windows |
| `opencode/lich.js` | opencode | the whole client, as a module |
| `skills/` | all | skills, same layout |

Expand Down Expand Up @@ -101,6 +102,17 @@ waits.
`hooks/hooks.json` when a plugin manifest declares none; this one declares
`./hooks/codex-hooks.json`, so the Claude Code registration sitting beside it
is not also loaded — a session reports each event once, not twice.
- **The plugin root is spelled `${CLAUDE_PLUGIN_ROOT}`, braces included.** Codex
substitutes the braced form and nothing else before handing the line to a
shell, so a bare `$CLAUDE_PLUGIN_ROOT` only survives where the shell itself
expands it. That is every Unix run — hooks go to `$SHELL -lc` — and no Windows
one, where they go to `cmd.exe /C`.
- **Windows runs the scripts through `hooks/win-run.cmd`.** `cmd.exe` cannot
execute a `.sh`, so each hook registers a `commandWindows` handing the same
script and the same argument to that wrapper, which looks for Git Bash — on
`PATH`, then at its default install — and always exits 0, bash or no bash.
Codex picks `commandWindows` over `command` on Windows and ignores it
everywhere else, so one registration still serves both.

## opencode specifics

Expand Down
27 changes: 18 additions & 9 deletions hooks/codex-hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PLUGIN_ROOT/hooks/report-session-start.sh\" codex",
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-session-start.sh\" codex",
"commandWindows": "\"${CLAUDE_PLUGIN_ROOT}/hooks/win-run.cmd\" \"${CLAUDE_PLUGIN_ROOT}/hooks/report-session-start.sh\" codex",
"timeout": 5
}
]
Expand All @@ -16,7 +17,8 @@
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PLUGIN_ROOT/hooks/report-state.sh\" busy",
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh\" busy",
"commandWindows": "\"${CLAUDE_PLUGIN_ROOT}/hooks/win-run.cmd\" \"${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh\" busy",
"timeout": 5
}
]
Expand All @@ -27,7 +29,8 @@
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PLUGIN_ROOT/hooks/report-tool.sh\"",
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-tool.sh\"",
"commandWindows": "\"${CLAUDE_PLUGIN_ROOT}/hooks/win-run.cmd\" \"${CLAUDE_PLUGIN_ROOT}/hooks/report-tool.sh\"",
"timeout": 5
}
]
Expand All @@ -38,7 +41,8 @@
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PLUGIN_ROOT/hooks/report-state.sh\" busy",
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh\" busy",
"commandWindows": "\"${CLAUDE_PLUGIN_ROOT}/hooks/win-run.cmd\" \"${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh\" busy",
"timeout": 5
}
]
Expand All @@ -48,7 +52,8 @@
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PLUGIN_ROOT/hooks/report-touched.sh\"",
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-touched.sh\"",
"commandWindows": "\"${CLAUDE_PLUGIN_ROOT}/hooks/win-run.cmd\" \"${CLAUDE_PLUGIN_ROOT}/hooks/report-touched.sh\"",
"timeout": 5
}
]
Expand All @@ -59,7 +64,8 @@
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PLUGIN_ROOT/hooks/report-state.sh\" waiting",
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh\" waiting",
"commandWindows": "\"${CLAUDE_PLUGIN_ROOT}/hooks/win-run.cmd\" \"${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh\" waiting",
"timeout": 5
}
]
Expand All @@ -70,12 +76,14 @@
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PLUGIN_ROOT/hooks/report-state.sh\" done",
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh\" done",
"commandWindows": "\"${CLAUDE_PLUGIN_ROOT}/hooks/win-run.cmd\" \"${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh\" done",
"timeout": 5
},
{
"type": "command",
"command": "\"$CLAUDE_PLUGIN_ROOT/hooks/report-title.sh\"",
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-title.sh\"",
"commandWindows": "\"${CLAUDE_PLUGIN_ROOT}/hooks/win-run.cmd\" \"${CLAUDE_PLUGIN_ROOT}/hooks/report-title.sh\"",
"timeout": 5
}
]
Expand All @@ -86,7 +94,8 @@
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PLUGIN_ROOT/hooks/report-state.sh\" idle",
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh\" idle",
"commandWindows": "\"${CLAUDE_PLUGIN_ROOT}/hooks/win-run.cmd\" \"${CLAUDE_PLUGIN_ROOT}/hooks/report-state.sh\" idle",
"timeout": 3
}
]
Expand Down
13 changes: 13 additions & 0 deletions hooks/win-run.cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@echo off
rem Runs a lich hook script through a POSIX shell on Windows, where Codex
rem spawns a hook with `cmd.exe /C` and cmd cannot execute a .sh itself.
rem Registered as `commandWindows` in codex-hooks.json; see docs/providers.md.
rem
rem Git Bash is the shell it looks for, and its default install is not on PATH.
rem Never fails the turn: no bash on the machine, or a failing script, still
rem exits 0 — the rule every hook script here follows. Output is dropped for
rem the same reason: a report speaks over HTTP, and anything on stdout would
rem be read as a hook decision.
where bash >nul 2>&1 || set "PATH=%PATH%;%ProgramFiles%\Git\bin"
bash %* >nul 2>&1
exit /b 0
28 changes: 28 additions & 0 deletions tests/hooks.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,34 @@ test("Crush's registration keeps the placeholder for the clone's path", () => {
)
})

// Codex substitutes `${KEY}` and nothing else, then hands the line to a shell:
// `$SHELL -lc` on Unix, `cmd.exe /C` on Windows. A bare `$CLAUDE_PLUGIN_ROOT`
// therefore only works by accident — the Unix shell expands what Codex left
// alone, and cmd.exe does not, so every hook ran a path that does not exist.
test("Codex's registration spells the plugin root the way Codex expands it", () => {
const raw = readFileSync(path.join(ROOT, 'hooks/codex-hooks.json'), 'utf8')
assert.ok(
!/\$CLAUDE_PLUGIN_ROOT/.test(raw),
'codex-hooks.json must use ${CLAUDE_PLUGIN_ROOT}: Codex only substitutes the braced form',
)
})

// cmd.exe cannot execute a .sh, so the Windows line hands the same script and
// the same argument to win-run.cmd, which finds a POSIX shell for it.
test("Codex's registration runs every hook through the wrapper on Windows", () => {
const json = JSON.parse(readFileSync(path.join(ROOT, 'hooks/codex-hooks.json'), 'utf8'))
const hooks = Object.values(json.hooks).flatMap((groups) => groups.flatMap((g) => g.hooks))
assert.ok(hooks.length > 0)
for (const hook of hooks) {
assert.ok(hook.commandWindows, `no commandWindows for ${hook.command}`)
assert.equal(
hook.commandWindows,
`"\${CLAUDE_PLUGIN_ROOT}/hooks/win-run.cmd" ${hook.command}`,
`commandWindows must wrap the command unchanged: ${hook.command}`,
)
}
})

test('every registered state argument is an accepted state', () => {
const sent = REGISTRATIONS.filter((r) => r.script === 'report-state.sh').map((r) => r.argument)
assert.ok(sent.length > 0)
Expand Down
Loading