2149 字
11 分钟
给 Claude Code 加个"隔了多久没理你"提醒

Claude Code 的对话是纯文本轮次,模型看不到真实时间流逝。你上一条消息问完,模型答完,如果你隔了两小时才回来接着聊,模型拿到的输入跟”你秒回”是完全一样的——一段紧挨着的文本。它不知道这两小时里文件可能被别的进程改了、服务可能重启过、别的 Claude Code session 可能动过同一份代码。

这次要做的就是把这段”消失的时间”喂回给模型:用户隔多久才回复,就在这条消息前面注入一句提醒。

挂哪个 hook#

Claude Code 的 hooks 里有个 UserPromptSubmit,在用户提交 prompt、模型开始处理之前触发。它支持两种方式往上下文里塞东西:

  1. stdout 直接输出纯文本,非 JSON 的话会被当作 context 加进去
  2. 输出 JSON,用 hookSpecificOutput.additionalContext 字段传,这段文本会作为 system reminder 注入,模型能读到,但不会在可见的对话记录里留下痕迹

选第二种,因为语义更明确——这本来就是”系统提醒”,不是用户说的话,不该伪装成 assistant 或 user 的文本。

{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "这里放要塞给模型的提示"
}
}

怎么知道”隔了多久”#

UserPromptSubmit hook 的 stdin 里有个 transcript_path字段,指向当前 session 的 JSONL 记录文件——跟 Bark 通知 hook 里用到的是同一个字段。文件里每一行是一条消息,assistant 消息长这样:

{"type":"assistant","timestamp":"2026-07-13T00:45:41.599Z","message":{"role":"assistant","content":[...]}}

思路很直接:从 transcript 里翻出最后一条 type == "assistant" 的记录,取它的 timestamp,跟当前时间做差。差值超过阈值(默认 5 分钟),就往这条新 prompt 前面塞一句提醒。

第一版:Python,然后被毙了#

我最开始写的是 Python 版本——datetime.fromisoformat 处理 ISO 8601 时间戳、算时间差、拼人类可读的”N 小时 M 分钟”,逻辑清晰,写起来也快。脚本本身没问题,实测也通过了(假造 transcript 测 2 小时前触发、1 分钟前不触发、文件不存在时静默,三个用例都对)。

问题出在部署环境上:这个 hook 要跑在我的好几台机器上(MacBook、Mac mini、有的还走 SSH),而这些机器上的 Python 版本、有没有装某些标准库、python3 到底指向哪个解释器,都不能保证一致。一个本该是几行 shell 脚本就能搞定的东西,非要绑定一个解释器版本,纯属给自己埋雷——这类全局 hook 脚本要写得比业务代码更”抠”:越少依赖越好,因为它要在你不注意的时候,在随便哪台机器上,安安静静地跑成功。

所以整个重写成 POSIX sh + jqjq 本身已经是 settings.json 里其他 hook(比如那几个 git push --force 拦截器)的硬依赖,装了 Claude Code 的机器基本都有,不算新增负担。

用 jq 解析 ISO 8601 时间戳的坑#

jq 1.6 之后内置了 fromdateiso8601,本该是直接调用就完事:

Terminal window
echo '"2026-07-13T00:45:41Z"' | jq -r 'fromdateiso8601'
# 1783996 <- epoch 秒数

但 transcript 里的时间戳带毫秒:2026-07-13T00:45:41.599Z。喂给 fromdateiso8601 直接报错——它内部走的是 C 库的 strptime,格式串写死成不带小数秒的 %Y-%m-%dT%H:%M:%SZ,遇到 .599 直接解析失败。

解法是解析前先用 sub 正则把小数部分砍掉:

Terminal window
echo '"2026-07-13T00:45:41.599Z"' \
| jq -Rr 'sub("\\.[0-9]+"; "") | try fromdateiso8601 catch empty'
# 1783996

try ... catch empty 是保险丝——万一格式还是不对,返回空字符串而不是让整个 hook 崩掉报错退出。全局 hook 的原则是:宁可什么都不做,也不能因为一次解析失败就把用户的下一条消息卡住或者报错干扰对话。

完整脚本#

