Skip to content

Hooks intermittently fail on Windows Git Bash with 'printf: write error: Permission denied' #2607

@mlorentedev

Description

@mlorentedev

Summary

All 6 hooks declared in `plugin/hooks/hooks.json` (Setup, SessionStart, UserPromptSubmit, PostToolUse, PreToolUse, Stop) use the same path-resolution cascade in bash. On Windows + Git Bash, this pattern intermittently fails with:

```
UserPromptSubmit operation blocked by hook:
/usr/bin/bash: line 1: printf: write error: Permission denied
```

The hook exits with non-zero stderr output → Claude Code blocks the corresponding lifecycle event. Symptom is intermittent (timing-dependent), so a retry frequently succeeds.

Affected pattern

Every hook command in `plugin/hooks/hooks.json` uses this resolution cascade:

```bash
_C="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
_E="${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}"
_P=$({
[ -n "$_E" ] && printf '%s\n' "$_E"
ls -dt "$_C/plugins/cache/thedotmack/claude-mem"/[0-9]*/ 2>/dev/null
printf '%s\n' "$_C/plugins/marketplaces/thedotmack/plugin"
} | while IFS= read -r _R; do
_R="${_R%/}"
[ -d "$_R/plugin/scripts" ] && _Q="$_R/plugin" || _Q="$_R"
[ -f "$_Q/scripts/bun-runner.js" ] && [ -f "$_Q/scripts/worker-service.cjs" ] && {
printf '%s\n' "$_Q"; break
}
done)
```

Root cause

Producer-consumer race in the `{ ... } | while ... break` pipe:

  1. The `{ }` block writes 2 or 3 lines (depending on whether `PLUGIN_ROOT` is set) to the pipe.
  2. The `while ... read ... break` consumer reads the first line, finds a match, exits the loop.
  3. The remaining producers in the `{ }` block try to write to the now-closed read end of the pipe.
  4. Git Bash on Windows surfaces this as `printf: write error: Permission denied` (EPIPE).
  5. Claude Code sees stderr output → hook fails.

This is not a missing-file problem. `_P` is actually populated correctly with the resolved path; the script's logical state is fine. The error happens after the consumer breaks but before the producer block has finished flushing its writes.

Empirical evidence

Reproduced on Windows daily-driver 2026-05-21 (commit-installed v13.3.0, fully healthy install):

Without `PLUGIN_ROOT` set (2 producers in the pipe)

```
$ _C="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
$ _E="${PLUGIN_ROOT:-}"
$ _P=$( { ... pipe ... } )
$ echo "_P='$_P'"
_P='C:\Users\Manu.claude/plugins/cache/thedotmack/claude-mem/13.3.0'
(no stderr error)
```

With `PLUGIN_ROOT` set (3 producers — pipe race tighter)

```
$ export PLUGIN_ROOT="$_C/plugins/marketplaces/thedotmack-claude-mem/plugin"
$ _E="${PLUGIN_ROOT:-}"
$ _P=$( { ... pipe ... } )
/usr/bin/bash: line 54: printf: write error: Permission denied
$ echo "_P='$_P'"
_P='C:\Users\Manu.claude/plugins/marketplaces/thedotmack-claude-mem/plugin'
```

Note: even WITH the error, `_P` is correct. The error is purely about the unconsumed producer writes failing EPIPE.

This is fully reproducible on Windows. Linux side likely the same race exists but the printf error is handled differently (or not surfaced) by bash on Linux.

Real-world impact

Documented occurrences in our internal dotfiles tracker:

  • BUG-012 (2026-05-20): `UserPromptSubmit` blocked on Windows daily-driver. Root cause traced to plugin marketplace dir naming mismatch (`thedotmack-claude-mem` vs `thedotmack`). Side-fix shipped: create legacy junction. But the underlying pipe race persists.
  • BUG-014 (2026-05-21): claude-mem was never registered in `config.json` after a historical `.claude.json` truncation. Fixed in setup script. But after install was restored, the pipe race still fires intermittently in fresh project sessions.
  • BUG-015 (2026-05-21, this issue's parent on our side): formal acknowledgement that the race is unfixable from outside the upstream hooks. Ships a healthcheck assertion that detects breakage but cannot prevent the EPIPE race itself.

Public references: mlorentedev/dotfiles BUG-012 PR #70, BUG-014 PR #75, BUG-015 PR (open).

Proposed fix (any of these would work)

The goal is to avoid leaving producers in the `{ }` block with pending writes when the consumer breaks early.

Option A: `head -n1` instead of while/break

```bash
_P=$( {
[ -n "$_E" ] && printf '%s\n' "$_E"
ls -dt "$_C/plugins/cache/thedotmack/claude-mem"/[0-9]*/ 2>/dev/null
printf '%s\n' "$_C/plugins/marketplaces/thedotmack/plugin"
} | while IFS= read -r _R; do
_R="${_R%/}"
[ -d "$_R/plugin/scripts" ] && _Q="$_R/plugin" || _Q="$_R"
[ -f "$_Q/scripts/bun-runner.js" ] && [ -f "$_Q/scripts/worker-service.cjs" ] && printf '%s\n' "$_Q"
done | head -n1)
```

`head -n1` consumes the entire upstream pipe (or as much as it wants) before exiting. The producer's writes all succeed; `head` just outputs the first one.

Option B: Bash function with explicit return

```bash
_resolve() {
local _R _Q
for _R in "$_E" "$_C/plugins/cache/thedotmack/claude-mem"/[0-9]*/ "$_C/plugins/marketplaces/thedotmack/plugin"; do
[ -z "$_R" ] && continue
_R="${_R%/}"
[ ! -e "$_R" ] && continue
[ -d "$_R/plugin/scripts" ] && _Q="$_R/plugin" || _Q="$_R"
[ -f "$_Q/scripts/bun-runner.js" ] && [ -f "$_Q/scripts/worker-service.cjs" ] && { echo "$_Q"; return 0; }
done
return 1
}
_P=$(_resolve)
```

No pipe, no producer-consumer race. Slightly more verbose but rock-solid.

Option C: Atomic resolver script

Move the resolution logic to a dedicated `plugin/scripts/resolve.sh` and have every hook do:

```bash
_P=$(bash "$0_DIR/scripts/resolve.sh") || exit 1
node "$_P/scripts/bun-runner.js" ...
```

But this has a chicken-and-egg problem (you need to resolve `_P` to find `resolve.sh`).

Our recommendation: Option A — minimal change, preserves the existing cascade order, fixes the race.

Reproduction steps

  1. Install `claude-mem@thedotmack` on a Windows machine via Claude Code.
  2. Open Claude Code in any project.
  3. Type a prompt (triggers UserPromptSubmit hook).
  4. Most of the time it works. Occasionally (timing-dependent), the hook fails with the printf write error.

To force reproduction reliably on a healthy install, set `PLUGIN_ROOT` before the test:

```bash
export PLUGIN_ROOT="$HOME/.claude/plugins/marketplaces/thedotmack-claude-mem/plugin"

then run any hook command -- the printf error appears almost every time

```

Environment

  • OS: Windows 11 Pro 10.0.26100
  • Shell: Git Bash (mingw64) from Git for Windows
  • Node.js: v22.x
  • Claude Code: 2.x
  • claude-mem: v13.3.0 (latest as of 2026-05-21)

Workaround for end users

Until upstream fix lands: retrying the session usually succeeds (the race is intermittent). Our internal dotfiles ship a healthcheck assertion that detects when the resolution itself fails (BUG-015 PR), but cannot prevent the EPIPE race.

Happy to test Option A patch against my install if you ship a branch.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions