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

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.ts 118 lines1import 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