SLOPSHOPPER

uso-tokens

Status line with context-window usage, plan limits (5 h and weekly), Codex limits and session cost, plus a /uso command

newcommandtoaststatusprocess
v0.1.1no licenseupdated 2026-10-20wilson-romero/claude-uso-tokens
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · uso-tokens
› 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 › /uso ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ uso-tokens: ctx 97k/200k (49%) · 5h 31% · US$0.42
README

uso-tokens

Un mod para Claude Code que muestra, en una línea de estado fija bajo el prompt:

  • la cuenta y el plan (leídos de tu propio ~/.claude.json);
  • el uso de la ventana de contexto;
  • los límites del plan de Claude: ventana de 5 h y semanal, con su hora de reinicio;
  • los límites de Codex, si tienes codex instalado y con sesión iniciada;
  • el coste estimado de la sesión.
tu@••• Max 5x · ctx 112k/200k (56%) · 5h 21% → 12:20 · semana 51% → jue 22:00 · Codex 5h 33% → 14:28 · semana 64% → sáb 16:07 · US$1.23

Avisa con un toast cuando una ventana pasa del 80 % y del 90 %. El comando /uso muestra el detalle.

Instalar

En una sesión de Claude Code en la terminal:

/plugin install uso-tokens --marketplace wilson-romero/claude-uso-tokens

Responde y para añadir el marketplace y elige el ámbito (usuario, el primero, con Enter).

Desde una shell:

claude plugin install uso-tokens --marketplace wilson-romero/claude-uso-tokens

En la app de escritorio, donde /plugin no funciona: pulsa + junto al cuadro del prompt, luego Plugins, Add marketplace con wilson-romero/claude-uso-tokens, y después Add plugin.

Cómo obtiene los datos

  • Contexto, límites de Claude y coste: de $.session.usage() y del evento session.measure de la API de mods. Los límites de Claude solo existen en una suscripción y llegan con la primera respuesta de la sesión.
  • Cuenta: de $.session.identity() cuando el mod lo instala una configuración gestionada; si no, solo emailAddress y el plan del bloque oauthAccount de ~/.claude.json. Ningún otro dato de ese fichero sale del mod. El dominio del correo se muestra como •••, para que no salga en las capturas.
  • Codex: ejecuta codex app-server y pide account/rateLimits/read, que lee el uso sin gastar cupo. Lo hace como mucho una vez cada 5 minutos y al ejecutar /uso. Nunca usa los reinicios gratuitos del límite.

Las horas de reinicio se muestran en UTC−5 (Bogotá, sin horario de verano).

Desarrollo

claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.ts 106 lines
1import type { EngineInterface, Register, SessionContextUsage, SessionCost, SessionRateLimit } from 'claude-code'
2
3import { CODEX_SCRIPT, type CodexLimits, parseCodexLimits } from './codex'
4import { type Account, accountFromConfig, crossed, label, planLabel, report, statusLine } from './format'
5
6const CODEX_EVERY_MS = 5 * 60 * 1000
7
8type State = {
9  // Last percentage seen per window, so a toast fires once per threshold crossed.
10  seen: Record<string, number>
11  account: Account
12  codex?: CodexLimits
13  codexAt: number
14  isCodexBusy: boolean
15  // The last Claude figures drawn, so a Codex reading can redraw the line.
16  figures?: { context: SessionContextUsage; rateLimits: SessionRateLimit[]; cost?: SessionCost }
17}
18
19// $.session.identity() answers only a mod managed settings install; a personal mod
20// gets { deny } and reads the same facts from the person's own ~/.claude.json.
21const readAccount = async ($: EngineInterface): Promise<Account> => {
22  const id = await $.session.identity()
23  if (!id.deny && id.email) return { email: id.email, plan: planLabel(id.organizationType, undefined) }
24  try {
25    const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (await $.env.get('HOME'))
26    return dir ? accountFromConfig(await $.fs.read(`${dir}/.claude.json`)) : {}
27  } catch {
28    return {}
29  }
30}
31
32const readCodex = async ($: EngineInterface): Promise<CodexLimits | undefined> => {
33  try {
34    const { stdout } = await $.process.run(['/bin/sh', '-c', CODEX_SCRIPT], { timeoutMs: 20_000 })
35    return parseCodexLimits(stdout)
36  } catch {
37    return undefined
38  }
39}
40
41const draw = async ($: EngineInterface, st: State) => {
42  if (!st.figures) return
43  const { context, rateLimits, cost } = st.figures
44  $.ui.status(statusLine(context, rateLimits, cost, await $.clock.now(), st.account, st.codex))
45}
46
47const toastCrossings = ($: EngineInterface, st: State, prefix: string, windows: SessionRateLimit[]) => {
48  for (const l of windows) {
49    const key = `${prefix}${l.kind}`
50    const hit = crossed(st.seen[key], l.percentUsed)
51    if (hit !== undefined) $.ui.toast(`${prefix ? 'Codex' : 'Límite'} ${label(l.kind)} al ${l.percentUsed}% (pasó el ${hit}%)`)
52    st.seen[key] = l.percentUsed
53  }
54}
55
56// At most one read every five minutes; a read in flight is not started twice.
57const refreshCodex = async ($: EngineInterface, st: State, isForced = false) => {
58  const now = await $.clock.now()
59  if (st.isCodexBusy || (!isForced && now - st.codexAt < CODEX_EVERY_MS)) return
60  st.isCodexBusy = true
61  try {
62    const read = await readCodex($)
63    st.codexAt = now
64    if (read) {
65      toastCrossings($, st, 'codex.', read.windows)
66      st.codex = read
67      await draw($, st)
68    }
69  } finally {
70    st.isCodexBusy = false
71  }
72}
73
74export const register: Register = on => {
75  const st: State = { seen: {}, account: {}, codexAt: 0, isCodexBusy: false }
76
77  on('session.start', async ($, e, next) => {
78    await $.command.register({ name: 'uso', description: 'Uso de tokens, límites del plan y de Codex, y coste de la sesión' })
79    st.account = await readAccount($)
80    const usage = await $.session.usage()
81    for (const l of usage.rateLimits) st.seen[l.kind] = l.percentUsed
82    st.figures = { context: usage.context, rateLimits: usage.rateLimits, cost: usage.cost }
83    await draw($, st)
84    void refreshCodex($, st)
85    return next(e)
86  })
87
88  on('session.measure', async ($, e, next) => {
89    toastCrossings($, st, '', e.rateLimits)
90    st.figures = { context: e.context, rateLimits: e.rateLimits, cost: e.cost }
91    await draw($, st)
92    void refreshCodex($, st)
93    return next(e)
94  })
95
96  on('command.run', { command: 'uso' }, async $ => {
97    // 'summary' is estimated locally and sends no request: it costs nothing.
98    const usage = await $.session.usage({ breakdown: 'summary' })
99    const last = usage.context.breakdown?.apiUsage
100    st.account = await readAccount($)
101    await refreshCodex($, st, true)
102    const now = await $.clock.now()
103    return { text: report(usage.context, usage.rateLimits, usage.cost, now, last, st.account, st.codex) }
104  })
105}
106
hooks/codex.ts 57 lines
1import type { SessionRateLimit } from 'claude-code'
2
3export type CodexLimits = {
4  plan?: string
5  windows: SessionRateLimit[]
6  resetCredits?: number
7}
8
9// `codex app-server` speaks JSON-RPC over stdio and exits as soon as its input
10// closes, before answering: the requests go in and the input stays open six
11// seconds. account/rateLimits/read reads the plan's usage without spending it.
12// Never call account/rateLimitResetCredit/consume: it burns a free reset.
13const REQUESTS = [
14  { jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientInfo: { name: 'uso-tokens', version: '1' } } },
15  { jsonrpc: '2.0', method: 'initialized' },
16  { jsonrpc: '2.0', id: 2, method: 'account/rateLimits/read' },
17]
18
19const quote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
20
21export const CODEX_SCRIPT = `{ printf '%s\\n' ${REQUESTS.map(r => quote(JSON.stringify(r))).join(' ')}; sleep 6; } | codex app-server 2>/dev/null`
22
23type Window = { usedPercent?: number; resetsAt?: number }
24
25const toLimit = (kind: string, w: Window | undefined): SessionRateLimit | undefined =>
26  w && typeof w.usedPercent === 'number'
27    ? {
28        kind,
29        percentUsed: w.usedPercent,
30        resetsAt: typeof w.resetsAt === 'number' ? new Date(w.resetsAt * 1000).toISOString() : undefined,
31      }
32    : undefined
33
34// The answer to request 2, out of the server's line-per-message output.
35export const parseCodexLimits = (stdout: string): CodexLimits | undefined => {
36  for (const line of stdout.split('\n')) {
37    let msg: { id?: number; result?: Record<string, unknown> }
38    try {
39      msg = JSON.parse(line)
40    } catch {
41      continue
42    }
43    if (msg.id !== 2 || !msg.result) continue
44    const rl = msg.result.rateLimits as { primary?: Window; secondary?: Window; planType?: string } | undefined
45    if (!rl) return undefined
46    const credits = msg.result.rateLimitResetCredits as { availableCount?: number } | undefined
47    return {
48      plan: typeof rl.planType === 'string' ? rl.planType : undefined,
49      windows: [toLimit('five_hour', rl.primary), toLimit('seven_day', rl.secondary)].filter(
50        (w): w is SessionRateLimit => w !== undefined,
51      ),
52      resetCredits: typeof credits?.availableCount === 'number' ? credits.availableCount : undefined,
53    }
54  }
55  return undefined
56}
57
hooks/format.ts 138 lines
1import type { ModelUsage, SessionContextUsage, SessionCost, SessionRateLimit } from 'claude-code'
2
3import type { CodexLimits } from './codex'
4
5// Bogotá has no daylight saving time: UTC-5 all year.
6const BOGOTA_OFFSET_MS = -5 * 60 * 60 * 1000
7const WEEKDAYS = ['dom', 'lun', 'mar', 'mié', 'jue', 'vie', 'sáb']
8const KIND_LABEL: Record<string, string> = { five_hour: '5h', seven_day: 'semana', spend_limit: 'gasto' }
9
10export const THRESHOLDS = [80, 90] as const
11
12export type Account = { email?: string; plan?: string }
13
14const PLAN_LABEL: Record<string, string> = {
15  claude_pro: 'Pro',
16  claude_max: 'Max',
17  claude_team: 'Team',
18  claude_enterprise: 'Enterprise',
19}
20
21// "claude_max" + "default_claude_max_5x" -> "Max 5x"; an unknown type is shown as given.
22export const planLabel = (type: string | undefined, tier: string | undefined) => {
23  if (!type) return undefined
24  const name = PLAN_LABEL[type] ?? type
25  const multiplier = tier?.match(/_(\d+x)$/)?.[1]
26  return multiplier ? `${name} ${multiplier}` : name
27}
28
29// The oauthAccount block of ~/.claude.json, reduced to the two fields shown.
30export const accountFromConfig = (text: string): Account => {
31  const parsed: unknown = JSON.parse(text)
32  const raw = (parsed as { oauthAccount?: Record<string, unknown> } | null)?.oauthAccount
33  if (!raw) return {}
34  const str = (v: unknown) => (typeof v === 'string' && v ? v : undefined)
35  return {
36    email: str(raw.emailAddress),
37    plan: planLabel(str(raw.organizationType), str(raw.organizationRateLimitTier)),
38  }
39}
40
41// The domain names the employer or the provider, so it stays out of screenshots.
42export const maskEmail = (email: string) => email.replace(/@.*$/, '@•••')
43
44export const accountPart = (account: Account | undefined) =>
45  account && (account.email || account.plan)
46    ? [account.email && maskEmail(account.email), account.plan].filter(Boolean).join(' ')
47    : undefined
48
49const pad = (n: number) => String(n).padStart(2, '0')
50
51export const tokens = (n: number) =>
52  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
53
54export const label = (kind: string) => KIND_LABEL[kind] ?? kind
55
56// "04:25" when the reset is within a day of `now`, otherwise "sáb 16:07".
57export const resetAt = (iso: string | undefined, now: number) => {
58  if (!iso) return undefined
59  const at = Date.parse(iso)
60  if (Number.isNaN(at)) return undefined
61  const local = new Date(at + BOGOTA_OFFSET_MS)
62  const hhmm = `${pad(local.getUTCHours())}:${pad(local.getUTCMinutes())}`
63  return at - now < 24 * 60 * 60 * 1000 ? hhmm : `${WEEKDAYS[local.getUTCDay()]} ${hhmm}`
64}
65
66export const contextPart = (context: SessionContextUsage) =>
67  context.tokens === undefined
68    ? `ctx –/${tokens(context.window)}`
69    : `ctx ${tokens(context.tokens)}/${tokens(context.window)} (${context.percent ?? 0}%)`
70
71export const limitPart = (limit: SessionRateLimit, now: number) => {
72  const reset = resetAt(limit.resetsAt, now)
73  return `${label(limit.kind)} ${limit.percentUsed}%${reset ? ` → ${reset}` : ''}`
74}
75
76export const codexPart = (codex: CodexLimits | undefined, now: number) =>
77  codex && codex.windows.length ? `Codex ${codex.windows.map(l => limitPart(l, now)).join(' · ')}` : undefined
78
79export const costPart = (cost: SessionCost | undefined) =>
80  cost === undefined ? undefined : `US$${cost.usd.toFixed(2)}`
81
82export const statusLine = (
83  context: SessionContextUsage,
84  rateLimits: SessionRateLimit[],
85  cost: SessionCost | undefined,
86  now: number,
87  account?: Account,
88  codex?: CodexLimits,
89) =>
90  [accountPart(account), contextPart(context), ...rateLimits.map(l => limitPart(l, now)), codexPart(codex, now), costPart(cost)]
91    .filter(Boolean)
92    .join(' · ')
93
94// The highest threshold a window crossed since `before`, or undefined.
95export const crossed = (before: number | undefined, after: number) => {
96  const passed = THRESHOLDS.filter(t => after >= t && (before === undefined || before < t))
97  return passed.length ? passed[passed.length - 1] : undefined
98}
99
100export const report = (
101  context: SessionContextUsage,
102  rateLimits: SessionRateLimit[],
103  cost: SessionCost | undefined,
104  now: number,
105  last?: ModelUsage | null,
106  account?: Account,
107  codex?: CodexLimits,
108) => {
109  const lines = ['Uso de esta sesión', '']
110  const who = accountPart(account)
111  lines.push(`Cuenta: ${who ?? 'no disponible'}`, '')
112  lines.push(`Contexto: ${contextPart(context).replace(/^ctx /, '')}`)
113  if (last) {
114    const u = last
115    lines.push(
116      `  última respuesta: ${tokens(u.input_tokens)} entrada, ${tokens(u.cache_read_input_tokens)} de caché, ` +
117        `${tokens(u.cache_creation_input_tokens)} escritos en caché, ${tokens(u.output_tokens)} salida`,
118    )
119  }
120  lines.push('', 'Límites del plan:')
121  if (rateLimits.length === 0) lines.push('  sin datos todavía (llegan con la primera respuesta, solo en suscripción)')
122  for (const l of rateLimits) {
123    const reset = resetAt(l.resetsAt, now)
124    lines.push(`  ${label(l.kind)}: ${l.percentUsed}% usado${reset ? `, se reinicia ${reset} (Bogotá)` : ''}`)
125  }
126  lines.push('', `Límites de Codex${codex?.plan ? ` (plan ${codex.plan})` : ''}:`)
127  if (!codex || codex.windows.length === 0) lines.push('  sin lectura (¿codex instalado y con sesión iniciada?)')
128  for (const l of codex?.windows ?? []) {
129    const reset = resetAt(l.resetsAt, now)
130    lines.push(`  ${label(l.kind)}: ${l.percentUsed}% usado${reset ? `, se reinicia ${reset} (Bogotá)` : ''}`)
131  }
132  if (codex?.resetCredits !== undefined)
133    lines.push(`  reinicios gratuitos disponibles: ${codex.resetCredits} (este mod nunca los usa)`)
134  const c = costPart(cost)
135  if (c) lines.push('', `Coste estimado de la sesión: ${c}`)
136  return lines.join('\n')
137}
138