Capítulo 8: Manejo de Sesiones y Contexto

Por: Artiko
claudesdksesionescontextopersistenciapythontypescript

Capítulo 8: Manejo de Sesiones y Contexto

Una sesión es la memoria de una conversación. Sin sesiones, cada llamada a query() comienza desde cero — el agente no recuerda nada de lo que ocurrió antes. Con sesiones, puedes reanudar conversaciones, construir sobre análisis previos, y crear asistentes que “conocen” el proyecto.


1. ¿Qué son las sesiones?

Una sesión en el SDK es un identificador (session_id) que apunta a un historial de conversación almacenado en el servidor de Claude. Cuando reanudas una sesión, el modelo “recuerda” todo lo que ocurrió en ella.

Diagrama: ciclo de vida de una sesión

sequenceDiagram
    participant App as Aplicación
    participant SDK as Claude SDK
    participant API as Anthropic API
    participant Store as Session Store

    App->>SDK: query("Analiza este proyecto")
    SDK->>API: Petición nueva (sin session_id)
    API->>Store: Crear nueva sesión
    API-->>SDK: SystemMessage(session_id="abc123")
    SDK-->>App: Mensajes del agente...

    Note over App,Store: Segunda llamada — reanuda la sesión

    App->>SDK: query("Ahora refactoriza lo que encontraste", resume="abc123")
    SDK->>API: Petición con session_id="abc123"
    API->>Store: Cargar historial de "abc123"
    API-->>SDK: Continúa donde se quedó
    SDK-->>App: El agente recuerda el análisis anterior

Por qué las sesiones son fundamentales

Sin sesiones, cada query() es una conversación nueva. El agente no sabe:

Con sesiones, el agente mantiene todo ese contexto, permitiendo flujos de trabajo de múltiples pasos donde cada paso construye sobre el anterior.

Diferencia entre sesión y contexto manual

MecanismoCómo funcionaCuándo usar
Sesión (resume)El servidor mantiene el historial completoFlujos de trabajo multi-turno con continuidad
Contexto manualTu código pasa el historial en cada promptControl total, sin dependencia de sesiones del servidor
Sistema de archivosEl agente escribe notas en archivosPersistencia entre procesos, auditoría

2. SystemMessage de inicio: capturando el session_id

Cuando inicias una nueva sesión, el SDK emite un SystemMessage especial al principio del stream que contiene el session_id. Debes capturarlo para poder reanudar la sesión después.

Captura del session_id en Python

import asyncio
from claude_code_sdk import query, ClaudeCodeOptions
from claude_code_sdk.types import SystemMessage, AssistantMessage, ResultMessage

async def query_con_sesion(
    prompt: str,
    opciones: ClaudeCodeOptions,
    session_id: str = None
) -> tuple[str, str]:
    """
    Ejecuta un query y retorna (resultado, session_id).
    Si se provee session_id, reanuda esa sesión.
    Si no se provee, crea una nueva sesión y retorna su ID.
    """
    if session_id:
        opciones = ClaudeCodeOptions(
            **{k: v for k, v in vars(opciones).items() if v is not None},
            resume=session_id
        )

    session_capturada = session_id  # Puede ser None para sesiones nuevas
    resultado_texto = ""

    async for mensaje in query(prompt=prompt, options=opciones):
        # SystemMessage contiene el session_id al inicio de la sesión
        if isinstance(mensaje, SystemMessage):
            if hasattr(mensaje, 'session_id') and mensaje.session_id:
                session_capturada = mensaje.session_id
                print(f"[Sesión] ID capturado: {session_capturada}")

        # AssistantMessage contiene el texto de respuesta
        elif isinstance(mensaje, AssistantMessage):
            if hasattr(mensaje, 'message') and hasattr(mensaje.message, 'content'):
                for bloque in mensaje.message.content:
                    if hasattr(bloque, 'text'):
                        resultado_texto = bloque.text

        # ResultMessage marca el fin — puede contener session_id también
        elif isinstance(mensaje, ResultMessage):
            if hasattr(mensaje, 'session_id') and mensaje.session_id:
                session_capturada = mensaje.session_id

    return resultado_texto, session_capturada

# Uso básico
async def ejemplo_sesion_basica():
    opciones = ClaudeCodeOptions(
        model="claude-opus-4-5",
        allowed_tools=["Read", "Glob", "Grep"],
        max_turns=10,
        cwd="/mi/proyecto",
    )

    # Primera query — crea nueva sesión
    resultado1, session_id = await query_con_sesion(
        "Analiza la estructura general del proyecto y encuentra los archivos principales.",
        opciones
    )
    print(f"Primer análisis completado. Session ID: {session_id}")
    print(f"Resultado: {resultado1[:200]}...")

    # Segunda query — continúa la misma sesión
    if session_id:
        resultado2, _ = await query_con_sesion(
            "Basándote en el análisis anterior, ¿cuáles son los 3 problemas más críticos?",
            opciones,
            session_id=session_id
        )
        print(f"\nAnálisis profundo: {resultado2[:200]}...")

asyncio.run(ejemplo_sesion_basica())

Captura del session_id en TypeScript

import { query, ClaudeCodeOptions } from "@anthropic-ai/claude-code-sdk";

interface SesionResultado {
  texto: string;
  sessionId: string | null;
}

async function queryConSesion(
  prompt: string,
  opciones: ClaudeCodeOptions,
  sessionId?: string
): Promise<SesionResultado> {
  const opcionesSesion: ClaudeCodeOptions = sessionId
    ? { ...opciones, resume: sessionId }
    : opciones;

  let sessionCapturada: string | null = sessionId ?? null;
  let resultadoTexto = "";

  for await (const mensaje of query({ prompt, options: opcionesSesion })) {
    // Capturar session_id del SystemMessage inicial
    if (mensaje.type === "system" && "session_id" in mensaje) {
      sessionCapturada = (mensaje as any).session_id ?? sessionCapturada;
      console.log(`[Sesión] ID capturado: ${sessionCapturada}`);
    }

    // Capturar texto del AssistantMessage
    if (mensaje.type === "assistant" && mensaje.message.content) {
      for (const bloque of mensaje.message.content) {
        if (bloque.type === "text") {
          resultadoTexto = bloque.text;
        }
      }
    }

    // ResultMessage también puede tener session_id
    if (mensaje.type === "result" && "session_id" in mensaje) {
      sessionCapturada = (mensaje as any).session_id ?? sessionCapturada;
    }
  }

  return { texto: resultadoTexto, sessionId: sessionCapturada };
}

// Uso
async function ejemploSesionBasica() {
  const opciones: ClaudeCodeOptions = {
    model: "claude-opus-4-5",
    allowedTools: ["Read", "Glob", "Grep"],
    maxTurns: 10,
    cwd: "/mi/proyecto",
  };

  // Primera query — nueva sesión
  const { texto: res1, sessionId } = await queryConSesion(
    "Analiza la estructura del proyecto.",
    opciones
  );
  console.log(`Session ID: ${sessionId}`);

  if (sessionId) {
    // Segunda query — reanuda sesión
    const { texto: res2 } = await queryConSesion(
      "¿Cuáles son los 3 problemas más críticos que encontraste?",
      opciones,
      sessionId
    );
    console.log(res2);
  }
}

ejemploSesionBasica();

3. resume — Reanudar sesiones

La opción resume en ClaudeCodeOptions indica al SDK que debe continuar una sesión existente en lugar de crear una nueva. Cuando usas resume, el modelo tiene acceso a todo el historial de la sesión anterior.

Cómo funciona internamente

graph TD
    A[query con resume='abc123']
    A --> B[SDK envía session_id a la API]
    B --> C{¿Sesión existe?}
    C -->|Sí| D[Cargar historial completo de la sesión]
    C -->|No — expirada o inválida| E[Error: SessionNotFound]
    D --> F[Agregar nuevo prompt al historial]
    F --> G[Modelo procesa con contexto completo]
    G --> H[Stream de respuesta]
    H --> I[Actualizar historial de la sesión]

Escenarios de uso de resume

import asyncio
from claude_code_sdk import query, ClaudeCodeOptions

