Start Debugging

Fix: Claude Code hooks fail with "Plugin directory does not exist"

A plugin hook fails with "Failed to run: Plugin directory does not exist" when the directory your session loaded the plugin from is gone: an update past the 14-day orphan window, a claude.ai sync that moved the folder to .trash, or a --plugin-dir you moved. Run /reload-plugins, read its summary line, and know which cases need a restart. Checked against Claude Code 2.1.310.

Short answer: the message means the hook command is fine but the folder your running session loaded the plugin from has been deleted or moved. Type /reload-plugins at the prompt, even though the error tells you to reinstall: the reload points every hook at the plugin’s current directory. Then read the reload’s summary, not the transcript, because Claude Code prints this error only once per session for each hook event and command. If the plugin was synced from claude.ai and you are on anything older than Claude Code v2.1.295, the reload may not help, so update and start a new process with claude --continue. Everything below was checked against Claude Code 2.1.310, the current release on 2026-10-25.

The exact error and what it is telling you

The full notice looks like this, with the event name in front:

PreToolUse hook error
Failed to run: Plugin directory does not exist: /Users/you/.claude/plugins/cache/acme-tools/guard/1.4.0 (guard@acme-tools — run /plugin to reinstall)

Three parts matter:

Do not confuse it with Plugin directory not found at path: <path>. That one appears in the Errors tab of /plugin, not as a hook notice, and means a marketplace entry with a relative source path points at a folder that does not exist inside the marketplace. It is a broken marketplace, not a stale session.

Why this one is dangerous: the hook fails open

When Claude Code cannot start a plugin hook because its directory is gone, it shows the notice and carries on. The tool call runs. The prompt goes to the model. Nothing blocks.

For a formatter, that is a cosmetic problem. For a guard plugin it is a security hole. In anthropics/claude-code#97847, opened on 2026-09-28 against v2.1.283, the plugin was a secret-redaction plugin with UserPromptSubmit, PreToolUse and PostToolUse hooks. After its folder moved, tool output containing a credential reached the model unredacted, and the only trace was a few hook error lines.

The second trap is that the error is deduplicated. The troubleshooting docs say the failure “is shown once per session for each hook event and command.” So after the first PreToolUse hook error, every later PreToolUse call is also skipping the hook, silently. A quiet transcript does not mean the hook recovered.

The four ways the directory disappears

1. A marketplace plugin updated and the old version was swept

Marketplace plugins are copied into a versioned cache directory, and ${CLAUDE_PLUGIN_ROOT} points at that exact version:

~/.claude/plugins/
  cache/<marketplace>/<plugin>/<version>/   # one dir per installed version
  data/<plugin-id>/                          # ${CLAUDE_PLUGIN_DATA}, survives updates
  marketplaces/<name>/                       # marketplace clones
  synced/                                    # plugins synced from claude.ai
  .trash/                                    # synced plugins the sync removed
  installed_plugins.json
  known_marketplaces.json

When a plugin updates, through claude plugin update or the background auto-update pass that runs up to ten minutes after your first message, the running session keeps the old path. Per the plugin loading reference, Claude Code writes an .orphaned_at marker into the previous version directory and deletes it in a background sweep 14 days later, “so a session that already loaded the old version keeps running.”

So the normal update path is safe for two weeks. The error shows up when:

The fix is the reload, because the plugin’s current version is on disk and the session only needs to switch to it.

2. A claude.ai-synced plugin was moved to .trash by another session

Plugins you or your organization turn on in claude.ai load as <name>@synced. Each time Claude Code starts in a signed-in terminal, it syncs once in the background into ~/.claude/plugins/synced/. Before v2.1.295, that sync did not respect running sessions: issue #97847 shows session A loading from synced/<bucket>/<name>~g2, session B starting later, installing the new release into <name>, and moving <name>~g2 into .trash. Every hook of that plugin in session A then failed. The reporter also found that /reload-plugins did not recover the session, three times in a row, and only a new process did.

The 2.1.295 changelog has the fix: “Fixed claude.ai-synced plugin hooks failing with ‘Plugin directory does not exist’ in long-running sessions after another session synced a plugin update.” 2.1.299 then removed a related spurious synced[0] commands path not found error at startup.

If your error names @synced, check the version first:

# Claude Code 2.1.310 is current as of 2026-10-25; the synced-plugin fix is in 2.1.295
claude --version
claude update

Then leave the broken session and resume it in a new process, so the conversation survives:

claude --continue          # most recent session in this directory
claude --resume <session-id>

3. A —plugin-dir or local-marketplace plugin moved

Plugins loaded with --plugin-dir, from ~/.claude/skills/, from CLAUDE_CODE_PLUGIN_DIRS, or as relative-path plugins in a marketplace you added from a local folder are not copied. They load in place, and their hooks receive a CLAUDE_PLUGIN_ROOT that points at your source directory. Rename that folder, git worktree remove the checkout it lives in, or switch to a branch where it does not exist, and the next hook call fails with this message.

For an @inline plugin there is no “current directory” for the reload to find, since the path came from the command line. Put the folder back where the session expects it, or restart with the new path:

# Claude Code 2.1.310
claude --continue --plugin-dir ./tools/guard-plugin

For a local marketplace, restore the folder or fix the entry’s source, then /reload-plugins.

4. You deleted the plugin’s install by hand

If you removed a version directory and the record in installed_plugins.json still points at it, the reload has nothing to switch to. You will see Plugin "<name>" not cached at <path> in the /plugin Errors tab instead of a clean reload. Reinstall in a shell, then reload:

claude plugin install guard@acme-tools

The fix, step by step

Step 1: reload. At the Claude Code prompt:

/reload-plugins

It works in the desktop app and in -p runs too, as long as you type it yourself (since v2.1.260). Over Remote Control or a relayed Slack message it replies that the command “isn’t available over a remote connection in this session.”

Step 2: read the summary line. The reload prints one line, and that line, not the transcript, tells you whether the hooks recovered:

Reloaded: 4 plugins · 11 skills · 3 agents · 7 hooks · 1 plugin MCP server · 0 plugin LSP servers

Step 3: prove the hook runs. Trigger the event on purpose. For a PreToolUse guard on Bash, ask Claude to run something harmless like echo hook-check. For hard proof, start the session with claude --debug and look for the hook’s exit code in the log under ~/.claude/debug/, or have the hook append to a file:

#!/usr/bin/env bash
# scripts/guard.sh in the plugin, Claude Code 2.1.310
# A heartbeat line per run so you can see the hook is alive after a reload
set -euo pipefail
echo "$(date -u +%FT%TZ) guard ran from ${CLAUDE_PLUGIN_ROOT}" >> "${CLAUDE_PLUGIN_DATA}/heartbeat.log"

${CLAUDE_PLUGIN_DATA} lives at ~/.claude/plugins/data/<plugin-id>/ and survives updates, so the log does not vanish with the old version directory. The path in each line also shows which version the session is running.

Find stale directories before a hook hits them

The error only appears when a hook fires. You can check ahead of time by comparing the install records with what is on disk. This script lists every recorded installPath that is missing, plus every cache version marked as orphaned with its age:

#!/usr/bin/env bash
# check-plugin-dirs.sh - Claude Code 2.1.310, macOS or Linux, needs jq
set -euo pipefail
root="${CLAUDE_CODE_PLUGIN_CACHE_DIR:-$HOME/.claude/plugins}"
records="$root/installed_plugins.json"

if [[ -f "$records" ]]; then
  # Walk the file for any installPath field so the script does not depend on its nesting
  jq -r '.. | objects | select(has("installPath")) | .installPath' "$records" |
    while read -r p; do
      [[ -d "$p" ]] || echo "MISSING  $p"
    done
fi

now=$(date +%s)
find "$root/cache" -mindepth 4 -maxdepth 4 -name .orphaned_at 2>/dev/null |
  while read -r marker; do
    dir=$(dirname "$marker")
    mtime=$(stat -f %m "$marker" 2>/dev/null || stat -c %Y "$marker")
    echo "ORPHANED $(( (now - mtime) / 86400 ))d  $dir"
  done

A MISSING line is case 4: reinstall. An ORPHANED 12d line is a version some session may still be running, and that session will lose its hooks in about two days. Reload it or restart it before then. claude plugin list --json gives the same installPath per plugin if you prefer to ask Claude Code instead of reading the file.

Make a guard fail closed

Reloading fixes the session you noticed. To make sure a missing directory blocks instead of waving actions through, you need the hook to fail closed. Since v2.1.295, a command or http hook can set "onFailure": "block". The hooks reference lists “can’t start” as a failure: “a command hook fails to start, for example because the script or executable doesn’t exist.” In a plugin’s hooks/hooks.json:

{
  "description": "Block dangerous Bash, fail closed",
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash",
            "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/guard.sh"],
            "timeout": 5,
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}

Two caveats. First, the docs do not say in so many words that the “Plugin directory does not exist” pre-check counts as “can’t start”, so test it once in a throwaway session: install the plugin, start a session, rename the version directory in another terminal, and ask for a Bash command. If the call is blocked, you are covered. If it runs, treat any hook error notice as a stop signal and reload. Second, onFailure does nothing on Stop, SubagentStop, TaskCompleted and TeammateIdle, or for async hooks. The Stop hooks in the tj-agents incident would have failed open either way. The details are in how to make a Claude Code hook fail closed with onFailure: “block”.

Gotchas that look like the same problem

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back