首页
/ caveman Hooks 深度解析:Claude Code 会话钩子、按会话模式状态与状态栏徽章的实现

caveman Hooks 深度解析:Claude Code 会话钩子、按会话模式状态与状态栏徽章的实现

2026-09-06 16:37:21作者:管翌锬

caveman 是一个让 Claude Code「像穴居人一样说话」以削减 token 消耗的开源项目,而 src/hooks/ 目录下的钩子脚本正是它落地到 Claude Code 的全部工程机制:SessionStart 注入规则集、UserPromptSubmit 跟踪模式切换、statusline 脚本渲染徽章。本文以 src/hooks/README.md 为主线,完整覆盖安装方式、按会话(per-session)模式状态的存储设计、各钩子的职责与关键参数,并结合 caveman-activate.jscaveman-mode-tracker.jscaveman-config.js 等源码补充实现细节与安全设计。读完你将理解每个钩子何时触发、模式状态存在哪里、如何在 settings.json 中配置徽章,以及如何安全卸载。

安装与激活方式

这些钩子随 caveman 插件一起分发:安装插件后自动激活,无需手动配置。

如果走的是独立安装(不装插件),仓库根目录的 install.sh / install.ps1 会调用统一安装器 bin/install.js,把钩子写入 settings.json。手动执行的方式是:

# 在 clone 出来的仓库内
node bin/install.js --only claude
# 或者 curl-pipe 路径
npx -y github:JuliusBrussee/caveman -- --only claude

独立安装器还会顺带完成 statusline 的接线:若你尚未配置自定义 statusline,它会自动写入配置;若已有,则保持原样并打印合并说明。settings.json 的读写由 bin/lib/settings.js 负责,该模块内置了 JSONC 容错(去注释、去尾逗号、字符串感知),带注释的配置文件不会让安装器或运行时钩子崩溃。

模式状态存在哪里(Per-session 状态设计)

这是整套钩子最核心的设计决策:模式是「按会话」的,不是按机器的。每个 Claude Code 窗口把自己的模式存放在:

$CLAUDE_CONFIG_DIR/.caveman-sessions/<session_id>.mode

默认 $CLAUDE_CONFIG_DIR~/.claude。键名 session_id 是 Claude Code 放入每个钩子 payload 和 statusline stdin JSON 中的会话标识。

$CLAUDE_CONFIG_DIR/.caveman-active 仍然存在,但降级为最后写入者获胜(last-write-wins)的镜像,只记录最近一次写入的会话模式。保留它的目的有两个:让 cat ~/.claude/.caveman-active 仍能回答「caveman 是否开着」,以及让第三方 statusline 片段继续工作。

关于这个镜像,有两点必须知道:

  • 永远不会包含字面量字符串 off。停用(deactivate)时直接删除该文件。原因是 off 本身是一个合法的 mode 名,旧版钩子或 statusline 若从该路径读到 off,会把它当成一个激活模式,渲染出 [CAVEMAN:OFF] 或注入 "CAVEMAN MODE ACTIVE (off)"。
  • 同时打开两个窗口时,它会有一半概率显示的是另一个窗口的模式。要拿到某个窗口的真实模式,请读对应的 per-session 文件。

读取方接受「off」的两种写法:文件缺失(旧语义)和会话文件中的字面 off(新的、持久化的语义)。

从源码看,这一设计集中在 caveman-config.jswriteSessionMode:会话文件字面存储 off(这正是停用能跨 SessionStart 存活的机制),而旧镜像则被 unlink,从不写入 off。注释里明确解释了混版安装的现实:插件钩子和独立钩子可能同时注册,settings.json 里还烘焙着安装时写入的 statusline 路径,因此镜像语义必须向后兼容。resolveActiveModeL455-L462)则把「文件缺失」和「字面 off」统一折叠为 null,实现双读兼容。

会话 id 会拼进文件路径,因此是路径穿越攻击面。caveman-config.js 用白名单字母表而非黑名单分隔符:

const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;