async def asistente_persistente(directorio: str):
    """
    Asistente que mantiene contexto a lo largo de múltiples interacciones.
    Simula una sesión de trabajo donde el agente recuerda el contexto.
    """
    opciones_base = ClaudeCodeOptions(
        model="claude-opus-4-5",
        allowed_tools=["Read", "Write", "Edit", "Bash", "Glob", "Grep"],
        max_turns=15,
        cwd=directorio,
    )

    session_id = None
    historial_local = []  # Para debug/logging

    async def ejecutar_paso(descripcion: str, prompt: str) -> str:
        nonlocal session_id

        opciones = ClaudeCodeOptions(
            **vars(opciones_base),
            resume=session_id  # None en la primera llamada
        )

        resultado = ""
        async for mensaje in query(prompt=prompt, options=opciones):
            if hasattr(mensaje, 'session_id') and mensaje.session_id:
                session_id = mensaje.session_id
            if hasattr(mensaje, 'content'):
                for bloque in mensaje.content:
                    if hasattr(bloque, 'text'):
                        resultado = bloque.text

        historial_local.append({
            "paso": descripcion,
            "session_id": session_id,
            "resultado_preview": resultado[:100]
        })
        print(f"[Paso completado] {descripcion} (session: {session_id})")
        return resultado

    # Paso 1: Exploración inicial
    estructura = await ejecutar_paso(
        "exploración",
        "Explora el proyecto completo. Toma nota de todos los archivos importantes, "
        "la arquitectura usada y las tecnologías involucradas."
    )

    # Paso 2: Análisis de dependencias (recuerda la exploración)
    deps = await ejecutar_paso(
        "análisis de dependencias",
        "Basándote en lo que exploraste, analiza las dependencias entre módulos. "
        "¿Hay dependencias circulares? ¿Módulos muy acoplados?"
    )

    # Paso 3: Identificación de mejoras (recuerda todo lo anterior)
    mejoras = await ejecutar_paso(
        "identificación de mejoras",
        "Con toda la información que tienes del proyecto, lista las 5 mejoras "
        "más importantes ordenadas por impacto. Sé específico con los archivos."
    )

    # Paso 4: Plan de acción (recuerda todo)
    plan = await ejecutar_paso(
        "plan de acción",
        "Genera un plan de acción detallado para implementar las mejoras que identificaste. "
        "Incluye estimación de esfuerzo y dependencias entre tareas."
    )

    return {
        "session_id": session_id,
        "estructura": estructura,
        "dependencias": deps,
        "mejoras": mejoras,
        "plan": plan,
        "historial": historial_local,
    }

asyncio.run(asistente_persistente("/mi/proyecto"))

Resume con verificación de sesión

from claude_code_sdk import query, ClaudeCodeOptions
import asyncio

async def verificar_y_reanudar(session_id: str, opciones: ClaudeCodeOptions) -> bool:
    """
    Verifica si una sesión es válida antes de reanudarla.
    Retorna True si la sesión existe y es reanudable.
    """
    opciones_test = ClaudeCodeOptions(
        model="claude-haiku-4-5",  # Modelo más barato para la verificación
        allowed_tools=[],
        max_turns=1,
        resume=session_id,
    )

    try:
        async for mensaje in query(
            prompt="¿Recuerdas el contexto de esta sesión? Responde solo 'sí' o 'no'.",
            options=opciones_test
        ):
            if hasattr(mensaje, 'content'):
                return True  # Si llegamos aquí, la sesión es válida
        return True
    except Exception as e:
        if "session" in str(e).lower() or "not found" in str(e).lower():
            return False
        raise  # Re-lanzar errores que no son de sesión inválida

async def reanudar_o_nueva(session_id: str, opciones: ClaudeCodeOptions) -> ClaudeCodeOptions:
    """
    Retorna opciones con resume si la sesión es válida,
    o opciones sin resume si la sesión expiró o no existe.
    """
    if session_id and await verificar_y_reanudar(session_id, opciones):
        print(f"[Sesión] Reanudando sesión: {session_id}")
        return ClaudeCodeOptions(**{**vars(opciones), 'resume': session_id})
    else:
        print(f"[Sesión] Sesión no disponible, iniciando nueva")
        return opciones

4. Persistencia de sesiones

Las sesiones tienen una duración limitada en el servidor de Anthropic. Para construir sistemas robustos, debes persistir el session_id y manejar la expiración.

Estrategias de persistencia

graph TD
    SID[session_id]
    SID --> MEM[In-Memory - Desarrollo]
    SID --> FILE[Archivo JSON - Single process]
    SID --> SQLITE[SQLite - Local multi-proceso]
    SID --> REDIS[Redis - Distribuido]
    SID --> PG[PostgreSQL - Producción]

    MEM --> |"Pérdida al reiniciar"| L1[Limitación]
    FILE --> |"Sin concurrencia"| L2[Limitación]
    SQLITE --> |"Un servidor"| L3[Limitación]
    REDIS --> |"Infraestructura"| L4[Require]
    PG --> |"Máxima fiabilidad"| OK[Producción]

Implementación: SQLite para persistencia local

import sqlite3
import asyncio
import json
from datetime import datetime, timedelta
from pathlib import Path
from claude_code_sdk import query, ClaudeCodeOptions
from typing import Optional

