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

Un mod para Claude Code que muestra, en una línea de estado fija bajo el prompt:
~/.claude.json);codex instalado y con sesión iniciada;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.
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.
$.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.$.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 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).
claude plugin validate .
claude plugin test .hooks/register.ts 106 lines1import 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}
106hooks/codex.ts 57 lines1import 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}
57hooks/format.ts 138 lines1import 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