SLOPSHOPPER

session-meter

A band above the prompt: context forecast and turns left, prompt cache countdown, rate limits with their pace, a cache hit warning, cost, and stand-up /…

newbandcommandtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-meter
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /meter ⎿ session-meter: ☁ 多云 上下文 49% 97k/200k │ ❄ 缓存 – │ 5小时 ▰▰▱▱▱ 31% 09:53重置 │ $0.42 ⎿ session-meter: ⎿ session-meter: 提示缓存:主线程还没有响应 ⎿ session-meter: 上下文:97k / 200k(49%),160k 时提醒 ⎿ session-meter: 额度 5小时:已用 31%,09:53 重置 ⎿ session-meter: 本会话费用:$0.42 ☁ 多云 上下文 49% 97k/200k │ ❄ 缓存 – │ 5小时 ▰▰▱▱▱ 31% 09:53重置 │ $0.42 │ 已坐 0m ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
☁ 多云 上下文 49% 97k/200k │ ❄ 缓存 – │ 5小时 ▰▰▱▱▱ 31% 09:53重置 │ $0.42 │ 已坐 0m ⟨Claude Code's own drawing⟩
README

claude-mods

Claude Code mods (function-hook plugins). The UI text is in Simplified Chinese.

Claude Code 的 mod 合集(function-hook 插件)。界面文字为简体中文。

session-meter

A band above the prompt that answers "what should I do now?":

ShowsWhy
☁ 多云 上下文 21% 210k/1MContext size, judged in tokens (default warning at 200k), not in share of a 1M window
约 6 轮到 200kTurns left before the warning, from the recent growth per turn
❄ 缓存 57:42Prompt cache countdown (1h / 5m, from the cacheTtl option, else inferred from your plan)
5小时 ▰▰▰▱▱ 67% 预计14:20用完 15:10重置Rate-limit windows, with a run-out forecast from the last 30 minutes of use
命中 40% 缓存可能失效Cache hit rate, only when it looks broken
已坐 47mStand-up reminder: a pixel figure, a rotating tip and two buttons after 60 minutes

Toasts, each sent once: cache about to expire (only when the rewrite is costly and you are at the desk), context at 200k / 400k, a rate-limit window running out before its reset, time to stand up, and a late-night nudge from 23:00. Reminders about you never interrupt a running turn. /meter prints every figure in full (the command works in the terminal; the desktop app does not route mod commands).

Install

Needs a Claude Code build with function-hook plugins (developed against 2.1.288–2.1.291).

claude plugin marketplace add feizaodemon/claude-mods
claude plugin install session-meter@feizao-mods

Or clone this repo and list the folder in CLAUDE_CODE_PLUGIN_DIRS (;-separated on Windows, : elsewhere) in ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-mods/mods/session-meter" } }

Options

OptionDefault
cacheTtlautoauto, 1h or 5m; set it to match if you fix the TTL with an environment variable or promptCacheTtl
contextWarnPercent80Warn at this share of the window; 0 off
contextWarnTokens200000Warn at this many tokens, again at twice it; 0 off
standupMinutes60Stand-up reminder interval; 10 minutes without a prompt counts as a break; 0 off
bedtimeHour23Late-night reminder from this hour to 5 am; 0 off

What the hooks do

The mod only reads: it never changes what other code sends, and it sends nothing off the machine.

HookWhat it does
prompt.submitNotes the time: a turn has started (stand-up and late-night reminders wait for it to end), and a prompt you typed means you are at the desk. Passes the prompt on unchanged.
turn.stepAfter each main-thread response, records when it landed, its cache hit rate and the cache TTL it got. Passes the result on unchanged.
turn.completeMarks the main turn over and records the context size, for the turns-left estimate.
session.start / session.endRegisters /meter and starts the once-a-second tick; clears the per-conversation figures on /clear.
ui.render (AbovePrompt)Draws the band, and the stand-up panel while it is due. What others draw there is kept.
command.run (meter)Prints every figure in full.

It reads nothing from the environment or settings.json: if you fix the cache TTL there, set cacheTtl to match. Its sitting clock and late-night flag live in the plugin's own store.

Develop

claude plugin validate mods/session-meter
claude plugin test mods/session-meter

