caveman Hooks 深度解析:Claude Code 会话钩子、按会话模式状态与状态栏徽章的实现
caveman 是一个让 Claude Code「像穴居人一样说话」以削减 token 消耗的开源项目,而 src/hooks/ 目录下的钩子脚本正是它落地到 Claude Code 的全部工程机制:SessionStart 注入规则集、UserPromptSubmit 跟踪模式切换、statusline 脚本渲染徽章。本文以 src/hooks/README.md 为主线,完整覆盖安装方式、按会话(per-session)模式状态的存储设计、各钩子的职责与关键参数,并结合 caveman-activate.js、caveman-mode-tracker.js、caveman-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.js 的 writeSessionMode:会话文件字面存储 off(这正是停用能跨 SessionStart 存活的机制),而旧镜像则被 unlink,从不写入 off。注释里明确解释了混版安装的现实:插件钩子和独立钩子可能同时注册,settings.json 里还烘焙着安装时写入的 statusline 路径,因此镜像语义必须向后兼容。resolveActiveMode(L455-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 可以是 startup、resume、clear、compact 或 fork。它做四件事:
- 解析本会话的模式,并通过符号链接安全的
safeWriteFlag助手持久化(statusline 读它); - 把 caveman 规则集作为隐藏的 SessionStart 上下文输出;
- 清理 14 天以前的会话文件(可用环境变量
CAVEMAN_SESSION_TTL_MS覆盖),且仅在新会话时执行; - 检测缺失的 statusline 配置并发出 setup 提示(Claude 会主动提出帮忙配置)。
为什么 source 重要。 该钩子注册时不带 matcher,因此对所有 source 都会触发——这是刻意为之:上下文压缩(compaction)正是把规则集从上下文里剪掉、让模型滑回冗长文风的元凶,所以压缩之后必须重新注入规则。但它在继续性事件(compact、resume、fork)上绝不能重新推导配置默认值并覆盖会话已存的模式——否则用户显式的「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 过滤掉强度表格中与当前档位无关的行和示例。commit、review、compress 三个独立模式有自己的 skill 文件,钩子只输出一行激活声明「CAVEMAN MODE ACTIVE — level: commit. Behavior defined by /caveman-commit skill.」。所有候选都失败时回退到硬编码的最小可用规则集(L405-L426)。
statusline 提示(nudge)是一次性的。 每会话的提示文本约 90 token,因此用标记文件 .caveman-nudge-shown 限制只提示一次,拒绝的用户不再为此付费。检测逻辑读 settings.json 的 statusLine 字段;若文件是 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的窗口各回各的档位; - 支持的完整模式列表:
lite、full、ultra、wenyan、wenyan-lite、wenyan-full、wenyan-ultra、commit、review、compress。
模式判定逻辑全部提取在共享解析器 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) caveman、caveman (mode) off|stop|disabled、turn off (the) caveman,以及命令位置的 normal mode(可带 go/back to/switch to/return to 前缀)。normal mode 只在命令位置或同句出现 caveman 上下文时才触发——避免 "how do I exit vim normal mode" 这类问题误停。更微妙的是 L72 的 QUOTED_SPAN_REGEX:匹配前先空白化引号内的跨段文本(只认 " 和反引号),因为曾有用户在 bug 报告里引用帮助卡片的原话("Say "stop caveman" or "normal mode".")导致任务中途被停用。以 / 开头的 prompt 也整体跳过自然语言匹配——那是命令调用,其自身文本不该切换本钩子的模式。
激活短语。 包括 activate|enable|start|turn on|use|switch to|want|give me … caveman、talk like … caveman、caveman mode on,以及简洁性请求(less tokens、fewer tokens、be brief、be terse、shorter answers)——后者带否定前瞻,作用域限于单节("be brief in the summary")的一次性指令不算会话级切换。疑问句(what/how/why/… 开头)不触发激活。
档位参数的宽容解析。 /caveman ultra; still too verbose 这种粘连标点的情况:normalizeModeArg 剥掉首尾非 [a-z0-9-] 字符。off/stop/disable → 停用;wenyan-full 是 wenyan 的别名;裸 /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_path 与 session_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_NOFOLLOW 以 0600 建临时文件、写入、再 rename 原子替换,rename 对 Windows 锁竞争重试 3 次并用 Atomics.wait 真实退避(不引入子进程)。读取端 readFlag 与写入对称:拒符号链接、64 字节硬上限(最长合法值 wenyan-ultra 仅 12 字节)、白名单校验,任何异常返回 null——绝不把不可信字节注入模型上下文或回显到终端。
模式变更还会经 recordModeChange(caveman-config.js L591-L603)追加 {ts, mode, prev, session_id} 到 $CLAUDE_CONFIG_DIR/.caveman-mode-log.jsonl,让 caveman-stats 能把每条消息的输出 token 归因到生成时真正活动的模式,而不是统计时刻的标记值。
默认模式的解析顺序
「裸 /caveman 激活到什么档位」「新会话重置为什么档位」由 getDefaultMode 按固定优先级解析:
- 环境变量
CAVEMAN_DEFAULT_MODE(最高优先级,不做 trim); - 仓库本地配置——从会话 cwd 向上查找
.caveman/config.json或.caveman.json(限 64 层防符号链接循环,拒符号链接文件),供团队把项目默认档位 check in,而不污染每个贡献者的用户级配置; - 用户配置文件
defaultMode字段——$XDG_CONFIG_HOME/caveman/config.json(若设置)、~/.config/caveman/config.json(macOS/Linux)、%APPDATA%\caveman\config.json(Windows); - 内置默认
full。
合法取值即 VALID_MODES(L32-L36):off、lite、full、ultra、wenyan-lite、wenyan、wenyan-full、wenyan-ultra、commit、review、compress。项目级 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
手动卸载:
- 从
$CLAUDE_CONFIG_DIR/hooks/(默认~/.claude/hooks/)删除 caveman 钩子文件:caveman-activate.js、caveman-mode-tracker.js、caveman-parse.js、caveman-stats.js、caveman-config.js、cavecrew-model-overrides.js以及caveman-statusline.{sh,ps1}。 - 从
$CLAUDE_CONFIG_DIR/settings.json删除 SessionStart、UserPromptSubmit 与 statusLine 条目。 - 删除
$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 时直接中止而非继续删文件,避免留下半卸载状态。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00