正则刻意比 UUID 宽松,这样未来 id 格式变化时只会降级回旧的全局路径,而不是抛异常。

caveman-activate.js —— SessionStart 钩子

caveman-activate.js 在每次 SessionStart 时运行,source 可以是 startupresumeclearcompactfork。它做四件事:

  1. 解析本会话的模式,并通过符号链接安全的 safeWriteFlag 助手持久化(statusline 读它);
  2. 把 caveman 规则集作为隐藏的 SessionStart 上下文输出;
  3. 清理 14 天以前的会话文件(可用环境变量 CAVEMAN_SESSION_TTL_MS 覆盖),且仅在新会话时执行;
  4. 检测缺失的 statusline 配置并发出 setup 提示(Claude 会主动提出帮忙配置)。

为什么 source 重要。 该钩子注册时不带 matcher,因此对所有 source 都会触发——这是刻意为之:上下文压缩(compaction)正是把规则集从上下文里剪掉、让模型滑回冗长文风的元凶,所以压缩之后必须重新注入规则。但它在继续性事件(compactresumefork)上绝不能重新推导配置默认值并覆盖会话已存的模式——否则用户显式的「stop caveman」会在下一次自动压缩时被悄悄撤销。clear 则算作全新开始,因为它是用户显式的重置。

源码中这一分支落在 L220 与 L284-L308

const RESET_SOURCES = new Set(['startup', 'clear']);
// ...
if (RESET_SOURCES.has(source)) {
  mode = getDefaultMode(sessionCwd);   // 真正的新会话才重推导默认值
  gcSessionStore(claudeDir);           // 只在新会话清扫过期会话文件
} else {
  // 继续性事件:读已存值,绝不重推导
  let stored = readSessionModeRaw(claudeDir, sessionId);
  if (stored === null) stored = readFlag(legacyFlagPath(claudeDir));
  // ...
}

继续性分支读取的是字面存储值,这样「本会话选择了 off」就能与「本会话还没有状态」区分开——旧的全局标记时代「off」只能表达为「文件不存在」,停用的会话在下次 SessionStart 会找不到存储、直接落回 getDefaultMode(),这就是「stop caveman 之后 /compact 又复活」的漏洞。

此外还有一个值得注意的健壮性细节:钩子的 stdin 处理是事件驱动的,在第一个完整 JSON 对象到达时立即执行,而不是等 EOF。Windows 管道实现下宿主关闭写端可能任意延迟,同步 readFileSync(0) 会在读系统调用里阻塞到 EOF,吃光整个 5 秒钩子预算后被宿主杀掉。源码为此设置了 L209 的看门狗:

const PAYLOAD_WATCHDOG_MS = 2000;

看门狗触发时 source 记为 'unknown' 而非假设 'startup'——因为 startup 是唯一会重置模式的事件,慢 payload 下的 compact/resume 若被当成 startup,会悄悄把用户中途设置的 ultra 打回默认值。该行为有专门的回归测试 tests/test_hook_stdin_lifecycle.js:故意永不关闭 stdin,断言钩子在 4 秒预算内自行退出。

规则集的产出方式。 激活钩子运行时读取 SKILL.md——caveman 行为的唯一事实源——避免硬编码副本过期。候选路径按顺序尝试(L356-L363):$CLAUDE_PLUGIN_ROOT/skills/caveman/SKILL.md(插件安装时由 Claude Code 设置,权威)、../../skills/caveman/SKILL.md(仓库 checkout 布局)、../skills/caveman/SKILL.md(独立安装布局)。读到的内容先剥掉 YAML frontmatter,再按当前 level 过滤掉强度表格中与当前档位无关的行和示例。commitreviewcompress 三个独立模式有自己的 skill 文件,钩子只输出一行激活声明「CAVEMAN MODE ACTIVE — level: commit. Behavior defined by /caveman-commit skill.」。所有候选都失败时回退到硬编码的最小可用规则集(L405-L426)。

