SLOPSHOPPER

juanito-status

Muestra la salud en vivo de Juanito dentro de Claude Code.

newpanebandcommandstatusnetwork
v0.1.0no licenseupdated 2026-10-08Retia-Agencia/Juanito/.claude/skills/juanito-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · juanito-status
│ ┃ Juanito ✕ › fix the failing auth test and add an audit log call │ ┃ 🟢 generado --:-- · deploy ------- │ ⏺ 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 │ │ › /juanito │ ⎿ juanito-status: Panel de Juanito abierto. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ juanito-status: ⚪ Juanito: sin conexión

Draws

Pane · Juanito
🟢 generado --:-- · deploy -------
README

Juanito — Agente personal de WhatsApp

Asistente IA personal conectado directamente a WhatsApp via Baileys. Vive en un VPS, escucha grupos y chats, responde cuando lo mencionan, crea recordatorios y recuerda cosas importantes del jefe.

Arquitectura

Teléfono (SIM del bot)
    │  Baileys (WebSocket persistente)
    ▼
Node.js app (Docker, VPS)
├── Message router
│   ├── Grupo + mención → Claude responde en el grupo
│   └── DM del jefe    → Claude responde al jefe
├── Claude API (tool use: recordatorios, memoria, resúmenes)
├── SQLite  (historial, memoria, grupos, recordatorios, contactos)
└── Scheduler (recordatorios cada 1min, resúmenes cada 4h, limpieza 3am)

Un solo container. Sin Meta API. Sin servicios externos.

Requisitos

  • VPS con IP fija (DigitalOcean o similar) y Docker instalado
  • SIM física dedicada para el bot (número de WhatsApp)
  • API key de Anthropic

Setup

1. Clonar y configurar

git clone <repo> juanito && cd juanito
cp .env.example .env
nano .env  # completar ANTHROPIC_API_KEY, BOSS_PHONE, AGENT_PHONE

Variables obligatorias:

VariableDescripción
ANTHROPIC_API_KEYAPI key de Anthropic
BOSS_PHONENúmero del jefe sin +, ej: 573001234567
AGENT_PHONENúmero del bot con +, ej: +573009998877 (solo para el primer vinculado)

2. Levantar

docker compose up -d --build

3. Vincular WhatsApp (primera vez)

docker compose logs -f agent
# Esperar hasta ver el código de 8 dígitos:
# 📱 Código de vinculación: XXXX-XXXX

En el teléfono físico: WhatsApp → Dispositivos vinculados → Vincular con número de teléfono → ingresar el código

La sesión queda guardada en el volumen agent-data. No hace falta repetir este paso en reinicios.

4. Agregar el bot a los grupos

Desde el teléfono del jefe, agregar el número del bot a cada grupo relevante. El bot comenzará a escuchar y guardar mensajes automáticamente.

Uso

El jefe le escribe al número del bot:

"recuérdame llamar al banco mañana a las 10am"
"¿qué pasó en el grupo de proveedores hoy?"
"guarda que mi cuenta de banco es 1234567"
"¿qué tengo pendiente esta semana?"
"recuérdale a Juan el lunes a las 9 que tiene reunión"

En grupos, mencionar al bot por su nombre (BOT_NAME):

"@Juanito ¿qué se decidió sobre el presupuesto?"

Contactos (para recordatorios a terceros)

# Local
npm run contact -- "Juan" "+57 300 111 2222"
npm run contact -- --list

# En el VPS
docker compose exec agent node scripts/add-contact.js "Juan" "573001112222"

Estructura

src/
├── index.js            Entry point: conecta Baileys y wira handlers
├── whatsapp/index.js   Cliente Baileys (connect, sendMessage, listGroups, ...)
├── bot/index.js        Router: DM del jefe vs. mención en grupo
├── claude/index.js     Claude con tool use y memoria
├── scheduler/          Cron jobs: recordatorios, resúmenes, limpieza
├── db/                 SQLite: schema, migración, funciones de acceso
├── contacts/           Directorio de contactos (resolver nombre → número)
└── common/utils.js     Normalización de teléfonos, HMAC
entrypoint.sh           Backoff exponencial entre reinicios (evita softban)

