SLOPSHOPPER

ratelimit-warn

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

newstatustimer
★ 2v0.1.0no licenseupdated 2026-10-07jiikko/dotfiles/_claude/mods/ratelimit-warn
A shopper browsing a rack in a slop shop
README

dotfiles

対象は macOS のみ。Linux はサポート対象外です (2026-08-28 決定 / issue 133)。 Linux でも動きそうに見える箇所がありますが、意図的な対応ではありません (例: scripts/tmux_extract_popup.sh は pbcopy を無条件で呼び、zshlib/_fs_helpers.zsh の mount パースは macOS の出力形式のみを前提にしています)。CI も macOS runner で回します。

Installing

cd ~
git clone git@github.com:jiikko/dotfiles.git || git clone https://github.com/jiikko/dotfiles.git
cd dotfiles
./setup.sh

for Mac

設計文書・仕様・調査記録

docs/README.md が索引。触る前に読む制約 (glogx の bubbletea、テーマ色、tmux の セッション永続化)、glogx の画面の仕様、tmux 周りの仕組み、nvim の棚卸しがある。 自作ツールの使い方は src/README.md から各プロジェクトの README へ。

Git hooks

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 の冒頭にある

Testing

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)。

  • tmux のキーと表示 — prefix は C-t。ペイン・ウィンドウ操作、popup、ウィンドウ名、Claude Code の作業状態表示
  • 動画のシェル関数 — repair / av1ify (av1c) / concat
  • macOS 連携 — Karabiner-Elements / kernel-alloc-watch / Finder Quick Actions
  • Go で書いた自作ツール (glogx・pro-con など) — src/README.md から各プロジェクトの README へ
Source 3 files
hooks/register.ts 75 lines
1// 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}
75
hooks/limit.ts 94 lines
1// 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}
94
types/index.d.ts 14 lines
1/** 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