Si pasas mucho tiempo en Claude Code o la API de Anthropic, probablemente te has preguntado: *¿está lento ahora, o soy solo yo?* Este post explica cómo construí un pequeño plugin zsh que responde esa pregunta directamente en el prompt del terminal.
## Qué hace
El resultado final es un segmento de Powerlevel10k en el lado derecho de mi prompt que muestra:
| Ícono | Color | Significado |
|-------|-------|-------------|
| | Verde | API respondiendo con normalidad |
| | Amarillo | Latencia elevada (línea base + 1σ) |
| | Rojo | Carga máxima (línea base + 2σ) |
| | Gris | API inaccesible |
También muestra la latencia en milisegundos. El ícono se actualiza cada minuto automáticamente mediante un job launchd en segundo plano — sin ralentizar el shell, sin consumir tokens.
## Visión general de la arquitectura
Todo está empaquetado como un plugin personalizado de oh-my-zsh con cinco archivos:
```
~/.oh-my-zsh/custom/plugins/claude-status/
├── claude-status.plugin.zsh # punto de entrada — cargado por oh-my-zsh
├── statusline.sh # renderizador del statusline de Claude Code
├── latency-common.sh # lector de caché compartido (p10k + statusline)
├── latency-sampler.sh # script de ping en segundo plano
└── com.giorgiosaud.claude.latency.plist # plantilla del job de launchd
```
Dos archivos JSON se escriben en tiempo de ejecución:
```
~/.claude/latency_log.json # historial de 30 días en ventana deslizante
~/.claude/latency_cache.json # resultado más reciente, leído por el prompt
```
## Parte 1: Medir la latencia
El script sampler (`latency-sampler.sh`) hace un HTTP POST no autenticado a `api.anthropic.com/v1/messages` usando `curl`. El servidor devuelve `401` de inmediato — sin necesidad de API key, sin consumir tokens — pero se ejercita todo el stack TCP + TLS + HTTP:
```bash
curl_time=$(curl -s -o /dev/null -w "%{time_total}" \
--max-time "$TIMEOUT" \
-X POST \
-H "content-type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d '{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"h"}]}' \
"https://api.anthropic.com/v1/messages" 2>/dev/null)
if [ $? -eq 0 ] && [ -n "$curl_time" ]; then
ms=$(python3 -c "print(int(float('$curl_time') * 1000))")
fi
```
Esto mide el stack HTTP completo — handshake TCP, negociación TLS y el time-to-first-byte del servidor — lo que lo convierte en un mejor proxy de la carga de la API que un ping TCP simple.
## Parte 2: Construir una línea base con estadísticas
Una sola medición te dice la latencia actual. Para saber si eso es *rápido o lento*, necesitas contexto. El sampler construye una línea base histórica usando los últimos 30 días de muestras tomadas a la misma hora (±1) en el mismo día de la semana:
```python
samples = [
e["ms"] for e in log
if e["weekday"] == current_weekday
and abs(e["hour"] - current_hour) <= 1
and e["ts"] != current_ts
and e["ms"] is not None
]
if len(samples) >= 30:
baseline = statistics.median(samples)
stdev = statistics.stdev(samples)
warn_threshold = baseline + stdev # ~percentil 84
peak_threshold = baseline + 2 * stdev # ~percentil 97.5
```
En lugar de multiplicadores de ratio fijos, los umbrales se expresan en desviaciones estándar desde la mediana. Esto se adapta automáticamente a tu entorno de red — una conexión rápida y una lenta tendrán umbrales apropiadamente diferentes.
### Umbrales de nivel
| Condición | Nivel |
|-----------|-------|
| ms ≤ línea base + σ | normal |
| línea base + σ < ms ≤ línea base + 2σ | warn |
| ms > línea base + 2σ | peak |
| timeout de curl | unavailable |
| menos de 30 muestras | normal (sin indicador de nivel) |
El mínimo de muestras es 30 (no 3) para asegurar que el estimado de desviación estándar sea estable antes de usarlo para decisiones.
### Gestión del log
Cada ejecución agrega una entrada y elimina las de más de 30 días. A intervalos de 1 minuto el log crece rápido, pero la ventana de filtro por día+hora hace que el conjunto de trabajo efectivo para cualquier cálculo de línea base sea mucho menor.
```python
log.append({"ts": now, "ms": ms, "hour": hour, "weekday": weekday})
log = [e for e in log if e["ts"] >= cutoff] # cutoff = ahora - 30 días
```
## Parte 3: Ejecutarlo en segundo plano con launchd
En macOS, `launchd` es la herramienta correcta para jobs recurrentes en segundo plano. Funciona aunque no haya ningún terminal abierto, sobrevive a reinicios y no requiere cron.
El plugin incluye una plantilla plist con `__SAMPLER_PATH__` y `__HOME__` como marcadores de posición. El sampler corre cada 60 segundos:
```xml
Label
com.giorgiosaud.claude.latency
StartInterval
60
RunAtLoad
```
El punto de entrada del plugin renderiza la plantilla con las rutas reales y la carga silenciosamente en cada inicio del shell (idempotente — se salta si ya está cargada):
```zsh
sed \
-e "s|__SAMPLER_PATH__|$_claude_sampler|g" \
-e "s|__HOME__|$HOME|g" \
"$_claude_plist_src" > "$_claude_plist_dest"
if ! launchctl list "$_claude_plist_label" &>/dev/null; then
launchctl bootstrap "gui/$(id -u)" "$_claude_plist_dest" 2>/dev/null \
|| launchctl load "$_claude_plist_dest" 2>/dev/null \
|| true
fi
```
Sin configuración manual necesaria — instala el plugin, recarga el shell y el sampler empieza a correr.
## Parte 4: El lector de caché compartido
Tanto el segmento de Powerlevel10k como el renderizador del statusline necesitan leer el mismo archivo de caché y resolver íconos. En lugar de duplicar esa lógica, un `latency-common.sh` compartido define tres variables de entorno:
```bash
# latency-common.sh
claude_latency_read() {
CLAUDE_LATENCY_LEVEL=$(jq -r '.level // "normal"' "$CLAUDE_LATENCY_CACHE")
CLAUDE_LATENCY_MS=$(jq -r '.ms // ""' "$CLAUDE_LATENCY_CACHE")
case "$CLAUDE_LATENCY_LEVEL" in
normal) CLAUDE_LATENCY_ICON=$(printf '\xef\x83\xa7') ;; # U+F0E7 rayo
warn) CLAUDE_LATENCY_ICON=$(printf '\xef\x81\xb1') ;; # U+F071 alerta
peak) CLAUDE_LATENCY_ICON=$(printf '\xef\x88\x9e') ;; # U+F21E latido
unavailable) CLAUDE_LATENCY_ICON=$(printf '\xef\xae\xa4') ;; # U+FBA4 escudo
esac
}
```
Los íconos se codifican como secuencias de bytes UTF-8 crudas mediante `printf` en lugar de literales embebidos — esto evita el problema de caracteres invisibles de ancho cero que ocurre cuando los editores manglan silenciosamente los codepoints de Nerd Font.
## Parte 5: El segmento de Powerlevel10k
El segmento del prompt usa el lector compartido y colorizado solo de primer plano (sin relleno de fondo — más limpio en terminales transparentes):
```zsh
function prompt_claude_latency() {
claude_latency_read || return
local fg
case "$CLAUDE_LATENCY_LEVEL" in
normal) fg=76 ;; # verde (coincide con VCS_CLEAN)
warn) fg=178 ;; # amarillo (coincide con VCS_MODIFIED)
peak) fg=160 ;; # rojo (coincide con STATUS_ERROR)
unavailable) fg=66 ;; # gris (coincide con TIME)
esac
if [[ -n "$CLAUDE_LATENCY_MS" && "$CLAUDE_LATENCY_MS" != "null" ]]; then
p10k segment -f $fg -i "$CLAUDE_LATENCY_ICON" -t "${CLAUDE_LATENCY_MS}ms"
else
p10k segment -f $fg -i "$CLAUDE_LATENCY_ICON"
fi
}
```
El segmento se auto-registra mediante un hook `precmd` — no se requiere editar `.p10k.zsh` manualmente:
```zsh
function _claude_status_register() {
if (( ${#POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS} )); then
if [[ ${POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS[(Ie)claude_latency]} -eq 0 ]]; then
POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS+=(claude_latency)
fi
add-zsh-hook -d precmd _claude_status_register
unfunction _claude_status_register
fi
}
autoload -Uz add-zsh-hook
add-zsh-hook precmd _claude_status_register
```
El hook se dispara una vez en el primer prompt, agrega el segmento y luego se elimina a sí mismo.
> **Nota sobre colores:** Usar números literales de 256 colores con `-f`, no variables. Pasar variables `$POWERLEVEL9K_*` a `p10k segment` no funciona para segmentos personalizados.
## Parte 6: El statusline de Claude Code
El statusline integrado de Claude Code muestra el uso de la ventana de contexto y los límites de tasa. El plugin agrega un statusline más completo que integra todo esto junto con el indicador de latencia, la rama de git y el nombre del modelo:
```bash
# Lee el JSON que Claude Code envía por stdin
cwd=$(echo "$input" | jq -r '.workspace.current_dir // .cwd // ""')
model=$(echo "$input" | jq -r '.model.display_name // ""')
used_pct=$(echo "$input" | jq -r '.context_window.used_percentage // empty')
five_pct=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty')
```
Las barras de progreso se renderizan en bash puro:
```bash
make_bar() {
local pct=$1
local filled=$(( pct * 10 / 100 ))
local empty=$(( 10 - filled ))
local bar="" i
for (( i=0; i