Claude の 5h 枠が閾値を超えていたらプロンプトに 1 行注入し、status line にも出す (settings の hook ratelimit-warn.sh の mod 版。issue 658)

対象は macOS のみ。Linux はサポート対象外です (2026-08-28 決定 / issue 133)。 Linux でも動きそうに見える箇所がありますが、意図的な対応ではありません (例: scripts/tmux_extract_popup.sh は pbcopy を無条件で呼び、zshlib/_fs_helpers.zsh の mount パースは macOS の出力形式のみを前提にしています)。CI も macOS runner で回します。
cd ~
git clone git@github.com:jiikko/dotfiles.git || git clone https://github.com/jiikko/dotfiles.git
cd dotfiles
./setup.sh
docs/README.md が索引。触る前に読む制約 (glogx の bubbletea、テーマ色、tmux の セッション永続化)、glogx の画面の仕様、tmux 周りの仕組み、nvim の棚卸しがある。 自作ツールの使い方は src/README.md から各プロジェクトの README へ。
githooks/ を setup.sh が core.hooksPath に設定する。
pre-commit: ステージした差分に、成人向けを匂わせる語や作品番号の書式がないかを civility-lint (dotfiles の外にある private repo のツール) で検査する。本体が無いマシンでは警告だけ出して通す。 誤検出を 1 行だけ通すなら、その行に civility-lint:ignore を書くpre-push: issues/ を触る push のときだけ、push する commit を展開して issue の整合検査 (番号の一意性・相対リンク・next の目印など tests/issues/ の 6 本。数秒) を回し、落ちたら止める。 今回の push が壊したものでなくても止まる。回す検査と外す検査の一覧は hook の冒頭にあるRun the regression test suite (Neovim, tmux, setup.sh, plus existing zsh tests) with:
make test runs lint and the tests all the way through and reports every failure at the end; a lint failure no longer stops the tests from running. Use make test-lint when you only want lint.
make test
You can run individual checks as well:
make test-syntax # zsh/zlogin/setup.sh syntax checks + tmux/nvim smoke
make test-nvim # verifies Neovim config loads and lazy.nvim is reachable
make test-tmux # ensures _tmux.conf can boot a tmux server (skips if tmux sockets are disallowed)
make test-setup # exercises setup.sh in a temporary HOME
make test-shellcheck # runs shellcheck on shell-compatible scripts
make test-yaml # yamllint on workflow/pre-commit config
make test-json # jq validation for JSON configs
make test-lint # aggregate lint target (shellcheck + zsh syntax + YAML + JSON + karabiner + actionlint + gitconfig + ruby syntax + the repo-wide scripts/check_*.sh gates; full list in the Makefile)
make test-src # lint + unused check + test for all Go projects under src/ (same coverage as CI's src_*.yml)
make test-runtime # aggregate runtime target (syntax + auto-discovered tests/**/test_*.sh + bats)
make test-bats # bats tests (skips if bats is not installed)
tests/zshrc/test_zshrc.sh # existing zsh tests (also run via make test)
自作ツールの使い方は docs/tools/ に置いている (索引は docs/README.md)。
C-t。ペイン・ウィンドウ操作、popup、ウィンドウ名、Claude Code の作業状態表示repair / av1ify (av1c) / concatkernel-alloc-watch / Finder Quick Actionssrc/README.md から各プロジェクトの README へhooks/register.ts 75 lines1// settings の hook _claude/hooks/ratelimit-warn.sh の mod 版 (issue 658)。
2//
3// 枠は 2 つの出所から読み、使える観測の新しい方で判定する (hooks/limit.ts):
4// live: session.measure が運ぶ rateLimits (直近の応答ヘッダの値) を発火時刻つきで $.state に控える
5// file: statusline が書く $XDG_CACHE_HOME/glog/claude-rate-limits.json (別 session の観測も見える)
6// 判定できたプロンプトでは、hook が同じ注入をしないよう印 (ENV_MARK = `<session_id>:<epoch ms>`) を打つ。
7// 印を永続の値にしないのは、mod が後で落ちた・classic.* が素通しになった・子の claude -p に継承された、のどれでも
8// fallback の hook まで黙らせないため (658 の codex 反証 P1)。判定できないときは印を打たず、hook に任せる。
9// 子プロセスは起こさない (hook は zsh → Go → 裏の更新を毎プロンプト起こしていた)。
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import { fromLive, judge, message, parseFile, pick, statusText } from './limit'
14import type { Reading } from '../types'
15
16const live = atom({ plugin: 'ratelimit-warn', key: 'live' } as const, null as Reading | null)
17
18async function readFile($: EngineInterface, nowMs: number): Promise<Reading | null> {
19 const xdg = await $.env.get('XDG_CACHE_HOME')
20 const home = await $.env.get('HOME')
21 const base = xdg !== undefined && xdg !== '' ? xdg : home !== undefined && home !== '' ? `${home}/.cache` : undefined
22 if (base === undefined) return null
23 try {
24 const text = await $.fs.read(`${base}/glog/claude-rate-limits.json`)
25 return typeof text === 'string' ? parseFile(text, nowMs) : null
26 } catch {
27 return null // 無い・読めない = この出所は使えない
28 }
29}
30
31async function current($: EngineInterface): Promise<{ reading: Reading | null; nowMs: number }> {
32 const nowMs = await $.clock.now()
33 const [l, f] = await Promise.all([read($, live), readFile($, nowMs)])
34 return { reading: pick(l, f, nowMs), nowMs }
35}
36
37async function refreshStatus($: EngineInterface): Promise<void> {
38 try {
39 const { reading, nowMs } = await current($)
40 $.ui.status(reading === null ? undefined : statusText(judge(reading, nowMs)))
41 } catch {
42 // timer / measure から呼ばれる。$ の口が拒否しても未処理の reject にしない (表示は次の機会に直る)
43 }
44}
45
46export const register: Register = on => {
47 on('session.start', ($, e, next) => {
48 // 放置中にリセット・鮮度切れを迎えたら status を消す (プロンプトと measure の間を埋める)
49 $.clock.every(60_000, () => {
50 void refreshStatus($)
51 })
52 return next(e)
53 })
54
55 on('session.measure', async ($, e, next) => {
56 const nowMs = await $.clock.now()
57 // 空の rateLimits (subscription 外・まだ応答が無い) は「live の観測が無い」なので、前の控えを消す (古い超過を再注入しない)
58 const r = fromLive(e.rateLimits, nowMs)
59 await update($, live, () => r)
60 await refreshStatus($)
61 return next(e)
62 })
63
64 on('classic.UserPromptSubmit', async ($, e, next) => {
65 const { reading, nowMs } = await current($)
66 if (reading === null) return next(e) // 判定できない: 印を打たず settings の hook に任せる
67 const v = judge(reading, nowMs)
68 $.ui.status(statusText(v))
69 await $.env.set('DOTFILES_MOD_RATELIMIT_WARN', `${e.session_id}:${nowMs}`) // = limit.ts の ENV_MARK (validate が literal を求める)
70 const r = await next(e)
71 if (!v.over) return r
72 return { ...r, additionalContext: [...(r.additionalContext ?? []), message(v, nowMs)] }
73 }).catch(($, e, next) => (next.called ? next(e) : next(e)))
74}
75hooks/limit.ts 94 lines1// 5h 枠の判定 (純粋関数)。閾値・鮮度の境界は bin/ratelimit (src/ratelimit) と揃える:
2// THRESHOLD = `ratelimit -warn-5h` の既定値 80、比較は `>=` (src/ratelimit/main.go の overLimit)
3// LIVE_MAX_AGE = main.go の maxStale (30 分。古い % で判定し続けると、実際は超過していても黙る)
4// FILE_MAX_AGE = src/ratelimit/usage/statusline.go の statuslineMaxAge (15 分)
5// 揃えるべき値がそちらで変わったら、ここも変える (機械では突き合わせていない)。
6import type { Reading } from '../types'
7
8export const THRESHOLD = 80
9export const LIVE_MAX_AGE_MS = 30 * 60_000
10export const FILE_MAX_AGE_MS = 15 * 60_000
11export const ENV_MARK = 'DOTFILES_MOD_RATELIMIT_WARN'
12
13type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
14
15/** $.session.usage().rateLimits (直近の応答が報告した値) から 5h 枠を取る。観測時刻は呼び出し側の近似 (measure の発火時刻)。 */
16export function fromLive(limits: readonly RateLimit[], observedAtMs: number): Reading | null {
17 const w = limits.find(l => l.kind === 'five_hour')
18 if (w === undefined) return null
19 if (w.resetsAt === undefined) return null // 使用率があるのにリセット時刻が無い形は使わない (statusline reader と同じ)
20 const resetsAtMs = Date.parse(w.resetsAt)
21 if (Number.isNaN(resetsAtMs)) return null
22 // 切り捨て: statusline (write_rate_limits) も小数を切り捨てて int にするので、同じ観測が live と file で違う % にならないようにする
23 return { percent: Math.floor(w.percentUsed), resetsAtMs, observedAtMs, source: 'live' }
24}
25
26/** statusline が書く claude-rate-limits.json (src/ratelimit/usage/statusline.go の statuslineState と 1:1)。使えなければ null。 */
27export function parseFile(text: string, nowMs: number): Reading | null {
28 let st: { observedAt?: unknown; five_hour?: { used_percentage?: unknown; resets_at?: unknown } | null }
29 try {
30 st = JSON.parse(text)
31 } catch {
32 return null
33 }
34 if (typeof st.observedAt !== 'number' || st.observedAt <= 0) return null
35 const observedAtMs = st.observedAt * 1000
36 if (observedAtMs > nowMs || nowMs - observedAtMs >= FILE_MAX_AGE_MS) return null
37 const w = st.five_hour
38 if (w === null || w === undefined || w.used_percentage === null || w.used_percentage === undefined) {
39 // 窓がリセットを過ぎて落とされた: 空から始まっている
40 return { percent: 0, resetsAtMs: null, observedAtMs, source: 'file' }
41 }
42 if (typeof w.used_percentage !== 'number') return null
43 if (typeof w.resets_at !== 'number') return null // 使用率はあるのにリセット時刻が無い: 推測せず使わない
44 return { percent: Math.floor(w.used_percentage), resetsAtMs: w.resets_at * 1000, observedAtMs, source: 'file' }
45}
46
47export function isUsable(r: Reading, nowMs: number): boolean {
48 if (r.observedAtMs > nowMs) return false
49 const maxAge = r.source === 'live' ? LIVE_MAX_AGE_MS : FILE_MAX_AGE_MS
50 return nowMs - r.observedAtMs < maxAge
51}
52
53/** 使える観測のうち、観測時刻が新しい方。どちらも使えなければ null (= 判定できない)。 */
54export function pick(live: Reading | null, file: Reading | null, nowMs: number): Reading | null {
55 const candidates = [live, file].filter((r): r is Reading => r !== null && isUsable(r, nowMs))
56 if (candidates.length === 0) return null
57 return candidates.reduce((a, b) => (b.observedAtMs > a.observedAtMs ? b : a))
58}
59
60export type Verdict = { over: boolean; percent: number; resetsAtMs: number | null }
61
62export function judge(r: Reading, nowMs: number): Verdict {
63 if (r.resetsAtMs !== null && r.resetsAtMs <= nowMs) {
64 // 観測の後にリセットを過ぎた: 窓は空から始まっている
65 return { over: false, percent: 0, resetsAtMs: r.resetsAtMs }
66 }
67 return { over: r.percent >= THRESHOLD, percent: r.percent, resetsAtMs: r.resetsAtMs }
68}
69
70const p2 = (n: number) => String(n).padStart(2, '0')
71
72/** main.go の formatReset と同じ: 今日なら HH:MM、それ以外は M/D HH:MM (ローカル時刻) */
73export function formatReset(resetMs: number, nowMs: number): string {
74 const t = new Date(resetMs)
75 const now = new Date(nowMs)
76 const hm = `${p2(t.getHours())}:${p2(t.getMinutes())}`
77 const sameDay = t.getFullYear() === now.getFullYear() && t.getMonth() === now.getMonth() && t.getDate() === now.getDate()
78 return sameDay ? hm : `${t.getMonth() + 1}/${t.getDate()} ${hm}`
79}
80
81/** 注入する文。_claude/hooks/ratelimit-warn.sh の printf と同じ 3 行 (_claude/rules/subagent-model-tiering.md が名指しする文面) */
82export function message(v: Verdict, nowMs: number): string {
83 const reset = v.resetsAtMs === null ? '不明' : formatReset(v.resetsAtMs, nowMs)
84 return [
85 '🚨 Claude の 5h 枠が閾値を超えている:',
86 `claude 5h ${v.percent}% (${reset} にリセット)`,
87 '大きな作業に入る前に、控える・縮小する・リセット後に回す案をユーザーへ提案すること (基準は subagent-model-tiering.md の「枠の残量」)。',
88 ].join('\n')
89}
90
91export function statusText(v: Verdict): string | undefined {
92 return v.over ? `🚨 5h ${v.percent}%` : undefined
93}
94types/index.d.ts 14 lines1/** 5h 枠の 1 つの観測。resetsAtMs が null = 窓がリセットを過ぎて落とされた (0% で空から始まっている) */
2export type Reading = {
3 percent: number
4 resetsAtMs: number | null
5 observedAtMs: number
6 source: 'live' | 'file'
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'ratelimit-warn': { live: Reading | null }
12 }
13}
14