class SesionStore:
    """Almacén de sesiones con SQLite."""

    def __init__(self, db_path: str = "~/.claude_sessions.db"):
        self.db_path = Path(db_path).expanduser()
        self._inicializar_db()

    def _inicializar_db(self):
        """Crea las tablas necesarias si no existen."""
        with sqlite3.connect(self.db_path) as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS sesiones (
                    id TEXT PRIMARY KEY,
                    nombre TEXT NOT NULL,
                    session_id TEXT NOT NULL,
                    creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    actualizado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    expira_en TIMESTAMP,
                    metadatos TEXT DEFAULT '{}',
                    activo INTEGER DEFAULT 1
                )
            """)
            conn.execute("""
                CREATE INDEX IF NOT EXISTS idx_sesiones_nombre
                ON sesiones(nombre)
            """)
            conn.commit()

    def guardar(
        self,
        nombre: str,
        session_id: str,
        metadatos: dict = None,
        ttl_horas: int = 24
    ) -> str:
        """Guarda o actualiza una sesión."""
        expira_en = datetime.now() + timedelta(hours=ttl_horas)

        with sqlite3.connect(self.db_path) as conn:
            conn.execute("""
                INSERT INTO sesiones (id, nombre, session_id, expira_en, metadatos, actualizado_en)
                VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
                ON CONFLICT(id) DO UPDATE SET
                    session_id = excluded.session_id,
                    actualizado_en = CURRENT_TIMESTAMP,
                    expira_en = excluded.expira_en,
                    metadatos = excluded.metadatos
            """, (
                nombre,  # Usar nombre como ID para búsqueda por nombre
                nombre,
                session_id,
                expira_en.isoformat(),
                json.dumps(metadatos or {})
            ))
            conn.commit()

        return session_id

    def obtener(self, nombre: str) -> Optional[str]:
        """Retorna el session_id si la sesión existe y no expiró."""
        with sqlite3.connect(self.db_path) as conn:
            row = conn.execute("""
                SELECT session_id, expira_en
                FROM sesiones
                WHERE nombre = ? AND activo = 1
                ORDER BY actualizado_en DESC
                LIMIT 1
            """, (nombre,)).fetchone()

        if not row:
            return None

        session_id, expira_en_str = row
        if expira_en_str:
            expira_en = datetime.fromisoformat(expira_en_str)
            if datetime.now() > expira_en:
                self.invalidar(nombre)
                return None

        return session_id

    def invalidar(self, nombre: str):
        """Marca una sesión como inactiva."""
        with sqlite3.connect(self.db_path) as conn:
            conn.execute(
                "UPDATE sesiones SET activo = 0 WHERE nombre = ?",
                (nombre,)
            )
            conn.commit()

    def limpiar_expiradas(self):
        """Elimina sesiones expiradas de la base de datos."""
        with sqlite3.connect(self.db_path) as conn:
            eliminadas = conn.execute("""
                DELETE FROM sesiones
                WHERE expira_en < CURRENT_TIMESTAMP OR activo = 0
            """).rowcount
            conn.commit()
        return eliminadas

    def listar(self) -> list:
        """Lista todas las sesiones activas."""
        with sqlite3.connect(self.db_path) as conn:
            rows = conn.execute("""
                SELECT nombre, session_id, creado_en, actualizado_en, expira_en
                FROM sesiones
                WHERE activo = 1
                ORDER BY actualizado_en DESC
            """).fetchall()

        return [
            {
                "nombre": r[0],
                "session_id": r[1],
                "creado_en": r[2],
                "actualizado_en": r[3],
                "expira_en": r[4],
            }
            for r in rows
        ]

# Uso del store
store = SesionStore()

async def asistente_con_memoria(nombre_proyecto: str, pregunta: str) -> str:
    """
    Asistente que recuerda el contexto entre invocaciones.
    Busca sesión existente o crea una nueva.
    """
    session_id = store.obtener(nombre_proyecto)
    opciones = ClaudeCodeOptions(
        model="claude-opus-4-5",
        allowed_tools=["Read", "Glob", "Grep"],
        max_turns=10,
        cwd=f"/proyectos/{nombre_proyecto}",
        resume=session_id,  # None si es la primera vez
    )

    if session_id:
        print(f"[Memoria] Retomando sesión existente para: {nombre_proyecto}")
    else:
        print(f"[Memoria] Nueva sesión para: {nombre_proyecto}")

    resultado = ""
    nuevo_session_id = session_id

    async for mensaje in query(prompt=pregunta, options=opciones):
        if hasattr(mensaje, 'session_id') and mensaje.session_id:
            nuevo_session_id = mensaje.session_id
        if hasattr(mensaje, 'content'):
            for bloque in mensaje.content:
                if hasattr(bloque, 'text'):
                    resultado = bloque.text

    # Guardar/actualizar la sesión
    if nuevo_session_id:
        store.guardar(
            nombre=nombre_proyecto,
            session_id=nuevo_session_id,
            metadatos={"ultima_pregunta": pregunta[:100]},
            ttl_horas=8  # Expira en 8 horas
        )

    return resultado

# Uso
async def main():
    # Primera invocación — nueva sesión
    res1 = await asistente_con_memoria("mi-api", "¿Cuál es la arquitectura del proyecto?")
    print(res1)

    # Segunda invocación (horas después) — retoma la sesión
    res2 = await asistente_con_memoria("mi-api", "¿Qué mejoras recomendarías basándote en lo que analizaste?")
    print(res2)

asyncio.run(main())

Implementación: Redis para sistemas distribuidos

import asyncio
import json
from claude_code_sdk import query, ClaudeCodeOptions
from typing import Optional

try:
    import redis.asyncio as redis
    REDIS_DISPONIBLE = True
except ImportError:
    REDIS_DISPONIBLE = False

class SesionStoreRedis:
    """Almacén de sesiones con Redis para sistemas distribuidos."""

    def __init__(self, url: str = "redis://localhost:6379", ttl: int = 86400):
        self.url = url
        self.ttl = ttl  # TTL en segundos (default: 24 horas)
        self._cliente = None

    async def _cliente_redis(self):
        if not self._cliente:
            self._cliente = await redis.from_url(self.url)
        return self._cliente

    def _clave(self, nombre: str) -> str:
        return f"claude_session:{nombre}"

    async def guardar(self, nombre: str, session_id: str, metadatos: dict = None):
        """Guarda la sesión en Redis con TTL."""
        r = await self._cliente_redis()
        datos = {
            "session_id": session_id,
            "metadatos": json.dumps(metadatos or {}),
        }
        await r.hset(self._clave(nombre), mapping=datos)
        await r.expire(self._clave(nombre), self.ttl)

    async def obtener(self, nombre: str) -> Optional[str]:
        """Retorna el session_id si existe."""
        r = await self._cliente_redis()
        datos = await r.hget(self._clave(nombre), "session_id")
        return datos.decode() if datos else None

    async def invalidar(self, nombre: str):
        """Elimina la sesión."""
        r = await self._cliente_redis()
        await r.delete(self._clave(nombre))

    async def extender_ttl(self, nombre: str):
        """Extiende el TTL de una sesión activa (sliding expiration)."""
        r = await self._cliente_redis()
        await r.expire(self._clave(nombre), self.ttl)

5. Fork de sesiones

El fork permite crear una “rama” de una sesión existente, explorando alternativas sin afectar la sesión original.

graph LR
    S1[Sesión original]
    S1 --> F1[Fork A: estrategia conservadora]
    S1 --> F2[Fork B: estrategia agresiva]
    S1 --> F3[Fork C: estrategia experimental]
    F1 --> R1[Resultado A]
    F2 --> R2[Resultado B]
    F3 --> R3[Resultado C]
    R1 --> C[Comparar y elegir mejor]
    R2 --> C
    R3 --> C

Implementación de fork manual

El SDK no tiene un comando fork explícito, pero puedes implementarlo iniciando una nueva sesión que incluye el contexto de la sesión original como parte del prompt:

import asyncio
from claude_code_sdk import query, ClaudeCodeOptions
from typing import Optional

async def fork_sesion(
    session_id_original: str,
    prompt_divergencia: str,
    opciones: ClaudeCodeOptions,
    contexto_previo: str = ""
) -> tuple[str, str]:
    """
    Crea un 'fork' de una sesión: nueva sesión con contexto de la original.
    Retorna (resultado, nuevo_session_id).
    """
    # El fork es una nueva sesión que incluye el contexto previo
    prompt_completo = f"""Contexto de la sesión anterior:
{contexto_previo}