statusline 提示(nudge)是一次性的。 每会话的提示文本约 90 token,因此用标记文件 .caveman-nudge-shown 限制只提示一次,拒绝的用户不再为此付费。检测逻辑读 settings.jsonstatusLine 字段;若文件是 JSONC 无法严格解析,则退化为子串探测 "statusLine",并且倾向于提示——对一个已配置 statusline 的用户误报,比漏报一次提示更糟。

降级设计。 caveman-activate.js 依赖的兄弟模块(如 caveman-config.js)可能因不完整的安装而缺失。顶层裸 require 会把每次 SessionStart 都变成一次 MODULE_NOT_FOUND 崩溃。因此钩子用内联的 requireSibling 防御性加载:既校验模块能否加载,也校验导出形状;模块不可用时回退到内置桩(fallback 模式表、默认值解析的本地副本),会话仍能得到规则集,只是失去标记持久化。源码注释里还特别解释为什么这个降级逻辑刻意不抽成共享 helper:共享 loader 自己也会成为下一个可能缺失的兄弟文件。

caveman-mode-tracker.js —— UserPromptSubmit 钩子

caveman-mode-tracker.js 在每条用户 prompt 时触发,职责是:

  • 检查 /caveman 斜杠命令和自然语言激活/停用短语("talk like caveman"、"stop caveman"、"normal mode" 等);
  • 检测到命令时写入本会话的活动模式;停用时写入持久的 off 并清除旧镜像;
  • 当会话模式属于非独立档位(lite/full/ultra/wenyan*)时,每轮输出一条简短的强化提醒;
  • 按会话记忆被顶掉的散文模式,使两个各自运行 /caveman-commit 的窗口各回各的档位;
  • 支持的完整模式列表:litefullultrawenyanwenyan-litewenyan-fullwenyan-ultracommitreviewcompress

模式判定逻辑全部提取在共享解析器 caveman-parse.js 中(Claude Code 钩子与 opencode 插件共用同一份,防止两份正则漂移)。几个从源码可以确认的行为细节:

斜杠命令信封解包。 Claude Code 把斜杠命令以信封形式交给钩子,而非字面命令:

<command-message>caveman</command-message>
<command-name>/caveman</command-name>
<command-args>ultra</command-args>

tracker 会重建 <name> <args> 字面串(L161-L170),否则所有斜杠命令都会静默失效。非 caveman 命令的信封保持原样,且跳过自然语言检测,防止别的命令的参数误触发本钩子的开关。