Layout: hooks/format.ts (the band's figures), hooks/alerts.ts (every toast rule, pure), hooks/standup.ts (sitting clock, pixel figure), hooks/register.tsx (the once-a-second tick and the drawing).

License

MIT

Source 5 files
hooks/register.tsx 299 lines
1// session-meter: a band above the prompt with the context forecast and turns left, the prompt cache
2// countdown, the rate-limit windows and their pace, a cache-hit warning, the session cost, and
3// stand-up / late-night reminders, fitted to the band's width. /meter prints the figures in full.
4//
5// Once a second the tick reads the engine, works out what the band shows (a View in $.state, which
6// redraws its readers when it changes) and which toasts are due (alerts.ts). Drawing only reads.
7import type { EngineInterface, Register, SessionUsage } from 'claude-code'
8
9import type { View } from '../types'
10import type { Memo, Snapshot } from './alerts'
11import { EMPTY_MEMO, decideToasts } from './alerts'
12import type { Judge, Sample } from './format'
13import { SEP, WARN_MS, WINDOW_LABEL, cacheDetail, chips, exhaustsAt, hhmm, hhmmAt, hitPercent, kCount, layout, recentRate, ttlFor, ttlMs, warnTokens } from './format'
14import type { Standup } from './standup'
15import { IDLE_RESET_MS, advice, done, duration, figure, lateNight, nudgeKey, onActivity, parse, phase, snooze, standupChip } from './standup'
16
17const HISTORY = 12
18const STANDUP_KEY = 'standup' // in $.store: one sitting clock shared by every session
19const BEDTIME_KEY = 'bedtime' // in $.store: the night the late-hour reminder last went out for
20const STORE_SYNC_MS = 15_000 // how stale the stand-up clock may be before it is read again
21const SAMPLE_EVERY_MS = 60_000 // how often a rate-limit reading is kept for the recent pace
22const RUNNING_MAX_MS = 30 * 60_000 // a turn that never reported its end stops counting as running
23const FAILING_AFTER = 3 // ticks failed in a row before the band says its figures may be stale
24
25// This mod's values in $.state, each read and written with $.state.get / $.state.set.
26const LAST = { plugin: 'session-meter', key: 'last' } as const
27const READINGS = { plugin: 'session-meter', key: 'readings' } as const
28const VIEW = { plugin: 'session-meter', key: 'view' } as const
29
30// The options; set again on each (re)load.
31const cfg = { ttlOption: 'auto' as unknown, ctxWarn: 80, ctxWarnTokens: 200_000, standupMs: 60 * 60_000, bedtimeHour: 23 }
32
33// What this module remembers between ticks; a reload starts it over.
34// runningSince: when the main conversation's current turn began (0: idle); standup / standupReadAt:
35// this session's copy of the shared clock and when it was read; samples: each window's readings for
36// the recent pace, kept for the reset they belong to; nightChecked: the night whose shared flag was
37// read; viewKey: the View last written; failures / lastError: the tick's failures in a row.
38const fresh = () => ({
39  memo: { ...EMPTY_MEMO } as Memo,
40  runningSince: 0,
41  standup: undefined as Standup | undefined,
42  standupReadAt: 0,
43  samples: {} as Record<string, { resetsAt: string; list: Sample[] }>,
44  sampledAt: 0,
45  nightChecked: '',
46  viewKey: '',
47  failures: 0,
48  lastError: '',
49})
50let mem = fresh()
51
52const isRunning = (now: number) => mem.runningSince > 0 && now - mem.runningSince < RUNNING_MAX_MS
53
54// Keeps a reading of each window once a minute; a new reset starts its list over.
55function sample(now: number, usage: SessionUsage) {
56  if (now - mem.sampledAt < SAMPLE_EVERY_MS) return
57  mem.sampledAt = now
58  for (const r of usage.rateLimits) {
59    const held = mem.samples[r.kind]
60    const list = held && held.resetsAt === (r.resetsAt ?? '') ? held.list : []
61    mem.samples[r.kind] = { resetsAt: r.resetsAt ?? '', list: [...list, { t: now, pct: r.percentUsed }].slice(-40) }
62  }
63}
64
65function judge(now: number): Judge {
66  const rates: Record<string, number | undefined> = {}
67  for (const [kind, held] of Object.entries(mem.samples)) rates[kind] = recentRate(held.list, now)
68  return { warnPercent: cfg.ctxWarn, warnCapTokens: cfg.ctxWarnTokens, rates }
69}
70
71// Read from $.store at most every 15 s: the band redraws every second, the clock moves by minutes.
72async function loadStandup($: EngineInterface, now: number): Promise<Standup> {
73  if (mem.standup && now - mem.standupReadAt < STORE_SYNC_MS) return mem.standup
74  mem.standup = parse(await $.store.get(STANDUP_KEY).catch(() => undefined), now)
75  mem.standupReadAt = now
76  return mem.standup
77}
78
79async function saveStandup($: EngineInterface, s: Standup) {
80  mem.standup = s
81  await $.store.set(STANDUP_KEY, s).catch(() => {}) // the clock still runs in this session
82}
83
84// One second of the meter: the View the band draws, and the toasts now due.
85async function tick($: EngineInterface) {
86  const now = await $.clock.now()
87  const step = (await $.state.get(LAST)).value ?? null
88  const usage = await $.session.usage()
89  const ttl = step?.ttl ?? ttlMs('1h')
90  sample(now, usage)
91  const j = judge(now)
92
93  const s = await loadStandup($, now)
94  const p = cfg.standupMs > 0 ? phase(s, now, cfg.standupMs) : undefined
95  const upChip = p ? standupChip(p) : undefined
96  const isDue = p?.phase === 'due'
97
98  const next: View = {
99    chips: [...chips(step, now, ttl, usage, (await $.state.get(READINGS)).value ?? [], j), ...(upChip ? [upChip] : [])],
100    ...(isDue && p ? { standup: { tips: advice(s, p.sat, now), frame: Math.floor(now / 1000) % 2 } } : {}),
101  }
102  const key = JSON.stringify(next)
103  if (key !== mem.viewKey) {
104    mem.viewKey = key
105    await $.state.set(VIEW, next)
106  }
107
108  // The shared flags the toasts depend on: the sitting reminded, the night reminded.
109  const night = lateNight(now, cfg.bedtimeHour)
110  if (night && mem.nightChecked !== night) {
111    mem.nightChecked = night
112    const sent = await $.store.get(BEDTIME_KEY).catch(() => night) // unreadable: count it as sent
113    mem.memo.night = typeof sent === 'string' ? sent : ''
114  }
115  mem.memo.nudged = s.nudged
116
117  const snap: Snapshot = {
118    running: isRunning(now),
119    away: now - s.lastActiveAt >= IDLE_RESET_MS,
120    cache: step ? { at: step.at, left: step.at + ttl - now, warnMs: WARN_MS } : undefined,
121    context: { tokens: usage.context.tokens, percent: usage.context.percent, window: usage.context.window, warnAt: warnTokens(usage.context.window, cfg.ctxWarn, cfg.ctxWarnTokens) },
122    pace: usage.rateLimits.flatMap(r => {
123      const at = r.percentUsed >= 50 ? exhaustsAt(r, now, j.rates?.[r.kind]) : undefined
124      return at === undefined ? [] : [{ key: `${r.kind}:${r.resetsAt ?? ''}`, label: WINDOW_LABEL[r.kind] ?? r.kind, at: hhmmAt(at, now) ?? '', reset: hhmm(r.resetsAt, now) ?? undefined }]
125    }),
126    standup: isDue && p ? { key: nudgeKey(s), sat: duration(p.sat) } : undefined,
127    night: night ? { key: night, time: hhmmAt(now) ?? '' } : undefined,
128  }
129  const { toasts, memo } = decideToasts(snap, mem.memo)
130  if (memo.nudged !== mem.memo.nudged) await saveStandup($, { ...s, nudged: memo.nudged })
131  if (memo.night !== mem.memo.night) await $.store.set(BEDTIME_KEY, memo.night).catch(() => {})
132  mem.memo = memo
133  for (const t of toasts) $.ui.toast(t)
134}
135
136// The tick, with its failures counted rather than swallowed: after a few in a row the band says so
137// (its figures may be stale) and /meter names the last error.
138async function refresh($: EngineInterface) {
139  try {
140    await tick($)
141    if (mem.failures >= FAILING_AFTER) await setFailing($, false)
142    mem.failures = 0
143  } catch (error) {
144    mem.failures += 1
145    mem.lastError = error instanceof Error ? error.message : String(error)
146    if (mem.failures === FAILING_AFTER) await setFailing($, true).catch(() => {})
147  }
148}
149
150async function setFailing($: EngineInterface, failing: boolean) {
151  const v = (await $.state.get(VIEW)).value
152  if (v) await $.state.set(VIEW, { ...v, failing })
153}
154
155// A press on the stand-up panel's buttons: done or snooze the shared clock, then redraw.
156async function pressStandup($: EngineInterface, change: (s: Standup, t: number) => Standup) {
157  const t = await $.clock.now()
158  await saveStandup($, change(await loadStandup($, t), t))
159  await refresh($)
160}
161
162const FAILING_CHIP = { key: 'failing', text: '⚠ meter 数据可能过期(/meter 看原因)', short: '⚠ meter', color: 'yellow', rank: 2 }
163
164export const register: Register = (on, options) => {
165  cfg.ttlOption = options.cacheTtl
166  cfg.ctxWarn = typeof options.contextWarnPercent === 'number' ? options.contextWarnPercent : 80
167  cfg.ctxWarnTokens = typeof options.contextWarnTokens === 'number' ? options.contextWarnTokens : 200_000
168  cfg.standupMs = (typeof options.standupMinutes === 'number' ? options.standupMinutes : 60) * 60_000
169  cfg.bedtimeHour = typeof options.bedtimeHour === 'number' ? options.bedtimeHour : 23
170  mem = fresh()
171
172  // Any prompt starts a main-thread turn; one the person sent is also the sign they are at the desk.
173  // (turn.start cannot mark it: it does not say whose turn, and a subagent's would never be cleared.)
174  on('prompt.submit', async ($, e, next) => {
175    const now = await $.clock.now()
176    mem.runningSince = now
177    if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
178      await saveStandup($, onActivity(await loadStandup($, now), now)).catch(() => {})
179    }
180    return next(e)
181  })
182
183  on('session.start', async ($, e, next) => {
184    const r = await next(e)
185    $.ui.status(undefined) // the band replaces the status line an older version wrote
186    await $.command.register({ name: 'meter', description: '查看缓存、上下文、额度和费用明细' }).catch(() => {})
187    $.clock.every(1000, () => void refresh($))
188    await refresh($)
189    return r
190  })
191
192  // Main-thread responses only: a subagent's requests keep a cache of their own.
193  on('turn.step', async function* ($, e, next) {
194    const result = yield* next(e)
195    if (e.agentId === undefined && result.usage) {
196      const at = await $.clock.now()
197      const hit = hitPercent(result.usage)
198      const { rateLimits } = await $.session.usage()
199      const { ttl, source } = ttlFor(cfg.ttlOption, rateLimits)
200      await $.state.set(LAST, { at, hitPercent: hit, ttl, ttlSource: source })
201      await refresh($)
202    }
203    return result
204  })
205
206  // The main conversation's turn is over; one reading of the context, for the turns left.
207  on('turn.complete', async ($, e, next) => {
208    const r = await next(e)
209    if (!e.agentId) {
210      mem.runningSince = 0
211      const { context } = await $.session.usage()
212      if (context.tokens !== undefined) {
213        const held = (await $.state.get(READINGS)).value ?? []
214        await $.state.set(READINGS, [...held, { tokens: context.tokens }].slice(-HISTORY))
215      }
216      await refresh($)
217    }
218    return r
219  })
220
221  on('session.end', async ($, e, next) => {
222    await $.state.set(LAST, null) // /clear starts a fresh prefix
223    await $.state.set(READINGS, [])
224    mem.viewKey = ''
225    return next(e)
226  })
227
228  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
229    const rest = await next(e) // what other mods and Claude Code draw here stays
230    const v = (await $.state.get(VIEW)).value ?? null
231    if (e.props.hasSurvey || !v) return rest
232    const { Box, Text, Button } = $.ui.resolve(e)
233    const all = v.failing ? [...v.chips, FAILING_CHIP] : v.chips
234    const rows = layout(all, Math.max(10, e.props.bodyColumns - 2), Math.min(2, Math.max(1, e.props.maxRows)))
235
236    return (
237      <Box flexDirection="column">
238        {v.standup && (
239          <Box key="standup" flexDirection="row" columnGap={2} paddingX={1}>
240            <Box flexDirection="column">
241              {figure(v.standup.frame).map((r, i) => (
242                <Text key={`px${i}`} color="green">{r}</Text>
243              ))}
244            </Box>
245            <Box flexDirection="column">
246              {v.standup.tips.map((t, i) => (
247                <Text key={`tip${i}`} color={i === 0 ? 'yellow' : undefined} bold={i === 0}>{t}</Text>
248              ))}
249              <Box flexDirection="row" columnGap={1}>
250                <Button key="moved" label="已活动" variant="primary" onPress={() => pressStandup($, done)} />
251                <Button key="later" label="10 分钟后提醒" onPress={() => pressStandup($, snooze)} />
252              </Box>
253            </Box>
254          </Box>
255        )}
256        {rows.map((row, i) => (
257          <Box key={`row${i}`} flexDirection="row" paddingX={1}>
258            {row.flatMap((it, j) => [
259              j > 0 ? <Text key={`sep:${it.chip.key}`} dimColor>{SEP}</Text> : null,
260              <Text key={it.chip.key} color={it.chip.color} dimColor={it.chip.dim} bold={it.chip.key === 'context'} wrap="truncate">
261                {it.label}
262              </Text>,
263            ])}
264          </Box>
265        ))}
266        {rest}
267      </Box>
268    )
269  })
270
271  on('command.run', { command: 'meter' }, async $ => {
272    const now = await $.clock.now()
273    const step = (await $.state.get(LAST)).value ?? null
274    const usage = await $.session.usage({ breakdown: 'summary' })
275    const { context, rateLimits, cost } = usage
276    const ttl = step?.ttl ?? ttlMs('1h')
277    const j = judge(now)
278    const out: string[] = [chips(step, now, ttl, usage, (await $.state.get(READINGS)).value ?? [], j).map(c => c.text).join(SEP), '']
279    out.push(cacheDetail(step, now))
280    const warnAt = warnTokens(context.window, cfg.ctxWarn, cfg.ctxWarnTokens)
281    out.push(`上下文:${context.tokens !== undefined ? kCount(context.tokens) : '–'} / ${kCount(context.window)}(${context.percent ?? '–'}%)${warnAt ? `,${kCount(warnAt)} 时提醒` : ''}`)
282    for (const c of context.breakdown?.categories ?? []) {
283      if (c.tokens > 0) out.push(`  ${c.name}: ${kCount(c.tokens)}${c.isDeferred ? '(按需加载)' : ''}`)
284    }
285    for (const r of rateLimits) {
286      const runsOut = exhaustsAt(r, now, j.rates?.[r.kind])
287      const reset = hhmm(r.resetsAt, now)
288      out.push(`额度 ${WINDOW_LABEL[r.kind] ?? r.kind}:已用 ${r.percentUsed}%${reset ? `,${reset} 重置` : ''}${runsOut ? `;按当前速度约 ${hhmmAt(runsOut, now)} 用完` : ''}`)
289    }
290    if (cost) out.push(`本会话费用:$${cost.usd.toFixed(2)}`)
291    if (cfg.standupMs > 0) {
292      const p = phase(await loadStandup($, now), now, cfg.standupMs)
293      out.push(p.phase === 'away' ? '久坐:超过 10 分钟没发消息,下次发消息时重新计时' : `久坐:已坐 ${duration(p.sat)},每 ${cfg.standupMs / 60_000} 分钟提醒一次`)
294    }
295    if (mem.failures > 0) out.push(`刷新失败 ${mem.failures} 次,最后一次:${mem.lastError}`)
296    return { text: out.join('\n') }
297  })
298}
299
hooks/alerts.ts 73 lines
1// Every toast the meter sends, decided in one place from one snapshot, so the rules (once each,
2// never mid-turn for the person, never to an empty desk for the cache) are written once and tested.
3import { clock, kCount } from './format'
4
5export const CHEAP_REWRITE = 20_000 // context tokens under which a cache rewrite is not worth a toast
6
7export type Snapshot = {
8  /** A main-thread turn is under way: reminders about the person wait for it to end. */
9  running: boolean
10  /** Ten minutes without a prompt: nobody is there to act on a cache warning. */
11  away: boolean
12  /** The last main-thread response's `at`, and how long its cache has left. */
13  cache?: { at: number; left: number; warnMs: number }
14  context: { tokens?: number; percent?: number; window: number; warnAt?: number }
15  /** Windows the current pace runs out before their reset; `key` is the window and its reset. */
16  pace: { key: string; label: string; at: string; reset?: string }[]
17  /** The stand-up reminder while due; `key` is the sitting and snooze it is for. */
18  standup?: { key: string; sat: string }
19  /** The late-hour night we are in, and the clock time. */
20  night?: { key: string; time: string }
21}
22
23// What has been sent already. `nudged` and `night` are shared across sessions through $.store.
24export type Memo = { cacheFor: number; ctx: boolean; ctxStrong: boolean; pace: string[]; nudged: string; night: string }
25export const EMPTY_MEMO: Memo = { cacheFor: 0, ctx: false, ctxStrong: false, pace: [], nudged: '', night: '' }
26
27export function decideToasts(s: Snapshot, m: Memo): { toasts: string[]; memo: Memo } {
28  const toasts: string[] = []
29  const memo = { ...m }
30
31  // The cache: worth a toast only when the rewrite costs something (its price is the whole
32  // context) and someone is there to send the next message.
33  const tokens = s.context.tokens ?? 0
34  if (s.cache && s.cache.at !== m.cacheFor && tokens >= CHEAP_REWRITE && !s.away && s.cache.left > 0 && s.cache.left <= s.cache.warnMs) {
35    memo.cacheFor = s.cache.at
36    toasts.push(`提示缓存还剩 ${clock(s.cache.left)} 过期:现在发下一条消息,否则要重新写入约 ${kCount(tokens)} token`)
37  }
38
39  // The context: at the warning size, and again at twice it; each again after it drops well below.
40  const { warnAt, window } = s.context
41  if (warnAt !== undefined && s.context.tokens !== undefined) {
42    const t = s.context.tokens
43    if (!m.ctx && t >= warnAt) {
44      memo.ctx = true
45      toasts.push(`上下文已到 ${kCount(t)}(${s.context.percent ?? '–'}%):找个合适的节点手动 /compact`)
46    } else if (m.ctx && t < warnAt * 0.85) memo.ctx = false
47    if (!m.ctxStrong && t >= warnAt * 2 && warnAt * 2 <= window) {
48      memo.ctxStrong = true
49      toasts.push(`上下文已到 ${kCount(t)}:每轮都在重读这么多,建议现在 /compact,或写交接后 /clear`)
50    } else if (m.ctxStrong && t < warnAt * 1.7) memo.ctxStrong = false
51  }
52
53  // The rate limits: once per window and reset.
54  for (const p of s.pace) {
55    if (memo.pace.includes(p.key)) continue
56    memo.pace = [...memo.pace, p.key].slice(-8)
57    toasts.push(`${p.label}额度按当前速度约 ${p.at} 用完${p.reset ? `(${p.reset} 重置)` : ''}:可以降低 effort 或换小一点的模型`)
58  }
59
60  // The person: never in the middle of a turn.
61  if (!s.running) {
62    if (s.standup && s.standup.key !== m.nudged) {
63      memo.nudged = s.standup.key
64      toasts.push(`已经连续坐了 ${s.standup.sat},起来活动 3 分钟吧`)
65    }
66    if (s.night && s.night.key !== m.night) {
67      memo.night = s.night.key
68      toasts.push(`已经 ${s.night.time} 了:收个尾,早点休息`)
69    }
70  }
71  return { toasts, memo }
72}
73
hooks/format.ts 237 lines
1import type { ModelUsage, SessionUsage } from 'claude-code'
2
3import type { Chip, LastStep, Reading } from '../types'
4
5export const WARN_MS = 60_000 // the cache line turns to a warning this long before expiry
6
7export function ttlMs(option: unknown): number {
8  return option === '5m' ? 5 * 60_000 : 60 * 60_000
9}
10
11// The TTL a cache write gets now and why: this plugin's `cacheTtl` option when it is `1h` or `5m`,
12// else inferred from the plan. A subscription gets 1h within plan usage and 5m on usage credits,
13// and a rate-limit window at 100% stands for the latter. The mod reads nothing from the
14// environment or settings.json: whoever fixes the TTL there sets `cacheTtl` to match.
15export function ttlFor(option: unknown, rateLimits: readonly { percentUsed: number }[]): { ttl: number; source: string } {
16  if (option === '1h' || option === '5m') return { ttl: ttlMs(option), source: '插件设置' }
17  return rateLimits.some(r => r.percentUsed >= 100)
18    ? { ttl: ttlMs('5m'), source: '推断:额度已用满,按超额 5 分钟' }
19    : { ttl: ttlMs('1h'), source: '推断:额度内 1 小时' }
20}
21
22// Share of the prompt the cache served: read / (uncached + read + written).
23export function hitPercent(u: ModelUsage): number | null {
24  const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
25  return total > 0 ? Math.round((u.cache_read_input_tokens / total) * 100) : null
26}
27
28export function clock(ms: number): string {
29  const s = Math.max(0, Math.ceil(ms / 1000))
30  const h = Math.floor(s / 3600)
31  const m = Math.floor((s % 3600) / 60)
32  const ss = String(s % 60).padStart(2, '0')
33  return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
34}
35
36export function kCount(n: number): string {
37  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
38  return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
39}
40
41const WEEKDAY = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
42
43// A local clock time, with its weekday when it is not today (a weekly window resets days away),
44// or its month and day when it is six days or more off. `now` absent: the time alone.
45export function hhmm(iso: string | undefined, now?: number): string | null {
46  if (!iso) return null
47  const d = new Date(iso)
48  if (Number.isNaN(d.getTime())) return null
49  const time = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
50  if (now === undefined || d.toDateString() === new Date(now).toDateString()) return time
51  return Math.abs(d.getTime() - now) < 6 * 24 * 3600_000 ? `${WEEKDAY[d.getDay()]}${time}` : `${d.getMonth() + 1}/${d.getDate()} ${time}`
52}
53
54export const hhmmAt = (ms: number, now?: number) => hhmm(new Date(ms).toISOString(), now)
55
56export const WINDOW_LABEL: Record<string, string> = { five_hour: '5小时', seven_day: '7天', spend_limit: '额度' }
57
58// The context forecast (from token-weather): icon, word and color by how full the window is.
59const FORECAST = [
60  { upTo: 25, icon: '☀', word: '晴', color: 'yellow' },
61  { upTo: 50, icon: '☁', word: '多云', color: 'cyan' },
62  { upTo: 75, icon: '☂', word: '阵雨', color: 'blue' },
63  { upTo: 90, icon: '⛈', word: '雷雨', color: 'magenta' },
64  { upTo: Infinity, icon: '⚠', word: '该压缩了', color: 'red' },
65] as const
66export const forecast = (percent: number) => FORECAST.find(f => percent < f.upTo) ?? FORECAST[4]
67
68// The context size the warning fires at: `percent` of the window, capped at `capTokens`. What a
69// turn costs and how well the model attends follow the tokens, not the share of the window, so a
70// 1M window warns at the cap long before 80% of it. Undefined when both are off.
71export function warnTokens(window: number, percent: number, capTokens: number): number | undefined {
72  const byShare = percent > 0 ? (window * percent) / 100 : Infinity
73  const at = Math.min(byShare, capTokens > 0 ? capTokens : Infinity)
74  return Number.isFinite(at) && at > 0 ? at : undefined
75}
76
77// The forecast's scale: the warning sits at 80 on it, where the weather turns to storms.
78export const forecastPercent = (tokens: number, warnAt: number) => Math.round((tokens / warnAt) * 80)
79
80// How many more turns, at the recent average growth, until the context reaches `target` tokens.
81// Only growth counts: a /compact or /clear shrinks the context but says nothing about the pace.
82export function turnsLeft(history: readonly Reading[], target: number | undefined): number | undefined {
83  const grows = history.slice(-7).flatMap((r, i, a) => (i > 0 && r.tokens > (a[i - 1]?.tokens ?? 0) ? [r.tokens - (a[i - 1]?.tokens ?? 0)] : []))
84  const now = history.at(-1)?.tokens
85  if (grows.length === 0 || now === undefined || target === undefined) return undefined
86  const room = target - now
87  if (room <= 0) return undefined
88  return Math.ceil(room / (grows.reduce((a, b) => a + b, 0) / grows.length))
89}
90
91const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3600_000, seven_day: 7 * 24 * 3600_000 }
92const RECENT_MS = 30 * 60_000 // the stretch the recent pace is read over
93const RECENT_MIN_MS = 10 * 60_000 // less than this of it says too little
94
95export type Sample = { t: number; pct: number }
96
97// Percent per millisecond over the last half hour of samples, or undefined with under ten minutes
98// of them. Zero when nothing was used: at that pace the window never runs out.
99export function recentRate(samples: readonly Sample[], now: number): number | undefined {
100  const recent = samples.filter(s => s.t >= now - RECENT_MS)
101  const first = recent[0]
102  const lastOne = recent.at(-1)
103  if (!first || !lastOne || lastOne.t - first.t < RECENT_MIN_MS) return undefined
104  return Math.max(0, (lastOne.pct - first.pct) / (lastOne.t - first.t))
105}
106
107// When a rate-limit window runs out, if that comes before its reset: at the recent pace (`rate`,
108// percent per ms) when there is one, else at the window's average pace so far, which needs a
109// tenth of the window behind it. Undefined for a window with no known length.
110export function exhaustsAt(r: { kind: string; percentUsed: number; resetsAt?: string }, now: number, rate?: number): number | undefined {
111  const length = WINDOW_MS[r.kind]
112  const reset = r.resetsAt ? Date.parse(r.resetsAt) : NaN
113  if (!length || Number.isNaN(reset) || r.percentUsed <= 0 || r.percentUsed >= 100) return undefined
114  let at: number
115  if (rate !== undefined) {
116    if (rate <= 0) return undefined
117    at = now + (100 - r.percentUsed) / rate
118  } else {
119    const elapsed = length - (reset - now)
120    if (elapsed < length / 10) return undefined
121    at = now + ((100 - r.percentUsed) / r.percentUsed) * elapsed
122  }
123  return at < reset ? at : undefined
124}
125
126// A rate-limit window as a five-cell meter.
127export function meter(percent: number, width = 5): string {
128  const full = Math.min(width, Math.round((percent / 100) * width))
129  return '▰'.repeat(full) + '▱'.repeat(width - full)
130}
131
132// Cells a string takes: CJK, fullwidth forms and the weather symbols count two.
133export function cells(text: string): number {
134  let n = 0
135  for (const ch of text) n += /[ᄀ-ᅟ☀-➿⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/.test(ch) ? 2 : 1
136  return n
137}
138
139export type { Chip }
140
141export function cacheChip(last: LastStep | null, now: number, ttl: number): Chip {
142  if (last === null) return { key: 'cache', text: '❄ 缓存 –', short: '❄ –', dim: true, rank: 2 }
143  const left = last.at + ttl - now
144  if (left <= 0) return { key: 'cache', text: '❄ 缓存已过期', short: '❄ 过期', color: 'red', rank: 2 }
145  const isSoon = left <= WARN_MS
146  return { key: 'cache', text: `❄ 缓存 ${clock(left)}${isSoon ? ' 快过期' : ''}`, short: `❄ ${clock(left)}`, color: isSoon ? 'yellow' : 'cyan', rank: 2 }
147}
148
149// What the chips are judged against: the context warning's size in tokens, and each rate-limit
150// window's recent pace (percent per ms) where one is known.
151export type Judge = { warnPercent: number; warnCapTokens: number; rates?: Readonly<Record<string, number | undefined>> }
152export const DEFAULT_JUDGE: Judge = { warnPercent: 80, warnCapTokens: 200_000 }
153
154// Every part the engine has a figure for, in the order the band shows them.
155export function chips(last: LastStep | null, now: number, ttl: number, usage: SessionUsage, history: readonly Reading[] = [], judge: Judge = DEFAULT_JUDGE): Chip[] {
156  const out: Chip[] = []
157  const { context, rateLimits, cost } = usage
158  const warnAt = context.window ? warnTokens(context.window, judge.warnPercent, judge.warnCapTokens) : undefined
159  if (context.percent !== undefined && context.tokens !== undefined) {
160    const f = forecast(warnAt ? forecastPercent(context.tokens, warnAt) : context.percent)
161    out.push({
162      key: 'context', rank: 1, color: f.color,
163      text: `${f.icon} ${f.word} 上下文 ${context.percent}% ${kCount(context.tokens)}/${kCount(context.window)}`,
164      short: `${f.icon} ${kCount(context.tokens)}`,
165    })
166  }
167  out.push(cacheChip(last, now, ttl))
168  for (const r of rateLimits) {
169    const pct = Math.round(r.percentUsed)
170    const label = WINDOW_LABEL[r.kind] ?? r.kind
171    const reset = hhmm(r.resetsAt, now)
172    const runsOut = exhaustsAt(r, now, judge.rates?.[r.kind])
173    out.push({
174      key: `limit:${r.kind}`, rank: 3, color: pct >= 90 ? 'red' : pct >= 70 || runsOut ? 'yellow' : undefined,
175      text: `${label} ${meter(pct)} ${pct}%${runsOut ? ` 预计${hhmmAt(runsOut, now)}用完` : ''}${reset ? ` ${reset}重置` : ''}`,
176      short: `${label} ${pct}%${runsOut ? '!' : ''}`,
177    })
178  }
179  // Turns until the context warning, in place of a sparkline that took reading.
180  const left = turnsLeft(history, warnAt)
181  if (left !== undefined && warnAt !== undefined) out.push({ key: 'turns', text: `约 ${left} 轮到 ${kCount(warnAt)}`, short: `${left}轮`, color: left <= 3 ? 'yellow' : undefined, dim: left > 3, rank: 5 })
182  // The hit rate needs no action while the cache works; it shows only when it looks broken.
183  if (last?.hitPercent != null && last.hitPercent < 70) out.push({ key: 'hit', text: `命中 ${last.hitPercent}% 缓存可能失效`, short: `命中${last.hitPercent}%`, color: 'yellow', rank: 4 })
184  // A subscription's figure in API prices: informative, never a reason to act, so last to go.
185  if (cost) out.push({ key: 'cost', text: `$${cost.usd.toFixed(2)}`, short: `$${cost.usd.toFixed(2)}`, dim: true, rank: 8 })
186  return out
187}
188
189export const SEP = ' │ '
190
191// Fits the chips into at most `maxRows` rows of `columns` cells: full text where everything fits,
192// the short forms where it does not, then the lowest-ranked chips dropped until it fits.
193export function layout(all: readonly Chip[], columns: number, maxRows = 2): { chip: Chip; label: string }[][] {
194  const pack = (items: { chip: Chip; label: string }[]) => {
195    const rows: { chip: Chip; label: string }[][] = []
196    let used = 0
197    for (const it of items) {
198      const w = cells(it.label)
199      const row = rows.at(-1)
200      if (row && used + cells(SEP) + w <= columns) {
201        row.push(it)
202        used += cells(SEP) + w
203      } else {
204        rows.push([it])
205        used = w
206      }
207    }
208    return rows
209  }
210  let kept = [...all]
211  for (;;) {
212    for (const useShort of [false, true]) {
213      const rows = pack(kept.map(chip => ({ chip, label: useShort ? chip.short : chip.text })))
214      if (rows.length <= maxRows) return rows
215    }
216    if (kept.length <= 1) return pack(kept.map(chip => ({ chip, label: chip.short })))
217    const worst = Math.max(...kept.map(c => c.rank))
218    const at = kept.map(c => c.rank).lastIndexOf(worst)
219    kept = kept.filter((_, i) => i !== at)
220  }
221}
222
223// The whole meter as one plain line: /meter's first line, and what decides a redraw.
224export function line(last: LastStep | null, now: number, ttl: number, usage: SessionUsage, history: readonly Reading[] = [], judge: Judge = DEFAULT_JUDGE): string {
225  return chips(last, now, ttl, usage, history, judge).map(c => c.text).join(SEP)
226}
227
228// /meter's cache line. A step stored by an older version and kept across a reload has no TTL of its
229// own: it counts down 1h and names no source.
230export function cacheDetail(step: LastStep | null, now: number): string {
231  if (!step) return '提示缓存:主线程还没有响应'
232  const ttl = step.ttl ?? ttlMs('1h')
233  const source = step.ttlSource ? `(${step.ttlSource})` : ''
234  const left = step.at + ttl - now
235  return `提示缓存:上次主线程响应在 ${clock(now - step.at)} 前,TTL ${ttl / 60_000} 分钟${source},${left > 0 ? `还剩 ${clock(left)}` : '已过期'};命中率 ${step.hitPercent ?? '–'}%`
236}
237
hooks/standup.ts 125 lines
1// Stand-up reminder: how long the person has sat at Claude Code, kept in $.store so every open
2// session shares one clock. Ten minutes with no prompt counts as a break and starts it over.
3import type { Chip } from './format'
4
5const MINUTE = 60_000
6export const IDLE_RESET_MS = 10 * MINUTE // no prompt this long: they were away, the next prompt restarts the clock
7export const SNOOZE_MS = 10 * MINUTE
8const SOON_MS = 10 * MINUTE // the chip turns yellow this long before the reminder
9
10// `day` / `breaks`: the local date and how many breaks were taken on it, for the encouragement line.
11export type Standup = { since: number; lastActiveAt: number; snoozeUntil: number; nudged: string; day: string; breaks: number }
12export type Phase = 'away' | 'ok' | 'soon' | 'due'
13
14/** The local calendar date, `YYYY-MM-DD`. */
15export function dayOf(now: number): string {
16  const d = new Date(now)
17  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
18}
19
20export const fresh = (now: number): Standup => ({ since: now, lastActiveAt: now, snoozeUntil: 0, nudged: '', day: dayOf(now), breaks: 0 })
21
22/** A stored value as a Standup; anything else (nothing stored yet, an older shape) starts fresh. */
23export function parse(value: unknown, now: number): Standup {
24  const v = value as Partial<Standup> | undefined
25  const isOk = v && typeof v.since === 'number' && typeof v.lastActiveAt === 'number'
26  if (!isOk) return fresh(now)
27  return { since: v.since!, lastActiveAt: v.lastActiveAt!, snoozeUntil: v.snoozeUntil ?? 0, nudged: v.nudged ?? '', day: v.day ?? dayOf(now), breaks: v.breaks ?? 0 }
28}
29
30/** A new sitting after a break, counting the break toward today. */
31function afterBreak(s: Standup, now: number): Standup {
32  const today = dayOf(now)
33  return { ...fresh(now), day: today, breaks: (s.day === today ? s.breaks : 0) + 1 }
34}
35
36/** A prompt from the person: after a break (ten minutes without one) the clock starts over. */
37export function onActivity(s: Standup, now: number): Standup {
38  return now - s.lastActiveAt >= IDLE_RESET_MS ? afterBreak(s, now) : { ...s, lastActiveAt: now }
39}
40
41/** Breaks taken today; a count from an earlier day is none. */
42export const breaksToday = (s: Standup, now: number) => (s.day === dayOf(now) ? s.breaks : 0)
43
44export const done = (s: Standup, now: number): Standup => afterBreak(s, now)
45export const snooze = (s: Standup, now: number): Standup => ({ ...s, snoozeUntil: now + SNOOZE_MS })
46
47export function phase(s: Standup, now: number, intervalMs: number): { phase: Phase; sat: number } {
48  const sat = Math.max(0, now - s.since)
49  if (now - s.lastActiveAt >= IDLE_RESET_MS) return { phase: 'away', sat }
50  if (sat >= intervalMs && now >= s.snoozeUntil) return { phase: 'due', sat }
51  if (sat >= intervalMs - SOON_MS) return { phase: 'soon', sat } // also while snoozed
52  return { phase: 'ok', sat }
53}
54
55/** The one reminder per sitting (and per snooze) the toast goes out for. */
56export const nudgeKey = (s: Standup) => `${s.since}:${s.snoozeUntil}`
57
58export function duration(ms: number): string {
59  const m = Math.floor(ms / MINUTE)
60  return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
61}
62
63export function standupChip(p: { phase: Phase; sat: number }): Chip | undefined {
64  const d = duration(p.sat)
65  if (p.phase === 'away') return undefined
66  if (p.phase === 'due') return { key: 'standup', text: `起来活动下!已坐 ${d}`, short: `起来!${d}`, color: 'red', rank: 1 }
67  if (p.phase === 'soon') return { key: 'standup', text: `已坐 ${d} 快该起来了`, short: `坐${d}`, color: 'yellow', rank: 3 }
68  return { key: 'standup', text: `已坐 ${d}`, short: `坐${d}`, dim: true, rank: 7 }
69}
70
71// Two frames of an 8×6 pixel figure: arms down, then stretching up. '#' is a lit pixel.
72const FRAMES: readonly (readonly string[])[] = [
73  ['...##...', '...##...', '..####..', '.#.##.#.', '..#..#..', '..#..#..'],
74  ['.#.##.#.', '.#.##.#.', '..####..', '...##...', '..#..#..', '.#....#.'],
75]
76
77/** Pixel rows two at a time as half blocks: one text row shows two pixel rows. */
78export function halfBlocks(grid: readonly string[]): string[] {
79  const out: string[] = []
80  for (let y = 0; y < grid.length; y += 2) {
81    const top = grid[y] ?? ''
82    const bottom = grid[y + 1] ?? ''
83    let row = ''
84    for (let x = 0; x < Math.max(top.length, bottom.length); x++) {
85      const t = top[x] === '#'
86      const b = bottom[x] === '#'
87      row += t && b ? '█' : t ? '▀' : b ? '▄' : ' '
88    }
89    out.push(row)
90  }
91  return out
92}
93
94export const figure = (frame: number): string[] => halfBlocks(FRAMES[frame % FRAMES.length] ?? FRAMES[0]!)
95
96// One suggestion per sitting, in turn: the same sentence every hour soon goes unread.
97const TIPS = [
98  '站起来走两步,伸个懒腰,3 分钟就够。',
99  '看向 6 米外的地方 20 秒,让眼睛放松。',
100  '去倒杯水,顺便走动一下。',
101  '转转脖子、耸耸肩,放松肩颈。',
102  '站起来做 10 个深蹲,或者原地踏步一分钟。',
103] as const
104
105/** The words beside the figure, so the picture is never the only message. */
106export function advice(s: Standup, sat: number, now: number): string[] {
107  const n = breaksToday(s, now)
108  return [
109    `你已经连续坐了 ${duration(sat)}。`,
110    TIPS[Math.floor(s.since / MINUTE) % TIPS.length] ?? TIPS[0],
111    ...(n > 0 ? [`今天已经活动了 ${n} 次,继续保持。`] : []),
112  ]
113}
114
115/**
116 * The night a late-hour reminder belongs to (the evening's date), or undefined outside the late
117 * hours: from `hour` until 5 in the morning. `hour` 0 turns it off.
118 */
119export function lateNight(now: number, hour: number): string | undefined {
120  if (hour <= 0 || hour > 23) return undefined
121  const h = new Date(now).getHours()
122  if (h >= hour) return dayOf(now)
123  return h < 5 ? dayOf(now - 24 * 3600_000) : undefined
124}
125
types/index.d.ts 21 lines
1// The last main-thread response: when it finished, how much of its prompt the cache served,
2// and the TTL its cache write got (fixed at that moment: a later overage does not shorten it).
3// `ttl` / `ttlSource` are absent on a step stored by an older version and kept across a reload.
4export type LastStep = { at: number; hitPercent: number | null; ttl?: number; ttlSource?: string }
5// Context tokens after each main-thread turn, newest last, for the turns-left estimate.
6export type Reading = { tokens: number }
7
8// One piece of the band. `short` is what it says when room runs out; `rank` 1 is kept longest.
9export type Chip = { key: string; text: string; short: string; color?: string; dim?: boolean; rank: number }
10
11// What the band shows, worked out once a second by the tick and only read while drawing.
12// `standup`: the reminder panel while it is due, with the figure's frame; `failing`: the tick has
13// failed several times in a row, so the figures may be stale.
14export type View = { chips: Chip[]; standup?: { tips: string[]; frame: number }; failing?: boolean }
15
16declare module 'claude-code' {
17  interface PluginState {
18    'session-meter': { last: LastStep | null; readings: Reading[]; view: View | null }
19  }
20}
21