---
Continuando desde ese contexto, pero explorando una alternativa diferente:
{prompt_divergencia}"""

    # Nueva sesión — NO usamos resume de la original
    opciones_fork = ClaudeCodeOptions(
        model=opciones.model,
        allowed_tools=opciones.allowed_tools,
        max_turns=opciones.max_turns,
        cwd=opciones.cwd,
        system_prompt=opciones.system_prompt,
        # Sin resume — nueva sesión independiente
    )

    resultado = ""
    nuevo_session_id = None

    async for mensaje in query(prompt=prompt_completo, options=opciones_fork):
        if hasattr(mensaje, 'session_id') and mensaje.session_id:
            nuevo_session_id = mensaje.session_id
        if hasattr(mensaje, 'content'):
            for bloque in mensaje.content:
                if hasattr(bloque, 'text'):
                    resultado = bloque.text

    return resultado, nuevo_session_id

async def explorar_alternativas(
    session_id_base: str,
    contexto_previo: str,
    alternativas: list,
    opciones: ClaudeCodeOptions
) -> list:
    """
    Explora múltiples alternativas en paralelo a partir de la misma base.
    """
    tareas = [
        fork_sesion(
            session_id_base,
            alternativa["prompt"],
            opciones,
            contexto_previo
        )
        for alternativa in alternativas
    ]

    resultados = await asyncio.gather(*tareas, return_exceptions=True)

    return [
        {
            "nombre": alternativas[i]["nombre"],
            "resultado": r[0] if not isinstance(r, Exception) else "",
            "session_id": r[1] if not isinstance(r, Exception) else None,
            "error": str(r) if isinstance(r, Exception) else None,
        }
        for i, r in enumerate(resultados)
    ]

# Uso: explorar diferentes estrategias de refactoring
async def comparar_estrategias():
    opciones = ClaudeCodeOptions(
        model="claude-opus-4-5",
        allowed_tools=["Read"],
        max_turns=10,
        cwd="/mi/proyecto",
    )

    # Sesión base: análisis del problema
    resultado_base = ""
    session_base = None
    async for m in query(
        prompt="Analiza este módulo de autenticación y describe sus problemas actuales.",
        options=opciones
    ):
        if hasattr(m, 'session_id') and m.session_id:
            session_base = m.session_id
        if hasattr(m, 'content'):
            for b in m.content:
                if hasattr(b, 'text'):
                    resultado_base = b.text

    # Explorar 3 estrategias de refactoring en paralelo
    alternativas_resultados = await explorar_alternativas(
        session_base,
        resultado_base,
        [
            {
                "nombre": "conservadora",
                "prompt": "Propón una refactorización conservadora que minimice el riesgo de romper cosas existentes."
            },
            {
                "nombre": "moderna",
                "prompt": "Propón una refactorización moderna usando los últimos patrones de Python."
            },
            {
                "nombre": "incremental",
                "prompt": "Propón una refactorización incremental en 5 pasos pequeños independientes."
            },
        ],
        opciones
    )

    for alt in alternativas_resultados:
        print(f"\n=== Estrategia: {alt['nombre']} ===")
        if alt['resultado']:
            print(alt['resultado'][:300])
        else:
            print(f"Error: {alt['error']}")

asyncio.run(comparar_estrategias())

6. Sesiones largas y compactación

Las sesiones largas acumulan muchos tokens en el historial. El SDK compacta automáticamente el contexto cuando se acerca a los límites, pero hay señales que puedes detectar.

Cómo funciona la compactación automática

sequenceDiagram
    participant App as Aplicación
    participant SDK as SDK
    participant API as Anthropic API

    Note over App,API: Sesión normal
    App->>SDK: query(resume=session_id) — turno 50
    SDK->>API: Historial: 80K tokens
    API-->>SDK: Respuesta normal

    Note over App,API: Acercándose al límite
    App->>SDK: query(resume=session_id) — turno 80
    SDK->>API: Historial: 150K tokens
    API->>API: Compactar historial antiguo
    API-->>SDK: SystemMessage con is_compaction=True
    SDK-->>App: Notificación de compactación
    API-->>SDK: Respuesta (contexto compactado)

Detectar y manejar compactación

import asyncio
from claude_code_sdk import query, ClaudeCodeOptions
from claude_code_sdk.types import SystemMessage

async def query_con_deteccion_compactacion(
    prompt: str,
    opciones: ClaudeCodeOptions
) -> dict:
    """
    Ejecuta query y detecta si ocurrió compactación del contexto.
    """
    resultado = ""
    session_id = None
    hubo_compactacion = False
    tokens_usados = 0

    async for mensaje in query(prompt=prompt, options=opciones):
        # Detectar SystemMessage de compactación
        if isinstance(mensaje, SystemMessage):
            if hasattr(mensaje, 'session_id') and mensaje.session_id:
                session_id = mensaje.session_id

            # La compactación se señala en el SystemMessage
            if hasattr(mensaje, 'subtype') and mensaje.subtype == 'compact':
                hubo_compactacion = True
                print("[Sesión] Contexto compactado por el SDK")

        if hasattr(mensaje, 'content'):
            for bloque in mensaje.content:
                if hasattr(bloque, 'text'):
                    resultado = bloque.text

        # Rastrear uso de tokens si está disponible
        if hasattr(mensaje, 'usage'):
            tokens_usados = getattr(mensaje.usage, 'total_tokens', 0)

    return {
        "resultado": resultado,
        "session_id": session_id,
        "hubo_compactacion": hubo_compactacion,
        "tokens_usados": tokens_usados,
    }

async def sesion_con_punto_de_guardado(
    sesion_nombre: str,
    pasos: list,
    directorio: str
):
    """
    Ejecuta múltiples pasos en la misma sesión.
    Guarda puntos de control por si necesitamos retomar.
    """
    store = SesionStore()  # Del ejemplo anterior
    session_id = store.obtener(sesion_nombre)

    opciones = ClaudeCodeOptions(
        model="claude-opus-4-5",
        allowed_tools=["Read", "Write", "Edit", "Glob", "Grep"],
        max_turns=15,
        cwd=directorio,
        resume=session_id,
    )

    resultados = []

    for i, paso in enumerate(pasos):
        print(f"\n[Paso {i+1}/{len(pasos)}] {paso['nombre']}")

        res = await query_con_deteccion_compactacion(paso['prompt'], opciones)

        # Guardar sesión después de cada paso exitoso
        if res['session_id']:
            store.guardar(
                nombre=sesion_nombre,
                session_id=res['session_id'],
                metadatos={
                    "ultimo_paso": paso['nombre'],
                    "paso_numero": i + 1,
                    "total_pasos": len(pasos),
                    "tokens": res['tokens_usados'],
                }
            )
            # Actualizar opciones con el session_id para el siguiente paso
            opciones = ClaudeCodeOptions(**{**vars(opciones), 'resume': res['session_id']})

        if res['hubo_compactacion']:
            print(f"[Sesión] Nota: el contexto fue compactado en este paso")

        resultados.append({
            "paso": paso['nombre'],
            **res
        })

    return resultados

7. Gestión de ventana de contexto

La ventana de contexto es finita. Estrategias para optimizarla:

Estimación de tokens por tipo de contenido

def estimar_tokens(texto: str) -> int:
    """
    Estimación aproximada de tokens.
    Regla general: ~4 caracteres por token en inglés/español.
    """
    return len(texto) // 4

def planificar_sesion(
    contexto_inicial: str,
    pasos: list,
    limite_tokens: int = 100_000,
    reserva_output: int = 4_000
) -> dict:
    """
    Planifica cuántos pasos caben en una sesión dado el límite de tokens.
    """
    tokens_disponibles = limite_tokens - reserva_output
    tokens_usados = estimar_tokens(contexto_inicial)
    pasos_posibles = []
    pasos_excluidos = []

    for paso in pasos:
        tokens_paso = estimar_tokens(paso.get('prompt', ''))
        # Cada turno acumula tokens del historial
        tokens_acumulados = tokens_usados + tokens_paso + reserva_output

        if tokens_acumulados < tokens_disponibles:
            pasos_posibles.append(paso)
            tokens_usados = tokens_acumulados
        else:
            pasos_excluidos.append(paso)

    return {
        "pasos_posibles": pasos_posibles,
        "pasos_excluidos": pasos_excluidos,
        "tokens_estimados": tokens_usados,
        "porcentaje_ventana": round(tokens_usados / limite_tokens * 100, 1),
    }

# Estrategia: dividir en sub-sesiones si hay demasiados pasos
async def ejecutar_con_division_automatica(
    pasos: list,
    opciones: ClaudeCodeOptions,
    limite_tokens: int = 80_000
) -> list:
    """
    Divide automáticamente pasos en múltiples sesiones si el contexto es muy grande.
    """
    grupos = []
    grupo_actual = []
    tokens_grupo = 0

    for paso in pasos:
        tokens_paso = estimar_tokens(paso.get('prompt', ''))
        if tokens_grupo + tokens_paso > limite_tokens and grupo_actual:
            grupos.append(grupo_actual)
            grupo_actual = [paso]
            tokens_grupo = tokens_paso
        else:
            grupo_actual.append(paso)
            tokens_grupo += tokens_paso

    if grupo_actual:
        grupos.append(grupo_actual)

    print(f"[Contexto] Dividido en {len(grupos)} sesiones")

    todos_resultados = []
    for i, grupo in enumerate(grupos):
        print(f"[Contexto] Ejecutando sesión {i+1}/{len(grupos)} con {len(grupo)} pasos")
        resultados_grupo = await ejecutar_pasos_en_sesion(grupo, opciones)
        todos_resultados.extend(resultados_grupo)

    return todos_resultados

async def ejecutar_pasos_en_sesion(
    pasos: list,
    opciones: ClaudeCodeOptions
) -> list:
    """Ejecuta una lista de pasos en una sola sesión."""
    session_id = None
    resultados = []

    for paso in pasos:
        opts = ClaudeCodeOptions(**{**vars(opciones), 'resume': session_id})
        resultado = ""

        async for m in query(prompt=paso['prompt'], options=opts):
            if hasattr(m, 'session_id') and m.session_id:
                session_id = m.session_id
            if hasattr(m, 'content'):
                for b in m.content:
                    if hasattr(b, 'text'):
                        resultado = b.text

        resultados.append({"paso": paso['nombre'], "resultado": resultado})

    return resultados

8. Sesiones conversacionales

Un chatbot con memoria que recuerda toda la conversación usando sesiones del SDK.

import asyncio
import readline  # Para historial en la terminal
from claude_code_sdk import query, ClaudeCodeOptions
from datetime import datetime

class ChatbotConMemoria:
    """Chatbot que mantiene memoria de la conversación usando sesiones SDK."""

    def __init__(self, nombre: str, directorio: str = None):
        self.nombre = nombre
        self.directorio = directorio
        self.session_id = None
        self.turno = 0
        self.inicio = datetime.now()

    @property
    def opciones(self) -> ClaudeCodeOptions:
        return ClaudeCodeOptions(
            model="claude-opus-4-5",
            allowed_tools=["Read", "Glob", "Grep"] if self.directorio else [],
            max_turns=20,
            cwd=self.directorio,
            resume=self.session_id,
            system_prompt=f"""Eres un asistente de desarrollo para el proyecto '{self.nombre}'.