停用短语优先且防引用误触发。 停用意图先于激活模式计算("turn caveman mode off" 不能落入激活分支),匹配集合包括 stop|disable|deactivate|quit|exit|kill (the) cavemancaveman (mode) off|stop|disabledturn off (the) caveman,以及命令位置的 normal mode(可带 go/back to/switch to/return to 前缀)。normal mode 只在命令位置或同句出现 caveman 上下文时才触发——避免 "how do I exit vim normal mode" 这类问题误停。更微妙的是 L72QUOTED_SPAN_REGEX:匹配前先空白化引号内的跨段文本(只认 " 和反引号),因为曾有用户在 bug 报告里引用帮助卡片的原话("Say "stop caveman" or "normal mode".")导致任务中途被停用。以 / 开头的 prompt 也整体跳过自然语言匹配——那是命令调用,其自身文本不该切换本钩子的模式。

激活短语。 包括 activate|enable|start|turn on|use|switch to|want|give me … cavemantalk like … cavemancaveman mode on,以及简洁性请求(less tokensfewer tokensbe briefbe terseshorter answers)——后者带否定前瞻,作用域限于单节("be brief in the summary")的一次性指令不算会话级切换。疑问句(what/how/why/… 开头)不触发激活。

档位参数的宽容解析。 /caveman ultra; still too verbose 这种粘连标点的情况:normalizeModeArg 剥掉首尾非 [a-z0-9-] 字符。off/stop/disable → 停用;wenyan-fullwenyan 的别名;裸 /caveman → 按配置默认值激活。无法解析的参数不会静默吞掉(caveman-parse.js L90-L114):tracker 会生成一条 notice 让模型告诉用户有效档位列表,且不回显被拒绝的参数本身(那是不可信输入,不应进入模型上下文)。若参数其实是一个独立模式名,notice 会引导用户用对应的 /caveman-<mode> 命令。

独立模式的一次性语义(#599)。 /caveman-commit/caveman-review/caveman-compress 是「一次性」模式:进入前,tracker 把当前散文模式存入 .caveman-active.prev(按会话隔离),下一个普通 prompt 到来时自动恢复(L289-L304)。两个保护:已存的 prev 不会被另一个独立模式覆盖(/caveman-commit 后接 /caveman-review 仍恢复最初档位);prev !== 'off' 的校验不是冗余——prev 是字面存储的,把存储的 off 当模式恢复会向一个刻意关闭 caveman 的会话注入 "CAVEMAN MODE ACTIVE (off)"。

每轮强化。 SessionStart 只注入一次完整规则集,但当其他插件每轮注入竞争性风格指令时模型会丢失它。tracker 因此每轮输出 CAVEMAN MODE ACTIVE (lite) — session ruleset applies.。该输出受三道门控:模式存在、非独立模式、以及会话所在目录的 getDefaultMode(cwd) !== 'off'(仓库级 .caveman.json 可以用 defaultMode: "off" 让整个项目退出 caveman,此检查只门控强化输出,从不删写标记文件)。notice 与强化合并为一次 stdout 写入,因为每次钩子运行只有一个 hookSpecificOutput 会被读取。

永远退出 0 的契约。 stdin 的 error(断管、父进程崩溃)挂监听器后静默退出 0;stdout/stderr 的 error 同样处理——宿主可能在钩子写入后关闭自己那端,未监听的 error 事件会让 Node 抛异常、钩子以非零码退出,产生虚假的钩子失败。

/caveman-stats 直通。 tracker 识别 /caveman-stats [--share] [--all] [--since …] 后以子进程运行同目录的 caveman-stats.js(2.5 秒超时,传入 transcript_pathsession_id),把统计块作为 additionalContext 注入,指示模型逐字输出。

Statusline 徽章脚本

caveman-statusline.sh(Windows 对应 caveman-statusline.ps1)把当前窗口的模式直接渲染在 Claude Code 状态栏。其行为:

  • 读取 Claude Code 经 stdin 发送的会话 JSON,取 session_id,渲染该窗口的模式;id 不可用时回退旧镜像;
  • 显示 [CAVEMAN][CAVEMAN:ULTRA][CAVEMAN:WENYAN] 等;停用的会话什么都不渲染——绝不出现 [CAVEMAN:OFF]
  • 永不阻塞:交互终端完全不读 stdin,管道读取有 1 秒上限(必须是整数,因为 macOS 自带 bash 3.2 会拒绝小数 read -t);
  • 追加来自 $CLAUDE_CONFIG_DIR/.caveman-statusline-suffix 的累计节省后缀(形如 ⛏ 12.4k),该文件由 caveman-stats.js 在每次 /caveman-stats 运行时写入;首次运行前不存在,所以新安装不会渲染假数字。设 CAVEMAN_STATUSLINE_SAVINGS=0 可关闭。

手动配置。 若需要自己配置,往 ~/.claude/settings.json 添加其一:

{
  "statusLine": {
    "type": "command",
    "command": "bash /path/to/caveman-statusline.sh"
  }
}
{
  "statusLine": {
    "type": "command",
    "command": "powershell -ExecutionPolicy Bypass -File C:\\path\\to\\caveman-statusline.ps1"
  }
}

路径替换为脚本实际位置(独立安装通常在 ~/.claude/hooks/,插件安装则在插件目录)。插件用户若尚未配置 statusLine,安装后的首个会话 Claude 会检测到并主动提出配置;已有自定义 statusline 的用户不会被覆盖,只需把下面的片段并入现有脚本。

并入现有 statusline 脚本的片段: 它从 stdin JSON 中取会话 id,使徽章跟随所属窗口。若你的脚本已经消费过 stdin,把已读内容作为 $caveman_payload 传入,不要重复读——stdin 只能被抽干一次:

caveman_cfg="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
caveman_payload="${caveman_payload:-}"
if [ -z "$caveman_payload" ] && [ ! -t 0 ]; then
  IFS= read -r -d '' -t 1 caveman_payload
fi
caveman_sid=$(printf '%s' "$caveman_payload" \
  | grep -o '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' \
  | head -1 | sed -e 's/.*:[[:space:]]*"//' -e 's/"$//')
case "$caveman_sid" in ''|*[!A-Za-z0-9_-]*) caveman_sid="" ;; esac

caveman_flag="$caveman_cfg/.caveman-active"
if [ -n "$caveman_sid" ] && [ -f "$caveman_cfg/.caveman-sessions/$caveman_sid.mode" ]; then
  caveman_flag="$caveman_cfg/.caveman-sessions/$caveman_sid.mode"
fi

caveman_text=""
if [ -f "$caveman_flag" ]; then
  caveman_mode=$(cat "$caveman_flag" 2>/dev/null)
  if [ "$caveman_mode" = "off" ]; then
    caveman_text=""                       # 已停用 — 什么都不渲染
  elif [ "$caveman_mode" = "full" ] || [ -z "$caveman_mode" ]; then
    caveman_text=$'\033[38;5;172m[CAVEMAN]\033[0m'
  else
    caveman_suffix=$(echo "$caveman_mode" | tr '[:lower:]' '[:upper:]')
    caveman_text=$'\033[38;5;172m[CAVEMAN:'"${caveman_suffix}"$']\033[0m'
  fi
fi

旧版单文件版片段(只读 .caveman-active)仍然可用——它只是显示最后写入的窗口,且永远不会看到字面 off,因为停用删除镜像而不是写入 off

徽章示例:

  • /caveman[CAVEMAN]
  • /caveman ultra[CAVEMAN:ULTRA]
  • /caveman wenyan[CAVEMAN:WENYAN]
  • /caveman-commit[CAVEMAN:COMMIT]
  • /caveman-review[CAVEMAN:REVIEW]

脚本内部的安全处理值得细看(caveman-statusline.sh):状态文件若是符号链接直接退出(本地攻击者可以把标记指向 ~/.ssh/id_rsa,让 statusline 每次按键都渲染其内容);读取硬性截断 64 字节并用 tr -cd 'a-z0-9-' 剥离一切其他字符,阻断 ANSI 转义与 OSC 超链接注入;最终通过模式白名单 case 校验,白名单外一律不渲染。脚本末尾显式 exit 0——空后缀文件会让最后一个测试的退出码为 1,而非零退出会让 Claude Code 隐藏整个状态栏。

工作原理:数据流总览

SessionStart hook ──┐                                        ┌── UserPromptSubmit hook
  (session_id,      │                                        │     (session_id, prompt)
   source)          ▼                                        ▼
        $CLAUDE_CONFIG_DIR/.caveman-sessions/<session_id>.mode
                             │             │
                          mirrors       reads
                             ▼             ▼
              .caveman-active      Statusline script  ◀── session JSON on stdin
           (last-write-wins,        [CAVEMAN:ULTRA]
            compat only)

SessionStart 的 stdout 被注入为隐藏的 system 上下文——模型看得到,用户看不到。statusline 是独立进程。三个面都从 Claude Code 拿到 session_id,这正是每个窗口能各自保有模式的原因。

所有路径都尊重 CLAUDE_CONFIG_DIR。所有状态写入走 safeWriteFlag()——拒符号链接、原子写入、0600 权限;所有读取做白名单校验,session id 必须先通过 ^[A-Za-z0-9_-]{1,128}$ 才能进入路径。

从源码看,safeWriteFlag 的完整防护链是:父目录若是符号链接,解析到真实路径并校验属主(Unix 下 uid 匹配,允许 ln -s /opt/shared ~/.claude 这类合法用法,拒绝指向他人属主目录的符号链接;Windows 下回退为校验解析路径位于用户主目录内);标记文件本身若是符号链接直接放弃;以 O_EXCL | O_NOFOLLOW0600 建临时文件、写入、再 rename 原子替换,rename 对 Windows 锁竞争重试 3 次并用 Atomics.wait 真实退避(不引入子进程)。读取端 readFlag 与写入对称:拒符号链接、64 字节硬上限(最长合法值 wenyan-ultra 仅 12 字节)、白名单校验,任何异常返回 null——绝不把不可信字节注入模型上下文或回显到终端。

模式变更还会经 recordModeChangecaveman-config.js L591-L603)追加 {ts, mode, prev, session_id}$CLAUDE_CONFIG_DIR/.caveman-mode-log.jsonl,让 caveman-stats 能把每条消息的输出 token 归因到生成时真正活动的模式,而不是统计时刻的标记值。