~/.claude/scripts/time-gap-notice.sh
#!/bin/sh
# UserPromptSubmit hook: 用户隔了很久才回复时,注入"已过去多久"的提示,
# 避免模型把长间隔后的回复当作紧接上一轮的即时消息。
# 依赖: POSIX sh + jq。阈值可用 TIME_GAP_THRESHOLD_S 覆盖(默认 300 秒)。
THRESHOLD_S="${TIME_GAP_THRESHOLD_S:-300}"
tp=$(jq -r '.transcript_path // empty' 2>/dev/null) || exit 0
[ -n "$tp" ] && [ -f "$tp" ] || exit 0
# 只扫 transcript 末尾 256KB;tail -c 可能截断首行,fromjson? 会静默跳过坏行
ts=$(tail -c 262144 "$tp" | jq -Rr 'fromjson? | select(.type=="assistant") | .timestamp // empty' 2>/dev/null | tail -n 1)
[ -n "$ts" ] || exit 0
# 去掉毫秒(旧版 jq 的 fromdateiso8601 不认小数秒),转 epoch
last=$(printf '%s' "$ts" | jq -Rr 'sub("\\.[0-9]+"; "") | try fromdateiso8601 catch empty' 2>/dev/null)
[ -n "$last" ] || exit 0
now=$(date +%s)
gap=$((now - last))
[ "$gap" -ge "$THRESHOLD_S" ] || exit 0
mins=$((gap / 60))
if [ "$mins" -lt 60 ]; then
human="${mins} 分钟"
elif [ "$mins" -lt 1440 ]; then
h=$((mins / 60)); m=$((mins % 60))
if [ "$m" -gt 0 ]; then human="${h} 小时 ${m} 分钟"; else human="${h} 小时"; fi
else
d=$((mins / 1440)); h=$((mins % 1440 / 60))
if [ "$h" -gt 0 ]; then human="${d} 天 ${h} 小时"; else human="${d} 天"; fi
fi
nowstr=$(date '+%Y-%m-%d %H:%M %Z')
jq -cn --arg c "[time-gap] 距离你上一条回复已过去约 ${human}(当前时间 ${nowstr})。用户是隔了很久才发这条消息的,不要当作紧接上一轮的即时回复;外部状态(文件、进程、服务、其他 session)可能已经变化,必要时先重新确认。" \
'{hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext: $c}}'

几个设计细节:

只扫 transcript 末尾 256KBtail -c 262144),不读整个文件。长 session 的 transcript 能有几十 MB,全读一遍会拖慢每次 prompt 提交的响应——而 UserPromptSubmit hook 默认超时 30 秒,卡住会直接阻塞模型开始处理,这个延迟必须压到毫秒级。

fromjson? 带问号tail -c 按字节数截断,开头那一行大概率是被从中间切断的半截 JSON,fromjson? 遇到解析失败会静默跳过而不是报错中断整条 pipeline。

jq -cn 构造输出 JSON,而不是用 shell 字符串拼接 printf 出 JSON。中文、括号、分号这些字符如果手工拼接容易转义出错,交给 jq --arg 处理转义就没有这个问题。

退出码全部走 exit 0,间隔不够或者任何一步拿不到值就直接静默退出,不产生任何输出,不影响正常对话流程。

验证#

写完不能只跑一遍真实场景就算完事,因为真实场景下”隔了很久”这个条件不好稳定复现。构造了三组假 transcript 覆盖边界情况:

Terminal window
# 2 小时前的 assistant 消息 —— 应该触发
echo '{"transcript_path":"'"$PWD"'/fake-old.jsonl"}' | sh time-gap-notice.sh
# {"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"[time-gap] 距离你上一条回复已过去约 2 小时 1 分钟...
# 1 分钟前 —— 不该有任何输出
echo '{"transcript_path":"'"$PWD"'/fake-recent.jsonl"}' | sh time-gap-notice.sh
# (无输出,exit 0)
# transcript 文件不存在 —— 不该有任何输出
echo '{"transcript_path":"/nonexistent.jsonl"}' | sh time-gap-notice.sh
# (无输出,exit 0)

三组都通过之后,再拿真实 session 的 transcript 跑一遍,确认它能从几十 MB 的真实文件里正确解析出最后一条 assistant 消息的时间戳,而不是只在人造的、干净的测试数据上工作。最后接进 settings.json 之后,在真实对话里隔了 7 分钟发消息,确认提醒确实注入到了模型的上下文里,收尾。

装到 settings.json 里#

~/.claude/settings.json
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "/Users/you/.claude/scripts/time-gap-notice.sh",
"timeout": 10
}
]
}
]
}
}

timeout 设成 10 秒——脚本正常跑完远用不了这么久(截 256KB + 几次 jq 调用,本地文件 I/O,毫秒级),留这个余量只是为了应对偶尔文件系统抖动的极端情况,不是常态耗时。

效果#

隔 7 分钟发消息,这条会先于用户 prompt 被模型看到:

[time-gap] 距离你上一条回复已过去约 7 分钟(当前时间 2026-07-12 20:55 EDT)。用户是隔了很久才发这条消息的,不要当作紧接上一轮的即时回复;外部状态(文件、进程、服务、其他 session)可能已经变化,必要时先重新确认。

阈值默认 5 分钟,改 TIME_GAP_THRESHOLD_S 环境变量就能调。往上加的话,比如你习惯边写代码边跟 Claude Code 聊,5 分钟静默不算什么,可以调到 15 或 30 分钟,避免正常思考停顿也被当成”长间隔”打扰。

TIP

这条 hook 跟同一份 settings.json 里的 Bark 通知 hook 挂在不同事件上,互不冲突:Notification 负责把消息推到手机,UserPromptSubmit 负责在消息回到对话里时补时间感知,两者共用同一套 transcript_path 读取思路。

依赖#

  • jqbrew install jq(需要支持 fromdateiso8601try/catch 的版本,1.6+ 都可以)
  • POSIX sh — macOS / Linux 自带,不依赖 bash 特有语法,方便跨机器同步这份 hook 脚本
给 Claude Code 加个"隔了多久没理你"提醒
https://blog.lishuyu.app/posts/claude-code-time-gap-hook/
作者
猫猫魔女
发布于
2026-07-13
许可协议
CC BY-NC-SA 4.0