Recuerdas toda la conversación y el contexto del proyecto.
Eres conciso, técnico y accionable.
Cuando el usuario hace referencia a algo mencionado antes, úsalo directamente."""
        )

    async def chat(self, mensaje: str) -> str:
        """Envía un mensaje y retorna la respuesta."""
        self.turno += 1

        resultado = ""
        async for m in query(prompt=mensaje, options=self.opciones):
            if hasattr(m, 'session_id') and m.session_id:
                self.session_id = m.session_id
            if hasattr(m, 'content'):
                for b in m.content:
                    if hasattr(b, 'text'):
                        resultado = b.text

        return resultado

    def info_sesion(self) -> dict:
        duracion = (datetime.now() - self.inicio).seconds
        return {
            "session_id": self.session_id,
            "turnos": self.turno,
            "duracion_segundos": duracion,
        }

async def chat_interactivo():
    """Chatbot interactivo en terminal."""
    print("=== Chatbot con Memoria (Claude Agent SDK) ===")
    nombre_proyecto = input("Nombre del proyecto: ").strip() or "mi-proyecto"
    directorio = input("Directorio del proyecto (Enter para omitir): ").strip() or None

    bot = ChatbotConMemoria(nombre_proyecto, directorio)
    print(f"\nChat iniciado. Escribe 'salir' para terminar, 'info' para ver la sesión.\n")

    while True:
        try:
            usuario = input("Tú: ").strip()
        except (KeyboardInterrupt, EOFError):
            break

        if not usuario:
            continue

        if usuario.lower() == "salir":
            break

        if usuario.lower() == "info":
            info = bot.info_sesion()
            print(f"[Sesión] ID: {info['session_id']}")
            print(f"[Sesión] Turnos: {info['turnos']}, Duración: {info['duracion_segundos']}s")
            continue

        print("Claude: ", end="", flush=True)
        respuesta = await bot.chat(usuario)
        print(respuesta)
        print()

    info = bot.info_sesion()
    print(f"\n[Sesión terminada] {info['turnos']} turnos, {info['duracion_segundos']}s")
    print(f"Session ID para retomar: {info['session_id']}")

asyncio.run(chat_interactivo())

Chatbot en TypeScript con historial

import { query, ClaudeCodeOptions } from "@anthropic-ai/claude-code-sdk";
import * as readline from "readline";

class ChatbotConMemoria {
  private sessionId: string | null = null;
  private turno = 0;
  private readonly nombre: string;

  constructor(nombre: string) {
    this.nombre = nombre;
  }

  private get opciones(): ClaudeCodeOptions {
    return {
      model: "claude-opus-4-5",
      allowedTools: [],
      maxTurns: 20,
      resume: this.sessionId ?? undefined,
      systemPrompt: `Eres un asistente llamado ${this.nombre}.
Recuerdas toda la conversación anterior.
Eres conciso y directo.`,
    };
  }

  async chat(mensaje: string): Promise<string> {
    this.turno++;
    let resultado = "";

    for await (const m of query({ prompt: mensaje, options: this.opciones })) {
      if ("session_id" in m && (m as any).session_id) {
        this.sessionId = (m as any).session_id;
      }
      if (m.type === "assistant" && m.message.content) {
        for (const bloque of m.message.content) {
          if (bloque.type === "text") resultado = bloque.text;
        }
      }
    }

    return resultado;
  }

  get infoSesion() {
    return { sessionId: this.sessionId, turnos: this.turno };
  }
}

async function chatInteractivo() {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  const bot = new ChatbotConMemoria("Asistente");
  console.log('Chat iniciado. Escribe "salir" para terminar.\n');

  const preguntar = () => {
    rl.question("Tú: ", async (input) => {
      const mensaje = input.trim();

      if (!mensaje || mensaje.toLowerCase() === "salir") {
        const info = bot.infoSesion;
        console.log(`\nSesión: ${info.sessionId}, Turnos: ${info.turnos}`);
        rl.close();
        return;
      }

      const respuesta = await bot.chat(mensaje);
      console.log(`Claude: ${respuesta}\n`);
      preguntar();
    });
  };

  preguntar();
}

chatInteractivo();

9. Sesiones de desarrollo

Un asistente que recuerda el estado del proyecto entre sesiones de trabajo.

import asyncio
import json
from pathlib import Path
from claude_code_sdk import query, ClaudeCodeOptions

class AsistenteDesarrollo:
    """
    Asistente de desarrollo que persiste el contexto del proyecto.
    Recuerda análisis previos, decisiones tomadas y estado del trabajo.
    """

    def __init__(self, directorio_proyecto: str):
        self.directorio = Path(directorio_proyecto)
        self.nombre = self.directorio.name
        self.archivo_estado = self.directorio / ".claude_session.json"
        self.store = SesionStore()  # Del ejemplo de SQLite

    def _cargar_estado(self) -> dict:
        """Carga el estado guardado del proyecto."""
        if self.archivo_estado.exists():
            try:
                with open(self.archivo_estado, encoding='utf-8') as f:
                    return json.load(f)
            except (json.JSONDecodeError, IOError):
                pass
        return {}

    def _guardar_estado(self, estado: dict):
        """Guarda el estado del proyecto."""
        with open(self.archivo_estado, 'w', encoding='utf-8') as f:
            json.dump(estado, f, ensure_ascii=False, indent=2)

    def _construir_contexto_inicial(self) -> str:
        """Construye el contexto del sistema con el historial del proyecto."""
        estado = self._cargar_estado()
        if not estado:
            return ""

        partes = [f"Estás trabajando en el proyecto '{self.nombre}'."]

        if estado.get("ultimo_analisis"):
            partes.append(f"\nÚltimo análisis (resumido):\n{estado['ultimo_analisis'][:500]}")

        if estado.get("tareas_pendientes"):
            tareas = "\n".join(f"- {t}" for t in estado["tareas_pendientes"][:5])
            partes.append(f"\nTareas pendientes:\n{tareas}")

        if estado.get("decisiones"):
            decisiones = "\n".join(f"- {d}" for d in estado["decisiones"][-3:])
            partes.append(f"\nDecisiones recientes:\n{decisiones}")

        return "\n".join(partes)

    async def consultar(self, pregunta: str) -> str:
        """Hace una consulta al asistente con contexto del proyecto."""
        estado = self._cargar_estado()
        session_id = self.store.obtener(f"dev_{self.nombre}")

        # Construir system prompt con contexto histórico
        contexto = self._construir_contexto_inicial()
        system_prompt = f"""Eres un asistente de desarrollo experto.
{contexto}
Recuerdas toda la conversación actual y el contexto histórico del proyecto.
Cuando tomes decisiones importantes, menciónalas explícitamente."""

        opciones = ClaudeCodeOptions(
            model="claude-opus-4-5",
            allowed_tools=["Read", "Write", "Edit", "Bash", "Glob", "Grep"],
            max_turns=15,
            cwd=str(self.directorio),
            resume=session_id,
            system_prompt=system_prompt,
        )

        resultado = ""
        nuevo_session_id = session_id

        async for m in query(prompt=pregunta, options=opciones):
            if hasattr(m, 'session_id') and m.session_id:
                nuevo_session_id = m.session_id
            if hasattr(m, 'content'):
                for b in m.content:
                    if hasattr(b, 'text'):
                        resultado = b.text

        # Actualizar estado
        if nuevo_session_id:
            self.store.guardar(f"dev_{self.nombre}", nuevo_session_id)

        # Guardar resumen en estado local
        estado["ultima_consulta"] = pregunta[:100]
        if len(resultado) > 100:
            estado["ultimo_analisis"] = resultado[:500]
        self._guardar_estado(estado)

        return resultado

    async def agregar_tarea(self, tarea: str):
        """Agrega una tarea al estado del proyecto."""
        estado = self._cargar_estado()
        tareas = estado.get("tareas_pendientes", [])
        tareas.append(tarea)
        estado["tareas_pendientes"] = tareas[-10:]  # Mantener solo las últimas 10
        self._guardar_estado(estado)
        print(f"[Asistente] Tarea agregada: {tarea}")

    async def marcar_completada(self, tarea: str):
        """Marca una tarea como completada."""
        estado = self._cargar_estado()
        tareas = estado.get("tareas_pendientes", [])
        nuevas_tareas = [t for t in tareas if tarea.lower() not in t.lower()]
        estado["tareas_pendientes"] = nuevas_tareas

        decisiones = estado.get("decisiones", [])
        decisiones.append(f"Completado: {tarea}")
        estado["decisiones"] = decisiones[-20:]  # Mantener las últimas 20

        self._guardar_estado(estado)

# Uso
async def main():
    asistente = AsistenteDesarrollo("/mi/proyecto")

    # Primera sesión de trabajo
    analisis = await asistente.consultar(
        "Analiza el módulo de autenticación y encuentra los problemas más urgentes."
    )
    print(analisis)

    await asistente.agregar_tarea("Refactorizar el hash de passwords a bcrypt")
    await asistente.agregar_tarea("Agregar rate limiting al endpoint de login")

    # Días después — retoma el contexto
    plan = await asistente.consultar(
        "¿Cómo debería implementar las tareas pendientes? Empieza con la más urgente."
    )
    print(plan)

asyncio.run(main())

10. Multi-tenant sessions

Cuando múltiples usuarios utilizan el mismo sistema, cada uno necesita su propia sesión aislada.

import asyncio
from claude_code_sdk import query, ClaudeCodeOptions
from typing import Dict
import hashlib

class GestorSesionesTenant:
    """Gestiona sesiones aisladas por tenant/usuario."""

    def __init__(self, store: SesionStore):
        self.store = store

    def _clave_sesion(self, tenant_id: str, contexto: str) -> str:
        """Genera una clave única para un tenant y contexto."""
        hash_corto = hashlib.md5(f"{tenant_id}_{contexto}".encode()).hexdigest()[:8]
        return f"tenant_{tenant_id}_{hash_corto}"

    async def query_para_tenant(
        self,
        tenant_id: str,
        prompt: str,
        contexto: str = "default",
        directorio: str = None,
        system_prompt: str = None
    ) -> str:
        """
        Ejecuta un query en la sesión del tenant.
        Cada tenant tiene sesiones completamente aisladas.
        """
        clave = self._clave_sesion(tenant_id, contexto)
        session_id = self.store.obtener(clave)

        opciones = ClaudeCodeOptions(
            model="claude-opus-4-5",
            allowed_tools=["Read", "Glob"] if directorio else [],
            max_turns=10,
            cwd=directorio,
            resume=session_id,
            system_prompt=system_prompt or f"Asistes al usuario {tenant_id}.",
        )

        resultado = ""
        nuevo_session_id = session_id

        async for m in query(prompt=prompt, options=opciones):
            if hasattr(m, 'session_id') and m.session_id:
                nuevo_session_id = m.session_id
            if hasattr(m, 'content'):
                for b in m.content:
                    if hasattr(b, 'text'):
                        resultado = b.text

        if nuevo_session_id:
            self.store.guardar(
                nombre=clave,
                session_id=nuevo_session_id,
                metadatos={"tenant": tenant_id, "contexto": contexto},
                ttl_horas=4
            )

        return resultado

    def invalidar_tenant(self, tenant_id: str):
        """Invalida todas las sesiones de un tenant (logout completo)."""
        # En una implementación real, iterar sobre todas las claves del tenant
        print(f"[Tenant] Invalidando sesiones de: {tenant_id}")

# Uso: múltiples usuarios en paralelo
async def simular_multitenant():
    store = SesionStore()
    gestor = GestorSesionesTenant(store)

    # Múltiples usuarios haciendo consultas al mismo tiempo
    usuarios = [
        ("user_001", "Explica el patrón Repository en Python"),
        ("user_002", "¿Cómo implemento autenticación JWT?"),
        ("user_003", "Muéstrame un ejemplo de Clean Architecture"),
    ]

    resultados = await asyncio.gather(*[
        gestor.query_para_tenant(
            tenant_id=user_id,
            prompt=pregunta,
            contexto="general"
        )
        for user_id, pregunta in usuarios
    ])

    for (user_id, pregunta), resultado in zip(usuarios, resultados):
        print(f"\n{user_id}: {pregunta[:50]}")
        print(f"Respuesta: {resultado[:100]}...")

    # Segunda ronda — cada usuario tiene su propio contexto
    seguimiento = await asyncio.gather(*[
        gestor.query_para_tenant(
            tenant_id="user_001",
            prompt="Dame un ejemplo más completo",
            contexto="general"
        ),
        gestor.query_para_tenant(
            tenant_id="user_002",
            prompt="¿Y cómo manejo el refresh token?",
            contexto="general"
        ),
    ])

asyncio.run(simular_multitenant())

11. Session store patterns

Comparación de implementaciones

StoreVentajasDesventajasCuándo usar
In-Memory (dict)Simple, rápidoPérdida al reiniciarTests, desarrollo
Archivo JSONSimple, portátilSin concurrenciaScripts single-process
SQLiteLocal, ACID, queriesNo distribuidoApps mono-servidor
RedisRápido, TTL nativoInfraestructura extraProducción distribuida
PostgreSQLACID, queries complejasMás overheadProducción crítica

In-Memory store para tests y desarrollo

from typing import Optional, Dict
from datetime import datetime, timedelta
import asyncio

class SesionStoreMemoria:
    """Store in-memory. Solo para tests y desarrollo."""

    def __init__(self, ttl_horas: int = 8):
        self._sesiones: Dict[str, dict] = {}
        self.ttl = ttl_horas

    def guardar(
        self,
        nombre: str,
        session_id: str,
        metadatos: dict = None,
        ttl_horas: int = None
    ) -> str:
        self._sesiones[nombre] = {
            "session_id": session_id,
            "creado_en": datetime.now(),
            "expira_en": datetime.now() + timedelta(hours=ttl_horas or self.ttl),
            "metadatos": metadatos or {},
        }
        return session_id

    def obtener(self, nombre: str) -> Optional[str]:
        sesion = self._sesiones.get(nombre)
        if not sesion:
            return None
        if datetime.now() > sesion["expira_en"]:
            del self._sesiones[nombre]
            return None
        return sesion["session_id"]

    def invalidar(self, nombre: str):
        self._sesiones.pop(nombre, None)

    def limpiar_expiradas(self) -> int:
        ahora = datetime.now()
        antes = len(self._sesiones)
        self._sesiones = {
            k: v for k, v in self._sesiones.items()
            if v["expira_en"] > ahora
        }
        return antes - len(self._sesiones)

    def listar(self) -> list:
        return [
            {"nombre": k, "session_id": v["session_id"], "expira_en": v["expira_en"]}
            for k, v in self._sesiones.items()
        ]

# Factory para obtener el store correcto según el entorno
def crear_store(entorno: str = "desarrollo") -> object:
    """Factory que retorna el store apropiado según el entorno."""
    if entorno == "produccion":
        return SesionStoreRedis()
    elif entorno == "desarrollo":
        return SesionStore()  # SQLite
    elif entorno == "test":
        return SesionStoreMemoria()
    else:
        raise ValueError(f"Entorno desconocido: {entorno}")

12. Ejemplo completo: Asistente de desarrollo persistente

Un sistema completo que recuerda análisis previos entre sesiones de trabajo.

import asyncio
import json
from pathlib import Path
from datetime import datetime
from claude_code_sdk import query, ClaudeCodeOptions

class AsistenteDesarrolloPersistente:
    """
    Asistente de desarrollo completo con memoria persistente.
    Recuerda: análisis previos, bugs encontrados, mejoras implementadas, arquitectura.
    """

    def __init__(self, directorio: str):
        self.directorio = Path(directorio)
        self.nombre = self.directorio.name
        self.store = SesionStore()
        self.clave_sesion = f"dev_principal_{self.nombre}"
        self.clave_memoria = f"memoria_{self.nombre}"
        self._memoria = self._cargar_memoria()

    def _cargar_memoria(self) -> dict:
        """Carga la memoria persistente del proyecto."""
        archivo = self.directorio / ".claude_memory.json"
        if archivo.exists():
            try:
                with open(archivo, encoding='utf-8') as f:
                    return json.load(f)
            except Exception:
                pass
        return {
            "analisis_realizados": [],
            "bugs_encontrados": [],
            "mejoras_implementadas": [],
            "arquitectura": "",
            "notas": [],
        }

    def _guardar_memoria(self):
        """Persiste la memoria del proyecto."""
        archivo = self.directorio / ".claude_memory.json"
        with open(archivo, 'w', encoding='utf-8') as f:
            json.dump(self._memoria, f, ensure_ascii=False, indent=2, default=str)

    def _resumen_memoria(self) -> str:
        """Genera un resumen de la memoria para el system prompt."""
        partes = []

        if self._memoria.get("arquitectura"):
            partes.append(f"Arquitectura conocida: {self._memoria['arquitectura'][:200]}")

        ultimos_analisis = self._memoria.get("analisis_realizados", [])[-3:]
        if ultimos_analisis:
            fechas = [a.get('fecha', 'N/A') for a in ultimos_analisis]
            partes.append(f"Últimos análisis: {', '.join(fechas)}")

        bugs = self._memoria.get("bugs_encontrados", [])
        bugs_pendientes = [b for b in bugs if not b.get("resuelto")]
        if bugs_pendientes:
            descripciones = [b.get('descripcion', '')[:50] for b in bugs_pendientes[:3]]
            partes.append(f"Bugs pendientes: {'; '.join(descripciones)}")

        mejoras = self._memoria.get("mejoras_implementadas", [])
        if mejoras:
            ultimas = [m.get('descripcion', '')[:50] for m in mejoras[-3:]]
            partes.append(f"Mejoras recientes: {'; '.join(ultimas)}")

        return "\n".join(partes) if partes else "Sin historial previo."

    async def analizar(self, aspecto: str = "general") -> str:
        """Realiza un análisis del proyecto."""
        session_id = self.store.obtener(self.clave_sesion)
        memoria_resumen = self._resumen_memoria()

        opciones = ClaudeCodeOptions(
            model="claude-opus-4-5",
            allowed_tools=["Read", "Glob", "Grep", "Bash"],
            disallowed_tools=["Write", "Edit"],
            max_turns=15,
            cwd=str(self.directorio),
            resume=session_id,
            system_prompt=f"""Eres el asistente de desarrollo persistente del proyecto '{self.nombre}'.
