Skip to content

SessionStart/UserPromptSubmit hooks hard-crash with MODULE_NOT_FOUND when a sibling hook file is missing — bare top-level require('./caveman-config') in 3 of 4 entrypoints #848

Description

@rebel-daekyeong

Every session start prints:

SessionStart:startup hook error
Failed with non-blocking status code: node:internal/modules/cjs/loader:1408

Root cause is a missing sibling file (src/hooks/caveman-config.js) in the installed plugin dir. But the reason it surfaces as an opaque loader stack trace instead of a diagnosable message is a code issue in caveman: 3 of the 4 hook entrypoints require ./caveman-config at top level with no guard, so any missing/unreadable sibling turns into an uncaught MODULE_NOT_FOUND and exit 1 on every SessionStart and every UserPromptSubmit.

Reproduction

$ echo '{"session_id":"t","cwd":"/tmp","hook_event_name":"SessionStart","source":"startup"}' \
    | node ~/.claude/plugins/cache/caveman/caveman/27d5a3981a34/src/hooks/caveman-activate.js
node:internal/modules/cjs/loader:1408
  throw err;
  ^

Error: Cannot find module './caveman-config'
Require stack:
- .../src/hooks/caveman-activate.js
    at Function._resolveFilename (node:internal/modules/cjs/loader:1405:15)
    ...
    at Object.<anonymous> (.../src/hooks/caveman-activate.js:12:84)
  code: 'MODULE_NOT_FOUND'
$ echo $?
1

The code issue

src/hooks/caveman-activate.js L12 — mandatory dependency, unguarded:

const { getDefaultMode, safeWriteFlag, recordModeChange, readFlag, VALID_MODES } = require('./caveman-config');

Same shape in src/hooks/caveman-mode-tracker.js L9 and src/hooks/caveman-stats.js L13.

src/hooks/caveman-parse.js L43-48 already does the right thing for the identical import:

let cavemanConfig;
try {
  cavemanConfig = require('./caveman-config');
} catch (e) {
  cavemanConfig = require('./caveman-config.cjs');
}

And caveman-activate.js itself demonstrates the fail-soft philosophy 18 lines further down (L30-33), for the optional dep:

// Best-effort: any error is swallowed so SessionStart is never blocked.
try {
  const { applyOverrides, resolvePluginRoot } = require('./cavecrew-model-overrides');
  applyOverrides(resolvePluginRoot(__dirname));
} catch (e) {}

So the codebase already treats "a sibling hook file may be absent" as a real risk — it just guards one of the four call sites. This is the same failure class as #801 (install.sh/install.ps1 omitting caveman-parse.js); that fix added the file to the copy list, but the underlying "top-level require ⇒ hard crash" remains, so the next missing file reproduces it.

How the file went missing

I could not attribute the deletion, and I am not claiming caveman deleted it — reporting the observed state in case it is a known install-path problem:

The installed plugin dir is a git worktree at the exact HEAD commit, and git reports the file as deleted from the worktree only:

$ git -C ~/.claude/plugins/cache/caveman/caveman/27d5a3981a34 status --porcelain
 D src/hooks/caveman-config.js
 D src/mcp-servers/caveman-shrink/README.md
 D src/mcp-servers/caveman-shrink/compress.js
 D src/mcp-servers/caveman-shrink/index.js
 D src/mcp-servers/caveman-shrink/package.json
 D src/mcp-servers/caveman-shrink/spawn-options.js
 D src/rules/caveman-activate.md
 D src/rules/caveman-openclaw-bootstrap.md
?? .in_use/

Exactly 8 tracked files, all under src/, all present at HEAD (git ls-tree HEAD src/hooks/ lists caveman-config.js, blob d94131b, and the blob is present in .git/objects). Not sparse-checkout (fatal: this worktree is not sparse), not gitignored (git check-ignore exits 1 for all three paths). The marketplace clone at ~/.claude/plugins/marketplaces/caveman has all 8 files. Other cached version dirs for the same 2.0.0 release (613d7f0402fb, a0109974ea32, c72984e4392c, 099327780ef6) all have caveman-config.js; only the current 27d5a3981a34 does not. git status on this worktree shows no other drift, so it is not a broad truncation.

Note that src/hooks/checksums.sha256 already lists caveman-config.js — the manifest that would have caught this ships with the plugin, but nothing verifies it at runtime.

Workaround that fixed it locally:

$ git -C ~/.claude/plugins/cache/caveman/caveman/27d5a3981a34 restore src/hooks/caveman-config.js src/mcp-servers src/rules
$ echo '{"session_id":"t","cwd":"/tmp","hook_event_name":"SessionStart","source":"startup"}' \
    | node .../src/hooks/caveman-activate.js
CAVEMAN MODE ACTIVE — level: full
...
$ echo $?
0

Suggested fix

  1. Guard the three unguarded requires the way caveman-parse.js already does — a hook must never exit non-zero because a sibling is missing. On failure, degrade to the built-in fallback ruleset (as Plugin install: SKILL.md lookup resolves to nonexistent src/skills/, hook silently degrades to fallback ruleset #792 describes for the SKILL.md lookup) rather than crashing.
  2. Emit one actionable line on stderr instead of a Node stack trace, e.g. caveman: plugin install incomplete (missing caveman-config.js) — run /plugin update caveman.
  3. Optionally verify src/hooks/checksums.sha256 on SessionStart (cheap, manifest already shipped) so a partial install self-reports instead of failing at a random require.

Environment

  • Claude Code 2.1.233, plugin install path (.claude-plugin/plugin.json hooks), not standalone install.sh
  • caveman 2.0.0 @ 27d5a3981a34 ("README: whole-cave table gains status column, Browse row, frozen tier")
  • Node v23.11.0 (Homebrew), macOS 26.5.1 (arm64)

Related: #801 (same failure class, standalone installer), #792 (missing-file path degrading silently), #78 / #471 (same loader error, different root cause — path resolution / orphaned hooks).

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