aboutsummaryrefslogtreecommitdiffstats
path: root/claude_sesiones
diff options
context:
space:
mode:
Diffstat (limited to 'claude_sesiones')
-rw-r--r--claude_sesiones/__init__.py8
-rw-r--r--claude_sesiones/__main__.py6
-rw-r--r--claude_sesiones/cli.py312
-rw-r--r--claude_sesiones/sessions.py409
-rw-r--r--claude_sesiones/template.html1022
-rw-r--r--claude_sesiones/terminal.py251
-rw-r--r--claude_sesiones/webpage.py64
7 files changed, 2072 insertions, 0 deletions
diff --git a/claude_sesiones/__init__.py b/claude_sesiones/__init__.py
new file mode 100644
index 0000000..fa839b1
--- /dev/null
+++ b/claude_sesiones/__init__.py
@@ -0,0 +1,8 @@
+"""Explorador de las sesiones que Claude Code guarda en ~/.claude/projects/.
+
+Sin dependencias: solo la biblioteca estándar.
+"""
+
+__version__ = "1.0.0"
+
+__all__ = ["__version__"]
diff --git a/claude_sesiones/__main__.py b/claude_sesiones/__main__.py
new file mode 100644
index 0000000..dbdd066
--- /dev/null
+++ b/claude_sesiones/__main__.py
@@ -0,0 +1,6 @@
+import sys
+
+from .cli import main
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/claude_sesiones/cli.py b/claude_sesiones/cli.py
new file mode 100644
index 0000000..90fb51b
--- /dev/null
+++ b/claude_sesiones/cli.py
@@ -0,0 +1,312 @@
+"""Interfaz de línea de comandos."""
+
+import argparse
+import io
+import os
+import sys
+import textwrap
+import time
+import webbrowser
+
+from . import __version__
+from .sessions import (
+ SessionError, apply_filters, default_root, drop_from_cache, latest_activity,
+ load_sessions, pick, public_records, session_path,
+)
+from .terminal import (
+ Style, clip, fmt_date, fmt_size, plural, print_chat, print_table,
+ resume_cmd,
+)
+from . import webpage
+
+DEFAULT_HTML = "sesiones.html"
+
+# Una sesión escrita hace menos de esto puede estar abierta en otra terminal.
+RECENT_SECONDS = 300
+
+EPILOG = """\
+ejemplos:
+ claude-sesiones tabla de todas las sesiones
+ claude-sesiones docker filtra por título, ruta o rama
+ claude-sesiones -s 3 lee el chat nº 3 de la tabla
+ claude-sesiones -s 5d10f1ee lo mismo, por prefijo de UUID
+ claude-sesiones -g "port already" busca dentro de las conversaciones
+ claude-sesiones -r 3 comando para reanudar la nº 3
+ eval "$(claude-sesiones -r 3)" reanudarla directamente
+ claude-sesiones --html --open genera sesiones.html y lo abre
+
+el nº es la posición en la tabla que estás viendo, así que si filtraste
+hay que repetir el filtro para leer esa fila:
+
+ claude-sesiones docker muestra 3 resultados
+ claude-sesiones docker -s 2 lee el 2º de esos tres
+
+borrado (irreversible; pregunta antes, salvo con -y):
+ claude-sesiones --delete-empty --dry-run qué borraría
+ claude-sesiones --delete-empty borra las vacías
+ claude-sesiones -D 101 -D e0a4300e borra sesiones puntuales
+ claude-sesiones -p /tmp --delete-empty solo las vacías de ese proyecto
+"""
+
+
+def build_parser():
+ ap = argparse.ArgumentParser(
+ prog="claude-sesiones",
+ description="Explorador de sesiones de Claude Code para la terminal.",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog=textwrap.dedent(EPILOG),
+ )
+ ap.add_argument("query", nargs="*", help="texto a buscar en título, ruta o rama")
+ ap.add_argument("-s", "--show", metavar="REF",
+ help="muestra el chat: índice de la tabla o prefijo de UUID")
+ ap.add_argument("-r", "--resume", metavar="REF",
+ help="imprime el comando para reanudar esa sesión")
+ ap.add_argument("-g", "--grep", metavar="TEXTO",
+ help="filtra por contenido de las conversaciones")
+ ap.add_argument("-p", "--project", metavar="RUTA",
+ help="filtra por ruta del proyecto")
+ ap.add_argument("-n", "--limit", type=int, metavar="N",
+ help="muestra solo las N más recientes")
+ ap.add_argument("-E", "--hide-empty", action="store_true",
+ help="oculta las sesiones sin mensajes")
+ ap.add_argument("--no-tools", action="store_true",
+ help="en el chat, oculta las llamadas a herramientas")
+ ap.add_argument("--no-pager", action="store_true",
+ help="no usa $PAGER para el chat")
+ ap.add_argument("--no-color", action="store_true", help="salida sin color")
+
+ salida = ap.add_argument_group("exportar")
+ salida.add_argument("--json", action="store_true",
+ help="vuelca todas las sesiones en JSON")
+ salida.add_argument("--html", nargs="?", const=DEFAULT_HTML, metavar="ARCHIVO",
+ help=f"genera una página autocontenida (por defecto {DEFAULT_HTML})")
+ salida.add_argument("--template", metavar="ARCHIVO",
+ help="usa otro template para --html")
+ salida.add_argument("--open", action="store_true",
+ help="abre en el navegador lo que genere --html")
+
+ borrar = ap.add_argument_group("borrado")
+ borrar.add_argument("-D", "--delete", metavar="REF", nargs="+",
+ help="borra esas sesiones (índice o prefijo de UUID)")
+ borrar.add_argument("--delete-empty", action="store_true",
+ help="borra todas las sesiones sin mensajes")
+ borrar.add_argument("-y", "--yes", action="store_true",
+ help="no pregunta antes de borrar")
+ borrar.add_argument("--dry-run", action="store_true",
+ help="muestra qué se borraría y no toca nada")
+
+ ap.add_argument("--no-cache", action="store_true",
+ help="ignora el caché y re-parsea todo")
+ ap.add_argument("--version", action="version",
+ version=f"claude-sesiones {__version__}")
+ return ap
+
+
+def filtered(sessions, args):
+ return apply_filters(
+ sessions,
+ project=args.project,
+ grep=args.grep,
+ query=" ".join(args.query) if args.query else None,
+ hide_empty=args.hide_empty,
+ )
+
+
+# ──────────────────────────────── borrado ────────────────────────────────
+
+def confirm(question):
+ """Pregunta s/N. Sin terminal no hay confirmación posible: devuelve False."""
+ try:
+ tty = open("/dev/tty")
+ except OSError:
+ return False
+ try:
+ sys.stderr.write(question)
+ sys.stderr.flush()
+ return tty.readline().strip().lower() in ("s", "si", "sí", "y", "yes")
+ except (OSError, KeyboardInterrupt):
+ return False
+ finally:
+ tty.close()
+
+
+def delete_sessions(targets, args, st):
+ """Borra las sesiones dadas. Devuelve el código de salida."""
+ if not targets:
+ print("No hay sesiones que borrar con ese criterio.", file=sys.stderr)
+ return 0
+
+ print(f"{st.bold}Se van a borrar "
+ f"{plural(len(targets), 'sesión', 'sesiones')}:{st.reset}\n")
+
+ total_kb = 0
+ recent = []
+ for s in targets:
+ path = session_path(s)
+ total_kb += s["k"]
+ title = s["t"] or "sesión abierta sin mensajes"
+ flag = ""
+ try:
+ if time.time() - os.stat(path).st_mtime < RECENT_SECONDS:
+ recent.append(s)
+ flag = f" {st.copper}← modificada hace menos de 5 min{st.reset}"
+ except OSError:
+ flag = f" {st.copper}← ya no existe{st.reset}"
+ print(f" {st.faint}{s['id'][:8]}{st.reset} {clip(title, 52):<52} "
+ f"{st.grey}{clip(s['p'], 34):<34}{st.reset} "
+ f"{fmt_date(s['l'])} {st.faint}{s['k']:>7.1f} KB{st.reset}{flag}")
+
+ print(f"\n{st.faint}{fmt_size(total_kb)} en total{st.reset}")
+
+ if recent:
+ verbo = "se escribió" if len(recent) == 1 else "se escribieron"
+ print(f"\n{st.copper}Ojo: {len(recent)} de estas {verbo} hace menos de "
+ f"5 minutos. Si es una sesión abierta ahora mismo, Claude Code la "
+ f"sigue usando y va a volver a escribirla al cerrarse.{st.reset}")
+
+ if args.dry_run:
+ print(f"\n{st.faint}--dry-run: no se tocó nada.{st.reset}")
+ return 0
+
+ if not args.yes:
+ print(f"\n{st.copper}Esto no se puede deshacer.{st.reset}")
+ if not confirm("¿Confirmás? [s/N] "):
+ print("Cancelado.", file=sys.stderr)
+ return 1
+
+ done, failed, paths = 0, 0, []
+ for s in targets:
+ path = session_path(s)
+ try:
+ os.remove(path)
+ paths.append(path)
+ done += 1
+ except OSError as e:
+ print(f"error: {s['id'][:8]}: {e}", file=sys.stderr)
+ failed += 1
+
+ drop_from_cache(paths)
+
+ print(f"\n{plural(done, 'sesión borrada', 'sesiones borradas')}.")
+ return 1 if failed else 0
+
+
+def delete_targets(pool, args):
+ """Las sesiones que pidió borrar, sin repetidas y en el orden pedido."""
+ targets, seen = [], set()
+
+ if args.delete_empty:
+ for s in pool:
+ if s["e"]:
+ targets.append(s)
+ seen.add(s["id"])
+
+ for ref in args.delete or []:
+ s = pick(pool, ref)
+ if s["id"] not in seen:
+ seen.add(s["id"])
+ targets.append(s)
+
+ return targets
+
+
+# ──────────────────────────────── comandos ────────────────────────────────
+
+def cmd_json(sessions):
+ import json
+ json.dump(public_records(sessions), sys.stdout,
+ ensure_ascii=False, separators=(",", ":"))
+ sys.stdout.write("\n")
+ return 0
+
+
+def cmd_html(sessions, args):
+ out = args.html
+ stats = webpage.write(public_records(sessions), out,
+ template=webpage.template_text(args.template))
+ print(f"{stats['sesiones']} sesiones · {stats['proyectos']} proyectos · "
+ f"{stats['mensajes']} mensajes · {stats['bloques']} bloques "
+ f"de transcripción → {out}", file=sys.stderr)
+ if args.open:
+ webbrowser.open("file://" + os.path.abspath(out))
+ return 0
+
+
+def cmd_show(sessions, args, st):
+ s = pick(filtered(sessions, args), args.show)
+ buf = io.StringIO()
+ print_chat(s, st, buf, show_tools=not args.no_tools)
+ text = buf.getvalue()
+ if args.no_pager:
+ sys.stdout.write(text)
+ else:
+ from .terminal import pager
+ pager(text)
+ return 0
+
+
+def cmd_table(sessions, args, st):
+ shown = filtered(sessions, args)
+ if not shown:
+ print("Ninguna sesión coincide con ese filtro.", file=sys.stderr)
+ return 1
+ if args.limit:
+ shown = shown[: args.limit]
+
+ print_table(shown, st, latest_activity(sessions), sys.stdout)
+
+ total, projects = len(sessions), len({s["p"] for s in sessions})
+ tail = (f"{len(shown)} de {total} sesiones" if len(shown) != total
+ else f"{plural(total, 'sesión', 'sesiones')} · "
+ f"{plural(projects, 'proyecto', 'proyectos')}")
+ print(f"\n{st.faint}{tail} · -s <nº> para leer una{st.reset}")
+ return 0
+
+
+def run(args):
+ root = default_root()
+ if not os.path.isdir(root):
+ print(f"error: no existe {root} — ¿usaste Claude Code en esta máquina?",
+ file=sys.stderr)
+ return 2
+
+ sessions = load_sessions(root=root, use_cache=not args.no_cache)
+ if not sessions:
+ print("No hay ninguna sesión registrada todavía.", file=sys.stderr)
+ return 1
+
+ if args.json:
+ return cmd_json(sessions)
+ if args.html:
+ return cmd_html(sessions, args)
+
+ st = Style.from_stream(sys.stdout, args.no_color)
+
+ if args.delete or args.delete_empty:
+ targets = delete_targets(filtered(sessions, args), args)
+ return delete_sessions(targets, args, st)
+
+ if args.resume:
+ print(resume_cmd(pick(filtered(sessions, args), args.resume)))
+ return 0
+
+ if args.show:
+ return cmd_show(sessions, args, st)
+
+ return cmd_table(sessions, args, st)
+
+
+def main(argv=None):
+ args = build_parser().parse_args(argv)
+ try:
+ return run(args)
+ except SessionError as e:
+ print(f"error: {e}", file=sys.stderr)
+ return 2
+ except (BrokenPipeError, KeyboardInterrupt):
+ # El pipe ya está cerrado: silenciamos el flush de salida al terminar.
+ try:
+ sys.stdout.close()
+ except Exception:
+ pass
+ return 130
diff --git a/claude_sesiones/sessions.py b/claude_sesiones/sessions.py
new file mode 100644
index 0000000..0a00f8e
--- /dev/null
+++ b/claude_sesiones/sessions.py
@@ -0,0 +1,409 @@
+"""Parseo de los .jsonl que Claude Code deja en ~/.claude/projects/.
+
+Cada conversación es un archivo JSON Lines: una línea por evento. De ahí sale un
+registro por sesión con claves de una letra, porque ese mismo registro viaja
+embebido dentro del HTML y los nombres largos se pagan una vez por sesión.
+
+Esquema del registro que devuelve `read_session`:
+
+ id uuid de la sesión (el nombre del archivo)
+ p cwd del proyecto
+ b rama de git
+ t título
+ ai True si el título lo generó Claude, False si es el primer mensaje
+ n True si parece un `claude -p` no interactivo
+ e True si la sesión no tiene ningún mensaje
+ i True si `p` se dedujo de otra sesión del mismo proyecto
+ f/l timestamp del primer y del último evento (ISO 8601)
+ d duración en minutos
+ u/a cantidad de mensajes tuyos / de Claude
+ k tamaño del .jsonl en KB
+ v versión de Claude Code
+ c transcripción: [{"r": "u" | "a" | "t", "x": texto}]
+
+`project_dir` y `mtime` son internos y no salen del módulo: `public_records()`
+los saca antes de que el registro se serialice.
+"""
+
+import glob
+import json
+import os
+import re
+from datetime import datetime, timezone
+
+# Sube si cambia el esquema del registro: invalida los cachés viejos en vez de
+# leer registros con la forma anterior.
+CACHE_VERSION = 2
+
+EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
+
+INTERNAL_KEYS = ("project_dir", "mtime")
+
+
+class SessionError(Exception):
+ """Error de uso que la CLI convierte en un mensaje y un código de salida."""
+
+
+# ──────────────────────────────── ubicaciones ────────────────────────────────
+
+def default_root():
+ """~/.claude/projects, o el equivalente si CLAUDE_CONFIG_DIR está seteada."""
+ base = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(
+ os.path.expanduser("~"), ".claude")
+ return os.path.join(base, "projects")
+
+
+def default_cache_path():
+ base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
+ return os.path.join(base, "claude-sesiones", "cache.json")
+
+
+def session_path(s, root=None):
+ """Ruta del .jsonl. El nombre del archivo es el UUID y el del directorio
+ padre es lo que guardamos en project_dir, así que es reconstruible."""
+ return os.path.join(root or default_root(),
+ s["project_dir"], s["id"] + ".jsonl")
+
+
+# ─────────────────────────── parseo de los .jsonl ───────────────────────────
+
+TAG_RE = re.compile(r"<[^>]+>")
+REMINDER_RE = re.compile(r"<system-reminder>.*?</system-reminder>", re.S)
+
+# Un mensaje que empieza con alguno de estos no es texto del usuario: es un
+# bloque que genera la propia CLI al ejecutar un comando local.
+SKIP_PREFIXES = (
+ "<local-command-caveat", "<command-name", "<command-message",
+ "<command-args", "<local-command-stdout", "<system-reminder",
+)
+
+TITLE_MAX = 160
+TOOL_ARG_MAX = 140
+
+# Umbral del heurístico de `claude -p`: un único mensaje más largo que esto, sin
+# ninguna ida y vuelta, es un pipe por stdin y no una conversación.
+NONINTERACTIVE_CHARS = 1500
+
+# Para cada herramienta, el parámetro que mejor resume qué hizo.
+TOOL_KEY = {
+ "Bash": "command", "Read": "file_path", "Edit": "file_path",
+ "Write": "file_path", "NotebookEdit": "notebook_path", "Glob": "pattern",
+ "Grep": "pattern", "WebFetch": "url", "WebSearch": "query",
+ "Task": "description", "Agent": "description", "Skill": "skill",
+}
+
+
+def clean_text(s):
+ """Devuelve texto de usuario legible, o None si es ruido del harness."""
+ if not isinstance(s, str):
+ return None
+ s = s.strip()
+ if not s or s.startswith(SKIP_PREFIXES):
+ return None
+ s = REMINDER_RE.sub(" ", s)
+ s = TAG_RE.sub(" ", s)
+ s = re.sub(r"\s+", " ", s).strip()
+ return s if len(s) >= 3 else None
+
+
+def tool_summary(block):
+ """Una línea del estilo 'Bash: git status' para una llamada a herramienta."""
+ name = block.get("name") or "tool"
+ args = block.get("input") or {}
+ if not isinstance(args, dict):
+ return name
+ val = args.get(TOOL_KEY.get(name, ""))
+ if val is None:
+ val = next((v for v in args.values() if isinstance(v, str)), None)
+ if not isinstance(val, str):
+ return name
+ val = re.sub(r"\s+", " ", val).strip()
+ if len(val) > TOOL_ARG_MAX:
+ val = val[:TOOL_ARG_MAX] + "…"
+ return f"{name}: {val}" if val else name
+
+
+def blocks_of(message):
+ content = message.get("content")
+ if isinstance(content, str):
+ return [{"type": "text", "text": content}]
+ return content if isinstance(content, list) else []
+
+
+def parse_ts(ts):
+ """ISO 8601 → datetime con zona, o None si no se puede leer."""
+ if not ts:
+ return None
+ try:
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
+ except (ValueError, AttributeError):
+ return None
+
+
+def read_session(path):
+ """Parsea un .jsonl entero y devuelve el registro de esa sesión."""
+ session_id = os.path.basename(path)[:-6] # sin .jsonl
+ first_ts = last_ts = cwd = git_branch = version = None
+ ai_title = fallback_title = None
+ user_msgs = assistant_msgs = 0
+ convo = []
+
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ obj = json.loads(line)
+ except json.JSONDecodeError:
+ continue # línea truncada por una sesión que sigue escribiendo
+ if not isinstance(obj, dict):
+ continue
+
+ kind = obj.get("type")
+
+ if kind == "ai-title":
+ if obj.get("aiTitle"):
+ ai_title = obj["aiTitle"] # nos quedamos con el más reciente
+ continue
+
+ ts = obj.get("timestamp")
+ if ts:
+ if first_ts is None:
+ first_ts = ts
+ last_ts = ts
+ if cwd is None and obj.get("cwd"):
+ cwd = obj["cwd"]
+ if git_branch is None and obj.get("gitBranch"):
+ git_branch = obj["gitBranch"]
+ if obj.get("version"):
+ version = obj["version"]
+
+ if kind not in ("user", "assistant") or obj.get("isSidechain"):
+ continue
+
+ message = obj.get("message")
+ if not isinstance(message, dict):
+ continue
+
+ if kind == "user":
+ if obj.get("isMeta"):
+ continue
+ for b in blocks_of(message):
+ if not isinstance(b, dict):
+ continue
+ if b.get("type") == "text":
+ text = clean_text(b.get("text"))
+ if text:
+ user_msgs += 1
+ if fallback_title is None:
+ fallback_title = text[:TITLE_MAX]
+ convo.append({"r": "u", "x": text})
+ elif b.get("type") == "image":
+ convo.append({"r": "u", "x": "[imagen adjunta]"})
+ else:
+ counted = False
+ for b in blocks_of(message):
+ if not isinstance(b, dict):
+ continue
+ if b.get("type") == "text":
+ text = (b.get("text") or "").strip()
+ if text:
+ convo.append({"r": "a", "x": text})
+ counted = True
+ elif b.get("type") == "tool_use":
+ convo.append({"r": "t", "x": tool_summary(b)})
+ if counted:
+ assistant_msgs += 1
+
+ st = os.stat(path)
+ ft, lt = parse_ts(first_ts), parse_ts(last_ts)
+
+ # Un único mensaje enorme y ninguna ida y vuelta es la firma de un
+ # `claude -p` con algo piped por stdin (p. ej. un git diff para redactar el
+ # mensaje de commit), no de una conversación.
+ noninteractive = (
+ user_msgs == 1 and not ai_title and bool(convo)
+ and len(convo[0]["x"]) > NONINTERACTIVE_CHARS
+ )
+
+ return {
+ "id": session_id,
+ "project_dir": os.path.basename(os.path.dirname(path)),
+ "p": cwd,
+ "b": git_branch,
+ "t": ai_title or fallback_title,
+ "ai": bool(ai_title),
+ "n": noninteractive,
+ "e": not convo,
+ "f": first_ts,
+ "l": last_ts,
+ "d": round((lt - ft).total_seconds() / 60) if ft and lt else None,
+ "u": user_msgs,
+ "a": assistant_msgs,
+ "k": round(st.st_size / 1024, 1),
+ "v": version,
+ "c": convo,
+ "mtime": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
+ }
+
+
+# ──────────────────────────────── caché ────────────────────────────────
+
+def _load_cache(path):
+ """Entradas del caché, o {} si no existe, está roto o quedó viejo."""
+ try:
+ with open(path, encoding="utf-8") as f:
+ blob = json.load(f)
+ except (OSError, ValueError, UnicodeDecodeError):
+ return {}
+ if not isinstance(blob, dict) or blob.get("v") != CACHE_VERSION:
+ return {}
+ entries = blob.get("entries")
+ return entries if isinstance(entries, dict) else {}
+
+
+def _save_cache(path, entries):
+ """Escribe el caché de forma atómica. Si falla, no pasa nada."""
+ try:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ # El pid en el temporal evita que dos corridas simultáneas se pisen.
+ tmp = f"{path}.{os.getpid()}.tmp"
+ with open(tmp, "w", encoding="utf-8") as f:
+ json.dump({"v": CACHE_VERSION, "entries": entries}, f,
+ ensure_ascii=False, separators=(",", ":"))
+ os.replace(tmp, path)
+ except OSError:
+ pass # el caché es una optimización, no una condición de uso
+
+
+def drop_from_cache(paths, cache_path=None):
+ """Saca del caché las sesiones borradas para que no reaparezcan."""
+ cache_path = cache_path or default_cache_path()
+ entries = _load_cache(cache_path)
+ if not entries:
+ return
+ if any(entries.pop(p, None) is not None for p in list(paths)):
+ _save_cache(cache_path, entries)
+
+
+# ──────────────────────────────── carga ────────────────────────────────
+
+def _fill_gaps(sessions):
+ """Completa lo que falta después de parsear todos los archivos.
+
+ Algunas sesiones (un /resume cancelado) nunca registran cwd. El nombre del
+ directorio no se puede invertir de forma fiable porque "/" y "." se
+ codifican los dos como "-", así que tomamos la ruta prestada de otra sesión
+ del mismo proyecto y lo dejamos marcado en `i`.
+ """
+ known = {}
+ for s in sessions:
+ if s["p"]:
+ known.setdefault(s["project_dir"], s["p"])
+
+ for s in sessions:
+ s["i"] = not s["p"]
+ if not s["p"]:
+ s["p"] = known.get(s["project_dir"], s["project_dir"])
+ if not s["l"]:
+ s["l"] = s["mtime"]
+ if not s["f"]:
+ s["f"] = s["mtime"]
+
+
+def load_sessions(root=None, cache_path=None, use_cache=True):
+ """Parsea todas las sesiones, reusando del caché las que no cambiaron."""
+ root = root or default_root()
+ cache_path = cache_path or default_cache_path()
+ paths = sorted(glob.glob(os.path.join(root, "*", "*.jsonl")))
+
+ cache = _load_cache(cache_path) if use_cache else {}
+
+ sessions, fresh, reparsed = [], {}, False
+ for path in paths:
+ try:
+ st = os.stat(path)
+ except OSError:
+ continue
+ stamp = f"{st.st_mtime_ns}:{st.st_size}"
+ hit = cache.get(path)
+ if (isinstance(hit, dict) and hit.get("stamp") == stamp
+ and isinstance(hit.get("rec"), dict)):
+ rec = hit["rec"]
+ else:
+ try:
+ rec = read_session(path)
+ except OSError:
+ continue
+ reparsed = True
+ fresh[path] = {"stamp": stamp, "rec": rec}
+ sessions.append(rec)
+
+ # Antes de `_fill_gaps`, a propósito: al caché va el registro tal como salió
+ # del archivo, sin los campos deducidos a partir de las otras sesiones.
+ if use_cache and (reparsed or len(fresh) != len(cache)):
+ _save_cache(cache_path, fresh)
+
+ _fill_gaps(sessions)
+ sessions.sort(key=lambda s: parse_ts(s["l"]) or EPOCH, reverse=True)
+ return sessions
+
+
+def latest_activity(sessions):
+ """El instante más reciente de los datos: el "ahora" contra el que se
+ calculan las fechas relativas, para que no dependan del reloj de quien mira."""
+ stamps = [parse_ts(s["l"]) for s in sessions]
+ return max([t for t in stamps if t], default=EPOCH)
+
+
+def public_records(sessions):
+ """Copias sin las claves internas, listas para serializar."""
+ return [{k: v for k, v in s.items() if k not in INTERNAL_KEYS}
+ for s in sessions]
+
+
+# ──────────────────────────────── filtros ────────────────────────────────
+
+def apply_filters(sessions, project=None, grep=None, query=None,
+ hide_empty=False):
+ out = sessions
+
+ if project:
+ needle = os.path.expanduser(project).rstrip("/").lower()
+ out = [s for s in out if needle in s["p"].lower()]
+
+ if grep:
+ needle = grep.lower()
+ out = [s for s in out
+ if any(needle in m["x"].lower() for m in s["c"])]
+
+ if query:
+ needle = query.lower()
+ out = [s for s in out
+ if needle in (s["t"] or "").lower()
+ or needle in s["p"].lower()
+ or needle in (s["b"] or "").lower()
+ or s["id"].startswith(needle)]
+
+ if hide_empty:
+ out = [s for s in out if not s["e"]]
+
+ return out
+
+
+def pick(sessions, ref):
+ """Resuelve un índice de la tabla (1-based) o un prefijo de UUID."""
+ if ref.isdigit():
+ i = int(ref)
+ if 1 <= i <= len(sessions):
+ return sessions[i - 1]
+ raise SessionError(
+ f"el índice {i} está fuera de rango (hay {len(sessions)})")
+
+ hits = [s for s in sessions if s["id"].startswith(ref.lower())]
+ if len(hits) == 1:
+ return hits[0]
+ if not hits:
+ raise SessionError(f"ninguna sesión empieza con '{ref}'")
+ raise SessionError(f"'{ref}' es ambiguo, coincide con {len(hits)} sesiones")
diff --git a/claude_sesiones/template.html b/claude_sesiones/template.html
new file mode 100644
index 0000000..279749c
--- /dev/null
+++ b/claude_sesiones/template.html
@@ -0,0 +1,1022 @@
+<!doctype html>
+<html lang="es">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="color-scheme" content="light dark">
+<meta name="robots" content="noindex, nofollow">
+<title>Sesiones de Claude Code</title>
+
+<script>
+ // Corre antes del primer pintado para que el tema elegido no parpadee:
+ // primero la preferencia guardada, si no la del sistema.
+ (function () {
+ var saved = null;
+ try { saved = localStorage.getItem('cs-tema'); } catch (e) {}
+ var dark = saved ? saved === 'dark'
+ : matchMedia('(prefers-color-scheme: dark)').matches;
+ document.documentElement.dataset.theme = dark ? 'dark' : 'light';
+ })();
+</script>
+
+<style>
+ /* Una paleta por tema y nada más: el data-theme lo pone el script de arriba
+ antes de pintar, así que no hace falta repetir los valores dentro de una
+ @media. El botón del toolbar cambia ese mismo atributo. */
+
+ :root, :root[data-theme="light"] {
+ --ground: #EDEEF2;
+ --surface: #FFFFFF;
+ --raised: #F6F7F9;
+ --ink: #14161C;
+ --ink-soft: #3D4250;
+ --muted: #6A6F7E;
+ --faint: #9AA0AE;
+ --line: #DCDEE5;
+ --line-soft:#E7E9EE;
+ --amber: #A85A16;
+ --amber-lo: #C98A47;
+ --amber-bg: rgba(168, 90, 22, 0.09);
+ --scrim: rgba(20, 22, 28, .38);
+ --shadow: 0 1px 2px rgba(20, 22, 28, .06), 0 8px 24px -12px rgba(20, 22, 28, .18);
+ --shadow-lg:0 24px 70px -20px rgba(20, 22, 28, .45);
+ --focus: #2C6FD1;
+
+ --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ --mono: ui-monospace, "JetBrains Mono", "SFMono-Regular", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
+ }
+
+ :root[data-theme="dark"] {
+ --ground: #0E1015;
+ --surface: #171A21;
+ --raised: #1D212A;
+ --ink: #DFE2EA;
+ --ink-soft: #B4BAC7;
+ --muted: #878DA0;
+ --faint: #666C7D;
+ --line: #262A34;
+ --line-soft:#1F232C;
+ --amber: #E29A4A;
+ --amber-lo: #8A6435;
+ --amber-bg: rgba(226, 154, 74, 0.11);
+ --scrim: rgba(0, 0, 0, .62);
+ --shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.7);
+ --shadow-lg:0 24px 70px -20px rgba(0,0,0,.85);
+ --focus: #6BA5F0;
+ }
+
+ * { box-sizing: border-box; }
+
+ body {
+ margin: 0;
+ background: var(--ground);
+ color: var(--ink);
+ font-family: var(--sans);
+ font-size: 15px;
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+ }
+ body.locked { overflow: hidden; }
+
+ .wrap { max-width: 1180px; margin: 0 auto; padding: 40px 24px 96px; }
+
+ /* ---------- masthead ---------- */
+
+ .masthead { display: flex; flex-direction: column; gap: 8px; margin-bottom: 28px; }
+
+ .eyebrow {
+ font-family: var(--mono);
+ font-size: 11px;
+ letter-spacing: .14em;
+ text-transform: uppercase;
+ color: var(--amber);
+ }
+
+ h1 {
+ margin: 0;
+ font-size: clamp(28px, 4.5vw, 40px);
+ line-height: 1.1;
+ letter-spacing: -.022em;
+ font-weight: 620;
+ text-wrap: balance;
+ }
+
+ .lede { margin: 0; max-width: 64ch; color: var(--muted); font-size: 15px; }
+ .lede code, .foot code {
+ font-family: var(--mono);
+ font-size: .88em;
+ background: var(--amber-bg);
+ color: var(--amber);
+ padding: 1px 5px;
+ border-radius: 4px;
+ }
+
+ /* ---------- stats ---------- */
+
+ .stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
+ gap: 1px;
+ background: var(--line);
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ overflow: hidden;
+ margin-bottom: 24px;
+ }
+ .stat { background: var(--surface); padding: 14px 16px 16px; }
+ .stat-k {
+ display: block;
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: .13em;
+ text-transform: uppercase;
+ color: var(--faint);
+ margin-bottom: 5px;
+ }
+ .stat-v {
+ font-family: var(--mono);
+ font-size: 22px;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -.02em;
+ color: var(--ink);
+ }
+ .stat-v small { font-size: 12px; color: var(--muted); letter-spacing: 0; }
+
+ /* ---------- toolbar ---------- */
+
+ .toolbar {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+ padding: 12px 0;
+ background: linear-gradient(var(--ground) 76%, transparent);
+ }
+
+ .field { position: relative; flex: 1 1 240px; min-width: 180px; }
+ .field svg { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--faint); pointer-events: none; }
+
+ input[type="search"], select {
+ width: 100%;
+ font: inherit;
+ font-size: 14px;
+ color: var(--ink);
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 9px 12px;
+ appearance: none;
+ }
+ input[type="search"] { padding-left: 34px; font-family: var(--mono); font-size: 13px; }
+ input[type="search"]::-webkit-search-cancel-button { filter: grayscale(1) opacity(.5); }
+
+ select {
+ flex: 0 1 auto;
+ width: auto;
+ max-width: 300px;
+ padding-right: 30px;
+ font-family: var(--mono);
+ font-size: 12.5px;
+ background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%);
+ background-position: calc(100% - 16px) calc(50% + 1px), calc(100% - 11px) calc(50% + 1px);
+ background-size: 5px 5px, 5px 5px;
+ background-repeat: no-repeat;
+ }
+
+ .toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ font-family: var(--mono);
+ font-size: 12px;
+ color: var(--muted);
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 9px 12px;
+ cursor: pointer;
+ user-select: none;
+ white-space: nowrap;
+ }
+ .toggle input { accent-color: var(--amber); margin: 0; }
+ .toggle:hover { color: var(--ink); }
+ .toggle:has(input:checked) { color: var(--amber); border-color: var(--amber-lo); background: var(--amber-bg); }
+
+ .tbtn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--muted);
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 9px 11px;
+ cursor: pointer;
+ flex-shrink: 0;
+ }
+ .tbtn:hover { color: var(--amber); border-color: var(--amber-lo); }
+ .tbtn .sun { display: none; }
+ :root[data-theme="dark"] .tbtn .sun { display: block; }
+ :root[data-theme="dark"] .tbtn .moon { display: none; }
+
+ .noscript {
+ margin: 0 0 24px;
+ padding: 14px 16px;
+ border: 1px solid var(--amber-lo);
+ background: var(--amber-bg);
+ border-radius: 10px;
+ color: var(--ink);
+ font-size: 14px;
+ }
+
+ :is(input, select, b