Tienes memoria de sesiones anteriores:
{memoria_resumen}

Cuando encuentres nuevos bugs o mejoras, mencionarlos explícitamente con el prefijo:
- BUG: descripción del bug (archivo:linea)
- MEJORA: descripción de la mejora
- ARQUITECTURA: hallazgo arquitectural importante""",
        )

        resultado = ""
        nuevo_session_id = session_id

        async for m in query(
            prompt=f"Analiza el aspecto '{aspecto}' del proyecto.",
            options=opciones
        ):
            if hasattr(m, 'session_id') and m.session_id:
                nuevo_session_id = m.session_id
            if hasattr(m, 'content'):
                for b in m.content:
                    if hasattr(b, 'text'):
                        resultado = b.text

        # Actualizar sesión
        if nuevo_session_id:
            self.store.guardar(self.clave_sesion, nuevo_session_id)

        # Extraer y guardar hallazgos en la memoria
        self._extraer_y_guardar_hallazgos(resultado, aspecto)

        return resultado

    def _extraer_y_guardar_hallazgos(self, resultado: str, aspecto: str):
        """Extrae hallazgos del resultado y los guarda en la memoria."""
        ahora = datetime.now().isoformat()

        # Registrar el análisis
        self._memoria["analisis_realizados"].append({
            "fecha": ahora,
            "aspecto": aspecto,
            "resumen": resultado[:200],
        })
        self._memoria["analisis_realizados"] = self._memoria["analisis_realizados"][-10:]

        # Extraer bugs
        lineas = resultado.split('\n')
        for linea in lineas:
            if linea.strip().startswith("BUG:"):
                descripcion = linea.replace("BUG:", "").strip()
                self._memoria["bugs_encontrados"].append({
                    "fecha": ahora,
                    "descripcion": descripcion,
                    "resuelto": False,
                })

            elif linea.strip().startswith("ARQUITECTURA:"):
                info = linea.replace("ARQUITECTURA:", "").strip()
                if len(info) > len(self._memoria.get("arquitectura", "")):
                    self._memoria["arquitectura"] = info

        self._guardar_memoria()

    async def implementar(self, tarea: str) -> str:
        """Implementa una mejora específica."""
        session_id = self.store.obtener(self.clave_sesion)
        memoria_resumen = self._resumen_memoria()

        opciones = ClaudeCodeOptions(
            model="claude-opus-4-5",
            allowed_tools=["Read", "Write", "Edit", "Bash", "Glob", "Grep"],
            max_turns=20,
            cwd=str(self.directorio),
            resume=session_id,
            system_prompt=f"""Eres el asistente de desarrollo del proyecto '{self.nombre}'.
