HUD sobre el prompt: modelo, git, contexto, uso, costo y actividad, con alertas

Tres mods para Claude Code que uso a diario. Están en español rioplatense, igual que el flujo de trabajo que imponen.
| Mod | Qué hace |
|---|---|
flujo-work | Flujo por fases. Al arrancar la sesión, Claude entra en modo plan e investiga: bitácora sesiones/, memorias, notas de Obsidian y código. Sin plan aprobado no se edita, ni con Edit/Write ni por Bash. Cada fase cierra con un commit propio, sin Co-Authored-By, y con la herramienta cerrar_fase, que verifica que el commit exista. Nunca hace git push ni escribe en el remoto con gh: eso lo hace uno. Avisa cuándo conviene /clear o /compact. |
aviso-fin | Sonido y notificación de escritorio (paplay + notify-send) cuando Claude termina, pregunta algo o se queda esperando un permiso de verdad. No avisa mientras hay subagentes trabajando. |
hud-devo | HUD sobre el prompt: modelo, rama y estado de git, contexto usado, límites de uso, costo y actividad de herramientas y subagentes, con alertas. |
Cloná el repo y apuntá Claude Code a las carpetas de los mods en ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/ruta/a/mods/hud-devo:/ruta/a/mods/flujo-work:/ruta/a/mods/aviso-fin"
}
}
Los tipos de la API de mods (.claude-plugin/types/) los genera Claude Code y no se versionan.
Están fijos para mi máquina. Cambialos antes de usar los mods:
flujo-work/hooks/register.ts: VAULT, la bóveda de Obsidian de donde salen las notas de proyecto y el diario.aviso-fin/hooks/register.ts: SONIDOS, la carpeta de fin.ogg y pregunta.ogg./sin-protocolo: apaga el flujo en la sesión. Los commits con Co-Authored-By siguen bloqueados./protocolo: lo vuelve a encender./plan-ok: da el plan por aprobado si saliste del modo plan a mano.claude plugin test flujo-work
claude plugin test aviso-fin
claude plugin test hud-devo
hooks/register.tsx 343 lines1import type { AgentInfo, EngineInterface, Register, SessionRateLimit } from 'claude-code'
2
3// Lo que dibuja el HUD bajo el prompt; se recalcula con los timers y con cada evento
4type Git = { branch: string; dirty: number; ahead: number; behind: number }
5type Bg = { id: string; type: string; label: string }
6
7let model = ''
8let project = ''
9let git: Git | null = null
10let ctxPercent: number | undefined
11let ctxTokens: number | undefined
12let ctxWindow = 0
13let limits: SessionRateLimit[] = []
14let tool = ''
15// Herramientas usadas desde el último prompt, en orden de primera aparición
16let tally = new Map<string, number>()
17// Subagentes en curso, y cuándo se vio cada uno por primera vez
18let running: AgentInfo[] = []
19const firstSeen = new Map<string, number>()
20// Tareas de fondo que no son subagentes (shells, monitores, workflows)
21let background: Bg[] = []
22
23// Paleta de tu terminal (Base16 Default Dark), la misma que pinta claude-hud
24// con sus códigos ANSI; los mods solo aceptan hex, así que va explícita
25const C = {
26 model: '#75b5aa',
27 project: '#f4bf75',
28 git: '#aa759f',
29 branch: '#75b5aa',
30 context: '#90a959',
31 usage: '#82b8c8',
32 warning: '#f4bf75',
33 usageWarning: '#aa759f',
34 critical: '#ac4142',
35 ok: '#90a959',
36 label: '#8e8e8e',
37}
38
39const UMBRALES = [80, 90, 95]
40const RAMAS_PROD = ['main', 'master']
41
42// --- helpers puros -------------------------------------------------------
43
44function modelLabel(id: string): string {
45 const m = /(opus|sonnet|haiku|fable)-(\d+)-(\d+)/i.exec(id)
46 const [, fam = '', major = '', minor = ''] = m ?? []
47 if (!m) return id
48 return fam.charAt(0).toUpperCase() + fam.slice(1).toLowerCase() + ' ' + major + '.' + minor
49}
50
51function barParts(percent: number, width = 10): [string, string] {
52 const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
53 return ['█'.repeat(filled), '░'.repeat(width - filled)]
54}
55
56function contextColor(percent: number): string {
57 if (percent >= 85) return C.critical
58 if (percent >= 70) return C.warning
59 return C.context
60}
61
62function usageColor(percent: number): string {
63 if (percent >= 90) return C.critical
64 if (percent >= 75) return C.usageWarning
65 return C.usage
66}
67
68function kTokens(n: number): string {
69 return n >= 1000 ? Math.round(n / 1000) + 'k' : String(n)
70}
71
72function duration(ms: number): string {
73 const mins = Math.max(0, Math.round(ms / 60000))
74 if (mins >= 1440) return Math.floor(mins / 1440) + 'd ' + Math.floor((mins % 1440) / 60) + 'h'
75 if (mins >= 60) return Math.floor(mins / 60) + 'h ' + (mins % 60) + 'm'
76 if (mins >= 1) return mins + 'm'
77 return Math.max(0, Math.round(ms / 1000)) + 's'
78}
79
80function untilReset(resetsAt: string | undefined, now: number): string {
81 return resetsAt ? duration(Date.parse(resetsAt) - now) : ''
82}
83
84function limitLabel(kind: string): string {
85 if (kind === 'five_hour') return '5h'
86 if (kind === 'seven_day') return '7d'
87 return kind
88}
89
90function clip(text: string, max: number): string {
91 const flat = text.replace(/\s+/g, ' ').trim()
92 return flat.length > max ? flat.slice(0, max - 1) + '…' : flat
93}
94
95export function parseGit(porcelain: string): Git | null {
96 const lines = porcelain.split('\n').filter(l => l.length > 0)
97 const head = lines[0]
98 if (!head || !head.startsWith('## ')) return null
99 const info = head.slice(3)
100 const branch = (info.split('...')[0] ?? '').replace(/^No commits yet on /, '').split(' ')[0] ?? ''
101 const ahead = Number(/ahead (\d+)/.exec(info)?.[1] ?? 0)
102 const behind = Number(/behind (\d+)/.exec(info)?.[1] ?? 0)
103 return { branch, dirty: lines.length - 1, ahead, behind }
104}
105
106function toolName(name: string): string {
107 return name.startsWith('mcp__') ? name.split('__').pop() ?? name : name
108}
109
110function shortTool(e: { tool: string } & Record<string, unknown>): string {
111 const target = (e.file_path ?? e.pattern ?? e.command ?? e.description ?? '') as string
112 const base = String(target).split('/').pop() ?? ''
113 const shown = clip(base, 40)
114 return shown ? toolName(e.tool) + ': ' + shown : toolName(e.tool)
115}
116
117// --- lecturas (reciben $) -----------------------------------------------
118
119async function refreshGit($: EngineInterface) {
120 try {
121 const r = await $.process.run(['git', 'status', '--porcelain=v1', '-b'], { timeoutMs: 5000 })
122 git = r.exitCode === 0 ? parseGit(r.stdout) : null
123 } catch {
124 git = null
125 }
126}
127
128async function refreshUsage($: EngineInterface) {
129 const u = await $.session.usage()
130 ctxPercent = u.context.percent
131 ctxTokens = u.context.tokens
132 ctxWindow = u.context.window
133 limits = u.rateLimits
134}
135
136async function refreshAgents($: EngineInterface) {
137 const now = await $.clock.now()
138 const all = await $.agent.list()
139 const before = running.length
140 running = all.filter(a => a.status === 'running')
141 for (const a of running) if (!firstSeen.has(a.id)) firstSeen.set(a.id, now)
142 if (running.length > 0 || before > 0) $.ui.invalidate('ui.render')
143}
144
145async function refreshAll($: EngineInterface) {
146 model = modelLabel(await $.session.model())
147 project = (await $.session.cwd()).split('/').pop() ?? ''
148 await refreshGit($)
149 await refreshUsage($)
150 await refreshAgents($)
151 $.ui.invalidate('ui.render')
152}
153
154// Avisa una sola vez por umbral y por ventana (la clave incluye el reset)
155async function warnLimits($: EngineInterface, rateLimits: SessionRateLimit[]) {
156 for (const l of rateLimits) {
157 const crossed = UMBRALES.filter(u => l.percentUsed >= u).pop()
158 if (crossed === undefined) continue
159 const key = 'aviso:' + l.kind + ':' + (l.resetsAt ?? '')
160 const last = Number((await $.store.get(key)) ?? 0)
161 if (crossed <= last) continue
162 await $.store.set(key, crossed)
163 const reset = untilReset(l.resetsAt, await $.clock.now())
164 $.ui.toast('Uso ' + limitLabel(l.kind) + ' al ' + Math.round(l.percentUsed) + '%' + (reset ? ' · reinicia en ' + reset : ''), { timeoutMs: 8000 })
165 }
166}
167
168// --- hooks ---------------------------------------------------------------
169
170export const register: Register = on => {
171 on('session.start', async ($, e, next) => {
172 const started = await next(e)
173 await refreshAll($)
174 $.clock.every(15000, () => void refreshAll($))
175 // Los subagentes cambian rápido y el contador de tiempo avanza solo
176 $.clock.every(3000, () => void refreshAgents($))
177 return started
178 })
179
180 on('turn.start', async ($, e, next) => {
181 model = modelLabel(await $.session.model())
182 return next(e)
183 })
184
185 on('prompt.submit', async ($, e, next) => {
186 tally = new Map()
187 $.ui.invalidate('ui.render')
188 return next(e)
189 })
190
191 on('session.measure', async ($, e, next) => {
192 ctxPercent = e.context.percent
193 ctxTokens = e.context.tokens
194 ctxWindow = e.context.window
195 limits = e.rateLimits
196 if (e.changed.includes('rateLimits')) await warnLimits($, e.rateLimits)
197 $.ui.invalidate('ui.render')
198 return next(e)
199 })
200
201 // Herramienta en curso del hilo principal; las de los subagentes no se cuentan
202 on('tool.call', async ($, e, next) => {
203 if (e.agentId) return next(e)
204 tool = shortTool(e as { tool: string } & Record<string, unknown>)
205 tally.set(toolName(e.tool), (tally.get(toolName(e.tool)) ?? 0) + 1)
206 $.ui.invalidate('ui.render')
207 try {
208 return await next(e)
209 } finally {
210 tool = ''
211 if (e.tool === 'Bash' || e.tool === 'Edit' || e.tool === 'Write') await refreshGit($)
212 if (e.tool === 'Agent') await refreshAgents($)
213 $.ui.invalidate('ui.render')
214 }
215 })
216
217 // Al cerrar cada turno Claude Code informa el trabajo de fondo vivo: es la
218 // fuente de los shells y monitores, que $.agent.list() no lista
219 on('classic.Stop', async ($, e, next) => {
220 background = (e.background_tasks ?? [])
221 .filter(t => t.type !== 'subagent')
222 .map(t => ({ id: t.id, type: t.type, label: clip(t.command ?? t.name ?? t.description, 50) }))
223 $.ui.invalidate('ui.render')
224 return next(e)
225 })
226
227 on('turn.complete', async ($, e, next) => {
228 tool = ''
229 await refreshGit($)
230 $.ui.invalidate('ui.render')
231 return next(e)
232 })
233
234 // Bajo el prompt, como el statusLine de claude-hud; la línea de Claude Code
235 // (modo, agentes, PR) se conserva debajo
236 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
237 const theirs = await next(e)
238 if (!model) return theirs
239 const { Box, Text } = $.ui.resolve(e)
240 const now = await $.clock.now()
241 const sep = <Text> │ </Text>
242 const bar = (percent: number, color: string, width = 10) => {
243 const [full, empty] = barParts(percent, width)
244 return (
245 <Text>
246 <Text color={color}>{full}</Text>
247 <Text color={color} dimColor>{empty}</Text>
248 </Text>
249 )
250 }
251
252 // Línea 1: modelo · proyecto · git
253 const onProd = git !== null && RAMAS_PROD.includes(git.branch)
254 const gitSeg = git && (
255 <Text>
256 <Text color={C.git}>git:(</Text>
257 <Text color={onProd ? C.critical : C.branch} bold={onProd}>{git.branch}{git.dirty > 0 ? '*' : ''}</Text>
258 <Text color={C.git}>)</Text>
259 {git.ahead > 0 ? <Text color={git.ahead >= 5 ? C.warning : C.branch}>{' ↑' + git.ahead}</Text> : ''}
260 {git.behind > 0 ? <Text color={C.warning}>{' ↓' + git.behind}</Text> : ''}
261 {git.dirty > 0 ? <Text color={C.label}>{' ' + git.dirty + (git.dirty === 1 ? ' cambio' : ' cambios')}</Text> : ''}
262 {onProd ? <Text color={C.critical} bold>{' ⚠ un push despliega a producción'}</Text> : ''}
263 </Text>
264 )
265 const line1 = (
266 <Box key="l1">
267 <Text color={C.model}>[{model}]</Text>
268 {sep}
269 <Text color={C.project}>{project + ' '}</Text>
270 {gitSeg ?? <Text color={C.label}>sin git</Text>}
271 </Box>
272 )
273
274 // Línea 2: contexto y ventanas de uso
275 const pct = ctxPercent ?? 0
276 const ctxColor = contextColor(pct)
277 const line2 = (
278 <Box key="l2">
279 <Text color={C.label}>Contexto </Text>
280 {bar(pct, ctxColor)}
281 <Text color={ctxColor}>{' ' + pct + '%'}</Text>
282 <Text color={C.label}>{ctxTokens !== undefined ? ' ' + kTokens(ctxTokens) + '/' + kTokens(ctxWindow) : ''}</Text>
283 {pct >= 80 ? <Text color={ctxColor}>{' · /compact'}</Text> : ''}
284 {limits.map(l => (
285 <Text key={l.kind}>
286 {sep}
287 <Text color={C.label}>{'Uso ' + limitLabel(l.kind) + ' '}</Text>
288 {bar(l.percentUsed, usageColor(l.percentUsed))}
289 <Text color={usageColor(l.percentUsed)}>{' ' + Math.round(l.percentUsed) + '%'}</Text>
290 {l.resetsAt ? ' (reinicia en ' + untilReset(l.resetsAt, now) + ')' : ''}
291 </Text>
292 ))}
293 </Box>
294 )
295
296 // Herramientas del turno, como claude-hud: ◐ Edit: x.py | ✓ Bash ×19
297 const tools = [...tally.entries()].slice(-6)
298 const toolsLine = (tools.length > 0 || (e.props.isWorking && tool)) && (
299 <Box key="tools">
300 {e.props.isWorking && tool ? <Text><Text color={C.warning}>◐</Text>{' ' + tool + (tools.length ? ' | ' : '')}</Text> : ''}
301 {tools.map(([name, n], i) => (
302 <Text key={'t-' + name}>
303 {i > 0 ? ' | ' : ''}
304 <Text color={C.ok}>✓</Text>
305 {' ' + name}
306 <Text color={C.label}>{' ×' + n}</Text>
307 </Text>
308 ))}
309 </Box>
310 )
311
312 // Una línea por subagente y por tarea de fondo, como claude-hud
313 const agentLines = running.slice(0, 4).map(a => (
314 <Box key={'a-' + a.id}>
315 <Text color={C.warning}>◐ </Text>
316 <Text color={C.model}>{a.type}</Text>
317 <Text>{': ' + clip(a.description, 50)}</Text>
318 <Text color={C.label}>{' (' + duration(now - (firstSeen.get(a.id) ?? now)) + ')'}</Text>
319 </Box>
320 ))
321 if (running.length > 4) agentLines.push(<Text key="a-more" color={C.label}>{' +' + (running.length - 4) + ' agentes más'}</Text>)
322 const bgLines = background.slice(0, 3).map(t => (
323 <Box key={'b-' + t.id}>
324 <Text color={C.usage}>▶ </Text>
325 <Text color={C.model}>{t.type}</Text>
326 <Text>{': ' + t.label}</Text>
327 </Box>
328 ))
329 if (background.length > 3) bgLines.push(<Text key="b-more" color={C.label}>{' +' + (background.length - 3) + ' tareas más'}</Text>)
330
331 return (
332 <Box flexDirection="column">
333 {line1}
334 {line2}
335 {toolsLine || ''}
336 {agentLines}
337 {bgLines}
338 {theirs}
339 </Box>
340 )
341 })
342}
343