Logs y operación

docker compose logs -f agent     # logs en tiempo real
docker compose restart agent     # reiniciar sin bajar la sesión de WA
docker compose down              # bajar (la sesión queda en el volumen)

Reinicios y seguridad de la sesión

entrypoint.sh implementa backoff exponencial: 30s → 60s → 120s → 240s → 300s. Después de 8 fallos consecutivos, Docker aplica su propia política de restart. Esto evita el loop rápido de reconexiones que puede triggerear detección de WhatsApp.

La sesión de Baileys persiste en el volumen agent-data:/app/data/wa-session. No se pierde en reinicios normales.

Source 3 files
hooks/register.tsx 153 lines
1import { atom, read } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { HealthCheck, Level, Snapshot } from '../types/index'
5import { bandLines, icon, statusText, truncate } from './format'
6
7const BASE = 'https://juanito.tail2df10b.ts.net'
8const PANE = 'juanito'
9const POLL_MS = 60_000
10const TIMEOUT_MS = 10_000
11const UNREACHABLE = 'No alcanzo el dashboard (¿Tailscale prendido?)'
12
13const initial: Snapshot = {
14  nivel: 'ok',
15  generadoAt: '',
16  checks: [],
17  sha: '',
18  error: null,
19  fetchedAt: 0,
20}
21
22const snapshotState = atom(
23  { plugin: 'juanito-status', key: 'snapshot' } as const,
24  initial,
25)
26
27type HealthResponse = {
28  nivel: Level
29  generadoAt: string
30  checks: HealthCheck[]
31}
32
33type MetaResponse = { sha: string }
34
35function withTimeout<T>($: EngineInterface, work: Promise<T>): Promise<T> {
36  return new Promise<T>((resolve, reject) => {
37    const timeout = $.clock.after(TIMEOUT_MS, () => reject(new Error('timeout')))
38    work.then(
39      value => {
40        timeout.cancel()
41        resolve(value)
42      },
43      error => {
44        timeout.cancel()
45        reject(error)
46      },
47    )
48  })
49}
50
51async function getJson<T>($: EngineInterface, path: string): Promise<T> {
52  const response = await $.http.fetch(`${BASE}${path}`, { method: 'GET' })
53  if (!response.ok) throw new Error(`HTTP ${response.status}`)
54  return JSON.parse(response.text) as T
55}
56
57async function refresh($: EngineInterface): Promise<void> {
58  const previous = await read($, snapshotState)
59
60  try {
61    const [health, meta] = await withTimeout(
62      $,
63      Promise.all([
64        getJson<HealthResponse>($, '/api/salud'),
65        getJson<MetaResponse>($, '/api/meta'),
66      ]),
67    )
68    const next: Snapshot = {
69      nivel: health.nivel,
70      generadoAt: health.generadoAt,
71      checks: health.checks,
72      sha: meta.sha,
73      error: null,
74      fetchedAt: await $.clock.now(),
75    }
76    await $.state.set(snapshotState, next)
77    $.ui.status(statusText(next))
78  } catch {
79    const next = { ...previous, error: UNREACHABLE, fetchedAt: await $.clock.now() }
80    await $.state.set(snapshotState, next)
81    $.ui.status(statusText(next))
82  }
83}
84
85function generatedTime(value: string): string {
86  if (!value) return '--:--'
87  const date = new Date(value)
88  if (Number.isNaN(date.getTime())) return '--:--'
89  return date.toLocaleTimeString('es-CO', {
90    hour: '2-digit',
91    minute: '2-digit',
92    hour12: false,
93  })
94}
95
96export const register: Register = on => {
97  on('session.start', async ($, e, next) => {
98    await $.command.register({
99      name: 'juanito',
100      description: 'Muestra el estado de salud de Juanito',
101    })
102    // Sin await: si el dashboard no responde, la sesión no espera los 10 s del timeout.
103    void refresh($)
104    $.clock.every(POLL_MS, () => {
105      void refresh($)
106    })
107
108    return next(e)
109  })
110
111  on('command.run', { command: 'juanito' }, async $ => {
112    await $.ui.open({ id: PANE, title: 'Juanito' })
113    return { text: 'Panel de Juanito abierto.' }
114  })
115
116  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
117    if (e.props.hasSurvey) return next(e)
118
119    const lines = bandLines(await read($, snapshotState))
120    if (lines.length === 0) return next(e)
121
122    const { Box, Text } = $.ui.resolve(e)
123    return (
124      <Box flexDirection="column">
125        {lines.map((line, index) => (
126          <Text key={`juanito-${index}`}>{line}</Text>
127        ))}
128      </Box>
129    )
130  })
131
132  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
133    const { Box, Text } = $.ui.resolve(e)
134    const snapshot = await read($, snapshotState)
135    const overall = snapshot.error ? '⚪' : icon(snapshot.nivel)
136
137    return (
138      <Box flexDirection="column">
139        <Text>
140          {overall} generado {generatedTime(snapshot.generadoAt)} · deploy{' '}
141          {snapshot.sha ? snapshot.sha.slice(0, 7) : '-------'}
142        </Text>
143        {snapshot.error && <Text>⚪ {snapshot.error}</Text>}
144        {snapshot.checks.map(check => (
145          <Text key={check.key} dimColor={check.level === 'ok'}>
146            {icon(check.level)} {check.label} — {truncate(check.detail)}
147          </Text>
148        ))}
149      </Box>
150    )
151  })
152}
153
hooks/format.ts 44 lines
1import type { Level, Snapshot } from '../types/index'
2
3export function icon(level: Level): string {
4  if (level === 'error') return '🔴'
5  if (level === 'warn') return '🟡'
6  return '🟢'
7}
8
9export function truncate(text: string, max = 100): string {
10  if (text.length <= max) return text
11  return `${text.slice(0, Math.max(0, max - 1)).trimEnd()}…`
12}
13
14export function statusText(snapshot: Snapshot): string {
15  if (snapshot.error) return '⚪ Juanito: sin conexión'
16
17  const errors = snapshot.checks.filter(check => check.level === 'error').length
18  if (snapshot.nivel === 'error' || errors > 0) {
19    return `🔴 Juanito: ${errors} error(es)`
20  }
21
22  const warnings = snapshot.checks.filter(check => check.level === 'warn').length
23  if (snapshot.nivel === 'warn' || warnings > 0) {
24    return `🟡 Juanito: ${warnings} aviso(s)`
25  }
26
27  return '🟢 Juanito ok'
28}
29
30export function bandLines(snapshot: Snapshot, max = 4): string[] {
31  if (!snapshot.error && snapshot.nivel === 'ok') return []
32
33  const lines = snapshot.checks
34    .filter(check => check.level !== 'ok')
35    .map(check => `${icon(check.level)} ${check.label} — ${truncate(check.detail)}`)
36
37  if (snapshot.error) {
38    lines.unshift(`⚪ Sin conexión — ${truncate(snapshot.error)}`)
39  }
40
41  if (lines.length <= max) return lines
42  return [...lines.slice(0, max), `… y ${lines.length - max} más`]
43}
44
types/index.d.ts 25 lines
1export type Level = 'ok' | 'warn' | 'error'
2
3export type HealthCheck = {
4  key: string
5  label: string
6  level: Level
7  count: number
8  detail: string
9}
10
11export type Snapshot = {
12  nivel: Level
13  generadoAt: string
14  checks: HealthCheck[]
15  sha: string
16  error: string | null
17  fetchedAt: number
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    'juanito-status': { snapshot: Snapshot }
23  }
24}
25