Contexto histórico:
{memoria_resumen}

Implementa los cambios solicitados de forma cuidadosa y segura.
Al finalizar, confirma qué archivos modificaste y qué cambios hiciste.""",
        )

        resultado = ""
        nuevo_session_id = session_id

        async for m in query(prompt=tarea, options=opciones):
            if hasattr(m, 'session_id') and m.session_id:
                nuevo_session_id = m.session_id
            if hasattr(m, 'content'):
                for b in m.content:
                    if hasattr(b, 'text'):
                        resultado = b.text

        if nuevo_session_id:
            self.store.guardar(self.clave_sesion, nuevo_session_id)

        # Registrar la mejora implementada
        self._memoria["mejoras_implementadas"].append({
            "fecha": datetime.now().isoformat(),
            "descripcion": tarea[:100],
            "resultado": resultado[:200],
        })
        self._guardar_memoria()

        return resultado

# Uso del asistente persistente
async def main():
    asistente = AsistenteDesarrolloPersistente("/mi/proyecto")

    # Sesión 1: Análisis inicial
    print("=== Análisis de seguridad ===")
    analisis = await asistente.analizar("seguridad")
    print(analisis[:500])

    # Sesión 2 (días después): retoma el contexto
    print("\n=== Implementando mejoras de seguridad ===")
    resultado = await asistente.implementar(
        "Implementa las mejoras de seguridad que encontramos en el análisis anterior"
    )
    print(resultado[:500])

asyncio.run(main())

13. Ejemplo completo: Sistema de chat con historial

WebSocket-based chat server con sesiones del SDK.

import asyncio
import json
from claude_code_sdk import query, ClaudeCodeOptions
from typing import Dict

class ServidorChatSesiones:
    """
    Servidor de chat simple que usa sesiones del SDK.
    Cada conexión de usuario tiene su propia sesión persistente.
    """

    def __init__(self):
        self.sesiones_usuarios: Dict[str, str] = {}  # user_id -> session_id
        self.store = SesionStoreMemoria()  # In-memory para este ejemplo

    async def procesar_mensaje(
        self,
        user_id: str,
        mensaje: str,
        contexto_extra: str = ""
    ) -> str:
        """Procesa un mensaje del usuario manteniendo su sesión."""
        session_id = self.store.obtener(f"chat_{user_id}")

        opciones = ClaudeCodeOptions(
            model="claude-opus-4-5",
            allowed_tools=[],  # Chat solo — sin acceso a archivos
            max_turns=5,
            resume=session_id,
            system_prompt=f"""Eres un asistente de chat útil y conciso.
