SLOPSHOPPER

devtime

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

panebandcommandtoaststatusprompt
v0.8.0MITupdated 2026-10-21billw2012/claude-devtime
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · devtime
│ ┃ devtime ✕ › fix the failing auth test and add an audit log call │ ┃ devtime dashboard http://localhost:8765/ ↻ │ ┃ no devtime server of this organisation (run ⏺ Read(src/auth.ts) │ ┃ /devtime:start in it) ⎿ 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 │ │ › /devtime-panel │ ⎿ devtime: devtime pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · devtime
devtime dashboard http://localhost:8765/ ↻ no devtime server of this organisation (run /devtime:start in it)
README

devtime

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.

  • Local dashboard (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/.
  • Weekly-limit chart: your plan's usage against the weekly limit, with exact readings harvested from the get_usage calls your sessions make, and projections to the next reset.
  • Readings from every session: a hooks module reports each session's 5-hour and weekly usage to the dashboard (no 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.)
  • Usage alerts: subagents that cost too much, re-write their prompt cache after long waits, run with huge contexts, or burn money fast. Alerts reach the dashboard and the session concerned.
  • Slim agent types: 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).
  • Optimization recommendations: your last 7 days ranked against a catalogue of changes, estimated in $/week.
  • A/B experiments: try a change (for example Sonnet instead of Opus for coding stages) with arms assigned by hash, and read the result per arm.

Privacy

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.

Requirements

  • Python 3.11 or newer (standard library only).
  • Claude Code. The Claude desktop app provides the get_usage tool used for plan readings; CLI-only sessions still get full cost and time tracking, just without exact weekly-limit readings.

Install

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.

Organisations

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.

Skills

/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).

Data and configuration

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.

Running from a checkout

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

Uninstall

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.

License

MIT, see LICENSE.

Source 4 files
hooks/register.tsx 258 lines
1import { 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}
258
hooks/readings.ts 18 lines
1// 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}
18
hooks/summary.ts 81 lines
1import 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}
81
types/index.d.ts 31 lines
1export 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