SLOPSHOPPER

hud-devo

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

spinnerguardtoastpromptprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hud-devo
› 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › [Opus 5.5] │ app sin git Contexto █████░░░░░ 49% 97k/200k │ Uso 5h ███░░░░░░░ 31% (reinicia en NaNs) ✓ Read ×1 | ✓ Grep ×1 | ✓ Edit ×1 | ✓ Write ×2 | ✓ Bash ×4 ⟨Claude Code's own drawing⟩

Draws

Prompt hint
[Opus 5.5] │ app sin git Contexto █████░░░░░ 49% 97k/200k │ Uso 5h ███░░░░░░░ 31% (reinicia en NaNs) ✓ Read ×1 | ✓ Grep ×1 | ✓ Edit ×1 | ✓ Write ×2 | ✓ Bash ×4 ⟨Claude Code's own drawing⟩
README

mods de Claude Code

Tres mods para Claude Code que uso a diario. Están en español rioplatense, igual que el flujo de trabajo que imponen.

ModQué hace
flujo-workFlujo 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-finSonido 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-devoHUD sobre el prompt: modelo, rama y estado de git, contexto usado, límites de uso, costo y actividad de herramientas y subagentes, con alertas.

Instalación

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.

Paths a ajustar

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.

Comandos de flujo-work

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

Tests

claude plugin test flujo-work
claude plugin test aviso-fin
claude plugin test hud-devo

Licencia

MIT

Source 1 files
hooks/register.tsx 343 lines
1import 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