fix: hooks were dead on a freshly installed plugin (0.50.2) - #63
Merged
Conversation
dist/ is a build artifact and is not in git, but the marketplace installs
this plugin from git. start.sh builds it — except start.sh only runs when
the MCP server starts, while hooks are invoked directly as
`node ${CLAUDE_PLUGIN_ROOT}/dist/index.js hook-*`. On a freshly installed
or updated plugin, every hook fired before the first MCP server start hit
a missing entry point and died.
It died quietly, which is the damaging part: Claude Code reads a failed
hook as no decision, so enforcement did not warn or error, it just was not
there. Found on a real machine — an updated 0.50.1 had no dist at all,
while a months-old 0.47.1 had one purely because past sessions had built
it. This is the most plausible explanation for "it doesn't always work".
Route hooks through hooks/run.sh, which:
- builds dist when missing (~3s; node_modules ships with the plugin)
- guards the build with a lock, because a session start fires several
hooks at once and they would otherwise all run tsc into one directory
- exits 0 if the build fails, so a hook that cannot decide never turns
into a hook that refuses
- does not cd, because the hooks read process.cwd() as the project root
Verified on a dist-less copy: one hook builds and decides; six concurrent
hooks all decide and leave no stale lock; a relative path still resolves
from the caller's directory.
The npm install path is untouched — installer.ts writes its own
absolute-path commands and never reads hooks/hooks.json.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Hooks did not run at all on a freshly installed or freshly updated plugin, until something happened to trigger a build.
Why
dist/is a build artifact and is not in git — but the marketplace installs this plugin from git.start.shbuilds it, exceptstart.shonly runs when the MCP server starts, and hooks are invoked directly:So every hook fired before the first MCP server start hit a missing entry point and died.
It died quietly, which is the damaging part. Claude Code reads a failed hook as no decision, so enforcement did not warn or error — it simply was not there.
Found on a real machine while cleaning the plugin cache: the freshly updated 0.50.1 had no
distat all, while a months-old 0.47.1 had one purely because past sessions had built it. This is the most plausible explanation for the long-standing "it doesn't always work" complaint.How
Hooks now go through
hooks/run.sh, which:distwhen missing — ~3s, andnode_modulesships with the plugin so no install is neededtscinto the same directorycd— the hooks readprocess.cwd()as the project root, so entering the plugin directory would break every relative pathRisk
The failure mode this replaces is silent non-enforcement, so almost any behaviour is an improvement. The one new cost is ~3s on the very first hook after an install.
Verified against a dist-less copy of the plugin:
denyFull suite 1450 green.
Notes for reviewer
The npm install path is untouched:
installer.tswrites its own absolute-path hook commands and never readshooks/hooks.json.hooks/run.shwas added to thefileslist so it ships in the npm tarball too.Alternatives considered and rejected: committing
dist(3.2 MB and 115 files churning on every release, plus src/dist duplication), and routing hooks through the existingstart.sh(itcds into the plugin directory, which would make every hook resolve the project root to the plugin).