# Sesión persistente de Claude Code (tmux en Docker)
## Problema que resuelve
Correr Claude Code directo en una shell SSH sobre el NAS lo ata al
ciclo de vida de esa conexión: un corte de red, cambiar de laptop a
tablet, o cerrar la terminal por accidente mata el proceso y con él
todo el contexto de trabajo en curso — no hay forma de "retomar
donde quedó" porque el proceso ya no existe.
**Esto es un problema distinto al de los límites de uso/cuota de
Claude Code.** Este setup no aumenta cuota de mensajes ni tokens —
eso lo controla el backend de Anthropic sin importar dónde corra el
CLI. Lo único que resuelve es que la *sesión de terminal* sobreviva
a cortes de red o cambios de dispositivo; si la cuota se agota, sigue
agotada exactamente igual dentro de este setup.
## Por qué Docker y no instalación nativa
ZimaOS no tiene gestor de paquetes tradicional utilizable para esto:
`/` está montado como `squashfs` de solo lectura (imagen del SO
inmutable), y `/usr` es un overlay de solo lectura sobre eso —
`/usr/local/bin` ni siquiera existe y no se puede crear, tampoco como
root (`mkdir` falla con "Read-only file system"). No hay dónde
instalar Node.js, npm ni tmux directamente sobre el host.
Un contenedor Docker sortea esto por completo: tiene su propio
filesystem de escritura (overlay2), independiente del `squashfs` del
host — ahí es donde `npm install -g` deja el binario de `claude` sin
tocar el host en absoluto. Lo único que el contenedor toma del host
es lo que se monta explícitamente como volumen.
## Arquitectura
```mermaid
flowchart LR
SSH["Cliente SSH
(laptop / tablet / lo que sea)"] -->|docker exec| C["Contenedor tmux-host
--network host, --restart unless-stopped"]
C --> T["tmux server
sesión 'lab'"]
T --> CC["proceso claude
(Claude Code)"]
C --> HC["healthcheck.sh (background)
cada 30 min"]
HC -.->|curl| NET["gitea.cruzcloud.net"]
HC -.->|recrea si falta| T
C -.->|bind mount| DATA["/DATA (host)
repos y configs del lab"]
C -.->|bind mount| SOCK["/var/run/docker.sock (host)
control de Docker/k3d"]
C -.->|bind mount| HOME["/DATA/AppData/k3d-lab-v3/claude-code-home (host)
-> /root/.claude"]
```
El contenedor sigue vivo aunque la conexión SSH se corte — es el
servidor tmux, no la shell del cliente, el que sostiene el proceso de
Claude Code. `--restart unless-stopped` además lo revive si el NAS
reinicia.
## Contenedor: `tmux-host`
La imagen se construye desde `scripts/tmux-host/Dockerfile` (antes era
un `docker run ... alpine sh -c "apk add ..."` inline — mismo contenido,
pero ahora como imagen versionada en vez de reinstalar todo en cada
arranque). El Dockerfile fija `WORKDIR /DATA/AppData/k3d-lab-v3/lab-cruzcloud`
(la raíz del lab, con `CLAUDE.md`, `apps-registry/`, `platform-infra/` y
`scripts/`) y agrega `healthcheck.sh` (ver más abajo) como proceso en
background del contenedor.
```bash
docker build -t tmux-host:local scripts/tmux-host
docker run -d \
--name tmux-host \
--restart unless-stopped \
-v /DATA:/DATA \
-v /media/HDD-Storage:/media/HDD-Storage \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /DATA/AppData/k3d-lab-v3/claude-code-home:/root/.claude \
-v /DATA/AppData/k3d-lab-v3/claude-code-home.json:/root/.claude.json \
--network host \
tmux-host:local
```
`WORKDIR` hace que `docker exec` (y por lo tanto `lab-session.sh`)
arranquen ya posicionados en la raíz del lab sin tener que navegar a
mano — `lab-session.sh` además pasa la ruta explícita vía `tmux new -c`
por las dudas, para no depender de que `docker exec` herede el `WORKDIR`
de la imagen.
| Volumen | Destino | Por qué |
|---|---|---|
| `/DATA` | `/DATA` | Acceso a los repos (`apps-registry`, `platform-infra`, `scripts`) y configs del lab, mismos paths que en el host |
| `/media/HDD-Storage` | `/media/HDD-Storage` | Ver "Gotcha: el symlink `/DATA/AppData`" abajo — sin esto, los repos no se ven dentro del contenedor pese a montar `/DATA` |
| `/var/run/docker.sock` | `/var/run/docker.sock` | Seguir controlando Docker y k3d (contenedores, `k3d node`, etc.) desde dentro del contenedor |
| `/DATA/AppData/k3d-lab-v3/claude-code-home` | `/root/.claude` | Persistir login/config de Claude Code — sobrevive no solo a un `docker restart`, sino a recrear el contenedor entero |
| `/DATA/AppData/k3d-lab-v3/claude-code-home.json` | `/root/.claude.json` | Ver "Gotcha: la confianza del folder no persistía" abajo — es un archivo suelto, no vive dentro de `~/.claude/` |
## Gotcha: el symlink `/DATA/AppData`
Montar `-v /DATA:/DATA` **no alcanza** para ver los repos dentro del
contenedor. `/DATA/AppData` en el host es un symlink *absoluto*:
```
/DATA/AppData -> /media/HDD-Storage/AppData
```
Los repos del lab (`lab-cruzcloud`) viven físicamente en el disco
btrfs `/media/HDD-Storage`, no en el ext4 de `/DATA` — ZimaOS lo
expone dentro de `/DATA` como submount (`/DATA/.media/HDD-Storage/...`,
que sí viaja al contenedor porque Docker hace bind recursivo por
defecto) pero el path "bonito" `/DATA/AppData/...` que usan `CLAUDE.md`
y todos los scripts del lab llega ahí vía ese symlink absoluto, no vía
el submount. Un symlink absoluto que apunta fuera del árbol montado
queda roto dentro del contenedor aunque el submount subyacente esté
perfectamente accesible por otra ruta.
**Cómo se detectó:** todo el trabajo de configuración inicial de este
mismo setup se hizo con `git`/`docker exec` corriendo *en el host*, no
dentro del contenedor — nadie había probado `cd
/DATA/AppData/k3d-lab-v3/lab-cruzcloud` desde una sesión `lab` real
hasta que se preguntó explícitamente por esto. `ls` devolvía "No such
file or directory" pese a que el volumen de `/DATA` estaba montado y
"andando".
**Fix:** montar también `-v /media/HDD-Storage:/media/HDD-Storage`,
para que el symlink resuelva exactamente igual que en el host.
## Gotcha: git "dubious ownership" dentro del contenedor
Con el mount anterior ya arreglado, `git status`/`git log` dentro del
contenedor fallan con:
```
fatal: detected dubious ownership in repository at '/media/HDD-Storage/...'
```
Los archivos en `/DATA`/`/media/HDD-Storage` pertenecen al usuario
`devops` del host (via samba), pero los procesos dentro del
contenedor corren como `root` (UID 0) — git, desde la versión que
introdujo la protección CVE-2022-24765, rechaza operar sobre un repo
cuyo dueño no coincide con el UID del proceso, a menos que el path
esté explícitamente marcado como seguro.
**Fix:** `git config --global --add safe.directory '*'` dentro del
contenedor. El wildcard (en vez de listar cada repo) es intencional
acá: este contenedor ya tiene acceso root-equivalente al host vía el
volumen de `/var/run/docker.sock`, así que restringir `safe.directory`
por repo no suma aislamiento real.
**Por qué está en el comando de arranque y no como paso manual
aparte:** igual que los paquetes `apk`, esta configuración vive en el
filesystem efímero del contenedor (`/root/.gitconfig`, que no está
bajo el volumen de `/root/.claude`) — se perdería si el contenedor se
recrea. Ponerla en el `sh -c` de arranque la deja recreándose sola
cada vez, sin pasos manuales que recordar.
## Gotcha: la confianza del folder no persistía
Al abrir `claude` por primera vez en un directorio, pide confirmar un
diálogo de "¿confiás en este folder?" (más aún acá, porque este repo
trae un `.claude/settings.local.json` con permisos pre-aprobados). Esa
confirmación se guarda en `~/.claude.json` — un **archivo suelto en
`/root`**, no dentro de la carpeta `~/.claude/` que ya estaba montada
como volumen. Resultado: aunque el login (`~/.claude/.credentials.json`)
sí persistía, el diálogo de confianza volvía a aparecer cada vez que
se recreaba el contenedor — justo el tipo de "reconfigurar de nuevo"
que este setup existe para evitar.
**Cómo se detectó:** al confirmar el diálogo por primera vez, se
comparó `/root/.claude.json` dentro del contenedor contra sí mismo
antes y después — mismo tamaño y mismo `mtime`, es decir, la
confirmación en pantalla nunca se había registrado. La causa real:
la primera vez se movió el selector con las flechas pero nunca se
presionó Enter, así que en rigor el diálogo seguía sin confirmar; una
vez confirmado *de verdad* (`tmux send-keys` moviendo el cursor a
"Yes, I trust this folder" + Enter), el archivo saltó de 2.2 KB a
40 KB y apareció la clave `projects` con la ruta del repo adentro.
**Fix:** copiar ese `.claude.json` ya confirmado a
`/DATA/AppData/k3d-lab-v3/claude-code-home.json` en el host, y
montarlo **archivo a archivo** (no como carpeta) en
`-v .../claude-code-home.json:/root/.claude.json`. A diferencia de
los demás volúmenes de este setup, este necesitó sembrarse con
contenido real primero — un bind mount de archivo con un origen que
no existe todavía en el host termina creando un directorio vacío en
su lugar, no un archivo, y rompe el arranque del contenedor.
Verificado en vivo: tras recrear el contenedor con este mount, `claude`
arrancó directo en la sesión normal, sin volver a pedir el diálogo de
confianza.
Sobre paquetes: alpine (imagen `alpine` sin pin de versión, la misma
que uso este setup) sí trae `kubectl` en sus repos — no hizo falta
cambiar de imagen base ni instalarlo por binario aparte, contrario a
lo que se sospechaba inicialmente. `nodejs`/`npm` (v24/v11 al momento
de escribir esto) también están en los repos de alpine y cumplen de
sobra el mínimo que pide Claude Code.
**Nota sobre `--network host` + `docker.sock`:** el contenedor tiene,
en la práctica, control equivalente a root sobre el NAS (puede lanzar
cualquier otro contenedor con cualquier mount). Es una concesión
deliberada — es exactamente lo que hace falta para seguir operando
Docker/k3d desde dentro — no un descuido.
## Reconexión: un solo comando
```bash
/media/HDD-Storage/AppData/k3d-lab-v3/lab-cruzcloud/scripts/lab-session.sh
```
El script (`scripts/lab-session.sh`) es intencionalmente el mismo
comando sin importar el estado previo:
```bash
docker exec -it "$CONTAINER" tmux attach -t "$SESSION" || \
docker exec -it "$CONTAINER" tmux new -s "$SESSION"
```
Si la sesión `lab` existe, se adjunta a ella (mismo shell, mismo
`claude` corriendo, mismo directorio de trabajo). Si no existe —por
ejemplo justo después de que el contenedor se recreó desde cero— la
crea. No hay que recordar cuál de los dos casos aplica.
## Chequeo de salud automático (cada 30 min)
`scripts/tmux-host/healthcheck.sh` corre en background dentro del
propio contenedor (lanzado por `entrypoint.sh`, el `ENTRYPOINT` de la
imagen) y cada `HEALTHCHECK_INTERVAL_SEC` (1800s por defecto) hace dos
chequeos independientes, con una respuesta distinta según el tipo de
falla:
- **Conectividad de red real** (`curl` a `https://gitea.cruzcloud.net/`):
si falla, **solo se registra** en el log y se notifica por Telegram —
el contenedor nunca intenta "arreglar" la red del host. Un problema de
red puede ser de hardware/BIOS (deuda técnica ya conocida, ver
`incidente-medusa-crashloop-2026-08.md`), fuera del alcance real de un
contenedor.
- **Existencia de la sesión tmux `lab`** (`tmux has-session`): si no
existe, **sí se recrea automáticamente** (`tmux new-session -d -s lab
-c `, seguido de `tmux send-keys -t lab claude Enter` — ver
gotcha "sesión recreada pero sin Claude Code adentro" más abajo) — es
solo recuperar el propio entorno de trabajo del contenedor, no tocar
infraestructura externa, así que es seguro hacerlo sin intervención
humana. Si `WORKDIR` todavía no está montado (ver gotcha siguiente),
no se crea la sesión esa vuelta — se reintenta en el próximo ciclo en
vez de crearla mal posicionada.
Todo evento (chequeo ok, falla de red, sesión recreada) queda en
`/DATA/AppData/k3d-lab-v3/tmux-host/logs/health.log` — persistente en
`/DATA`, no solo en el stdout del contenedor (que se pierde con
`docker logs` al recrearlo). Las notificaciones de Telegram reusan el
mismo bot/`chat_id` ya configurado para `scripts/lab-agent/`
(`lab-agent.env`), en vez de duplicar credenciales entre los dos
agentes del lab.
Para probar el intervalo sin esperar 30 minutos, se puede levantar el
contenedor con `-e HEALTHCHECK_INTERVAL_SEC=60` (solo para pruebas en un
contenedor descartable — nunca cambiar esto en el `tmux-host` real sin
volver a 1800 después).
## Gotcha: sesión recreada, pero sin Claude Code adentro (reinicio completo del NAS, 2026-09-19)
Distinto al caveat de persistencia de sesión de más abajo (ese cubre "el
*proceso* muere pero el contenedor sigue vivo" — cerrar la sesión `lab` a
mano, o un crash del propio `claude`). Este es otro escenario: **el HOST
completo se apaga y vuelve a arrancar desde cero** (corte de luz, no un
`docker restart` ordenado) — reordena cuándo arrancan Docker, la red y los
montajes de `/DATA`/`/media/HDD-Storage` entre sí, algo que un
`docker restart` normal nunca ejercita porque el host ya estaba arriba.
Tras un corte de luz real, se encontró que el usuario se reconectó a la
sesión `lab` y encontró un shell vacío, no Claude Code corriendo. Dos
causas distintas, ambas corregidas:
1. **La sesión tmux SÍ se recreaba sola** (confirmado en `health.log`: el
contenedor arrancó y la sesión `lab` quedó recreada en cuestión de
segundos), pero `check_tmux_session()` nunca lanzó `claude` adentro —
solo garantizaba que existiera el *shell*, no que el proceso de Claude
Code estuviera corriendo. El diagrama de arquitectura de este mismo doc
siempre mostró `T --> CC["proceso claude"]` como si fuera automático,
pero el código nunca lo implementó — el hueco quedó invisible mientras
nadie pasó por un reinicio completo real del NAS (en un `docker
restart` normal, el `claude` que ya estaba corriendo dentro de la
sesión simplemente sigue vivo, porque ahí el proceso *no* muere).
**Fix:** `check_tmux_session()` ahora llama `launch_claude_in_session()`
(`tmux send-keys -t lab claude Enter`) justo después de crear la sesión
— solo cuando la crea de cero, nunca sobre una sesión que ya existe (no
se reinyecta `claude` sobre un pane con trabajo en curso).
2. **Riesgo de carrera no ejercitado todavía, pero real:** `WORKDIR`
(`/DATA/AppData/k3d-lab-v3/lab-cruzcloud`) llega vía el symlink absoluto
`/DATA/AppData -> /media/HDD-Storage/AppData` (ver el gotcha del symlink
más arriba). Si el storage externo (`/media/HDD-Storage`) tarda en
montarse en el host más de lo que tarda Docker en arrancar este
contenedor — plausible después de un corte de luz, no de un simple
restart —, `mkdir -p "$LOG_DIR"` fallaba en silencio (`set -u` sin
`-e`) y ese arranque quedaba *sin ningún rastro* en el log, y
`tmux new-session -c "$WORKDIR"` habría creado la sesión mal
posicionada (o fallado) contra un directorio que todavía no existe.
**Fix:** `wait_for_workdir()` reintenta con backoff (pasos de 5s, hasta
`STARTUP_MOUNT_WAIT_MAX_SEC=120` por defecto) a que `WORKDIR` exista
antes de tocar el log o crear la sesión; si sigue sin existir pasado
ese máximo, se registra el error explícitamente y se reintenta en el
siguiente ciclo (30 min) en vez de fallar una sola vez y quedarse así.
**Validado en vivo:** imagen reconstruida (`tmux-host:test-fix`) y probada
en un contenedor descartable aparte (mismos mounts de `/DATA` y
`/media/HDD-Storage`, sin tocar el `tmux-host` real) — `docker stop` +
`docker start` (arranque desde cero, no `restart`) recreó la sesión `lab`
con `claude` corriendo adentro sin intervención manual, en ~1 segundo.
## Qué sobrevive a qué
| Evento | ¿Sobrevive la sesión tmux (`lab`)? | ¿Sobrevive el login de Claude Code? |
|---|---|---|
| Corte de SSH / cerrar la terminal / cambiar de dispositivo | Sí — es exactamente el caso que este setup resuelve | Sí |
| `docker restart tmux-host` (reinicio del NAS) | **No al toque, pero se auto-recupera solo** — el proceso `tmux server` muere con el contenedor; `healthcheck.sh` la recrea en el próximo ciclo (máx. 30 min) sin intervención, o `lab-session.sh` la crea al instante si alguien se reconecta antes | Sí, vía el volumen montado en `/root/.claude` |
| `docker rm` + recrear el contenedor desde cero | No, pero igual se auto-recupera sola (mismo mecanismo de arriba) | Sí, vía el mismo volumen — no hace falta volver a autenticar ni reconfirmar el diálogo de confianza del folder (ver el mount de `claude-code-home.json`) |
| Falla de red real del host/NAS | N/A | N/A — `healthcheck.sh` la detecta y notifica, pero no la corrige (fuera de su alcance) |
## Validación en vivo
Confirmado en este mismo lab (2026-08-27): se dejó una variable de
entorno (`export MARCA_PERSISTENCIA=ok_`) corriendo dentro
de la sesión `lab` vía `tmux send-keys`, y una invocación
*completamente separada* de `docker exec` (equivalente a una nueva
conexión) leyó de vuelta el mismo valor — confirma que el estado vive
en el servidor tmux del contenedor, no en el cliente que se conecta.
`claude --version` respondió `2.1.250` dentro de esa misma sesión, y
un archivo de prueba escrito en `/root/.claude` dentro del contenedor
apareció de inmediato en
`/DATA/AppData/k3d-lab-v3/claude-code-home` en el host, confirmando
el bind mount de credenciales.
Después de agregar el mount de `/media/HDD-Storage` y el
`safe.directory`, se validó además: `md5sum` de `CLAUDE.md` idéntico
leído desde el host y desde dentro del contenedor (vía el path con
symlink `/DATA/AppData/...`), y `git log`/`git status` corriendo sin
error sobre `apps-registry` y `scripts` desde una shell dentro de
`tmux-host`.