SLOPSHOPPER

aviso-fin

Sonido + notificación de escritorio cuando Claude termina o te pregunta algo (no mientras hay agentes laburando)

guardprocesstimer
A shopper browsing a rack in a slop shop
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.ts 118 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// Avisa (sonido + notificación de escritorio) cuando la sesión queda esperando a Dani:
4// terminó el turno, o pide permiso / hace una pregunta. Nunca mientras hay agentes laburando.
5
6const SONIDOS = '/home/dani/mods/aviso-fin/sonidos'
7const VIVOS = new Set(['pending', 'running', 'waiting'])
8const ANTIREBOTE_MS = 15000
9// tool.check da «ask» antes de que el modo lo resuelva: en modo auto/plan lo aprueba el clasificador
10// sin mostrarte nada. Solo es un diálogo real si la herramienta sigue sin arrancar pasado este rato.
11const ESPERA_PERMISO_MS = 6000
12
13let ultimoAviso = 0
14const resueltos = new Set<string>() // tool_use_id que ya corrieron
15const arrancados = new Set<string>() // tool_use_id ejecutando (pastilla ctrl+b del Bash)
16
17export function primeraLinea(texto: string | undefined, max = 120): string {
18  const linea = (texto ?? '').split('\n').map(l => l.replace(/[*_`#>]/g, '').trim()).find(l => l) ?? ''
19  return linea.length > max ? `${linea.slice(0, max - 1)}…` : linea
20}
21
22function carpeta(cwd: string): string {
23  return cwd.split('/').filter(Boolean).pop() ?? cwd
24}
25
26// De qué se trata la sesión, muy breve: el repo si el cwd está en uno; si no, la tarea
27// (el primer mensaje de Dani); y si tampoco, la carpeta.
28async function lugar($: EngineInterface): Promise<string> {
29  const cwd = await $.session.cwd()
30  try {
31    const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: 3000 })
32    const repo = r.exitCode === 0 ? r.stdout.trim() : ''
33    if (repo) return carpeta(repo)
34  } catch {}
35  try {
36    const tarea = primeraLinea((await $.session.messages()).find(m => m.role === 'user' && m.text?.trim())?.text, 40)
37    if (tarea) return tarea
38  } catch {}
39  return carpeta(cwd)
40}
41
42async function hayAgentesVivos($: EngineInterface): Promise<boolean> {
43  try {
44    return (await $.agent.list()).some(a => VIVOS.has(a.status))
45  } catch {
46    return false
47  }
48}
49
50async function avisar($: EngineInterface, sonido: string, titulo: string, cuerpo: string, critico = false) {
51  const ahora = Date.now()
52  if (ahora - ultimoAviso < ANTIREBOTE_MS) return
53  ultimoAviso = ahora
54  const donde = await lugar($)
55  $.process.run(['paplay', `${SONIDOS}/${sonido}`]).catch(() => {})
56  $.process
57    .run(['notify-send', '-a', 'Claude Code', '-i', 'dialog-information', '-u', critico ? 'critical' : 'normal', `${titulo} · ${donde}`, cuerpo])
58    .catch(() => {})
59}
60
61export const register: Register = on => {
62  // Fin del turno del loop principal (agentId presente = turno de un subagente).
63  // OJO: los classic.* (Stop, Notification) los saltea cc-plugin-sec-default para mods de usuario.
64  on('turn.complete', async ($, e, next) => {
65    const r = await next(e)
66    if (e.agentId === undefined && !(await hayAgentesVivos($))) {
67      if (e.reason === 'error' || e.reason === 'refusal') {
68        await avisar($, 'fin.ogg', 'Claude se frenó (error)', primeraLinea(e.answer) || 'Se cortó el turno.', true)
69      } else {
70        await avisar($, 'fin.ogg', 'Claude terminó', primeraLinea(e.answer) || 'Listo.')
71      }
72    }
73    return r
74  })
75
76  // Marca las llamadas que arrancaron (permiso dado, por vos o por el clasificador).
77  // Se marca antes de next: una herramienta que tarda más de ESPERA_PERMISO_MS no es un permiso trabado.
78  on('tool.call', async ($, e, next) => {
79    if (resueltos.size > 500) resueltos.clear()
80    resueltos.add(e.tool_use_id)
81    return next(e)
82  })
83
84  // La pastilla «ctrl+b to run in background» sale solo con el Bash ya ejecutando.
85  on('ui.render', { component: 'ToolProgress' }, ($, e, next) => {
86    if (arrancados.size > 500) arrancados.clear()
87    arrancados.add(e.props.tool_use_id)
88    return next(e)
89  })
90
91  // Una herramienta queda esperando tu permiso: se avisa solo si sigue trabada pasado ESPERA_PERMISO_MS.
92  on('tool.check', async ($, e, next) => {
93    const r = await next(e)
94    const id = e.tool_use_id
95    // agentId (ask dentro de un subagente) existe desde 2.1.291; en engines previos no viene y lo cubre hayAgentesVivos.
96    const agentId = (e as { agentId?: string }).agentId
97    if (r.decision === 'ask' && id !== undefined && agentId === undefined) {
98      $.clock.after(ESPERA_PERMISO_MS, () => {
99        const corrio = resueltos.delete(id)
100        const arranco = arrancados.delete(id)
101        if (corrio || arranco) return
102        hayAgentesVivos($)
103          .then(vivos => (vivos ? undefined : avisar($, 'pregunta.ogg', 'Claude te necesita', `Pide permiso para ${e.tool}.`)))
104          .catch(() => {})
105      })
106    }
107    return r
108  })
109
110  // Te hace una pregunta o te presenta un plan.
111  on('tool.call', { tool: ['AskUserQuestion', 'ExitPlanMode'] }, async ($, e, next) => {
112    if (!(await hayAgentesVivos($))) {
113      await avisar($, 'pregunta.ogg', 'Claude te necesita', e.tool === 'AskUserQuestion' ? 'Te hace una pregunta.' : 'Plan listo para aprobar.')
114    }
115    return next(e)
116  })
117}
118