Subscription limit meters above the prompt, plus a live 'what's happening' feed and a short AI summary of each turn.

A Claude Code mod that keeps an eye on your session: subscription limit meters, context use and cost in a card above the prompt, plus a Monitor pane with the current task, a short AI summary of each turn and a live log of every tool call.
╭─ monitor ───────────────────────────────────────────────────────────── opus-5-5 ──╮
│ 5h ██████┃▌░░░ 52% ● 2h13m │ 7d ███┃░░░░░░ 28% ▼ 3d4h │ ctx ██░░ 31% │ $1.84 │ │
├────────────────────────────────────────────────────────────────────────────────────┤
│ ● Running: Run tests 1m05s │
╰────────────────────────────────────────────────────────────────────────────────────╯
┃ tick on each meter is where an even burn through the window would put you right now. The glyph after the percent says whether you're ahead of it (▲), on track (●) or behind it (▼).The card adapts to the terminal width, dropping the less important stats first.
↳.| Command | What it does |
|---|---|
/monitor | Opens the Monitor pane. |
/monitor off | Turns the AI turn summary off. |
/monitor on | Turns it back on. |
The pane opens on its own when a session starts.
After each turn, the mod sends a short request to Haiku containing your prompt (cut to 400 characters), the list of tool-call descriptions from the turn, and the start of Claude's reply (cut to 1,500 characters). It uses your normal Claude Code account, so it counts toward your usage like any other request, though it's small (at most 120 output tokens). Use /monitor off if you'd rather not have it. The prompt it sends is in hooks/lib.ts.
Nothing else leaves your machine, and the mod doesn't write any files.
The mod is loaded from a folder on disk through the CLAUDE_CODE_PLUGIN_DIRS setting, so it's available in every project.
Windows (PowerShell)
git clone https://github.com/Andrew-MM-Net/claude-code-mod-monitor.git "$HOME\.claude\mods\mod-monitor"
macOS / Linux
git clone https://github.com/Andrew-MM-Net/claude-code-mod-monitor.git ~/.claude/mods/mod-monitor
~/.claude/settings.json (on Windows, C:\Users\<you>\.claude\settings.json), and add the folder to env. Use the full path; on Windows, double the backslashes: {
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "C:\\Users\\<you>\\.claude\\mods\\mod-monitor"
}
}
On macOS / Linux:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/home/<you>/.claude/mods/mod-monitor"
}
}
If you already have an env block, add the line to it rather than creating a second one. If CLAUDE_CODE_PLUGIN_DIRS already lists another mod, add this folder to the list.
cd ~/.claude/mods/mod-monitor
git pull
Then restart Claude Code.
Remove the folder from CLAUDE_CODE_PLUGIN_DIRS in settings.json, restart Claude Code, and delete the folder.
hooks/hooks.json lists the hook module to load
hooks/register.tsx the hooks: the /monitor command, the card, the pane and the turn summary
hooks/lib.ts pure helpers (meters, countdowns, tool-call descriptions, the summary prompt)
types/index.d.ts the mod's state types
tests/monitor.test.ts tests
From the repo folder:
claude plugin validate .
claude plugin test .
.claude-plugin/types/ holds generated Claude Code type definitions for editor support. It's ignored by git.
hooks/register.tsx 366 lines1import { atom, read, update } from 'claude-code'
2import type { Color, Register, SessionMeasureInput, SessionUsage } from 'claude-code'
3
4import type { Activity, Meter } from '../types'
5import {
6 bar,
7 clip,
8 countdown,
9 describe,
10 elapsed,
11 expectedPercent,
12 fill,
13 forecast,
14 levelColor,
15 limitLabel,
16 modelLabel,
17 paceOf,
18 rule,
19 SUMMARY_SYSTEM,
20 summaryPrompt,
21 tokens,
22} from './lib'
23import type { Pace, Sample } from './lib'
24
25const PANE = 'mod-monitor'
26// The card's edges, and the darker groove the meters fill along.
27const EDGE = '#4a5263'
28const TRACK = '#333a47'
29
30// One styled run of a card row; its length is its width in cells.
31type Seg = { text: string; color?: Color; dim?: boolean; bold?: boolean }
32
33// The glyph after a limit's percent: ahead of, on, or behind an even burn through its window.
34const PACE: Record<Pace, { glyph: string; color: Color }> = {
35 over: { glyph: '▲', color: 'error' },
36 on: { glyph: '●', color: 'success' },
37 under: { glyph: '▼', color: 'suggestion' },
38}
39
40// A meter's runs, with a tick at `mark` percent: where an even burn would be now.
41const meterSegs = (percent: number, width: number, color: Color, mark: number | null): Seg[] => {
42 const { fill: done, track } = bar(percent, width)
43 if (mark === null) return [{ text: done, color }, { text: track, color: TRACK }]
44 const cells = [...done, ...track].map(c => ({ text: c, color }) as Seg)
45 cells.forEach((c, i) => i >= done.length && (c.color = TRACK))
46 cells[Math.min(width - 1, Math.floor((mark / 100) * width))] = { text: '┃', color: 'text', bold: true }
47 return cells.reduce<Seg[]>((runs, c) => {
48 const last = runs[runs.length - 1]
49 if (last && last.color === c.color && last.bold === c.bold) last.text += c.text
50 else runs.push({ ...c })
51 return runs
52 }, [])
53}
54
55const meter = atom({ plugin: 'mod-monitor', key: 'meter' } as const, null)
56const now = atom({ plugin: 'mod-monitor', key: 'now' } as const, null)
57const feed = atom({ plugin: 'mod-monitor', key: 'feed' } as const, [])
58const goal = atom({ plugin: 'mod-monitor', key: 'goal' } as const, null)
59const summary = atom({ plugin: 'mod-monitor', key: 'summary' } as const, null)
60const isSummarizing = atom({ plugin: 'mod-monitor', key: 'isSummarizing' } as const, false)
61const isSummaryOn = atom({ plugin: 'mod-monitor', key: 'isSummaryOn' } as const, true)
62const tick = atom({ plugin: 'mod-monitor', key: 'tick' } as const, 0)
63
64const toMeter = (u: SessionUsage | SessionMeasureInput): Meter => ({
65 limits: u.rateLimits.map(l => ({ kind: l.kind, percent: l.percentUsed, resetsAt: l.resetsAt })),
66 contextPercent: u.context.percent,
67 contextTokens: u.context.tokens,
68 contextWindow: u.context.window,
69 costUsd: u.cost?.usd,
70})
71
72export const register: Register = on => {
73 let turnStart = 0
74 let startedAt = 0
75 // Recent 5h readings, for the "full in" forecast.
76 let fiveHour: Sample[] = []
77 const sample = (m: Meter, at: number) => {
78 const percent = m.limits.find(l => l.kind === 'five_hour')?.percent
79 if (percent === undefined) return
80 // A drop means the window reset: the old pace no longer applies.
81 const last = fiveHour[fiveHour.length - 1]
82 if (last && percent < last.percent) fiveHour = []
83 fiveHour = [...fiveHour.filter(s => at - s.at <= 60 * 60_000), { at, percent }]
84 }
85
86 on('session.start', async ($, e, next) => {
87 await $.command.register({
88 name: 'monitor',
89 description: 'Open the monitor pane. "/monitor off" or "/monitor on" turns the AI turn summary off or on.',
90 })
91 void $.session.usage().then(async u => {
92 startedAt = u.startedAt
93 sample(toMeter(u), await $.clock.now())
94 await update($, meter, () => toMeter(u))
95 })
96 // Redraw once a minute so the reset countdowns move.
97 $.clock.every(60_000, () => {
98 void update($, tick, n => n + 1)
99 })
100 void $.ui.open({ id: PANE, title: 'Monitor' })
101
102 return next(e)
103 })
104
105 on('command.run', { command: 'monitor' }, async ($, e) => {
106 const arg = e.args.trim().toLowerCase()
107 if (arg === 'off' || arg === 'on') {
108 await update($, isSummaryOn, () => arg === 'on')
109 return { text: `AI turn summaries ${arg === 'on' ? 'on' : 'off'}.` }
110 }
111 await $.ui.open({ id: PANE, title: 'Monitor' })
112 return { text: 'Monitor pane opened.' }
113 })
114
115 on('session.measure', async ($, e, next) => {
116 sample(toMeter(e), await $.clock.now())
117 await update($, meter, () => toMeter(e))
118 return next(e)
119 })
120
121 on('prompt.submit', async ($, e, next) => {
122 if (e.turnId === undefined) {
123 turnStart = await $.clock.now()
124 await update($, goal, () => clip(e.text, 200))
125 }
126 return next(e)
127 })
128
129 on('tool.call', async ($, e, next) => {
130 const args = e as unknown as Record<string, unknown>
131 const text = (e.agentId ? '↳ ' : '') + describe(String(e.tool), args)
132 const item: Activity = { id: e.tool_use_id, at: await $.clock.now(), text, state: 'running' }
133 if (!e.agentId) await update($, now, () => text)
134 await update($, feed, list => [...list, item].slice(-60))
135
136 const ran = await next(e)
137
138 const state: Activity['state'] = ran.deny !== undefined || ran.isError ? 'error' : 'done'
139 await update($, feed, list => list.map(a => (a.id === item.id ? { ...a, state } : a)))
140 return ran
141 })
142
143 on('turn.complete', async ($, e, next) => {
144 if (!e.agentId) {
145 await update($, now, () => null)
146 const answer = e.answer
147 // Summarize off the turn's own dispatch, so the turn ends right away.
148 $.clock.after(0, () => {
149 void (async () => {
150 if (!(await read($, isSummaryOn))) return
151 const steps = (await read($, feed)).filter(a => a.at >= turnStart).map(a => a.text).slice(-20)
152 await update($, isSummarizing, () => true)
153 try {
154 const reply = await $.model.complete({
155 model: 'haiku',
156 system: SUMMARY_SYSTEM,
157 prompt: summaryPrompt(await read($, goal), steps, answer),
158 maxTokens: 120,
159 effort: 'low',
160 })
161 if (reply.isAnswered && reply.text.trim()) {
162 await update($, summary, () => clip(reply.text, 300))
163 }
164 } finally {
165 // A failed call must not leave the pane saying "updating…" forever.
166 await update($, isSummarizing, () => false)
167 }
168 })()
169 })
170 }
171 return next(e)
172 })
173
174 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
175 if (e.props.hasSurvey) return next(e)
176 const { Box, Text } = $.ui.resolve(e)
177 await read($, tick)
178 const m = await read($, meter)
179 const doing = await read($, now)
180 const said = await read($, summary)
181 const model = modelLabel(await $.session.model())
182 const clock = await $.clock.now()
183
184 // A rounded card drawn by hand, so the title and model can sit in its top edge.
185 const cols = e.props.bodyColumns
186 const inner = cols - 2
187 const limits = m?.limits ?? []
188 const SEP: Seg = { text: ' │', color: EDGE }
189 const widthOf = (segs: Seg[]) => segs.reduce((n, s) => n + s.text.length, 0)
190
191 const limitSegs = (w: number): Seg[] =>
192 limits.length === 0
193 ? [{ text: ' limits: waiting for first reply', dim: true }, SEP]
194 : limits.flatMap(l => {
195 const resetsIn = l.resetsAt ? Date.parse(l.resetsAt) - clock : undefined
196 const resets = resetsIn !== undefined ? countdown(resetsIn) : ''
197 const expected = expectedPercent(l.kind, resetsIn)
198 const pace = expected === null ? null : PACE[paceOf(l.percent, expected)]
199 return [
200 { text: ` ${limitLabel(l.kind)} `, dim: true },
201 ...meterSegs(l.percent, w, levelColor(l.percent), expected),
202 { text: ` ${String(Math.round(l.percent)).padStart(3)}%`, color: levelColor(l.percent), bold: true },
203 ...(pace ? [{ text: ` ${pace.glyph}`, color: pace.color, bold: true }] : []),
204 { text: ` ${resets.padEnd(6)}`, dim: true },
205 SEP,
206 ]
207 })
208
209 const pct = m?.contextPercent
210 const ctxPlain: Seg[] =
211 pct === undefined ? [] : [{ text: ' ctx ', dim: true }, { text: `${pct}%`, color: levelColor(pct), bold: true }, SEP]
212 // On a wide band: a mini meter and the token count, lavender until it nears full.
213 const ctxColor: Color = pct !== undefined && pct >= 70 ? levelColor(pct) : 'permission'
214 const ctxRich: Seg[] =
215 pct === undefined || m?.contextTokens === undefined || !m.contextWindow
216 ? ctxPlain
217 : [
218 { text: ' ctx ', dim: true },
219 { text: bar(pct, 8).fill, color: ctxColor },
220 { text: bar(pct, 8).track, color: TRACK },
221 { text: ` ${pct}%`, color: ctxColor, bold: true },
222 { text: ` ${tokens(m.contextTokens)}/${tokens(m.contextWindow)}`, dim: true },
223 SEP,
224 ]
225
226 const usd = m?.costUsd
227 const ran = startedAt > 0 ? clock - startedAt : 0
228 const costPlain: Seg[] = usd === undefined ? [] : [{ text: ` $${usd.toFixed(2)}` }, SEP]
229 const costRich: Seg[] =
230 usd === undefined
231 ? []
232 : [
233 { text: ` $${usd.toFixed(2)}`, bold: true },
234 ...(ran >= 5 * 60_000 ? [{ text: ` ≈$${(usd / (ran / 3_600_000)).toFixed(2)}/h`, dim: true }] : []),
235 SEP,
236 ]
237 const session: Seg[] = ran > 0 ? [{ text: ' ◷ ', dim: true }, { text: countdown(ran) }, SEP] : []
238
239 const steps = turnStart > 0 ? (await read($, feed)).filter(a => a.at >= turnStart) : []
240 const failed = steps.filter(a => a.state === 'error').length
241 const tally: Seg[] = steps.length
242 ? [
243 { text: ' ✓', color: 'success' },
244 { text: `${steps.length - failed} `, color: 'success', bold: true },
245 { text: '✗', color: failed ? 'error' : undefined, dim: !failed },
246 { text: `${failed}`, color: failed ? 'error' : undefined, dim: !failed, bold: failed > 0 },
247 SEP,
248 ]
249 : []
250
251 const five = limits.find(l => l.kind === 'five_hour')
252 const eta = five ? forecast(fiveHour, clock, five.resetsAt ? Date.parse(five.resetsAt) - clock : undefined) : null
253 const pace: Seg[] =
254 eta === null
255 ? []
256 : [
257 { text: ' 5h full in ', dim: true },
258 { text: eta === 0 ? 'now' : `~${countdown(eta)}`, color: 'warning', bold: true },
259 { text: ' at this pace', dim: true },
260 SEP,
261 ]
262
263 // The richest row that fits; a narrow band falls back to the plain one with shrinking meters.
264 const wide = limitSegs(12)
265 const stats = [
266 [...wide, ...ctxRich, ...costRich, ...session, ...tally, ...pace],
267 [...wide, ...ctxRich, ...costRich, ...session, ...tally],
268 [...wide, ...ctxRich, ...costRich, ...session],
269 [...wide, ...ctxRich, ...costPlain],
270 ].find(segs => widthOf(segs) <= inner) ?? [
271 ...limitSegs(Math.max(4, Math.min(12, Math.floor((inner - 20 * limits.length - 20) / Math.max(1, limits.length))))),
272 ...ctxPlain,
273 ...costPlain,
274 ]
275
276 const tail = turnStart > 0 ? ` ${elapsed(clock - turnStart)}` : ''
277 const status: Seg[] | null =
278 e.props.isWorking && doing
279 ? [
280 { text: ' ● ', color: 'claude' },
281 { text: clip(doing, inner - 4 - tail.length) },
282 { text: tail, dim: true },
283 ]
284 : !e.props.isWorking && said
285 ? [
286 { text: ' ✓ ', color: 'success', bold: true },
287 { text: clip(said, inner - 4), dim: true },
288 ]
289 : null
290
291 const row = (segs: Seg[]) => (
292 <Text wrap="truncate-end">
293 <Text color={EDGE}>│</Text>
294 {segs.map(s => (
295 <Text color={s.color} dimColor={s.dim} bold={s.bold}>
296 {s.text}
297 </Text>
298 ))}
299 <Text>{fill(widthOf(segs), inner)}</Text>
300 <Text color={EDGE}>│</Text>
301 </Text>
302 )
303
304 return (
305 <Box flexDirection="column" width="100%">
306 <Text wrap="truncate-end">
307 <Text color={EDGE}>╭─</Text>
308 <Text color="claude" bold>
309 {' monitor '}
310 </Text>
311 <Text color={EDGE}>{rule('─', cols - 16 - model.length)}</Text>
312 <Text> </Text>
313 <Text color="success" bold>{model}</Text>
314 <Text> </Text>
315 <Text color={EDGE}>──╮</Text>
316 </Text>
317 {row(stats)}
318 {status && <Text color={EDGE}>├{rule('─', inner)}┤</Text>}
319 {status && row(status)}
320 <Text color={EDGE}>╰{rule('─', inner)}╯</Text>
321 </Box>
322 )
323 })
324
325 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
326 const { Box, Text } = $.ui.resolve(e)
327 const asked = await read($, goal)
328 const said = await read($, summary)
329 const busy = await read($, isSummarizing)
330 const isOn = await read($, isSummaryOn)
331 const list = await read($, feed)
332 const room = Math.max(3, e.props.scroll.bodyRows - 9)
333 const heading = (icon: string, label: string, color: Color, note?: string) => (
334 <Text wrap="truncate-end">
335 <Text color={color} bold>
336 {icon} {label}
337 </Text>
338 {note && <Text dimColor> · {note}</Text>}
339 </Text>
340 )
341
342 return (
343 <Box flexDirection="column" backgroundColor="black" width="100%" minHeight={e.props.scroll.bodyRows}>
344 {heading('◆', 'TASK', 'suggestion')}
345 <Text wrap="wrap">{asked ?? 'Nothing asked yet.'}</Text>
346 <Text> </Text>
347 {heading('✦', 'SUMMARY', 'permission', busy ? 'updating…' : isOn ? undefined : 'off')}
348 <Text wrap="wrap" dimColor={!said}>
349 {isOn ? (said ?? 'Appears after the first turn finishes.') : 'Off. Type /monitor on to turn it back on.'}
350 </Text>
351 <Text> </Text>
352 {heading('▶', 'ACTIVITY', 'claude', list.length ? `${list.length} steps` : undefined)}
353 {list.length === 0 && <Text dimColor>No tool calls yet.</Text>}
354 {list.slice(-room).map(a => (
355 <Text
356 wrap="truncate-end"
357 color={a.state === 'error' ? 'error' : a.state === 'running' ? 'claude' : 'success'}
358 >
359 {a.state === 'running' ? '●' : a.state === 'error' ? '✗' : '✓'} {a.text}
360 </Text>
361 ))}
362 </Box>
363 )
364 })
365}
366hooks/lib.ts 147 lines1// Pure helpers: no `$`, so the tests can call them directly.
2
3export const clip = (text: string, max: number): string => {
4 const flat = text.replace(/\s+/g, ' ').trim()
5 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
6}
7
8const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
9
10// A solid meter: `fill` is drawn in the level color, `track` (solid blocks) dimmed behind it.
11export const bar = (percent: number, width: number): { fill: string; track: string } => {
12 const eighths = Math.max(0, Math.min(width * 8, Math.round((percent / 100) * width * 8)))
13 const full = Math.floor(eighths / 8)
14 const part = EIGHTHS[eighths % 8]!
15 return { fill: '█'.repeat(full) + part, track: '█'.repeat(width - full - (part ? 1 : 0)) }
16}
17
18export const levelColor = (percent: number): 'success' | 'warning' | 'error' =>
19 percent >= 90 ? 'error' : percent >= 70 ? 'warning' : 'success'
20
21export const limitLabel = (kind: string): string =>
22 kind === 'five_hour' ? '5h' : kind === 'seven_day' ? '7d' : kind === 'spend_limit' ? 'spend' : kind
23
24export const countdown = (ms: number): string => {
25 if (ms <= 0) return 'now'
26 const minutes = Math.ceil(ms / 60_000)
27 const days = Math.floor(minutes / 1440)
28 const hours = Math.floor((minutes % 1440) / 60)
29 const mins = minutes % 60
30 if (days > 0) return `${days}d${hours}h`
31 if (hours > 0) return `${hours}h${String(mins).padStart(2, '0')}m`
32 return `${mins}m`
33}
34
35const base = (path: unknown): string =>
36 typeof path === 'string' ? (path.split(/[\\/]/).pop() ?? path) : 'a file'
37
38const str = (value: unknown): string => (typeof value === 'string' ? value : '')
39
40// One plain-language line for a tool call, from its name and arguments.
41export const describe = (tool: string, args: Record<string, unknown>): string => {
42 switch (tool) {
43 case 'Bash':
44 case 'PowerShell':
45 return `Running: ${clip(str(args.description) || str(args.command), 60)}`
46 case 'Read':
47 return `Reading ${base(args.file_path)}`
48 case 'Edit':
49 case 'MultiEdit':
50 return `Editing ${base(args.file_path)}`
51 case 'Write':
52 return `Writing ${base(args.file_path)}`
53 case 'NotebookEdit':
54 return `Editing notebook ${base(args.notebook_path)}`
55 case 'Grep':
56 return `Searching code for "${clip(str(args.pattern), 40)}"`
57 case 'Glob':
58 return `Finding files ${clip(str(args.pattern), 40)}`
59 case 'WebSearch':
60 return `Searching the web: ${clip(str(args.query), 50)}`
61 case 'WebFetch': {
62 const url = str(args.url)
63 try {
64 return `Fetching ${new URL(url).host}`
65 } catch {
66 return 'Fetching a web page'
67 }
68 }
69 case 'Agent':
70 case 'Task':
71 return `Started a helper agent: ${clip(str(args.description), 50)}`
72 case 'Skill':
73 return `Using skill ${str(args.skill)}`
74 case 'ToolSearch':
75 return 'Loading tools'
76 default: {
77 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
78 if (mcp) return `Using ${mcp[1]!.replace(/^claude_ai_/, '').replace(/_/g, ' ')}: ${mcp[2]!.replace(/_/g, ' ')}`
79 return `Using ${tool}`
80 }
81 }
82}
83
84export const summaryPrompt = (goal: string | null, steps: readonly string[], answer: string): string =>
85 [
86 `The user asked: ${goal ? clip(goal, 400) : '(unknown)'}`,
87 '',
88 'Steps the coding assistant took:',
89 ...(steps.length ? steps.map(s => `- ${s}`) : ['- (no tool calls)']),
90 '',
91 `Its final reply (may be cut): ${clip(answer, 1500)}`,
92 ].join('\n')
93
94export const SUMMARY_SYSTEM =
95 'You write a status line for someone watching a coding assistant work. ' +
96 'In one or two short, plain sentences, say what was just done and where things stand. ' +
97 'No preamble, no markdown, under 40 words.'
98
99// A short model label: "claude-opus-5-5[1m]" reads as "opus-5-5[1m]".
100export const modelLabel = (model: string): string => model.replace(/^claude-/, '').trim() || model
101
102// Repeats a box-drawing run, never negative (a narrow band just loses the run).
103export const rule = (char: string, count: number): string => char.repeat(Math.max(0, count))
104
105// Spaces that fill a card row out to `inner` cells, given what it already holds.
106export const fill = (used: number, inner: number): string => rule(' ', inner - used)
107
108// Time since the turn began: "12s", "3m05s".
109export const elapsed = (ms: number): string => {
110 const secs = Math.max(0, Math.floor(ms / 1000))
111 return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m${String(secs % 60).padStart(2, '0')}s`
112}
113
114// A token count as the band shows it: "62k", "1.2M".
115export const tokens = (n: number): string =>
116 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}k`
117
118export type Sample = { at: number; percent: number }
119
120// When a limit will fill at its recent pace, in ms from `now`, or null when it isn't
121// climbing, there's under 10 minutes of history, or it resets first.
122export const forecast = (samples: readonly Sample[], now: number, resetsIn?: number): number | null => {
123 const first = samples[0]
124 const last = samples[samples.length - 1]
125 if (!first || !last || last.at - first.at < 10 * 60_000 || last.percent <= first.percent) return null
126 const perMs = (last.percent - first.percent) / (last.at - first.at)
127 const eta = (100 - last.percent) / perMs - (now - last.at)
128 if (eta <= 0) return 0
129 return resetsIn !== undefined && eta >= resetsIn ? null : eta
130}
131
132const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3_600_000, seven_day: 7 * 86_400_000 }
133
134// Where an even burn through the window would have the meter now, or null for a
135// limit with no known window length or reset time.
136export const expectedPercent = (kind: string, resetsIn?: number): number | null => {
137 const window = WINDOW_MS[kind]
138 if (window === undefined || resetsIn === undefined) return null
139 return Math.max(0, Math.min(100, (1 - resetsIn / window) * 100))
140}
141
142export type Pace = 'over' | 'on' | 'under'
143
144// Within 5 points of the even-burn line counts as on track.
145export const paceOf = (percent: number, expected: number): Pace =>
146 percent - expected > 5 ? 'over' : expected - percent > 5 ? 'under' : 'on'
147types/index.d.ts 19 lines1export type Limit = { kind: string; percent: number; resetsAt?: string }
2export type Meter = { limits: Limit[]; contextPercent?: number; contextTokens?: number; contextWindow?: number; costUsd?: number }
3export type Activity = { id: string; at: number; text: string; state: 'running' | 'done' | 'error' }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'mod-monitor': {
8 meter: Meter | null
9 now: string | null
10 feed: Activity[]
11 goal: string | null
12 summary: string | null
13 isSummarizing: boolean
14 isSummaryOn: boolean
15 tick: number
16 }
17 }
18}
19