ターンが終わるたびに使用率などを記録し、7日枠の目安(ペース表)との差を入力欄の下に出す

Claude Code の利用枠(5 時間枠・7 日枠)の使用率を、ターンが終わるたびに記録する mod。自分で決めた週の目安と比べて、使いすぎか、まだ余裕があるかも分かります。
<img src="docs/screenshots/band.png" width="660" alt="入力欄の上の帯の右側。コンテキスト 39%、5時間枠 11%、7日枠 68% の 3 行が並び、7日枠の行には目安の位置を示す縦線と、赤字で「目安61%(土 0:00)超過7」が出ている">
Claude Code × Claude Opus 5.5(XHIGH) で作りました。
Claude Code の週の利用枠を、気づいたら木曜で使い切っていた、ということを避けたくて作りました。ターンが終わるたびに、その時刻と 5 時間枠・7 日枠の使用率を 1 行ずつファイルに残し、あとから「何曜日の時点で、平均どれくらい使っているか」を集計できます。土日を厚めにするなど、自分で決めた週の配分(目安)を設定すると、今の使用率との差を入力欄の上の帯(別の mod clawd-dance)に出せます。
記録先は ~/.claude/usage-log/turns.jsonl です。本体のターンが終わるたびに 1 行(JSON)を書き足します。同時に開いている複数のセッションが、同じファイルに書き足します。
| 項目 | 中身 |
|---|---|
| 時刻 | ts(UTC)、local(時差つきのローカル時刻)、dow(曜日、0 = 日曜)、hour |
| 利用枠 | limits.five_hour と limits.seven_day:ターンの終わりの使用率 pct、始まりの使用率 startPct、リセットの時刻 resetsAt |
| どこで・何で | session(セッション ID)、turn(そのセッションで何回目の発言か)、cwd(作業フォルダ)、model、effort(推論強度)、surfaces(デスクトップかターミナルか)、cc(Claude Code の版)、mod(この mod の版) |
| どれだけ | durationMs(ターンの長さ)、reason(正常終了・中断・エラー)、context(コンテキストの使用量)、costUsd・turnCostUsd(API 料金に換算した額。セッションの累計と、そのターンの分)、tokens(本体のトークン数)、subTokens(サブエージェントの分) |
| 何をしたか | steps(API 呼び出しの回数)、tools・toolCounts(ツールの使用回数と内訳)、subagents(サブエージェントの数)、promptChars・answerChars(発言と返答の文字数) |
{
"v": 1,
"ts": "2026-10-02T14:26:45.472Z",
"local": "2026-10-02T23:26:45+09:00",
"dow": 5,
"hour": 23,
"session": "(セッション ID)",
"turn": 9,
"cwd": "/path/to/my-app",
"model": "claude-opus-5-5",
"effort": "xhigh",
"surfaces": ["desktop"],
"cc": "2.1.286",
"mod": "0.3.0",
"reason": "answer",
"durationMs": 63732,
"limits": {
"five_hour": { "pct": 12, "startPct": 12, "resetsAt": "2026-10-02T18:20:00.000Z" },
"seven_day": { "pct": 68, "startPct": 68, "resetsAt": "2026-10-05T11:00:00.000Z" }
},
"context": { "tokens": 407366, "window": 1000000, "pct": 41 },
"costUsd": 14.4573,
"turnCostUsd": 0.7459,
"tokens": { "input": 12, "output": 5594, "cacheRead": 2405540, "cacheWrite": 19107 },
"subTokens": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
"steps": 6,
"subSteps": 0,
"tools": 7,
"subTools": 0,
"toolCounts": { "Bash": 5, "ToolSearch": 1, "mcp__ccd_session_mgmt__get_session": 1 },
"subagents": 0,
"promptChars": 149,
"answerChars": 1110
}
7 日枠について、「各曜日の 0:00 の時点で、累計で何 % まで使ってよいか」を目安として決めておきます。既定は次の表で、土日を厚めにした配分です(/config で変えられます)。
| 時点 | 火 0:00 | 水 0:00 | 木 0:00 | 金 0:00 | 土 0:00 | 日 0:00 | 月 0:00 |
|---|---|---|---|---|---|---|---|
| 累計の目安 | 12% | 25% | 37% | 49% | 61% | 80% | 98% |
~/.claude/usage-log/pace.json に書き出します。clawd-dance(v0.12.0 以降)を一緒に入れていると、帯の 7 日枠の行に目安の位置(┃)と差が出ます。超過は赤、余裕は緑です(上の画像)。/usage-log と打つと、今週のグラフ(目安と実際の使用率の推移)と、曜日ごとの「配分・目安・実際・使った量・差」の表をパネルで開きます。記録先の場所と行数、直近の値も表示します。<img src="docs/screenshots/pane.png" width="560" alt="「週間ペース」のパネル。上に「7日枠 68% 目安は 土 0:00 までに 61% → 7% 超過」、その下に月曜 20:00 から次の月曜 20:00 までのグラフ(灰色の点線が目安、青が実際、赤い点が今)、曜日ごとの配分・目安(累計)・実際(累計)・使った・差の表が並ぶ"><br> <sub>/usage-log で開くパネル。灰色の点線が目安、青が実際の使用率、赤い点が今です。青の細い点線は記録を始める前の区間で、実際の推移は分かりません(この画像は記録を始めた日のもの)。</sub>
画像は、実際のアプリの画面を切り出したものです。
{
"v": 1,
"sevenDay": { "pct": 68, "resetsAt": "2026-10-05T11:00:00.000Z" },
"fiveHour": { "pct": 13, "resetsAt": "2026-10-02T18:20:00.000Z" },
"windowEnd": 1791198000000,
"checkpoints": [
{ "at": 1790593200000, "pct": 0, "label": "月 20:00" },
{ "at": 1790607600000, "pct": 12, "label": "火 0:00" },
{ "at": 1791198000000, "pct": 100, "label": "月 20:00" }
],
"updatedAt": "2026-10-02T14:32:06.779Z"
}
sevenDay と fiveHour は、どのセッションのものも含めていちばん新しい値です。checkpoints は今の 7 日枠の目安の時点(時刻はミリ秒、上の例は途中を省略)。値が変わったときだけ書き直します。
リポジトリの analyze.py(Python 3 の標準ライブラリだけ)で、記録を表にします。
python3 analyze.py
記録ファイルの場所を変えたときはパスを渡します。目安表は --pace "火=12, 水=25, 木=37, 金=49, 土=61, 日=80, 月=98" のように渡せます(省略すると上の既定の表)。
リセットのチケットなどで枠の途中に使用率が戻ったとき(同じ枠の中で 5 ポイント以上下がったとき、または次の枠が前倒しで始まったとき)は、途中のリセットとみなします。その枠のリセット後の時点は「各曜日 0:00 の累計」の平均に入れず、表の下に注記します。増分はリセット後 0 から数え直します。記録ファイル自体は書き換えません。
出力の例です。記録がまだ少ないため、3 週間分のダミーデータで実行しました。
■ 7日枠:各曜日 0:00 の時点の累計(平均)と目安
枠の切り替わり: 毎週月曜 20:00(記録にある枠 4 個)
時点 平均 % 枠の数 今の枠 % 目安 % 平均−目安
-------------------- ------ ------ -------- ------ ---------
火 0:00 3.2 4 3.0 12 -8.8
水 0:00 16.1 4 17.6 25 -8.9
木 0:00 29.6 4 29.5 37 -7.4
金 0:00 42.5 4 41.8 49 -6.5
土 0:00 56.1 3 - 61 -4.9
日 0:00 75.2 3 - 80 -4.8
月 0:00 97.4 3 - 98 -0.6
月 20:00(リセット) 100.0 3 - 100 +0.0
■ 曜日ごとの消費
曜日 1日に使った7日枠 %(平均) 日数
---- -------------------------- ----
日 22.2 3
月 5.1 4
火 13.0 4
水 13.5 4
木 12.8 4
金 12.4 4
土 19.4 3
このあとに、5 時間枠ごとの最高値、時間帯ごと・モデルと推論強度ごと・作業フォルダごとの消費が続きます。
limits が空のまま記録されます。動作環境:Claude Code の mod の仕組み(function hooks)が入った版。公式の要件は v2.1.287 以上です。作者は macOS 版のデスクトップアプリ 2.19675.0(中に入っている Claude Code は 2.1.286)で確かめました。ターミナル版の Claude Code でも記録はできるつくりですが、作者は試していません。
ターミナルで次の 2 行を実行します。
claude plugin marketplace add tanuu5/usage-log
claude plugin install usage-log@usage-log
入れたあと、新しいセッションから記録が始まります。外すときは /plugin の画面で無効にするか、claude plugin uninstall usage-log@usage-log を実行します。記録ファイルは外しても残るので、要らなければ ~/.claude/usage-log/ を消してください。
/config の一覧に、次の 2 つが並びます。
| 項目 | 既定 | 内容 |
|---|---|---|
| 記録ファイルの場所 | ~/.claude/usage-log/turns.jsonl | ~ はホームフォルダ。pace.json はこのファイルと同じフォルダに書きます |
| 7日枠の目安(各曜日 0:00 の累計 %) | 火=12, 水=25, 木=37, 金=49, 土=61, 日=80, 月=98 | 曜日=累計% をカンマで区切って並べます。読めない書き方のときは既定の表を使います |
記録ファイルの場所を変えたときは、pace.json の場所も変わります。clawd-dance は ~/.claude/usage-log/pace.json だけを読むので、帯に目安を出したいときは既定の場所のままにしてください。
mod は Claude Code の中で、あなたの権限のまま動きます。入れる前に中身を確かめてください。
session.start(コマンドの登録、1 分ごとの確認の開始)、command.run(/usage-log)、turn.start・turn.step・tool.call・turn.complete(ターンの始まりの値、API 呼び出し・ツールの回数、モデルと推論強度を数え、終わりに 1 行書く)、session.measure(利用枠が動いたことを知る)、ui.close・ui.render(このパネルだけ)turn.step と tool.call は数えるだけです。モデルへの依頼やツールの許可・入力は変えません。/bin/date +%z(時差を知る)、/bin/sh -c 'mkdir -p … && cat >> …'(記録ファイルへの書き足し)、tail・wc(記録ファイルの末尾と行数を読む)HOME を読むだけです。企画・ディレクション:たぬ / 開発:Claude Code(Claude Opus 5.5・推論レベル XHIGH)
「毎セッション、対話が終わったら、その時刻と週間制限・時間制限の何 % かを記録したい。n 曜日の時点で平均どれくらい使うのかを知りたい」という一言から作りました。記録の仕組みはすぐに動いたので、次に土日を厚めにした目安表を足し、今の使用率との差を見えるようにしました。最初は入力欄の下、dev-dashboard のボタンの横に小さなチップとして置きました。しかし、その欄は収まらない分を「…」で隠してしまい、ボタンと並ぶとどちらかが見えなくなりました。そこで、すでに使用率を出していた clawd-dance の帯に、ファイル(pace.json)経由で目安を渡す形にしています。記録の分析(analyze.py)も、目安表と同じ「各曜日 0:00 の時点」で並べて、目安と見比べられるようにしました。
.claude-plugin/marketplace.json マーケットプレイスの定義
plugins/usage-log/
.claude-plugin/plugin.json mod の定義(/config の 2 項目)
hooks/hooks.json 読み込むモジュール
hooks/register.tsx 本体(記録、pace.json、パネル)
hooks/pace.ts 目安の計算とグラフの SVG($ を使わない関数)
tests/pace.test.tsx テスト(claude plugin test)
analyze.py 記録の集計
手元の版を直接読み込むには、claude --plugin-dir ./plugins/usage-log で起動します。起動フラグを渡せないデスクトップアプリでは、~/.claude/settings.json の env に CLAUDE_CODE_PLUGIN_DIRS として、このフォルダの絶対パスを書きます(複数の mod を並べるときは : で区切ります)。
claude plugin validate ./plugins/usage-log
claude plugin test ./plugins/usage-log
型を確かめるには、mod を一度読み込ませて型定義(.claude-plugin/types/)を書き出させてから、npx -p typescript tsc -p plugins/usage-log/tsconfig.json を実行します。
hooks/register.tsx 562 lines1import type { EngineInterface, ModelUsage, PluginOptions, Register, TurnCompleteInput } from 'claude-code'
2
3import {
4 DEFAULT_PACE_TABLE,
5 DOW,
6 chartSvg,
7 checkpointLabel,
8 computePace,
9 daySegments,
10 fmt,
11 formatPaceTable,
12 localDow,
13 parsePaceTable,
14 untilText,
15} from './pace'
16import type { PaceEntry, PaceView, Point } from './pace'
17
18// 本体(メインループ)のターンが終わるたびに、そのときの利用枠(5時間枠・7日枠の使用率)などを
19// JSON Lines で 1 行ずつ追記する。1 行 = 本体の 1 ターン。
20// サブエージェントのステップ・ツール・トークンは、次に終わる本体のターンの行にまとめる。
21// 書き込みは `cat >>` の追記なので、同時に動いている複数のセッションが同じファイルに書き足せる。
22//
23// 7日枠の使用率と目安(ペース表)との差は、記録ファイルの隣の pace.json に書き出し、
24// 入力欄の上の帯(clawd-dance)が読んで出す。/usage-log で今週のグラフと曜日ごとの表のパネルを開く。
25// (入力欄の下の欄にチップを置く案は、幅が狭くダッシュボードのボタンと共存できなかったのでやめた)
26
27const SCHEMA_VERSION = 1
28const DEFAULT_LOG_FILE = '~/.claude/usage-log/turns.jsonl'
29const PANE = 'usage-pace'
30// パネルのために読む記録の行数(1 週間分より十分多く、4 MiB に収まる量)
31const TAIL_LINES = 3000
32const WEEK_MS = 7 * 24 * 60 * 60 * 1000
33
34type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
35
36// 前の行を書いてから今までに数えたもの
37type Tally = {
38 startedAt: number | undefined
39 limitsAtStart: Record<string, number>
40 costAtStart: number | undefined
41 promptChars: number | undefined
42 steps: number
43 subSteps: number
44 tools: number
45 subTools: number
46 toolCounts: Record<string, number>
47 subagents: string[]
48 subTokens: Tokens
49 model: string | undefined
50 effort: string | number | undefined
51}
52
53type Reading = { pct: number; resetsAt: string }
54
55type Recorder = {
56 logFileSetting: string
57 logFile: string | undefined
58 tzOffset: string | undefined
59 tally: Tally
60 paceTable: PaceEntry[]
61 paceTableError: string | undefined
62 isPaneOpen: boolean
63 // パネル用:記録ファイルから読んだ 7日枠の値(古い順)
64 history: (Point & { resetsAt: number })[]
65 // 最後に書いた pace.json の中身(同じなら書き直さない)
66 publishedPace: string | undefined
67 // 記録ファイルの最後の枠の値(どのセッションが書いたものでも)
68 logLatest: { at: number; limits: Record<string, Reading> } | undefined
69 // このセッションが最後に枠の値を受け取った時刻
70 ownAt: number | undefined
71 // 読み込まれている mod の版(plugin.json から)。記録の行と /usage-log に出す
72 modVersion: string | undefined
73}
74
75const zeroTokens = (): Tokens => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
76
77function freshTally(): Tally {
78 return {
79 startedAt: undefined,
80 limitsAtStart: {},
81 costAtStart: undefined,
82 promptChars: undefined,
83 steps: 0,
84 subSteps: 0,
85 tools: 0,
86 subTools: 0,
87 toolCounts: {},
88 subagents: [],
89 subTokens: zeroTokens(),
90 model: undefined,
91 effort: undefined,
92 }
93}
94
95function toTokens(usage: ModelUsage | undefined): Tokens {
96 if (usage === undefined) return zeroTokens()
97 return {
98 input: usage.input_tokens,
99 output: usage.output_tokens,
100 cacheRead: usage.cache_read_input_tokens,
101 cacheWrite: usage.cache_creation_input_tokens,
102 }
103}
104
105function addTokens(into: Tokens, usage: ModelUsage | undefined) {
106 const t = toTokens(usage)
107 into.input += t.input
108 into.output += t.output
109 into.cacheRead += t.cacheRead
110 into.cacheWrite += t.cacheWrite
111}
112
113function readLogFileSetting(options: PluginOptions): string {
114 const value = options.log_file
115 return typeof value === 'string' && value.trim() !== '' ? value.trim() : DEFAULT_LOG_FILE
116}
117
118async function resolveLogFile($: EngineInterface, rec: Recorder): Promise<string> {
119 if (rec.logFile !== undefined) return rec.logFile
120 let path = rec.logFileSetting
121 if (path === '~' || path.startsWith('~/')) {
122 const home = await $.env.get('HOME')
123 if (home === undefined) throw new Error('HOME が分からないため ~ を展開できない')
124 path = home + path.slice(1)
125 }
126 rec.logFile = path
127 return path
128}
129
130// サンドボックスの Date は時差を知らないので、`date +%z`(+0900)で一度だけ聞く
131async function resolveTzOffset($: EngineInterface, rec: Recorder): Promise<string> {
132 if (rec.tzOffset !== undefined) return rec.tzOffset
133 const { stdout } = await $.process.run(['/bin/date', '+%z'])
134 const m = /^([+-])(\d\d)(\d\d)$/.exec(stdout.trim())
135 rec.tzOffset = m ? `${m[1]}${m[2]}:${m[3]}` : '+00:00'
136 return rec.tzOffset
137}
138
139function offsetMinutes(offset: string): number {
140 const sign = offset.startsWith('-') ? -1 : 1
141 const [h = '0', m = '0'] = offset.slice(1).split(':')
142 return sign * (Number(h) * 60 + Number(m))
143}
144
145async function resolveModVersion($: EngineInterface, rec: Recorder): Promise<string> {
146 if (rec.modVersion !== undefined) return rec.modVersion
147 try {
148 const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: string }
149 rec.modVersion = manifest.version ?? '?'
150 } catch {
151 rec.modVersion = '?'
152 }
153 return rec.modVersion
154}
155
156async function snapshotStart($: EngineInterface, rec: Recorder, promptChars: number) {
157 const [now, usage] = await Promise.all([$.clock.now(), $.session.usage()])
158 rec.tally.startedAt = now
159 rec.tally.promptChars = promptChars
160 rec.tally.costAtStart = usage.cost?.usd
161 rec.tally.limitsAtStart = Object.fromEntries(usage.rateLimits.map(l => [l.kind, l.percentUsed]))
162 // 読み込み直しや再開の直後は、このセッションがまだ枠の値を持たない。記録ファイルの最後の値で補う
163 if (rec.tally.limitsAtStart.seven_day === undefined) {
164 const { seven, five } = await freshestReadings($, rec)
165 if (seven !== undefined) rec.tally.limitsAtStart.seven_day = seven.pct
166 if (five !== undefined && rec.tally.limitsAtStart.five_hour === undefined) rec.tally.limitsAtStart.five_hour = five.pct
167 }
168}
169
170async function writeRecord($: EngineInterface, rec: Recorder, e: TurnCompleteInput) {
171 const [now, usage, sessionId, turns, cwd, surfaces, version, sessionModel, tz, logFile, modVersion] = await Promise.all([
172 $.clock.now(),
173 $.session.usage(),
174 $.session.id(),
175 $.session.turns(),
176 $.session.cwd(),
177 $.session.surfaces(),
178 $.session.version(),
179 $.session.model(),
180 resolveTzOffset($, rec),
181 resolveLogFile($, rec),
182 resolveModVersion($, rec),
183 ])
184 const t = rec.tally
185
186 const local = new Date(now + offsetMinutes(tz) * 60_000)
187 const limits: Record<string, { pct: number; startPct: number | null; resetsAt: string | null }> = {}
188 for (const l of usage.rateLimits) {
189 limits[l.kind] = { pct: l.percentUsed, startPct: t.limitsAtStart[l.kind] ?? null, resetsAt: l.resetsAt ?? null }
190 }
191 const costUsd = usage.cost?.usd
192 const round4 = (n: number) => Math.round(n * 10_000) / 10_000
193
194 const row = {
195 v: SCHEMA_VERSION,
196 ts: new Date(now).toISOString(),
197 local: local.toISOString().replace(/\.\d+Z$/, tz),
198 dow: local.getUTCDay(), // 0 = 日曜
199 hour: local.getUTCHours(),
200 session: sessionId,
201 turn: turns,
202 cwd,
203 model: e.usage?.model ?? t.model ?? sessionModel,
204 effort: t.effort ?? null,
205 surfaces,
206 cc: version.version,
207 mod: modVersion,
208 reason: e.reason,
209 durationMs: e.durationMs,
210 limits,
211 context: { tokens: usage.context.tokens ?? null, window: usage.context.window, pct: usage.context.percent ?? null },
212 costUsd: costUsd === undefined ? null : round4(costUsd),
213 turnCostUsd: costUsd === undefined || t.costAtStart === undefined ? null : round4(costUsd - t.costAtStart),
214 tokens: toTokens(e.usage),
215 subTokens: t.subTokens,
216 steps: t.steps,
217 subSteps: t.subSteps,
218 tools: t.tools,
219 subTools: t.subTools,
220 toolCounts: t.toolCounts,
221 subagents: t.subagents.length,
222 promptChars: t.promptChars ?? null,
223 answerChars: e.answer.length,
224 }
225
226 const { exitCode, stderr } = await $.process.run(
227 ['/bin/sh', '-c', 'mkdir -p "$(dirname "$1")" && cat >> "$1"', 'sh', logFile],
228 { stdin: JSON.stringify(row) + '\n' },
229 )
230 if (exitCode !== 0) throw new Error(stderr.trim() || `exit ${exitCode}`)
231}
232
233async function showSummary($: EngineInterface, rec: Recorder): Promise<string> {
234 const logFile = await resolveLogFile($, rec)
235 const loaded = `usage-log ${await resolveModVersion($, rec)}(${$.plugin.root})`
236 if (!(await $.fs.exists(logFile))) return `${loaded}\n記録先: ${logFile}\nまだ記録がありません(ターンが終わると 1 行目が書かれます)。`
237 const [count, last] = await Promise.all([
238 $.process.run(['/usr/bin/wc', '-l', logFile]),
239 $.process.run(['/usr/bin/tail', '-n', '1', logFile]),
240 ])
241 const lines = [loaded, `記録先: ${logFile}`, `記録数: ${count.stdout.trim().split(/\s+/)[0] ?? '?'} 行`]
242 try {
243 const row = JSON.parse(last.stdout) as {
244 local?: string
245 limits?: Record<string, { pct: number; resetsAt: string | null }>
246 }
247 const five = row.limits?.five_hour?.pct
248 const seven = row.limits?.seven_day?.pct
249 lines.push(
250 `直近: ${row.local ?? '?'} 5時間枠 ${five ?? '-'}% 7日枠 ${seven ?? '-'}%`,
251 )
252 } catch {
253 lines.push('直近の行を読めませんでした。')
254 }
255 return lines.join('\n')
256}
257
258// ---- 目安(ペース表)との差 ----
259
260function readPaceSetting(options: PluginOptions): { table: PaceEntry[]; error: string | undefined } {
261 const text = typeof options.pace_table === 'string' && options.pace_table.trim() !== '' ? options.pace_table : DEFAULT_PACE_TABLE
262 const table = parsePaceTable(text)
263 if (table) return { table, error: undefined }
264 return {
265 table: parsePaceTable(DEFAULT_PACE_TABLE) ?? [],
266 error: `目安表「${text}」を読めなかったので、既定の表(${DEFAULT_PACE_TABLE})を使っています。`,
267 }
268}
269
270// 記録ファイルの末尾から、最後に記録された枠の値(5時間枠・7日枠)を読む。
271// 枠の値は API の応答と一緒に届くので、新しいセッションは最初の応答まで値を持たない。
272// その間や、別のセッションのほうが新しい値を受け取っているときは、こちらを使う。
273async function refreshLogLatest($: EngineInterface, rec: Recorder) {
274 const logFile = await resolveLogFile($, rec)
275 if (!(await $.fs.exists(logFile))) return
276 const { stdout } = await $.process.run(['/usr/bin/tail', '-n', '20', logFile])
277 for (const line of stdout.split('\n').reverse()) {
278 try {
279 const row = JSON.parse(line) as { ts?: string; limits?: Record<string, { pct: number; resetsAt: string | null }> }
280 const at = Date.parse(row.ts ?? '')
281 if (!row.limits?.seven_day?.resetsAt || !Number.isFinite(at)) continue
282 const limits: Record<string, Reading> = {}
283 for (const [kind, l] of Object.entries(row.limits)) if (l.resetsAt) limits[kind] = { pct: l.pct, resetsAt: l.resetsAt }
284 rec.logLatest = { at, limits }
285 return
286 } catch {
287 // 空行や途中で切れた行は飛ばす
288 }
289 }
290}
291
292// このセッションの値と記録ファイルの値のうち、新しいほう。リセットを越えていたら新しい枠として扱う
293async function freshestReadings($: EngineInterface, rec: Recorder): Promise<{ now: number; seven?: Reading; five?: Reading }> {
294 const [usage, now] = await Promise.all([$.session.usage(), $.clock.now()])
295 const own: Record<string, Reading> = {}
296 for (const l of usage.rateLimits) if (l.resetsAt) own[l.kind] = { pct: l.percentUsed, resetsAt: l.resetsAt }
297 if (own.seven_day === undefined && rec.logLatest === undefined) await refreshLogLatest($, rec).catch(() => undefined)
298 const logged = rec.logLatest
299 const useOwn = own.seven_day !== undefined && (logged === undefined || (rec.ownAt ?? 0) >= logged.at)
300 const source = useOwn ? own : (logged?.limits ?? {})
301 let seven = source.seven_day
302 if (seven !== undefined) {
303 let end = Date.parse(seven.resetsAt)
304 if (Number.isFinite(end) && end <= now) {
305 while (end <= now) end += WEEK_MS
306 seven = { pct: 0, resetsAt: new Date(end).toISOString() }
307 }
308 }
309 // 5時間枠は使い始めた時点から数えるので、切れた後の枠の時刻は分からない。切れていたら出さない
310 const five = source.five_hour !== undefined && Date.parse(source.five_hour.resetsAt) > now ? source.five_hour : undefined
311 return { now, seven, five }
312}
313
314async function currentPace($: EngineInterface, rec: Recorder): Promise<{ view: PaceView; now: number; offMin: number } | undefined> {
315 const [{ now, seven }, tz] = await Promise.all([freshestReadings($, rec), resolveTzOffset($, rec)])
316 if (seven === undefined) return undefined
317 const offMin = offsetMinutes(tz)
318 const view = computePace(seven.pct, seven.resetsAt, now, offMin, rec.paceTable)
319 return view === undefined ? undefined : { view, now, offMin }
320}
321
322// 帯(clawd-dance)など、ほかの mod が目安を出せるよう、記録ファイルの隣に pace.json を書く。
323// 中身:今の 5時間枠・7日枠の値と、今の 7日枠の目安の時点(時刻・累計 %・ラベル)
324async function publishPace($: EngineInterface, rec: Recorder) {
325 const [{ now, seven, five }, tz, logFile] = await Promise.all([
326 freshestReadings($, rec),
327 resolveTzOffset($, rec),
328 resolveLogFile($, rec),
329 ])
330 if (seven === undefined) return
331 const offMin = offsetMinutes(tz)
332 const view = computePace(seven.pct, seven.resetsAt, now, offMin, rec.paceTable)
333 if (view === undefined) return
334 const body = {
335 v: 1,
336 sevenDay: seven,
337 fiveHour: five ?? null,
338 windowEnd: view.windowEnd,
339 checkpoints: view.checkpoints.map(c => ({ at: c.at, pct: c.pct, label: checkpointLabel(c.at, offMin) })),
340 }
341 const signature = JSON.stringify(body)
342 if (signature === rec.publishedPace) return
343 const path = `${logFile.slice(0, logFile.lastIndexOf('/'))}/pace.json`
344 await $.fs.write(path, JSON.stringify({ ...body, updatedAt: new Date(now).toISOString() }, null, 2) + '\n')
345 rec.publishedPace = signature
346}
347
348// 値が動いた・時間がたった:pace.json を書き直し、パネルが開いていれば描き直す
349async function refreshOutputs($: EngineInterface, rec: Recorder) {
350 await publishPace($, rec).catch(err => $.ui.log(`usage-log: pace.json を書けませんでした(${err instanceof Error ? err.message : String(err)})`))
351 if (rec.isPaneOpen) $.ui.invalidate('ui.render')
352}
353
354// 1 分ごと:別のセッションが新しい値を記録していないか見る。0:00 を越えると目安も変わる
355async function tick($: EngineInterface, rec: Recorder) {
356 await refreshLogLatest($, rec).catch(() => undefined)
357 await refreshOutputs($, rec)
358}
359
360// パネル用に、記録ファイルの末尾から 7日枠の値を読む
361async function refreshHistory($: EngineInterface, rec: Recorder) {
362 const logFile = await resolveLogFile($, rec)
363 if (!(await $.fs.exists(logFile))) {
364 rec.history = []
365 return
366 }
367 const { stdout } = await $.process.run(['/usr/bin/tail', '-n', String(TAIL_LINES), logFile])
368 const history: Recorder['history'] = []
369 for (const line of stdout.split('\n')) {
370 if (line.trim() === '') continue
371 try {
372 const row = JSON.parse(line) as { ts?: string; limits?: Record<string, { pct: number; resetsAt: string | null }> }
373 const weekly = row.limits?.seven_day
374 const at = Date.parse(row.ts ?? '')
375 const resetsAt = Date.parse(weekly?.resetsAt ?? '')
376 if (weekly && Number.isFinite(at) && Number.isFinite(resetsAt)) history.push({ at, pct: weekly.pct, resetsAt })
377 } catch {
378 // 途中で切れた行などは飛ばす
379 }
380 }
381 history.sort((a, b) => a.at - b.at)
382 rec.history = history
383}
384
385async function openPane($: EngineInterface, rec: Recorder) {
386 rec.isPaneOpen = true
387 await refreshHistory($, rec).catch(() => undefined)
388 await $.ui.open({ id: PANE, title: '週間ペース' })
389 $.ui.invalidate('ui.render')
390}
391
392async function refreshPane($: EngineInterface, rec: Recorder) {
393 await refreshHistory($, rec).catch(() => undefined)
394 $.ui.invalidate('ui.render')
395}
396
397function summaryMarkdown(view: PaceView, now: number, offMin: number): string {
398 const until = checkpointLabel(view.next.at, offMin)
399 const verdict = view.margin < 0 ? `**${fmt(-view.margin)}% 超過**` : `**あと ${fmt(view.margin)}%** 使える`
400 return [
401 `**7日枠 ${fmt(view.current)}%** 目安は ${until} までに ${fmt(view.next.pct)}% → ${verdict}`,
402 '',
403 `今の区間(${DOW[localDow(now, offMin)]})の配分 ${fmt(view.allowance)}%・リセットは ${checkpointLabel(view.windowEnd, offMin)}(あと ${untilText(view.windowEnd - now)})`,
404 ].join('\n')
405}
406
407function tableMarkdown(view: PaceView, points: Point[], now: number, offMin: number): string {
408 const lines = ['| 日 | 配分 | 目安(累計) | 実際(累計) | 使った | 差 |', '|---|---:|---:|---:|---:|---:|']
409 for (const seg of daySegments(view, points, now, offMin)) {
410 const used = seg.startValue !== undefined && seg.endValue !== undefined ? seg.endValue - seg.startValue : undefined
411 let diff = '-'
412 if (seg.isNow && seg.endValue !== undefined) {
413 const left = seg.target - seg.endValue
414 diff = left < 0 ? `${fmt(-left)} 超過` : `あと ${fmt(left)}`
415 } else if (used !== undefined) {
416 const left = seg.allowance - used
417 diff = left < 0 ? `${fmt(-left)} 超過` : `${fmt(left)} 余り`
418 }
419 const label = seg.isNow ? `**${seg.label}(今)**` : seg.label
420 lines.push(
421 `| ${label} | ${fmt(seg.allowance)} | ${fmt(seg.target)} | ${seg.endValue === undefined ? '-' : fmt(seg.endValue)} | ${used === undefined ? '-' : fmt(used)} | ${diff} |`,
422 )
423 }
424 return lines.join('\n')
425}
426
427export const register: Register = (on, options) => {
428 const pace = readPaceSetting(options)
429 const rec: Recorder = {
430 logFileSetting: readLogFileSetting(options),
431 logFile: undefined,
432 tzOffset: undefined,
433 tally: freshTally(),
434 paceTable: pace.table,
435 paceTableError: pace.error,
436 isPaneOpen: false,
437 history: [],
438 publishedPace: undefined,
439 logLatest: undefined,
440 ownAt: undefined,
441 modVersion: undefined,
442 }
443
444 on('session.start', async ($, e, next) => {
445 await $.command.register({
446 name: 'usage-log',
447 description: '7日枠の週間ペースのパネルを開き、記録ファイルの場所・行数・直近の値を表示する',
448 })
449 await resolveTzOffset($, rec).catch(() => undefined)
450 if (rec.paceTableError !== undefined) $.ui.log(`usage-log: ${rec.paceTableError}`)
451 await refreshLogLatest($, rec).catch(() => undefined)
452 await refreshOutputs($, rec)
453 $.clock.every(60_000, () => void tick($, rec))
454 return next(e)
455 })
456
457 on('command.run', { command: 'usage-log' }, async $ => {
458 await refreshLogLatest($, rec).catch(() => undefined)
459 await refreshOutputs($, rec)
460 await openPane($, rec)
461 return { text: await showSummary($, rec) }
462 })
463
464 // turn.start は本体のターンでだけ来る(サブエージェントの実行は turn.start を出さない)
465 on('turn.start', async ($, e, next) => {
466 try {
467 await snapshotStart($, rec, e.text.length)
468 } catch {
469 // 記録の失敗でターンを止めない
470 }
471 return next(e)
472 })
473
474 on('turn.step', async function* ($, e, next) {
475 if (e.agentId === undefined) {
476 rec.tally.steps += 1
477 rec.tally.model = e.model
478 rec.tally.effort = e.effort
479 } else {
480 rec.tally.subSteps += 1
481 }
482 return yield* next(e)
483 })
484
485 on('tool.call', ($, e, next) => {
486 if (e.agentId === undefined) rec.tally.tools += 1
487 else rec.tally.subTools += 1
488 rec.tally.toolCounts[e.tool] = (rec.tally.toolCounts[e.tool] ?? 0) + 1
489 return next(e)
490 })
491
492 on('turn.complete', async ($, e, next) => {
493 const result = await next(e)
494 if (e.agentId !== undefined) {
495 addTokens(rec.tally.subTokens, e.usage)
496 if (!rec.tally.subagents.includes(e.agentId)) rec.tally.subagents.push(e.agentId)
497 return result
498 }
499 try {
500 await writeRecord($, rec, e)
501 } catch (err) {
502 $.ui.log(`usage-log: 記録に失敗しました(${err instanceof Error ? err.message : String(err)})`)
503 }
504 rec.tally = freshTally()
505 rec.ownAt = await $.clock.now()
506 await refreshLogLatest($, rec).catch(() => undefined)
507 if (rec.isPaneOpen) await refreshHistory($, rec).catch(() => undefined)
508 await refreshOutputs($, rec)
509 return result
510 })
511
512 // 利用枠が 1 ポイント動いたとき(別のセッションや claude.ai の分も含む)
513 on('session.measure', async ($, e, next) => {
514 if (e.changed.includes('rateLimits')) {
515 rec.ownAt = await $.clock.now()
516 await refreshOutputs($, rec)
517 }
518 return next(e)
519 })
520
521 on('ui.close', async ($, e, next) => {
522 const closed = await next(e)
523 if (e.id === PANE) rec.isPaneOpen = false
524 return closed
525 })
526
527 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
528 const pace = await currentPace($, rec).catch(() => undefined)
529 const { view, now, offMin } = pace ?? { view: undefined, now: 0, offMin: 0 }
530 const points = view === undefined ? [] : rec.history.filter(p => Math.abs(p.resetsAt - view.windowEnd) < 3_600_000)
531 const footer = `目安表:${formatPaceTable(rec.paceTable)}(/config で変更)`
532 const onRefresh = () => void refreshPane($, rec)
533 if (e.surface === 'desktop') {
534 const { Box, Text, Button, Svg, Markdown } = $.ui.resolve(e)
535 if (view === undefined) return <Text dimColor>7日枠の値がまだ届いていません(最初の応答のあとに出ます)。</Text>
536 return (
537 <Box flexDirection="column">
538 <Markdown text={summaryMarkdown(view, now, offMin)} />
539 <Svg source={chartSvg(view, points, now, offMin)} alt="今週の 7日枠の使用率(実線)と目安(点線)" isInteractive />
540 <Markdown text={tableMarkdown(view, points, now, offMin)} />
541 <Box flexDirection="row" justifyContent="space-between">
542 <Text dimColor>{footer}</Text>
543 <Button label="更新" hotkey="r" onPress={onRefresh} />
544 </Box>
545 </Box>
546 )
547 }
548 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
549 if (view === undefined) return <Text dimColor>7日枠の値がまだ届いていません(最初の応答のあとに出ます)。</Text>
550 return (
551 <Box flexDirection="column">
552 <Markdown text={summaryMarkdown(view, now, offMin)} />
553 <Markdown text={tableMarkdown(view, points, now, offMin)} />
554 <Box flexDirection="row" justifyContent="space-between">
555 <Text dimColor>{footer}</Text>
556 <Button label="更新" hotkey="r" onPress={onRefresh} />
557 </Box>
558 </Box>
559 )
560 })
561}
562hooks/pace.ts 217 lines1// 7日枠の「目安」(ペース表)の計算と、その絵(SVG)。$ を使わない関数だけを置く。
2//
3// 目安表は「各曜日の 0:00 の時点で、累計で何 % まで使ってよいか」。
4// 枠の始まり(リセット)は 0%、終わり(次のリセット)は 100% として、その間に表の時点を並べる。
5// 時刻はすべて UTC のミリ秒で持ち、曜日や 0:00 はローカルの時差(分)を足して求める。
6
7export const DOW = ['日', '月', '火', '水', '木', '金', '土'] as const
8export const DEFAULT_PACE_TABLE = '火=12, 水=25, 木=37, 金=49, 土=61, 日=80, 月=98'
9
10const MINUTE = 60_000
11const HOUR = 60 * MINUTE
12const DAY = 24 * HOUR
13const WEEK = 7 * DAY
14
15export type PaceEntry = { dow: number; pct: number }
16export type Checkpoint = { at: number; pct: number }
17export type Point = { at: number; pct: number }
18
19export type PaceView = {
20 current: number
21 windowStart: number
22 windowEnd: number
23 checkpoints: Checkpoint[]
24 prev: Checkpoint
25 next: Checkpoint
26 // 今の区間(たいてい今日)の配分
27 allowance: number
28 // 次の時点の目安まであと何 %(負なら超過)
29 margin: number
30}
31
32export type DaySegment = {
33 from: number
34 to: number
35 label: string
36 allowance: number
37 target: number
38 isNow: boolean
39 isFuture: boolean
40 startValue: number | undefined
41 endValue: number | undefined
42}
43
44// 「火=12, 水=25」「火曜:12%、水曜:25%」などを読む。読めなければ undefined
45export function parsePaceTable(text: string): PaceEntry[] | undefined {
46 const entries: PaceEntry[] = []
47 for (const raw of text.split(/[,、,\n]+/)) {
48 const part = raw.replace(/\s+/g, '')
49 if (part === '') continue
50 const m = /^([日月火水木金土])(?:曜日?)?[=:=:](\d+(?:\.\d+)?)[%%]?$/.exec(part)
51 if (!m || m[1] === undefined || m[2] === undefined) return undefined
52 entries.push({ dow: DOW.indexOf(m[1] as (typeof DOW)[number]), pct: Number(m[2]) })
53 }
54 return entries.length > 0 ? entries : undefined
55}
56
57export function formatPaceTable(table: PaceEntry[]): string {
58 return table.map(e => `${DOW[e.dow]}=${fmt(e.pct)}`).join(', ')
59}
60
61export function localDow(at: number, offMin: number): number {
62 return new Date(at + offMin * MINUTE).getUTCDay()
63}
64
65export function localHM(at: number, offMin: number): string {
66 const d = new Date(at + offMin * MINUTE)
67 return `${d.getUTCHours()}:${String(d.getUTCMinutes()).padStart(2, '0')}`
68}
69
70function isLocalMidnight(at: number, offMin: number): boolean {
71 return (at + offMin * MINUTE) % DAY === 0
72}
73
74// t より後で最初のローカル 0:00
75function nextLocalMidnight(t: number, offMin: number): number {
76 const off = offMin * MINUTE
77 return Math.floor((t + off) / DAY) * DAY + DAY - off
78}
79
80export function buildCheckpoints(windowEnd: number, offMin: number, table: PaceEntry[]): Checkpoint[] {
81 const start = windowEnd - WEEK
82 const cps: Checkpoint[] = [{ at: start, pct: 0 }]
83 for (let at = nextLocalMidnight(start, offMin); at < windowEnd; at += DAY) {
84 const entry = table.find(e => e.dow === localDow(at, offMin))
85 if (entry) cps.push({ at, pct: entry.pct })
86 }
87 cps.push({ at: windowEnd, pct: 100 })
88 return cps
89}
90
91export function computePace(
92 current: number,
93 resetsAt: string | undefined,
94 now: number,
95 offMin: number,
96 table: PaceEntry[],
97): PaceView | undefined {
98 const windowEnd = resetsAt === undefined ? NaN : Date.parse(resetsAt)
99 if (!Number.isFinite(windowEnd)) return undefined
100 const checkpoints = buildCheckpoints(windowEnd, offMin, table)
101 let i = checkpoints.findIndex(c => c.at > now)
102 if (i === -1) i = checkpoints.length - 1
103 if (i === 0) i = 1
104 const prev = checkpoints[i - 1] as Checkpoint
105 const next = checkpoints[i] as Checkpoint
106 return {
107 current,
108 windowStart: windowEnd - WEEK,
109 windowEnd,
110 checkpoints,
111 prev,
112 next,
113 allowance: next.pct - prev.pct,
114 margin: next.pct - current,
115 }
116}
117
118export function checkpointLabel(at: number, offMin: number): string {
119 return `${DOW[localDow(at, offMin)]} ${localHM(at, offMin)}`
120}
121
122// 時刻 t の時点で何 % だったか:t 以前で最後の記録。枠の始まりは 0。分からなければ undefined
123export function valueAt(points: Point[], t: number, windowStart: number): number | undefined {
124 if (t <= windowStart) return 0
125 let value: number | undefined
126 for (const p of points) {
127 if (p.at <= t) value = p.pct
128 else break
129 }
130 return value
131}
132
133export function daySegments(view: PaceView, points: Point[], now: number, offMin: number): DaySegment[] {
134 const cps = view.checkpoints
135 const out: DaySegment[] = []
136 for (let i = 0; i + 1 < cps.length; i++) {
137 const from = cps[i] as Checkpoint
138 const to = cps[i + 1] as Checkpoint
139 let label: string = DOW[localDow(from.at, offMin)] ?? ''
140 if (!isLocalMidnight(from.at, offMin)) label += `(${localHM(from.at, offMin)}〜)`
141 else if (!isLocalMidnight(to.at, offMin)) label += `(〜${localHM(to.at, offMin)})`
142 const isNow = from.at <= now && now < to.at
143 const isFuture = from.at > now
144 out.push({
145 from: from.at,
146 to: to.at,
147 label,
148 allowance: to.pct - from.pct,
149 target: to.pct,
150 isNow,
151 isFuture,
152 startValue: isFuture ? undefined : valueAt(points, from.at, view.windowStart),
153 endValue: isFuture ? undefined : isNow ? view.current : valueAt(points, to.at, view.windowStart),
154 })
155 }
156 return out
157}
158
159export function fmt(n: number): string {
160 return Number.isInteger(n) ? String(n) : n.toFixed(1)
161}
162
163export function untilText(ms: number): string {
164 const h = Math.max(0, Math.floor(ms / HOUR))
165 const d = Math.floor(h / 24)
166 return d > 0 ? `${d}日${h % 24}時間` : `${h}時間${Math.floor((ms % HOUR) / MINUTE)}分`
167}
168
169const OK = '#22a06b'
170const OVER = '#e5484d'
171const LINE = '#3b82f6'
172const MUTED = '#8a8a8a'
173
174// パネルの週のグラフ:目安の折れ線(点線)と、記録した実際の使用率
175export function chartSvg(view: PaceView, points: Point[], now: number, offMin: number): string {
176 const W = 640
177 const H = 260
178 const L = 34
179 const R = 12
180 const T = 12
181 const B = 26
182 const pw = W - L - R
183 const ph = H - T - B
184 const x = (at: number) => L + ((at - view.windowStart) / WEEK) * pw
185 const y = (pct: number) => T + (1 - Math.min(100, Math.max(0, pct)) / 100) * ph
186 const xy = (p: Point) => `${x(p.at).toFixed(1)},${y(p.pct).toFixed(1)}`
187 const parts: string[] = [
188 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="system-ui, sans-serif" font-size="11">`,
189 ]
190 for (const pct of [0, 25, 50, 75, 100]) {
191 parts.push(`<line x1="${L}" x2="${W - R}" y1="${y(pct)}" y2="${y(pct)}" stroke="${MUTED}" stroke-opacity="0.25"/>`)
192 parts.push(`<text x="${L - 6}" y="${y(pct) + 4}" text-anchor="end" fill="${MUTED}">${pct}</text>`)
193 }
194 const cps = view.checkpoints
195 for (let i = 0; i + 1 < cps.length; i++) {
196 const a = cps[i] as Checkpoint
197 const b = cps[i + 1] as Checkpoint
198 if (i > 0) parts.push(`<line x1="${x(a.at)}" x2="${x(a.at)}" y1="${T}" y2="${T + ph}" stroke="${MUTED}" stroke-opacity="0.25"/>`)
199 if (b.at - a.at >= 3 * HOUR) {
200 parts.push(`<text x="${((x(a.at) + x(b.at)) / 2).toFixed(1)}" y="${H - 8}" text-anchor="middle" fill="${MUTED}">${DOW[localDow(a.at, offMin)]}</text>`)
201 }
202 }
203 parts.push(`<polyline points="${cps.map(xy).join(' ')}" fill="none" stroke="${MUTED}" stroke-width="1.5" stroke-dasharray="4 3"/>`)
204
205 const inWindow = points.filter(p => p.at >= view.windowStart && p.at <= now)
206 const actual: Point[] = [...inWindow, { at: now, pct: view.current }]
207 const first = actual[0] as Point
208 // 記録が始まる前の区間は分からないので細い点線でつなぐ
209 parts.push(`<polyline points="${xy({ at: view.windowStart, pct: 0 })} ${xy(first)}" fill="none" stroke="${LINE}" stroke-width="1" stroke-dasharray="2 3"/>`)
210 parts.push(`<polyline points="${actual.map(xy).join(' ')}" fill="none" stroke="${LINE}" stroke-width="2" stroke-linejoin="round"/>`)
211 parts.push(`<line x1="${x(now)}" x2="${x(now)}" y1="${T}" y2="${T + ph}" stroke="${LINE}" stroke-opacity="0.35"/>`)
212 parts.push(`<circle cx="${x(view.next.at)}" cy="${y(view.next.pct)}" r="4" fill="none" stroke="${MUTED}" stroke-width="1.5"/>`)
213 parts.push(`<circle cx="${x(now)}" cy="${y(view.current)}" r="4.5" fill="${view.margin < 0 ? OVER : OK}"/>`)
214 parts.push('</svg>')
215 return parts.join('')
216}
217