Recuerdas toda la conversación con el usuario.
{f"Contexto adicional: {contexto_extra}" if contexto_extra else ""}
Responde en máximo 3 párrafos cortos.""",
        )

        resultado = ""
        nuevo_session_id = session_id

        async for m in query(prompt=mensaje, options=opciones):
            if hasattr(m, 'session_id') and m.session_id:
                nuevo_session_id = m.session_id
            if hasattr(m, 'content'):
                for b in m.content:
                    if hasattr(b, 'text'):
                        resultado = b.text

        if nuevo_session_id:
            self.store.guardar(f"chat_{user_id}", nuevo_session_id, ttl_horas=2)
            self.sesiones_usuarios[user_id] = nuevo_session_id

        return resultado

    def limpiar_sesion_usuario(self, user_id: str):
        """Limpia la sesión de un usuario (logout o reset)."""
        self.store.invalidar(f"chat_{user_id}")
        self.sesiones_usuarios.pop(user_id, None)
        print(f"[Chat] Sesión limpiada para usuario: {user_id}")

    def stats(self) -> dict:
        return {
            "usuarios_activos": len(self.sesiones_usuarios),
            "sesiones_activas": len(self.store.listar()),
        }

# Simulación de múltiples usuarios
async def simular_chat_multiusuario():
    servidor = ServidorChatSesiones()

    # Usuario 1 inicia conversación
    res1 = await servidor.procesar_mensaje("alice", "Hola, ¿me puedes ayudar con Python?")
    print(f"Alice: {res1[:100]}")

    # Usuario 2 inicia conversación independiente
    res2 = await servidor.procesar_mensaje("bob", "¿Qué es async/await en JavaScript?")
    print(f"Bob: {res2[:100]}")

    # Usuario 1 continúa (recuerda el contexto)
    res3 = await servidor.procesar_mensaje(
        "alice",
        "¿Cómo hago una lista por comprensión con filter?"
    )
    print(f"Alice (cont.): {res3[:100]}")

    # Usuario 2 continúa (recuerda su propio contexto, no el de Alice)
    res4 = await servidor.procesar_mensaje(
        "bob",
        "¿Y cómo manejo errores en código async?"
    )
    print(f"Bob (cont.): {res4[:100]}")

    print(f"\nStats: {servidor.stats()}")

asyncio.run(simular_chat_multiusuario())

14. Debugging de sesiones

Inspeccionar el estado de una sesión

import asyncio
from claude_code_sdk import query, ClaudeCodeOptions

async def inspeccionar_sesion(session_id: str) -> dict:
    """Prueba una sesión y retorna información sobre su estado."""
    try:
        opciones = ClaudeCodeOptions(
            model="claude-haiku-4-5",
            allowed_tools=[],
            max_turns=2,
            resume=session_id,
            system_prompt="Eres un asistente de diagnóstico."
        )

        info = {
            "session_id": session_id,
            "valida": False,
            "respuesta": "",
            "error": None,
        }

        async for m in query(
            prompt="Describe brevemente (máximo 2 oraciones) qué contexto recuerdas de esta sesión.",
            options=opciones
        ):
            if hasattr(m, 'content'):
                for b in m.content:
                    if hasattr(b, 'text'):
                        info["respuesta"] = b.text
                        info["valida"] = True

        return info

    except Exception as e:
        return {
            "session_id": session_id,
            "valida": False,
            "respuesta": "",
            "error": str(e),
        }

async def diagnostico_store(store: SesionStore):
    """Verifica qué sesiones del store son aún válidas."""
    sesiones = store.listar()
    print(f"Sesiones en el store: {len(sesiones)}")

    for sesion in sesiones:
        info = await inspeccionar_sesion(sesion["session_id"])
        estado = "VALIDA" if info["valida"] else "EXPIRADA/INVALIDA"
        print(f"  {sesion['nombre']}: {estado}")
        if info["valida"]:
            print(f"    Contexto: {info['respuesta'][:80]}")
        elif info["error"]:
            print(f"    Error: {info['error']}")

        if not info["valida"]:
            store.invalidar(sesion["nombre"])
            print(f"    -> Invalidada")

# Logging de sesiones
class SesionLogger:
    """Registra todas las interacciones con sesiones para debugging."""

    def __init__(self, log_file: str = "sesiones.log"):
        self.log_file = log_file

    def registrar(self, evento: str, datos: dict):
        import json
        from datetime import datetime
        linea = json.dumps({
            "timestamp": datetime.now().isoformat(),
            "evento": evento,
            **datos
        })
        with open(self.log_file, 'a', encoding='utf-8') as f:
            f.write(linea + '\n')

logger = SesionLogger()

async def query_con_logging(
    prompt: str,
    opciones: ClaudeCodeOptions,
    nombre_sesion: str = "default"
) -> str:
    """Wrapper de query con logging completo de la sesión."""
    import time
    inicio = time.time()

    logger.registrar("query_inicio", {
        "sesion": nombre_sesion,
        "resume": opciones.resume,
        "prompt_preview": prompt[:50],
    })

    resultado = ""
    session_id = opciones.resume

    try:
        async for m in query(prompt=prompt, options=opciones):
            if hasattr(m, 'session_id') and m.session_id:
                session_id = m.session_id
            if hasattr(m, 'content'):
                for b in m.content:
                    if hasattr(b, 'text'):
                        resultado = b.text

        logger.registrar("query_exito", {
            "sesion": nombre_sesion,
            "session_id": session_id,
            "duracion": round(time.time() - inicio, 2),
            "resultado_chars": len(resultado),
        })

    except Exception as e:
        logger.registrar("query_error", {
            "sesion": nombre_sesion,
            "error": str(e),
            "duracion": round(time.time() - inicio, 2),
        })
        raise

    return resultado

15. Anti-patrones

Anti-patrón 1: Sesiones nunca limpiadas

# MAL: acumular sesiones sin límite
async def crear_sesion_y_olvidar(user_id: str, pregunta: str) -> str:
    session_id = f"sesion_nueva_{time.time()}"
    # Crear nueva sesión cada vez — miles de sesiones abandonadas
    opciones = ClaudeCodeOptions(model="claude-haiku-4-5")
    # ...
    return resultado
    # La sesión nunca se limpia

# BIEN: usar un store con TTL y limpiar regularmente
store = SesionStore()

async def tarea_limpieza():
    """Ejecutar periódicamente para limpiar sesiones expiradas."""
    while True:
        eliminadas = store.limpiar_expiradas()
        if eliminadas > 0:
            print(f"[Limpieza] {eliminadas} sesiones expiradas eliminadas")
        await asyncio.sleep(3600)  # Cada hora

asyncio.create_task(tarea_limpieza())

Anti-patrón 2: Sesiones demasiado largas sin compactación manual

# MAL: sesión de 500 turnos sin control
async def sesion_interminable():
    session_id = None
    for i in range(500):  # 500 pasos en la misma sesión
        opciones = ClaudeCodeOptions(resume=session_id, max_turns=50)
        # Cada turno acumula más contexto hasta sobrepasar el límite

# BIEN: dividir en sesiones lógicas con resúmenes
async def sesion_con_control(pasos: list) -> list:
    PASOS_POR_SESION = 20  # Límite por sesión
    resultados = []
    resumen_previo = ""

    for i in range(0, len(pasos), PASOS_POR_SESION):
        grupo = pasos[i:i+PASOS_POR_SESION]
        print(f"[Sesión] Nueva sesión para pasos {i+1}-{i+len(grupo)}")

        # Iniciar nueva sesión con resumen de la anterior
        opciones = ClaudeCodeOptions(
            model="claude-opus-4-5",
            # Sin resume — nueva sesión con contexto del resumen
            system_prompt=f"Contexto anterior: {resumen_previo}" if resumen_previo else None,
        )

        resultado_grupo = await ejecutar_pasos_en_sesion(grupo, opciones)
        resultados.extend(resultado_grupo)

        # Generar resumen de esta sesión para la siguiente
        if resultado_grupo:
            resumen_previo = resultado_grupo[-1].get("resultado", "")[:500]

    return resultados

Anti-patrón 3: Compartir sesiones entre usuarios

# MAL: múltiples usuarios comparten la misma sesión
SESION_COMPARTIDA = None

async def responder_usuario(user_id: str, pregunta: str) -> str:
    global SESION_COMPARTIDA
    opciones = ClaudeCodeOptions(resume=SESION_COMPARTIDA)
    # ¡Todos los usuarios ven el historial de los demás! VIOLACIÓN DE PRIVACIDAD

# BIEN: una sesión por usuario
async def responder_usuario_correcto(user_id: str, pregunta: str) -> str:
    store = SesionStore()
    session_id = store.obtener(f"user_{user_id}")
    opciones = ClaudeCodeOptions(resume=session_id)
    # Cada usuario tiene su propio contexto aislado

Anti-patrón 4: No manejar sesiones expiradas

# MAL: asumir que el session_id siempre es válido
async def usar_sesion_sin_verificar(session_id: str) -> str:
    opciones = ClaudeCodeOptions(resume=session_id)
    async for m in query(prompt="Continúa", options=opciones):
        pass  # Puede fallar silenciosamente si la sesión expiró

# BIEN: manejar la posible expiración
async def usar_sesion_con_fallback(session_id: str, opciones_base: ClaudeCodeOptions) -> tuple:
    try:
        opciones = ClaudeCodeOptions(**{**vars(opciones_base), 'resume': session_id})
        resultado = ""
        nuevo_sid = session_id

        async for m in query(prompt="Continúa el análisis", options=opciones):
            if hasattr(m, 'session_id') and m.session_id:
                nuevo_sid = m.session_id
            if hasattr(m, 'content'):
                for b in m.content:
                    if hasattr(b, 'text'):
                        resultado = b.text

        return resultado, nuevo_sid

    except Exception as e:
        if "session" in str(e).lower():
            print(f"[Sesión] Expirada o inválida, iniciando nueva")
            return await usar_sesion_con_fallback(None, opciones_base)
        raise

Resumen del capítulo

Las sesiones son el mecanismo central para construir agentes con memoria y flujos de trabajo de múltiples pasos.

mindmap
  root((Manejo de Sesiones))
    Conceptos clave
      session_id como identificador
      SystemMessage con session_id
      resume para reanudar
    Persistencia
      SQLite para local
      Redis para distribuido
      In-memory para tests
      TTL y limpieza automática
    Patrones avanzados
      Fork de sesiones
      Multi-tenant isolation
      Sesiones conversacionales
      Asistente de desarrollo
    Gestión de contexto
      Estimación de tokens
      División en sub-sesiones
      Compactación automática
    Testing y debugging
      Inspección de sesiones
      Logging estructurado
      Verificación de validez
    Anti-patrones
      Sesiones sin TTL
      Contexto demasiado largo
      Sesiones compartidas entre usuarios
      No manejar sesiones expiradas

Próximo capítulo: Casos de uso avanzados — implementaciones completas y funcionales para automatización real.