Claude Code "sueña" cuando no lo usas: anatomia del sistema de tasks
Nota: Los fragmentos de codigo que aparecen en esta serie son recreaciones en Python basadas en la arquitectura y patrones descritos publicamente tras la filtracion. No se reproduce codigo original de Anthropic. El objetivo es puramente educativo: entender los conceptos de ingenieria detras de una herramienta de IA de produccion.
Seguimos desgranando la filtracion del codigo de Claude Code, y esta vez toca hablar de una parte especialmente rica: su sistema de tareas. En este post vamos a ver como Claude Code organiza el trabajo por dentro, empezando por la funcion que lo hizo famoso (el to-do list visible en el terminal, que hoy copian casi todos los agentes) y bajando hasta descubrir que detras hay en realidad dos sistemas completamente distintos que casualmente se llaman igual. Recorreremos los 7 tipos de procesos que Claude Code puede lanzar en background (shells, sub-agentes, teammates, agentes remotos...) y terminaremos en lo mas curioso de todo: el DreamTask, un componente que se activa cuando llevas tiempo sin usar Claude Code y pone al modelo a "soñar" para consolidar sus memorias. De ahi el titulo del post.
1. El to-do list: la funcion que Claude Code invento (y todos copiaron)
Una de las cosas que mas llamaba la atencion cuando salio Claude Code, y que hoy parece obvia pero entonces no lo era, fue algo muy sencillo: que el propio modelo mantuviera una lista de to-dos visible en el terminal mientras trabajaba. Le pedias algo complicado, y en vez de lanzarse a escribir codigo a lo loco, paraba un segundo, creaba una lista de tareas, las marcaba en curso, las iba tachando una a una, y tu veias el plan en tiempo real.
Claude Code fue el primer agente de codigo en popularizar este patron. Antes, los agentes simplemente ejecutaban: el usuario tenia que confiar ciegamente en que el modelo sabia lo que estaba haciendo, sin ver jamas que pensaba hacer a continuacion. La TodoWrite tool de Claude Code cambio eso, y a dia de hoy practicamente todos los agentes serios han copiado el patron.
Pero detras de esa funcionalidad tan aparentemente simple hay un sistema mucho mas sofisticado de lo que parece: una estructura de datos persistente en disco, con dependencias entre tareas, compartida entre todos los agentes de un equipo, con locking para concurrencia. No es solo una lista, es un pequeño gestor de trabajo distribuido.
Vamos a verlo.
2. El sistema de task list: un DAG para planificar trabajo
Cada tarea es un archivo JSON en disco. Los agentes las leen, las reclaman, las marcan como en curso, las completan, y dejan el archivo actualizado para que cualquier otro agente del mismo equipo pueda ver el estado.
# Recreacion del sistema de task list (DAG)
import json
import os
from pathlib import Path
from filelock import FileLock
@dataclass
class WorkTask:
id: str
subject: str # "Run tests for auth module"
description: str
active_form: str # "Running tests" (para el spinner)
owner: str = "" # ID del agente asignado
status: str = "pending" # pending → in_progress → completed
blocks: list = field(default_factory=list)
blocked_by: list = field(default_factory=list)
class TaskList:
"""Almacenamiento en disco con locking para concurrencia."""
# Configuracion de locks: 30 reintentos, 5-100ms timeout
# "Budget sized for ~10+ concurrent swarm agents: each critical
# section does readdir + N x readFile + writeFile (~50-100ms)"
LOCK_RETRIES = 30
LOCK_MIN_TIMEOUT_MS = 5
LOCK_MAX_TIMEOUT_MS = 100
def __init__(self, base_dir, list_id):
self.dir = Path(base_dir) / list_id
self.dir.mkdir(parents=True, exist_ok=True)
self.highwatermark_file = self.dir / ".highwatermark"
def create_task(self, subject, description, active_form=""):
task_id = self._next_id()
task = WorkTask(
id=task_id,
subject=subject,
description=description,
active_form=active_form,
)
self._write_task(task)
return task
def claim_task(self, task_id, agent_id, check_agent_busy=False):
"""Asigna una tarea a un agente.
Con check_agent_busy, verifica atomicamente que el agente
no tenga ya otra tarea abierta."""
with self._lock():
task = self._read_task(task_id)
if not task:
return {"success": False, "reason": "task_not_found"}
if task.owner:
return {"success": False, "reason": "already_claimed"}
if task.status == "completed":
return {"success": False, "reason": "already_resolved"}
# Verificar bloqueos
all_tasks = self._read_all()
unresolved = {t.id for t in all_tasks if t.status != "completed"}
blocked_by = [bid for bid in task.blocked_by if bid in unresolved]
if blocked_by:
return {"success": False, "reason": "blocked",
"blocked_by": blocked_by}
if check_agent_busy:
busy_tasks = [t for t in all_tasks
if t.owner == agent_id
and t.status == "in_progress"]
if busy_tasks:
return {"success": False, "reason": "agent_busy"}
task.owner = agent_id
self._write_task(task)
return {"success": True}
def add_dependency(self, from_id, to_id):
"""from_id blocks to_id (to_id no puede empezar hasta que from termine)."""
with self._lock():
from_task = self._read_task(from_id)
to_task = self._read_task(to_id)
from_task.blocks.append(to_id)
to_task.blocked_by.append(from_id)
self._write_task(from_task)
self._write_task(to_task)
def delete_task(self, task_id):
"""Al borrar, limpia todas las referencias en blocks/blockedBy."""
with self._lock():
all_tasks = self._read_all()
for task in all_tasks:
task.blocks = [b for b in task.blocks if b != task_id]
task.blocked_by = [b for b in task.blocked_by if b != task_id]
self._write_task(task)
(self.dir / f"{task_id}.json").unlink(missing_ok=True)
def _next_id(self):
"""High water mark previene reutilizacion de IDs tras reset."""
try:
hwm = int(self.highwatermark_file.read_text())
except (FileNotFoundError, ValueError):
hwm = 0
new_id = str(hwm + 1)
self.highwatermark_file.write_text(new_id)
return new_id
def _lock(self):
return FileLock(self.dir / ".lock",
timeout=self.LOCK_MAX_TIMEOUT_MS / 1000)Fijate en las dos listas blocks y blocked_by: son la misma relacion de dependencia leida en dos direcciones. Si la tarea A tiene que terminar antes que la B, A guarda a B en su blocks ("cuando yo acabe, desbloqueo a B") y B guarda a A en su blocked_by ("no puedo empezar hasta que A termine"). Duplicar la informacion permite responder instantaneamente a las dos preguntas que se hacen todo el rato los agentes: "¿puedo empezar ya?" (mirando mi propio blocked_by) y "¿a quien desbloqueo cuando acabe?" (mirando mi propio blocks), sin tener que escanear todas las demas tareas del sistema. Es el mismo patron que un indice en una base de datos: gastas un poco mas de espacio a cambio de consultas mucho mas rapidas.
Y fijate tambien en que no hay deteccion de ciclos. El sistema confia en que el modelo no creara dependencias circulares del tipo A → B → C → A (que dejarian a las tres tareas bloqueadas para siempre esperandose entre si). Las listas son arrays simples sin validacion topologica.
El high water mark (.highwatermark) es un detalle sutil: cuando se ejecuta resetTaskList() (que borra todos los archivos de tareas completadas), el high water mark garantiza que los nuevos IDs nunca repitan los anteriores. Esto evita colisiones si otro agente todavia tiene una referencia al ID antiguo.
¿Quien tiene acceso a la task list?
La lista se comparte entre todos los agentes de un equipo. El ID de la lista se resuelve por prioridad:
Variable de entorno
CLAUDE_CODE_TASK_LIST_IDNombre del equipo del teammate
Variable de entorno
CLAUDE_CODE_TEAM_NAMEID de la sesion (fallback)
Y cuando un teammate muere, todas sus tareas abiertas se desasignan automaticamente y vuelven a pending para que otro agente las pueda coger.
3. Dos sistemas de "tasks" con el mismo nombre
Y aqui empieza la parte confusa cuando lees el codigo por primera vez: esta lista de to-dos del modelo no es lo unico que Claude Code llama "tasks". Existe un segundo sistema completamente separado que tambien se llama asi, y que vive en un sitio totalmente distinto:
|
|
Sistema de task list (DAG) |
Sistema de background tasks |
|---|---|---|
|
Proposito |
Planificar trabajo logico |
Gestionar procesos en ejecucion |
|
Donde vive |
En disco (archivos JSON) |
En memoria (AppState) |
|
Quien lo usa |
El modelo (LLM) |
El runtime del CLI |
|
Ejemplo |
"Tarea: escribir tests para auth" |
"Shell ejecutando |
|
Persiste entre sesiones |
Si |
No (excepto agentes remotos) |
El primero es la capa de planificacion que acabamos de ver: to-dos organizados en un DAG que el modelo usa para decidir que hacer. El segundo es la infraestructura: controla shells, sub-agentes y procesos reales que estan vivos en el CLI en un momento dado. Son mundos totalmente distintos que casualmente comparten nombre.
Vamos a ver el segundo.
4. Los 7 tipos de background tasks
Cada proceso en ejecucion tiene un tipo, un ciclo de vida con 5 estados posibles, y un ID unico con un prefijo que identifica su tipo:
# Recreacion del sistema de tipos de tareas
from enum import Enum
import os
class TaskType(Enum):
LOCAL_BASH = "local_bash" # prefijo 'b'
LOCAL_AGENT = "local_agent" # prefijo 'a'
REMOTE_AGENT = "remote_agent" # prefijo 'r'
IN_PROCESS_TEAMMATE = "in_process_teammate" # prefijo 't'
LOCAL_WORKFLOW = "local_workflow" # prefijo 'w'
MONITOR_MCP = "monitor_mcp" # prefijo 'm'
DREAM = "dream" # prefijo 'd'
class TaskStatus(Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
KILLED = "killed"
TERMINAL_STATES = {TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.KILLED}La tabla completa de tipos:
|
Tipo |
Prefijo |
Que hace |
Concurrencia |
|---|---|---|---|
|
|
|
Shell en background |
Una por comando |
|
|
|
Sub-agente Claude |
Multiples en paralelo |
|
|
|
Agente en servidores de Anthropic |
Multiples, con polling |
|
|
|
Agente colaborativo en mismo proceso |
Multiples, memoria aislada |
|
|
|
Script de workflow |
Feature-gated |
|
|
|
Monitor MCP |
Feature-gated |
|
|
|
Consolidacion de memorias |
Una sola a la vez |
5. Shells en background: el vigilante de stall
Las shell tasks (local_bash) son las mas simples: ejecutan un comando y devuelven el resultado. Pero tienen un detalle elegante: un detector de bloqueo que vigila si el comando se ha quedado esperando input interactivo.
# Recreacion del detector de stall en shell tasks
import re
import time
STALL_TIMEOUT_SECONDS = 45
POLL_INTERVAL_SECONDS = 5
# Patrones que indican que el comando espera input del usuario
INTERACTIVE_PATTERNS = re.compile(
r"(\(y/n\)|Continue\?|Press Enter|password:|Are you sure|"
r"\[Y/n\]|\[yes/no\]|Proceed\?)",
re.IGNORECASE
)
class ShellStallWatchdog:
def __init__(self):
self.last_output_size = 0
self.stall_started_at = None
def check(self, current_output):
"""Se ejecuta cada 5 segundos mientras el comando corre."""
current_size = len(current_output)
if current_size != self.last_output_size:
# Hay output nuevo → reiniciar timer
self.last_output_size = current_size
self.stall_started_at = None
return None
# Output no ha crecido
if self.stall_started_at is None:
self.stall_started_at = time.time()
elapsed = time.time() - self.stall_started_at
if elapsed < STALL_TIMEOUT_SECONDS:
return None
# 45 segundos sin output → comprobar si espera input
tail = current_output[-500:]
if INTERACTIVE_PATTERNS.search(tail):
return "El comando parece estar esperando input interactivo"
return NoneSi el output lleva 45 segundos sin crecer y las ultimas lineas contienen patrones como (y/n), Continue? o password:, el sistema notifica al modelo de que el comando probablemente esta atascado esperando input, algo que un proceso en background no puede proporcionar.
Las shells tambien tienen un sistema de foreground/background: los comandos empiezan en primer plano, y si tardan lo suficiente, el usuario puede pulsar Ctrl+B para enviarlos al background. Y cuando un sub-agente termina, todos sus shells en background se matan automaticamente, el comentario en el codigo dice textualmente: "prevents 10-day fake-logs.sh zombies."
6. Sub-agentes locales: la gracia de los 30 segundos
Los sub-agentes (local_agent) son instancias de Claude corriendo en paralelo. Cada uno tiene su propia conversacion, sus propias herramientas, y su propio presupuesto de tokens.
Lo interesante esta en como se gestiona su ciclo de vida en la UI:
# Recreacion del sistema de eviccion de sub-agentes
import time
PANEL_GRACE_MS = 30_000 # 30 segundos de gracia
class AgentTaskState:
def __init__(self, task_id, prompt, agent_type):
self.task_id = task_id
self.prompt = prompt
self.agent_type = agent_type
self.status = "running"
self.retain = False # True si el usuario esta viendolo
self.evict_after = None # Timestamp de expiracion
self.messages = []
self.pending_messages = [] # Cola de SendMessage
def complete(self, result):
self.status = "completed"
if not self.retain:
# 30 segundos de gracia antes de desaparecer del panel
self.evict_after = time.time() * 1000 + PANEL_GRACE_MS
def should_evict(self):
if self.retain:
return False
if self.evict_after is None:
return False
return time.time() * 1000 > self.evict_afterCuando un sub-agente termina, no desaparece inmediatamente del panel del coordinador. Tiene 30 segundos de gracia para que el usuario pueda ver su resultado. Pero si el usuario esta mirando activamente esa tarea (retain: True), la eviccion se bloquea indefinidamente.
Los sub-agentes tambien tienen un sistema de mensajes pendientes: el modelo principal puede enviar mensajes a un sub-agente via la herramienta SendMessage, y esos mensajes se encolan y se procesan al final de cada ronda de herramientas del sub-agente.
7. Teammates: 292 agentes en 2 minutos
Los teammates (in_process_teammate) son la evolucion de los sub-agentes. La diferencia clave: corren dentro del mismo proceso de Node.js, usando AsyncLocalStorage para aislar su contexto. Esto los hace mucho mas eficientes que lanzar procesos separados.
¿Por que "InProcess" y no "Subprocess"? Porque existen tres backends posibles para teammates:
tmux — proceso separado en un panel de tmux
iTerm2 — proceso separado en un tab de iTerm2
in-process — mismo proceso, aislamiento via
AsyncLocalStorage
El nombre describe exactamente lo que hace: corre dentro del proceso principal.
# Recreacion del sistema de teammates
from dataclasses import dataclass, field
from typing import Optional, List, Callable
# Cap de mensajes en UI: 50 por teammate
TEAMMATE_MESSAGES_UI_CAP = 50
# Motivo del cap (comentario real del codigo):
# "BQ analysis showed ~20MB RSS per agent at 500+ turns.
# Whale session 9a990de8 launched 292 agents in 2 minutes
# and reached 36.8GB."
@dataclass
class TeammateIdentity:
agent_id: str
agent_name: str
team_name: str
color: str
plan_mode_required: bool
parent_session_id: str
@dataclass
class TeammateTaskState:
identity: TeammateIdentity
prompt: str
status: str = "running"
awaiting_plan_approval: bool = False
is_idle: bool = False
shutdown_requested: bool = False
messages: List = field(default_factory=list)
pending_user_messages: List[str] = field(default_factory=list)
on_idle_callbacks: List[Callable] = field(default_factory=list)
def add_message(self, message):
self.messages.append(message)
# Mantener solo los ultimos 50 mensajes para no explotar la memoria
if len(self.messages) > TEAMMATE_MESSAGES_UI_CAP:
self.messages = self.messages[-TEAMMATE_MESSAGES_UI_CAP:]El cap de 50 mensajes existe por una razon real: un usuario (sesion 9a990de8) lanzo 292 agentes en 2 minutos y el proceso alcanzo 36.8 GB de RAM. El coste dominante era el array de mensajes de UI, que mantenia una copia completa de cada conversacion.
Los teammates tienen dos formas de morir:
Graceful (
requestTeammateShutdown): se marcashutdown_requested, se envia un mensaje al buzon, el teammate termina su turno actual y paraForceful (
killInProcessTeammate): se aborta el controlador, se marcakilled, se elimina del equipo inmediatamente
Y cuando un teammate muere, todas las tareas que tenia asignadas se desasignan automaticamente y vuelven a pending para que otro teammate las pueda coger.
8. Agentes remotos: sesiones en la nube que sobreviven al cierre
Los agentes remotos (remote_agent) son los unicos que sobreviven al cierre del CLI. Corren en los servidores de Anthropic ("CCR" — Claude Code Remote) y se sincronizan con tu maquina local via polling.
# Recreacion del sistema de agentes remotos
import json
from pathlib import Path
class RemoteAgentTask:
# Tipos de tareas remotas
REMOTE_TASK_TYPES = [
"remote-agent", # Agente generico remoto
"ultraplan", # Planificacion avanzada
"ultrareview", # Revision de codigo
"autofix-pr", # Correccion automatica de PRs
"background-pr", # PR en background
]
def __init__(self, session_id, task_type, title):
self.session_id = session_id
self.task_type = task_type
self.title = title
self.log = [] # Mensajes SDK acumulados
self.poll_started_at = None
def save_metadata(self, session_dir):
"""Persiste metadata para poder reconectar tras reinicio."""
sidecar_path = Path(session_dir) / "remote_agents.json"
metadata = {
"session_id": self.session_id,
"task_type": self.task_type,
"title": self.title,
}
# Escribe sidecar para que --resume pueda restaurar
with open(sidecar_path, "a") as f:
f.write(json.dumps(metadata) + "\n")
@staticmethod
def restore_from_resume(session_dir):
"""Al hacer --resume, reconecta con sesiones remotas activas."""
sidecar_path = Path(session_dir) / "remote_agents.json"
if not sidecar_path.exists():
return []
tasks = []
for line in sidecar_path.read_text().splitlines():
meta = json.loads(line)
# Consulta CCR API para ver si sigue viva
status = poll_remote_session(meta["session_id"])
if status == "archived" or status == "not_found":
continue # Sesion muerta, ignorar
tasks.append(RemoteAgentTask(**meta))
return tasksCuando cierras Claude Code y lo reabres con --resume, el sistema lee los metadatos guardados en disco, consulta la API de CCR para cada sesion, y restaura solo las que siguen activas. Las sesiones archivadas o eliminadas se ignoran y su archivo sidecar se limpia.
El panel de UI usa simbolos de diamante para mostrar el estado: diamante abierto mientras la sesion corre, diamante relleno cuando el plan esta listo y espera aprobacion.
9. DreamTask: el sueño REM de Claude Code
Este es el componente mas fascinante del sistema de tasks. La DreamTask es un agente de consolidacion de memorias que se dispara automaticamente cuando detecta que llevas tiempo sin usar Claude Code.
¿Cuando se dispara?
Basicamente, cuando llevas un rato sin usar Claude Code y hay suficiente material nuevo que consolidar: al menos 24 horas desde la ultima vez que soñó y unas cuantas sesiones nuevas en el medio. Si no se cumple, no pasa nada y el modelo sigue a lo suyo.
¿Que hace cuando sueña?
El prompt de consolidacion tiene 4 fases:
# Recreacion del prompt de consolidacion del DreamTask
MAX_ENTRYPOINT_LINES = 200 # CLAUDE.md no puede superar 200 lineas
CONSOLIDATION_PROMPT = """
Fase 1 — ORIENT:
- Ejecuta ls en el directorio de memorias
- Lee el indice CLAUDE.md
- Ojea los archivos de temas existentes
Fase 2 — GATHER RECENT SIGNAL:
- Escanea los logs diarios
- Busca memorias que han cambiado
- Haz grep en las transcripciones JSONL de sesiones recientes
Fase 3 — CONSOLIDATE:
- Escribe o actualiza archivos de memoria
- Fusiona en vez de crear duplicados
- Convierte fechas relativas a absolutas ("ayer" → "2026-04-05")
- Elimina contradicciones
Fase 4 — PRUNE AND TRIM:
- Manten CLAUDE.md por debajo de {max_lines} lineas (~25KB)
- Cada entrada en una linea, maximo ~150 caracteres
""".format(max_lines=MAX_ENTRYPOINT_LINES)
# IMPORTANTE: en modo auto-dream (no manual /dream),
# Bash esta restringido a comandos de solo lectura:
# ls, find, grep, cat, stat, wc, head, tail
# Cualquier comando que escriba sera denegado.Es decir: el DreamTask lee tus sesiones anteriores, identifica que ha cambiado, y reescribe los archivos de memoria para mantenerlos actualizados y concisos. Si una memoria dice "ayer configure el linter", la convierte a "el 5 de abril de 2026 se configuro el linter". Si dos memorias se contradicen, elimina la obsoleta.
Y hay una restriccion de seguridad importante: cuando el dream se dispara automaticamente (no via el comando manual /dream), Bash esta limitado a comandos de solo lectura. No puede ejecutar rm, mv, ni redirigir output a archivos. Solo puede leer.
El ciclo de vida del DreamTask:
Mientras sueña, el DreamTask pasa por dos fases visibles en la UI: primero esta en modo starting (esta leyendo y explorando lo que hay) y cuando empieza a tocar archivos de memoria cambia a updating. Tiene un limite de 30 turnos para hacer su trabajo, asi que no puede quedarse indefinidamente procesando.
Y hay un detalle importante sobre como gestiona el fallo: si el DreamTask se mata o se cae a medias, restaura el lock a su estado anterior. Esto garantiza que la proxima vez que abras Claude Code el sistema volvera a intentar la consolidacion, en vez de pensar que ya se hizo y saltarsela. Es un mecanismo de "todo o nada" para que no se pierdan memorias por culpa de una consolidacion que no llego a terminar.
10. El modo coordinador
Cuando se activa el modo coordinador (CLAUDE_CODE_COORDINATOR_MODE=1), Claude deja de ejecutar trabajo directamente y se convierte en un director de orquesta que solo puede hacer tres cosas: lanzar agentes, enviar mensajes, y detener tareas.
El coordinador recibe un prompt de sistema masivo que incluye:
Las herramientas disponibles (solo Agent, SendMessage, TaskStop)
Guias de ciclo de vida de workers: investigacion → sintesis → implementacion → verificacion
Reglas de concurrencia: operaciones de solo lectura en paralelo, escritura en serie
Frameworks de decision: cuando continuar un worker vs. lanzar uno nuevo
Una lista completa de las herramientas que tienen los workers (incluyendo herramientas MCP)
Es el patron clasico de "manager agent" que delega todo el trabajo real a sub-agentes.
11. Curiosidades
El sistema de pills en el footer
Cada tipo de tarea tiene su propia etiqueta visual en la barra inferior del CLI. Las shells muestran "N shells, M monitors". Los teammates cuentan equipos unicos ("N teams"). Los agentes remotos usan diamantes Unicode. Y cuando el DreamTask esta activo, simplemente muestra: "dreaming".
El "verification nudge"
Cuando el agente principal completa la ultima de 3 o mas tareas y ninguna tenia "verif" en el nombre, el sistema inserta automaticamente un recordatorio: "Before writing your final summary, spawn the verification agent." Es una forma elegante de forzar que el modelo verifique su trabajo.
Output en disco con proteccion anti-symlink
Los archivos de output de tareas se abren con el flag O_NOFOLLOW, que impide seguir symlinks. Esto previene un ataque donde un proceso malicioso en el sandbox crea un symlink task_output → /etc/passwd y espera a que el sistema escriba ahi. El limite de output es de 5 GB por tarea.
La auto-asignacion de teammates
Cuando un teammate marca una tarea como in_progress sin especificar owner, el sistema automaticamente le asigna como propietario. Esto asegura que la task list siempre refleja quien esta trabajando en que.
La UI auto-oculta las tareas completadas
5 segundos despues de que todas las tareas se completan, el panel de tareas se oculta automaticamente y ejecuta resetTaskList() para limpiar los archivos JSON del disco.
Fuentes
Analisis basado en la filtracion del codigo fuente de Claude Code v2.1.88, documentada en el Post 1 de esta serie
The Verge — "Claude Code leak exposes a Tamagotchi-style 'pet' and an always-on agent"
Esta serie es un proyecto de Levante — un cliente de escritorio open source para trabajar con MCPs, modelos de IA y herramientas en una sola interfaz. Si te interesa la arquitectura de herramientas de IA, te interesa Levante.
Saul Gomez es fundador de Levante, un cliente de escritorio open source para trabajar con modelos de IA.