默认模式的解析顺序

「裸 /caveman 激活到什么档位」「新会话重置为什么档位」由 getDefaultMode 按固定优先级解析:

  1. 环境变量 CAVEMAN_DEFAULT_MODE(最高优先级,不做 trim);
  2. 仓库本地配置——从会话 cwd 向上查找 .caveman/config.json.caveman.json(限 64 层防符号链接循环,拒符号链接文件),供团队把项目默认档位 check in,而不污染每个贡献者的用户级配置;
  3. 用户配置文件 defaultMode 字段——$XDG_CONFIG_HOME/caveman/config.json(若设置)、~/.config/caveman/config.json(macOS/Linux)、%APPDATA%\caveman\config.json(Windows);
  4. 内置默认 full

合法取值即 VALID_MODESL32-L36):offlitefullultrawenyan-litewenyanwenyan-fullwenyan-ultracommitreviewcompress。项目级 defaultMode: "off" 即可让整个仓库退出 caveman;注意钩子的 stdin payload 携带的是会话的 cwd,仓库配置查找必须从它起步,而不是钩子进程自己的 cwd。

诊断方面,CAVEMAN_DEBUG=1 会在标记写入被拒、rename 竞争失败等场景输出 stderr 诊断。

卸载

插件安装: 禁用插件即可(claude plugin disable caveman),钩子随之自动失活。

