Local dashboard of where Claude Code time and tokens go, usage alerts, and tools to cut token use

A Claude Code plugin that shows where your Claude Code time and tokens go, warns you when sessions burn too much, and helps you cut usage.
http://127.0.0.1:8765): time and estimated cost per project, session, subagent and kind of work (reading code, editing, builds, tests, waiting on subagents, idle ...), built from the transcripts Claude Code already writes to ~/.claude/projects/.get_usage calls your sessions make, and projections to the next reset.get_usage call needed), and costs are shown as a share of the weekly limit ("% of week") once the allowance is fitted, with its uncertainty; dollars stay in the tooltips. (Needs a Claude Code build with function hooks.)coder, reader and runner agents with a small fixed context, plus hooks that stop subagents waiting on long jobs (each wake-up re-writes their whole cache).Everything stays on your machine. The dashboard shows labels, timings and token counts only (shell command descriptions, file names, subagent descriptions, model names). Prompts and tool outputs are never stored or served. The server binds to 127.0.0.1. No account, token or network access is needed.
get_usage tool used for plan readings; CLI-only sessions still get full cost and time tracking, just without exact weekly-limit readings.claude plugin marketplace add billw2012/claude-devtime claude plugin install devtime@devtime
Then restart Claude Code (or run /reload-plugins) and run /devtime:start. Inside a session the equivalent is /plugin marketplace add billw2012/claude-devtime and /plugin install devtime@devtime.
Or ask Claude Code to do it: open a session in a clone of this repo and say "set this up"; it follows INSTALL.md.
If you are signed into several Claude organisations, each gets its own devtime: separate data, server, port and dashboard (the page shows the org's name), and nothing of one org is visible to another. Run /devtime:start in a session of each org you want to track; python -m devtime org shows which org and port a shell uses. Settings in ~/.claude/devtime/config are shared.
/devtime:start sets everything up the first time and keeps it running. It checks Python and, with your confirmation, adds a marked block to ~/.claude/CLAUDE.md that asks sessions to read get_usage before launching agent stages and to stop starting new ones once the weekly limit is used up to the usable share: a reserve you choose (5 % of the limit per day left before the reset by default: 65 % usable with a week left, 85 % with 3 days, 95 % on the last day; or a fixed cap), and to pause new agents at a share of the current 5-hour window until it resets. It offers to remove duplicate hook entries, starts the dashboard and opens it, then runs a 30-minute loop in the session: record a plan reading, label new work for the weekly work lane, and every two hours let a small reviewer agent flag sessions that waste money. Keep that session open; a cheap model is fine for it.
/devtime:optimize measures your last 7 days, ranks the catalogue of optimizations by estimated $/week (slim agent types, long jobs on a Haiku runner, hand-off past ~250k context, auto-compact window, scripts first, limited parallelism, background jobs instead of poll loops, ...; the items that only matter for parallel work are offered once parallel support is on), asks which to apply, shows a dry-run diff and applies them to ~/.claude/CLAUDE.md or settings.json. Each application is recorded as a marker on the weekly chart so the effect is visible, and python -m devtime optimize remove <id> undoes it.
/devtime:ab turns an optimization (or any idea) into an experiment: /devtime:ab <id> creates it, /devtime:ab report <id> compares the arms (median cost and time, commits, rework rate, cost per commit, with a warning when there are too few stages to call it), /devtime:ab stop <id> ends it and offers to apply the winner. Arms are assigned by hash, not by the orchestrating session, and a hook enforces them.
/devtime:parallel is the optional add-on for running several sessions (or autonomous pipelines) on one PC, and it is off by default. devtime has a base set that is always on: the dashboard, usage alerts, the optimizations and A/B experiments. Parallel support only adds to it, and never turns anything off: the machine-wide slot queue (below) and the agent budget rules (subagents are refused sleeps, poll loops and long blocking waits, and are asked to hand off past ~250k tokens of context). Note that both also act in your interactive sessions on this PC: heavy commands there get queued too (interactive editors are exempt). /devtime:start asks once at first setup; later run /devtime:parallel or ask Claude to turn devtime parallel support on or off. It also adopts the slot queue in a project: several sessions (say one per project, running autonomously) share the GPU, headless editor / test runs and builds through one FIFO queue per resource (default gpu=2, test=3, build=2 at once; quiet = test+gpu exclusively). The plugin's hook queues any command that matches a rule in ~/.claude/devtime/config/slots.toml by rewriting it to python ~/.claude/devtime/devtime.py slot run <resource> -- <command>. The rules ship covering UnrealEditor(-Cmd), Build.bat / RunUAT / ue-parallel-build and background Blender, and rules for a project's own scripts are learned automatically: the devtime server watches for heavy programs (UnrealEditor, blender -b, UBT) started by a Claude Code shell outside the queue, finds the project script that started them and writes a rule for it (slot rules --learned lists them; timing runs, perf / measure / benchmark ..., are queued quiet). The skill is optional: review or adjust learned rules and add ones learning can't see. A slot frees itself however its job ends (crash, kill, closed session, reboot), and the hook refuses loops that wait for another process or a lock file. slot status shows who holds what and who waits; the dashboard shows queue waits and alerts a session whose ticket waits over 20 min. DEVTIME_SLOTS=0 turns the wrapping off. Without parallel support none of this is active (the slot queue and the agent budget hook rules stay off).
Runtime data (caches, alerts, plan readings, server log) lives in ~/.claude/devtime/; your settings and project profiles in ~/.claude/devtime/config/. Set DEVTIME_HOME to use another folder, CLAUDE_CONFIG_DIR if your Claude config is not ~/.claude. See docs/reference.md for settings, pricing, classification rules, project profiles (examples/projects/example.toml) and alert thresholds.
git clone https://github.com/billw2012/claude-devtime cd claude-devtime python -m devtime serve # dashboard on http://127.0.0.1:8765 python -m devtime report # text summary python -m unittest discover -s tests
claude plugin uninstall devtime@devtime claude plugin marketplace remove devtime
Then delete the blocks between <!-- devtime:start --> and <!-- devtime:end --> (and any <!-- devtime:opt:... --> / <!-- devtime:ab:... --> blocks; python -m devtime optimize remove <id> does the same) from ~/.claude/CLAUDE.md, and ~/.claude/devtime/ if you want the data gone.
MIT, see LICENSE.
hooks/register.tsx 258 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import { readingOf } from './readings'
5import { arrow, cost, paceText, pct, summarize } from './summary'
6
7// devtime's perf numbers (pace against the weekly limit, $/h, $/commit, waste, live burners, alerts), read from the
8// local devtime server every minute. No model calls. Only in the devtime session: the one that ran /devtime:start (or
9// /devtime-panel); every other session gets nothing drawn, no polling and no toasts. `/devtime-panel off` ends it.
10//
11// Each organisation on the PC has its own devtime server (devtime/paths.py). A session talks only to the server of its
12// own org: the org is env CLAUDE_CODE_ORGANIZATION_UUID, else the .claude.json login's; the port is that org's
13// <data dir>/orgs/<org>/cache/port.txt, and the server must say (/api/version `org`) that it is that org's. Every
14// request carries the session id and org, which the server checks against its transcripts. No org, no port file or
15// another org's server: nothing is posted or shown.
16
17const PANE = 'devtime'
18const TITLE = 'devtime'
19// What a click opens: the desktop app makes a link only of https: or http://localhost
20let DASHBOARD = 'http://localhost:8765/'
21let URL_BASE = ''
22const EVERY_MS = 60_000
23const COLUMNS = 46
24
25const summary = atom({ plugin: 'devtime', key: 'summary' } as const, null)
26const error = atom({ plugin: 'devtime', key: 'error' } as const, null)
27const active = atom({ plugin: 'devtime', key: 'active' } as const, false)
28const START = /^\/?devtime:start/
29
30let timer: { cancel: () => void } | null = null
31
32let lastAlertKey: string | null | undefined
33
34const ORG_ID = /^[0-9A-Za-z][0-9A-Za-z-]{3,63}$/
35const trim = (p: string) => p.replace(/[\/]+$/, '')
36
37async function homeDir($: EngineInterface): Promise<string | undefined> {
38 return (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
39}
40
41/** This session's org: env CLAUDE_CODE_ORGANIZATION_UUID, else oauthAccount.organizationUuid of the .claude.json
42 * ($CLAUDE_CONFIG_DIR/.claude.json or ~/.claude.json). null when neither names one. */
43export async function sessionOrg($: EngineInterface): Promise<string | null> {
44 try {
45 const env = ((await $.env.get('CLAUDE_CODE_ORGANIZATION_UUID')) || '').trim()
46 if (env) return ORG_ID.test(env) ? env : null
47 const config = await $.env.get('CLAUDE_CONFIG_DIR')
48 const home = await homeDir($)
49 const file = config ? trim(config) + '/.claude.json' : home ? trim(home) + '/.claude.json' : null
50 if (!file) return null
51 const org = JSON.parse(String(await $.fs.read(file)))?.oauthAccount?.organizationUuid
52 return typeof org === 'string' && ORG_ID.test(org.trim()) ? org.trim() : null
53 } catch {
54 return null
55 }
56}
57
58/** The port of this org's server: <data dir>/orgs/<org>/cache/port.txt (data dir = DEVTIME_HOME, else
59 * ~/.claude/devtime); null without one (no fallback: it could be another org's server). */
60async function orgPort($: EngineInterface, org: string): Promise<number | null> {
61 try {
62 let dir = await $.env.get('DEVTIME_HOME')
63 if (!dir) {
64 const home = await homeDir($)
65 if (!home) return null
66 dir = trim(home) + '/.claude/devtime'
67 }
68 const text = await $.fs.read(`${trim(dir)}/orgs/${org}/cache/port.txt`)
69 const n = parseInt(String(text).trim(), 10)
70 return n > 0 && n < 65536 ? n : null
71 } catch {
72 return null
73 }
74}
75
76/** The base URL of this session's own org's server, after it confirmed being that org's; else null. */
77async function ownServer($: EngineInterface, org: string): Promise<string | null> {
78 const port = await orgPort($, org)
79 if (!port) return null
80 const base = `http://127.0.0.1:${port}`
81 try {
82 const res = await $.http.fetch(base + '/api/version')
83 if (!res.ok || JSON.parse(res.text)?.org !== org) return null
84 } catch {
85 return null
86 }
87 return base
88}
89
90let lastKey = ''
91
92/** Posts a reading to this org's server; never throws. Identical consecutive readings of this session are not sent
93 * twice. */
94async function postReading($: EngineInterface, windows: SessionRateLimit[]): Promise<void> {
95 try {
96 const session = await $.session.id()
97 const r = readingOf(session, Date.now() / 1000, windows)
98 if (!r) return
99 const key = JSON.stringify([r.session, r.fiveHour, r.fiveHourReset, r.weekly, r.weeklyReset])
100 if (key === lastKey) return
101 const org = await sessionOrg($)
102 if (!org) return
103 const base = await ownServer($, org)
104 if (!base) return
105 const res = await $.http.fetch(`${base}/api/reading`, {
106 method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...r, org }) })
107 if (res.ok) lastKey = key // a failed post (server down) is tried again with the next measurement
108 } catch {
109 // silent by design
110 }
111}
112
113async function getJson($: EngineInterface, path: string) {
114 const org = await sessionOrg($)
115 if (!org) throw new Error('the organisation of this session is not known')
116 const base = await ownServer($, org)
117 if (!base) throw new Error('no devtime server of this organisation')
118 URL_BASE = base
119 DASHBOARD = base.replace('127.0.0.1', 'localhost') + '/'
120 const session = await $.session.id()
121 const who = `session=${encodeURIComponent(session)}&org=${encodeURIComponent(org)}`
122 const res = await $.http.fetch(URL_BASE + path + (path.includes('?') ? '&' : '?') + who)
123 if (!res.ok) throw new Error(`${path}: HTTP ${res.status}`)
124 return JSON.parse(res.text)
125}
126
127async function refresh($: EngineInterface) {
128 try {
129 const [overview, alerts] = await Promise.all([getJson($, '/api/overview'), getJson($, '/api/alerts')])
130 const s = summarize(overview, alerts?.alerts ?? [], Date.now() / 1000)
131 await update($, summary, () => s)
132 await update($, error, () => null)
133 if (lastAlertKey !== undefined && s.alertKey && s.alertKey !== lastAlertKey && s.alert) $.ui.toast(`devtime: ${s.alert}`)
134 lastAlertKey = s.alertKey
135 } catch (e) {
136 await update($, error, () => URL_BASE ? 'devtime server not answering on ' + URL_BASE
137 : 'no devtime server of this organisation (run /devtime:start in it)')
138 }
139}
140
141/** Makes this the devtime session: polling, the band and toasts start (idempotent). */
142async function activate($: EngineInterface) {
143 await update($, active, () => true)
144 if (!timer) {
145 timer = $.clock.every(EVERY_MS, () => void refresh($))
146 void refresh($)
147 }
148}
149
150export const register: Register = on => {
151 // Every session (CLI and desktop), silently: report the plan's usage windows to the devtime server. Observe only.
152 on('session.measure', async ($, e, next) => {
153 if (e.rateLimits.length > 0 && e.changed.includes('rateLimits')) await postReading($, e.rateLimits)
154 return next(e)
155 })
156
157 on('session.start', async ($, e, next) => {
158 await $.command.register({ name: 'devtime-panel', description: 'devtime perf view in this session (off: stop)' })
159 $.ui.status(undefined) // no status entry anywhere (clears one an earlier version left)
160 if (await read($, active)) await activate($) // a reload, or a resumed devtime session
161 return next(e)
162 })
163
164 // /devtime:start (and its loop's ticks) mark the devtime session.
165 on('prompt.submit', async ($, e, next) => {
166 if (START.test(e.text.trim())) await activate($)
167 return next(e)
168 }).catch(($, e, next) => next(e))
169 on('skill.prompt', async ($, e, next) => {
170 if (START.test(e.skill)) await activate($)
171 return next(e)
172 })
173
174 on('command.run', { command: 'devtime-panel' }, async ($, e) => {
175 if ((e.args ?? '').trim() === 'off') {
176 timer?.cancel()
177 timer = null
178 await update($, active, () => false)
179 return { text: 'devtime view off in this session.' }
180 }
181 await activate($)
182 await refresh($)
183 const opened = await $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS })
184 return { text: opened.isPlaced ? 'devtime pane opened.' : `devtime pane waits: ${opened.reason}` }
185 })
186
187 // One line above the prompt; the pane (its "details" button, or /devtime-panel) has the rest.
188 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
189 const s = await read($, summary)
190 if (e.props.hasSurvey || !s || !(await read($, active))) return next(e)
191 const { Box, Text, Button, Link } = $.ui.resolve(e)
192 const pace = paceText(s)
193 const paceColor = pace.level === 'over' ? 'error' : pace.level === 'warn' ? 'warning' : undefined
194 return (
195 <Box flexDirection="row" gap={1}>
196 <Box flexShrink={1}>
197 <Text wrap="truncate-end">
198 <Text dimColor>devtime </Text>
199 <Text color={paceColor}>{pace.text}{pace.limit ? ` (${pace.limit})` : ''}</Text>
200 <Text> · {cost(s, s.costPerHour)}/h {arrow(s.rateTrend)} · {s.costPerCommit === null ? '-' : cost(s, s.costPerCommit)}/commit · cache {s.cacheHit === null ? '-' : pct(s.cacheHit)}</Text>
201 {s.alerts ? <Text color="warning"> · {s.alerts} alert{s.alerts > 1 ? 's' : ''}</Text> : null}
202 </Text>
203 </Box>
204 <Button key="details" plain onPress={() => void $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS })}>details</Button>
205 <Link href={DASHBOARD} label="dashboard" />
206 </Box>
207 )
208 })
209
210 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
211 const { Box, Text, Button, Link } = $.ui.resolve(e)
212 const s = await read($, summary)
213 const err = await read($, error)
214 // Narrow by design (COLUMNS wide): a dim label column, then short lines that wrap.
215 const row = (label: string, ...lines: any[]) => (
216 <Box flexDirection="row">
217 <Box width={6} flexShrink={0}><Text dimColor>{label}</Text></Box>
218 <Box flexDirection="column" flexShrink={1}>{lines}</Box>
219 </Box>
220 )
221 const header = (
222 <Box flexDirection="row" gap={1} flexWrap="wrap">
223 <Text bold>{s ? s.range : 'devtime'}</Text>
224 <Link href={DASHBOARD} label="dashboard" />
225 <Button key="refresh" plain onPress={() => void refresh($)}>↻</Button>
226 </Box>
227 )
228 if (!s) {
229 return (
230 <Box flexDirection="column">
231 {header}
232 <Text dimColor wrap="wrap">{err ?? 'reading the devtime server...'}</Text>
233 </Box>
234 )
235 }
236 const pace = paceText(s)
237 const paceColor = pace.level === 'over' ? 'error' : pace.level === 'warn' ? 'warning' : undefined
238 return (
239 <Box flexDirection="column">
240 {header}
241 {err && <Text color="warning" wrap="wrap">server not answering; last reading</Text>}
242 {row('Pace', <Text color={paceColor} wrap="wrap">{pace.text}</Text>,
243 pace.limit && <Text color={paceColor}>{pace.limit}</Text>)}
244 {row('Rate', <Text wrap="wrap">{cost(s, s.costPerHour)}/h {arrow(s.rateTrend)}{s.fivePerHour === null ? '' : ` 5h ${pct(s.fivePerHour, 2)}/h`}</Text>)}
245 {row('Out', <Text wrap="wrap">{s.commits} commits · {s.costPerCommit === null ? '-' : cost(s, s.costPerCommit)} each</Text>)}
246 {row('Waste', <Text wrap="wrap">cache {s.cacheHit === null ? '-' : pct(s.cacheHit)} · rewrites {cost(s, s.churn)}</Text>,
247 <Text wrap="wrap">big ctx {cost(s, s.big)} ({pct(s.bigShare)})</Text>)}
248 {row('Live', ...(s.live.length === 0
249 ? [<Text dimColor>nothing burning</Text>]
250 : s.live.map(l => <Text wrap="truncate-end">{cost(s, l.burn30)}/30m {l.ctxK}k {l.name}</Text>)))}
251 {row('Alert', s.alerts
252 ? <Text color="warning" wrap="wrap">{s.alerts} · {(s.alert ?? '').slice(0, 80)}</Text>
253 : <Text dimColor>none</Text>)}
254 </Box>
255 )
256 })
257}
258hooks/readings.ts 18 lines1// Plan-usage readings from every session. The engine hands each session its rate-limit windows (five_hour, seven_day:
2// percent used and reset time); this module posts them to the local devtime server (POST /api/reading), which keeps
3// them as plan samples. Numbers and the session id only; nothing is drawn and every failure is ignored.
4
5export type Window = { kind: string; percentUsed: number; resetsAt?: string }
6export type Reading = { session: string; at: number; fiveHour?: number; fiveHourReset?: string; weekly?: number; weeklyReset?: string }
7
8/** The reading to post for these windows, or null when neither the 5-hour nor the weekly window is there. */
9export function readingOf(session: string, at: number, windows: Window[]): Reading | null {
10 const five = windows.find(w => w.kind === 'five_hour' && typeof w.percentUsed === 'number')
11 const week = windows.find(w => w.kind === 'seven_day' && typeof w.percentUsed === 'number')
12 if (!five && !week) return null
13 const r: Reading = { session, at }
14 if (five) { r.fiveHour = five.percentUsed; if (five.resetsAt) r.fiveHourReset = five.resetsAt }
15 if (week) { r.weekly = week.percentUsed; if (week.resetsAt) r.weeklyReset = week.resetsAt }
16 return r
17}
18hooks/summary.ts 81 lines1import type { Live, Summary } from '../types'
2
3// The devtime server's /api/overview and /api/alerts, reduced to what the pane shows.
4
5type Window = { costPerHour?: number; commits?: number; costPerCommit?: number | null; commitsPerHour?: number;
6 cacheHit?: number | null; churn?: number; big?: number; cost?: number }
7type Pace = { perHour?: number; atReset?: number; limitAt?: number | null }
8type Alert = { key?: string; active?: boolean; title?: string; text?: string }
9
10const num = (x: unknown, d = 0) => (typeof x === 'number' && isFinite(x) ? x : d)
11
12export function trend(now: number, before: number): 'up' | 'down' | 'flat' {
13 if (before <= 0) return now > 0 ? 'up' : 'flat'
14 return now > before * 1.1 ? 'up' : now < before * 0.9 ? 'down' : 'flat'
15}
16
17export function summarize(overview: any, alerts: Alert[], at: number): Summary {
18 const cur: Window = overview?.current ?? {}
19 const prev: Window = overview?.previous ?? {}
20 const pace: Pace[] = overview?.limits?.pace ?? []
21 const week = pace[0] ?? {}
22 const five = pace[1] ?? {}
23 const live: Live[] = (overview?.live ?? [])
24 .filter((r: any) => num(r.burn30) >= 0.01)
25 .sort((a: any, b: any) => num(b.burn30) - num(a.burn30))
26 .slice(0, 3)
27 .map((r: any) => ({ name: String(r.name ?? r.id ?? '?'), burn30: num(r.burn30), ctxK: Math.round(num(r.context) / 1000) }))
28 const active = alerts.filter(a => a.active)
29 const cost = num(cur.cost)
30 return {
31 at,
32 range: String(overview?.range ?? '24h'),
33 allowance: typeof overview?.limits?.allowance === 'number' && overview.limits.allowance > 0 ? overview.limits.allowance : null,
34 weeklyPerHour: typeof week.perHour === 'number' ? week.perHour : null,
35 weeklyAtReset: typeof week.atReset === 'number' ? week.atReset : null,
36 limitAt: typeof week.limitAt === 'number' ? week.limitAt : null,
37 fivePerHour: typeof five.perHour === 'number' ? five.perHour : null,
38 costPerHour: num(cur.costPerHour),
39 rateTrend: trend(num(cur.costPerHour), num(prev.costPerHour)),
40 commits: num(cur.commits),
41 costPerCommit: num(cur.commits) > 0 ? num(cur.costPerCommit) : null,
42 commitsPerHour: num(cur.commitsPerHour),
43 cacheHit: typeof cur.cacheHit === 'number' ? cur.cacheHit : null,
44 churn: num(cur.churn),
45 big: num(cur.big),
46 bigShare: cost > 0 ? num(cur.big) / cost : 0,
47 live,
48 alerts: active.length,
49 alert: active[0] ? String(active[0].title ?? active[0].text ?? '') : null,
50 alertKey: active[0]?.key ?? null,
51 }
52}
53
54const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
55const pad = (n: number) => String(n).padStart(2, '0')
56
57export function when(ts: number): string {
58 const d = new Date(ts * 1000)
59 return `${DAYS[d.getDay()]} ${pad(d.getHours())}:${pad(d.getMinutes())}`
60}
61
62export const pct = (f: number, nd = 0) => `${(f * 100).toFixed(nd)}%`
63export const usd = (x: number) => `$${x >= 100 ? x.toFixed(0) : x.toFixed(2)}`
64/** A cost as a share of the weekly limit ("1.4%") when the allowance is known, else dollars. */
65export const cost = (s: Pick<Summary, 'allowance'>, x: number) => {
66 if (!s.allowance) return usd(x)
67 const v = (100 * x) / s.allowance
68 return `${v >= 10 ? v.toFixed(0) : v >= 1 ? v.toFixed(1) : v.toFixed(2)}%`
69}
70export const arrow = (t: Summary['rateTrend']) => (t === 'up' ? '▲' : t === 'down' ? '▼' : '=')
71
72/** The pace text (weekly burn per hour, projected share at the reset), when the limit is hit (if before the reset),
73 * and whether it is over the limit at the reset. */
74export function paceText(s: Summary): { text: string; limit: string | null; level: 'ok' | 'warn' | 'over' } {
75 if (s.weeklyPerHour === null) return { text: 'no plan reading yet', limit: null, level: 'ok' }
76 const at = s.weeklyAtReset ?? 0
77 return { text: `${pct(s.weeklyPerHour, 2)}/h → ${pct(at)} at reset`,
78 limit: at > 1 && s.limitAt ? `limit hit ${when(s.limitAt)}` : null,
79 level: at > 1 ? 'over' : at > 0.85 ? 'warn' : 'ok' }
80}
81types/index.d.ts 31 lines1export type Live = { name: string; burn30: number; ctxK: number }
2
3export type Summary = {
4 at: number
5 range: string
6 allowance: number | null // API-equivalent dollars per 100 % of the week, once the plan's readings give one
7 weeklyPerHour: number | null
8 weeklyAtReset: number | null
9 limitAt: number | null
10 fivePerHour: number | null
11 costPerHour: number
12 rateTrend: 'up' | 'down' | 'flat'
13 commits: number
14 costPerCommit: number | null
15 commitsPerHour: number
16 cacheHit: number | null
17 churn: number
18 big: number
19 bigShare: number
20 live: Live[]
21 alerts: number
22 alert: string | null
23 alertKey: string | null
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'devtime': { summary: Summary | null; error: string | null; active: boolean }
29 }
30}
31