Shows a right-aligned PEAK badge with a countdown above the prompt during 5-11am Pacific on weekdays, and nothing otherwise.

A Claude Code mod (hooks module, not a skill bundle). During the assumed peak window, 5-11am US Pacific on weekdays (PST or PDT as observed), it draws a right-aligned 🔴 PEAK · ends in 2h 15m badge above the prompt. Off-peak it draws nothing.
It never calls the model and adds nothing to the prompt, so it consumes no tokens. CPU is one clock check a minute.
Anthropic documents no official peak window, so the mod records how fast the 5-hour rate-limit window fills per weighted token, binned by Pacific hour (48 fixed bins, a few KB in the mod's $.store, capped at 16 KB). When at least two bins confidently contradict the assumed window (20+ observations over 3+ days, 3 standard errors), the badge turns into 🟠 PEAK? data disagrees. /peak-report prints the bins. Usage from other sessions on the same account adds noise.
/plugin install peak-hours --marketplace dannys42/agent-skills
Change the window in START_HOUR and END_HOUR in hooks/peak.ts.
hooks/register.tsx 146 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { isAssumedPeak, pacificParts, peakLabel } from './peak'
5import {
6 addObservation,
7 assess,
8 binKey,
9 fitsStore,
10 hasDisagreement,
11 mergeBins,
12 report,
13 weighTokens,
14} from './stats'
15import type { Bins } from './stats'
16
17const label = atom({ plugin: 'peak-hours', key: 'label' } as const, null)
18const hasDisagreementAtom = atom({ plugin: 'peak-hours', key: 'hasDisagreement' } as const, false)
19
20const STORE_KEY = 'bins-v1'
21const PERSIST_EVERY = 20
22const isAssumedKey = (key: string) => isAssumedPeak({ weekday: key[0] === 'w' ? 1 : 0, hour: Number(key.slice(1)) })
23
24// Merge a session's observations into the store; the caller clears its delta.
25async function persist($: EngineInterface, delta: Bins) {
26 const stored = ((await $.store.get(STORE_KEY)) ?? {}) as Bins
27 const merged = mergeBins(stored, delta)
28 // Never write past the cap: keep the old data and drop this delta.
29 if (fitsStore(merged)) await $.store.set(STORE_KEY, merged)
30}
31
32async function refreshAlert($: EngineInterface) {
33 const stored = ((await $.store.get(STORE_KEY)) ?? {}) as Bins
34 const flag = hasDisagreement(assess(stored, isAssumedKey))
35 if (flag !== (await read($, hasDisagreementAtom))) await update($, hasDisagreementAtom, () => flag)
36}
37
38async function flush($: EngineInterface, delta: Bins) {
39 await persist($, delta)
40 await refreshAlert($)
41}
42
43export const register: Register = on => {
44 // This session's observations only; merged into the store, then cleared.
45 let delta: Bins = {}
46 let unsaved = 0
47 let lastPct: number | null = null
48 let lastResetsAt: string | undefined
49 let pendingTokens = 0
50 let pendingTurns = 0
51
52 on('session.start', async ($, e, next) => {
53 await $.command.register({ name: 'peak-report', description: 'Show observed rate-limit cost by hour vs the assumed peak window' })
54
55 const refresh = async () => {
56 const text = peakLabel(await $.clock.now())
57 if (text !== (await read($, label))) await update($, label, () => text)
58 }
59
60 await refresh()
61 await refreshAlert($)
62 $.clock.every(60_000, refresh)
63
64 return next(e)
65 })
66
67 on('turn.complete', async ($, e, next) => {
68 const usage = (e as any).usage
69 if (usage) {
70 pendingTokens += weighTokens(usage)
71 pendingTurns += 1
72 }
73
74 const { rateLimits } = await $.session.usage()
75 const window = rateLimits.find(r => r.kind === 'five_hour')
76
77 if (window) {
78 const pct = window.percentUsed
79 const isSameWindow = lastPct !== null && window.resetsAt === lastResetsAt && pct >= lastPct
80
81 if (isSameWindow && pct > lastPct! && pendingTokens > 0) {
82 const parts = pacificParts(await $.clock.now())
83 addObservation(delta, binKey(parts), pct - lastPct!, pendingTokens, pendingTurns, parts.day)
84 pendingTokens = 0
85 pendingTurns = 0
86 unsaved += 1
87 lastPct = pct
88 if (unsaved >= PERSIST_EVERY) {
89 await flush($, delta)
90 delta = {}
91 unsaved = 0
92 }
93 } else if (!isSameWindow) {
94 lastPct = pct
95 pendingTokens = 0
96 pendingTurns = 0
97 }
98 lastResetsAt = window.resetsAt
99 }
100
101 return next(e)
102 })
103
104 on('session.end', async ($, e, next) => {
105 if (unsaved > 0) await persist($, delta)
106 delta = {}
107 unsaved = 0
108
109 return next(e)
110 })
111
112 on('command.run', { command: 'peak-report' }, async $ => {
113 if (unsaved > 0) await persist($, delta)
114 delta = {}
115 unsaved = 0
116 const stored = ((await $.store.get(STORE_KEY)) ?? {}) as Bins
117
118 return { text: report(assess(stored, isAssumedKey)) }
119 })
120
121 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
122 const text = await read($, label)
123 const isDisagreeing = await read($, hasDisagreementAtom)
124
125 if (text === null && !isDisagreeing) {
126 return next(e)
127 }
128
129 const { Box, Text } = $.ui.resolve(e)
130
131 return (
132 <Box justifyContent="flex-end">
133 {isDisagreeing ? (
134 <Text color="yellow" bold>
135 🟠 PEAK? data disagrees · /peak-report
136 </Text>
137 ) : (
138 <Text color="red" bold>
139 🔴 {text}
140 </Text>
141 )}
142 </Box>
143 )
144 })
145}
146hooks/peak.ts 51 lines1// Peak window in US Pacific wall-clock time (PST or PDT, whichever is observed).
2export const START_HOUR = 5
3export const END_HOUR = 11
4
5const HOUR = 3_600_000
6const MINUTE = 60_000
7
8// The nth Sunday of a month at the given UTC hour.
9const nthSunday = (year: number, month: number, n: number, utcHour: number) => {
10 const first = new Date(Date.UTC(year, month, 1))
11 const day = 1 + ((7 - first.getUTCDay()) % 7) + 7 * (n - 1)
12 return Date.UTC(year, month, day, utcHour)
13}
14
15// US DST: second Sunday of March 02:00 PST (10:00 UTC) to first Sunday of
16// November 02:00 PDT (09:00 UTC).
17const pacificOffsetMs = (ms: number) => {
18 const year = new Date(ms).getUTCFullYear()
19 const isDst = ms >= nthSunday(year, 2, 2, 10) && ms < nthSunday(year, 10, 1, 9)
20 return (isDst ? -7 : -8) * HOUR
21}
22
23export const formatDuration = (ms: number) => {
24 const minutes = Math.max(1, Math.ceil(ms / MINUTE))
25 const h = Math.floor(minutes / 60)
26 const m = minutes % 60
27 return h > 0 ? `${h}h ${m}m` : `${m}m`
28}
29
30export type PacificParts = { weekday: number; hour: number; sinceMidnightMs: number; day: number }
31
32export const pacificParts = (ms: number): PacificParts => {
33 const shifted = ms + pacificOffsetMs(ms)
34 const local = new Date(shifted)
35 const sinceMidnightMs =
36 local.getUTCHours() * HOUR + local.getUTCMinutes() * MINUTE + local.getUTCSeconds() * 1000 + local.getUTCMilliseconds()
37
38 return { weekday: local.getUTCDay(), hour: local.getUTCHours(), sinceMidnightMs, day: Math.floor(shifted / 86_400_000) }
39}
40
41export const isAssumedPeak = ({ weekday, hour }: { weekday: number; hour: number }) =>
42 weekday !== 0 && weekday !== 6 && hour >= START_HOUR && hour < END_HOUR
43
44// "PEAK · ends in 2h 15m" during peak, null otherwise.
45export const peakLabel = (ms: number): string | null => {
46 const parts = pacificParts(ms)
47 if (!isAssumedPeak(parts)) return null
48
49 return `PEAK · ends in ${formatDuration(END_HOUR * HOUR - parts.sinceMidnightMs)}`
50}
51hooks/stats.ts 136 lines1// Fixed-size aggregate of rate-limit cost per Pacific hour, so the file cannot grow:
2// 24 hours x weekday/weekend = 48 bins of 9 numbers each.
3
4// Each observation is one rise of the 5-hour window's percentUsed (d, in points)
5// over the weighted tokens spent since the last reading (t, in thousands).
6// Bin = [observations, turns, sum d, sum t, sum d^2, sum t^2, sum d*t, days seen, last day]
7export type Bin = [number, number, number, number, number, number, number, number, number]
8export type Bins = Record<string, Bin>
9
10export const MAX_STORE_BYTES = 16_000
11export const MIN_OBSERVATIONS = 20
12export const MIN_DAYS = 3
13// A bin counts as elevated when its rate beats the off-peak baseline by this factor.
14export const ELEVATED_FACTOR = 1.2
15export const Z = 3
16// Bins that must contradict the assumed window before the badge says so.
17export const MIN_DISAGREEING_BINS = 2
18
19// Relative token weights, from list-price ratios; an assumption, kept in one place.
20export const weighTokens = (u: {
21 input_tokens: number
22 output_tokens: number
23 cache_creation_input_tokens: number
24 cache_read_input_tokens: number
25}) => (u.input_tokens + 5 * u.output_tokens + 1.25 * u.cache_creation_input_tokens + 0.1 * u.cache_read_input_tokens) / 1000
26
27export const binKey = (parts: { weekday: number; hour: number }) =>
28 `${parts.weekday === 0 || parts.weekday === 6 ? 'e' : 'w'}${String(parts.hour).padStart(2, '0')}`
29
30export const emptyBin = (): Bin => [0, 0, 0, 0, 0, 0, 0, 0, -1]
31
32const round = (n: number) => Math.round(n * 1e6) / 1e6
33
34export const addObservation = (bins: Bins, key: string, d: number, t: number, turns: number, day: number) => {
35 const bin = (bins[key] ??= emptyBin())
36 bin[0] += 1
37 bin[1] += turns
38 bin[2] = round(bin[2] + d)
39 bin[3] = round(bin[3] + t)
40 bin[4] = round(bin[4] + d * d)
41 bin[5] = round(bin[5] + t * t)
42 bin[6] = round(bin[6] + d * t)
43 if (bin[8] !== day) {
44 bin[7] += 1
45 bin[8] = day
46 }
47}
48
49export const mergeBins = (into: Bins, from: Bins): Bins => {
50 for (const [key, b] of Object.entries(from)) {
51 const a = (into[key] ??= emptyBin())
52 for (let i = 0; i < 7; i++) a[i] = round(a[i]! + b[i]!)
53 a[7] += b[7]
54 a[8] = Math.max(a[8], b[8])
55 }
56
57 return into
58}
59
60export type Estimate = { rate: number; se: number; n: number; days: number }
61
62// Ratio estimator R = sum d / sum t, standard error by linearisation.
63export const estimate = (bin: Bin | undefined): Estimate | null => {
64 if (!bin || bin[0] < 2 || bin[3] <= 0) return null
65 const [n, , sd, st, sdd, stt, sdt, days] = bin
66 const rate = sd / st
67 const residual = Math.max(0, sdd - 2 * rate * sdt + rate * rate * stt)
68 const meanT = st / n
69
70 return { rate, se: Math.sqrt(residual / (n * (n - 1))) / meanT, n, days }
71}
72
73export type Verdict = 'elevated' | 'normal' | 'unsure'
74
75const isConfident = (e: Estimate | null): e is Estimate => e !== null && e.n >= MIN_OBSERVATIONS && e.days >= MIN_DAYS
76
77export type Row = { key: string; est: Estimate | null; times: number | null; verdict: Verdict; isAssumedPeak: boolean }
78
79export type Assessment = { baseline: Estimate | null; rows: Row[]; disagreeing: Row[] }
80
81// `isAssumed(key)` says whether a bin is inside the assumed peak window.
82export const assess = (bins: Bins, isAssumed: (key: string) => boolean): Assessment => {
83 const keys = Object.keys(bins).sort()
84 const offKeys = keys.filter(k => !isAssumed(k))
85 const total = emptyBin()
86 const acc: Bins = { x: total }
87 for (const k of offKeys) mergeBins(acc, { x: bins[k]! })
88 const baseline = estimate(acc.x)
89 const baselineOk = isConfident(baseline)
90
91 const rows = keys.map((key): Row => {
92 const est = estimate(bins[key])
93 const times = est && baseline && baseline.rate > 0 ? est.rate / baseline.rate : null
94 let verdict: Verdict = 'unsure'
95 if (baselineOk && isConfident(est)) {
96 const bar = baseline.rate * ELEVATED_FACTOR
97 if (est.rate - Z * est.se > bar) verdict = 'elevated'
98 else if (est.rate + Z * est.se < bar) verdict = 'normal'
99 }
100
101 return { key, est, times, verdict, isAssumedPeak: isAssumed(key) }
102 })
103
104 const disagreeing = rows.filter(r => (r.isAssumedPeak && r.verdict === 'normal') || (!r.isAssumedPeak && r.verdict === 'elevated'))
105
106 return { baseline, rows, disagreeing }
107}
108
109export const hasDisagreement = (a: Assessment) => a.disagreeing.length >= MIN_DISAGREEING_BINS
110
111export const fitsStore = (bins: Bins) => JSON.stringify(bins).length <= MAX_STORE_BYTES
112
113export const report = (a: Assessment): string => {
114 if (a.rows.length === 0) return 'peak-hours: no data collected yet.'
115 const lines = [
116 `peak-hours: cost per weighted kilotoken vs off-peak baseline (${a.baseline ? 'baseline known' : 'baseline not yet known'}).`,
117 'bin assumed obs days x baseline verdict',
118 ]
119 for (const r of a.rows) {
120 const hour = `${r.key[0] === 'w' ? 'wkday' : 'wkend'} ${r.key.slice(1)}h`
121 lines.push(
122 `${hour} ${r.isAssumedPeak ? 'peak ' : 'off '} ${String(r.est?.n ?? 0).padStart(3)} ${String(r.est?.days ?? 0).padStart(4)} ${
123 r.times === null ? ' - ' : `${r.times.toFixed(2)} ±${((r.est!.se / a.baseline!.rate) * Z).toFixed(2)}`
124 } ${r.verdict}`,
125 )
126 }
127 lines.push(
128 hasDisagreement(a)
129 ? `DISAGREES with the assumed window in: ${a.disagreeing.map(r => r.key).join(', ')}`
130 : 'No confident disagreement with the assumed 5-11 PT weekday window.',
131 )
132 lines.push(`Confidence needs >= ${MIN_OBSERVATIONS} observations over >= ${MIN_DAYS} days per bin, ${Z} standard errors. Other sessions on the same account add noise.`)
133
134 return lines.join('\n')
135}
136types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 'peak-hours': { label: string | null; hasDisagreement: boolean }
4 }
5}
6