独立安装(Node 安装器):

npx -y github:JuliusBrussee/caveman -- --uninstall
# 或者,在 clone 内:
node bin/install.js --uninstall

手动卸载:

  1. $CLAUDE_CONFIG_DIR/hooks/(默认 ~/.claude/hooks/)删除 caveman 钩子文件:caveman-activate.jscaveman-mode-tracker.jscaveman-parse.jscaveman-stats.jscaveman-config.jscavecrew-model-overrides.js 以及 caveman-statusline.{sh,ps1}
  2. $CLAUDE_CONFIG_DIR/settings.json 删除 SessionStart、UserPromptSubmit 与 statusLine 条目。
  3. 删除 $CLAUDE_CONFIG_DIR 下的模式状态:.caveman-sessions/ 目录、.caveman-active.caveman-active.prev.caveman-mode-log.jsonl.caveman-statusline-suffix.caveman-nudge-shown

卸载器会替你完成第 3 步,但刻意保留 .caveman-history.jsonl——那是你累计节省量的历史记录,不是 caveman 的管道设施;要彻底删掉需手动执行。

uninstall.sh 本身也有值得注意的顺序设计:先摘除 settings.json 条目、再删钩子文件。反过来的顺序会让 Claude Code 指向已删除的脚本,每次 SessionStart 都报 Cannot find module …caveman-activate.js;编辑 settings 前先备份 settings.json.bak(不覆盖已有备份),且缺少 node 时直接中止而非继续删文件,避免留下半卸载状态。

登录后查看全文
热门项目推荐
相关项目推荐