SLOPSHOPPER

transcript-plus

Añade marca de tiempo h:mm:ss a.m./p.m. con icono 🕐 dentro de recuadros box-drawing con líneas verticales │ sobre cada render site del transcript…

newspinnerrowsguardprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · transcript-plus
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │⟨Claude Code's own drawing⟩ │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ 🕐 2:19:08 p.m. | UserMessage ⏺ Read(src/auth.ts) ⎿ Read 6 lines ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │⟨Claude Code's own drawing⟩ │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ 🕐 2:19:08 p.m. | ToolUse ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │⟨Claude Code's own drawing⟩ │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ 🕐 2:19:08 p.m. | AssistantMessage ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Your message
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │⟨Claude Code's own drawing⟩ │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ 🕐 2:19:08 p.m. | UserMessage
Claude's reply
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │⟨Claude Code's own drawing⟩ │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ 🕐 2:19:08 p.m. | AssistantMessage
Tool row
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │⟨Claude Code's own drawing⟩ │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ 🕐 2:19:08 p.m. | ToolUse
Command output
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │⟨Claude Code's own drawing⟩ │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ 🕐 2:19:08 p.m. | CommandOutput
README

📡 Smart Code Proxy (Anthropic Observability)

TypeScript Fastify Architecture

Una implementación de alto rendimiento, modular y basada en Fastify + TypeScript diseñada específicamente para interceptar, auditar y analizar en tiempo real el tráfico entre Claude Code (el CLI oficial de Anthropic) y la API oficial de Anthropic. Claude Code permite redirigir sus peticiones al proxy vía la variable ANTHROPIC_BASE_URL, condición imprescindible para el funcionamiento del sistema; otros clientes (p. ej. Cursor) usan harnesses distintos y no están dentro del alcance de este proyecto.

Diseño desacoplado que garantiza latencia cero en la retransmisión mientras procesa auditorías enfocadas en la observabilidad inteligente para análisis humana — no busca registrar todo lo técnicamente posible, sino presentar los flujos lógicos que el usuario orquesta (secuenciales y/o paralelos con subagentes) de forma natural y trazable.


🏛 Diseño del Sistema (PKA - Progressive Kernel Architecture)

El proxy utiliza Progressive Kernel Architecture (PKA) — un modelo arquitectónico concéntrico de 6 capas que sintetiza los mejores patrones de Clean Architecture, Hexagonal Architecture, Onion Architecture, DDD y CQRS. Las dependencias del código fuente solo apuntan hacia el centro (Dominio). La Capa 6 (GUIs) no aplica a este proyecto.

🧩 Capas de Responsabilidad

CapaUbicaciónResponsabilidadComponentes Clave
1 - Dominiosrc/1-domain/Tipos puros (entidades) y lógica de dominio sin dependencias externasSessionResolverService, RequestClassifierService, RedactService, MarkdownRendererService, SseSimulatorService, Tipos de auditoría
2 - Servicios (Adapters)src/2-services/Implementaciones concretas con I/O (filesystem, streams) y ports (interfaces)EventBus, SessionPersistence, WorkflowRepositoryService, SseReconstructService, StreamTeeService, Ports: IEventBus, IWorkflowRepository, ISseReconstructor, IStreamTee
3 - Operaciones (Handlers)src/3-operations/Orquestación de casos de uso (Command Handlers)AuditWorkflowHandler, AuditSseResponseHandler, AuditStandardResponseHandler, AuditUpstreamErrorHandler, FilterToolsHandler
4 - API (Composition Root)src/4-api/Wiring de dependencias y configuracióncreateProxyDependencies(), Configuración de entorno
5 - Interfaces de Usuariosrc/5-user-interfaces/Adaptadores HTTP (reciben deps inyectadas via options)ProxyController, proxyRoutes, fastify.augments.d.ts

📐 Regla de Dependencia

Capa 5 → Capa 4 → Capa 3 → Capa 2 → Capa 1
(UI)     (API)     (Ops)    (Svcs)   (Domain)

Las capas internas nunca importan de capas externas. Esta regla garantiza que:

  • El dominio (Capa 1) es puro y testeable sin mocks
  • Los servicios (Capa 2) pueden ser reemplazados sin afectar la lógica de negocio
  • La UI (Capa 5) es un detalle de implementación desechable

Persistencia causal (P1)

Los handlers de capa 3 no escriben disco directamente. El correlador publica eventos (workflow_start, step_request, step_response, stream_chunk, tool_call, tool_result, workflow_complete, …) en un EventBus in-process; SessionPersistence es el único suscriptor que proyecta el layout causal-workflows-v2 bajo sessions/<id>/workflows/NN/. Ver docs/session-audit-model.md.


🔄 Flujo de Datos (Arquitectura de Intercepción)

graph TD
    A[Cliente: Claude Code] -->|Request + Audit Session Header| B(Fastify Proxy)
    B -->|Hook: preHandler| C{SessionResolver + TurnClassifier}
    C -->|Turno clasificado| D[AuditWorkflowHandler]
    D -->|publish| EB[EventBus]
    EB --> SP[SessionPersistence]
    SP --> W["workflows/NN/… en disco"]
    B -->|Transmisión| F[Upstream: API Anthropic]
    F -->|Response Stream| G{ProxyController: Interceptor}
    G -->|Clonación| H[Transmisión al Cliente]
    G -->|Clonación| I[AuditSseResponse / AuditStandardResponse]
    I -->|publish step_response| EB
    I -->|publish stream_chunk| EB
    I -->|terminal SSE| K[SseReconstructService]
    K --> L["output/result.json + meta.json"]

🚀 Casos de Uso del Sistema

🔍 Observabilidad de Flujos SSE

A diferencia de un proxy genérico, este sistema "entiende" los flujos binarios de Anthropic.

  • Persiste cada línea SSE en steps/NN/response/streaming/streaming.ndjson (append, un archivo por respuesta; fuente canónica de reconstrucción; P2 implementado).
  • Mantiene un volcado binario crudo (steps/NN/response/sse.txt) para depuración de paridad de protocolos. No es la fuente de la reconstrucción y puede truncarse por MAX_AUDIT_BYTES sin afectar al mensaje final reconstruido.

🛡️ Privacidad Avanzada

El diseño garantiza que nunca se filtren API Keys a los logs de servidor ni a los archivos de auditoría físicos, permitiendo compartir los volcados de sesión de forma segura entre equipos de desarrollo.

📦 Gestión de Sesiones Persistentes

Ideal para depurar comportamientos erráticos en herramientas de CLI (como claude):

  • Agrupa cada ciclo E2E bajo workflows/NN/ con subdirectorios steps/MM/ y tools/KK-slug/.
  • Tres tipos semánticos (agentic, client-preflight, side-request) se materializan como workflows hermanos bajo el mismo árbol workflows/ (no hay side-interactions/ en disco).
  • Los side-request abren un workflow propio sin desplazar el workflow main activo.
  • Varios workflows pueden coexistir en la misma sesión (subagentes paralelos, preflights, side-requests).
  • Las continuaciones (tool_result) se rutean al turno padre mediante correlación por tool_use_id, eliminando la misatribución de steps. Las continuaciones de Agent/subagentes se coalescen en el response del step que emitió los subagentes; las demás tools conservan steps separados.
  • Correlación de subagentes (plano A): Claude Code ≥ 2.1.139 emite cabeceras X-Claude-Code-Agent-Id y X-Claude-Code-Parent-Agent-Id en cada request. Cuando están presentes, la correlación es determinista (correlationMethod: 'agent-headers'), con mayor autoridad que los métodos heurísticos anteriores. Para clientes sin estas cabeceras el fallback heurístico (prompt / unique-pending) sigue operativo.
  • Borde hooks (plano C, activo desde C3): El proxy expone POST /hooks para recibir eventos del lifecycle de Claude Code (hooks). El evento SubagentStart confirma subagentes en el correlador (confirmSubagentFromHook); los demás eventos (UserPromptSubmit, PreToolUse, PostToolUse, Stop, etc.) son stubs con log diferidos a G2/C4. El endpoint responde HTTP 202 inmediatamente y nunca reenvía al upstream.
  • Los preflights (client-preflight) se cierran inmediatamente al recibir su respuesta, evitando turnos zombie que bloquean la sesión.
  • El correlador (IWorkflowRepository) mantiene tools pendientes y steps en memoria; al cierre, output/result.json expone IWorkflowResult (outcome, usage agregado, stepCount).
  • meta.json en cada workflow fusiona identidad y estado (status, timestamps, outcome); no hay state.json separado.

<a name="riesgos-seguridad"></a>

[!WARNING] Riesgos de Seguridad: Los directorios de auditoría pueden contener API keys, tokens y contenido de conversaciones en claro si se desactiva la redacción. Restringe los permisos del directorio sessions/ y manténlo fuera de repositorios públicos.


<a name="archivos-auditoria"></a>

Referencia de Archivos de Auditoría

Layout vigente (causal-workflows-v2) bajo ./sessions/<session-id>/:

sessions/<session-id>/
  session-metrics.json
  workflows/
    NN/                    # meta.json, request/, output/result.json, steps/MM/, tools/KK-slug/

Preflights y side-requests usan el mismo árbol workflows/ con otro índice NN (véase docs/session-audit-model.md).

Referencia completa: modelo conceptual, layout detallado, tipos de interacción, protocolo HTTP, subagentes, meta.json y correlación con logs — docs/session-audit-model.md.

Tipos de Interacción

interactionTypeOrigenCierre
agenticPrompt del usuario con tools no vacíos (fresh) + continuationsstop_reason terminal (end_turn, max_tokens)
client-preflightQuota check (max_tokens:1) o cache warm-up sin turno activoAl recibir la respuesta (inmediato)
side-requestPeticiones con tools: [] (ej. count_tokens, generación de títulos)Respuesta terminal; no desplaza al turno activo

Las peticiones sin cabecera de sesión válida no generan archivos bajo sessions/. Guía: docs/health-check-handling.md.


<a name="configuracion"></a>

⚙️ Configuración (Matriz de Entorno)

Personaliza el comportamiento ajustando estas variables en tu entorno o en un archivo configs/.env. Cabeceras de sesión, compresión upstream y límites internos de memoria están fijados en código; véase docs/advanced-configuration.md.

CategoríaVariableDescripciónDefault
CorePORTPuerto de escucha del proxy.8787
UpstreamUPSTREAM_ORIGINURL objetivo (Anthropic, OpenRouter, etc.).https://api.anthropic.com
LímitesMAX_REQUEST_BODYLímite del cuerpo de petición (memoria en proxy).50mb
MAX_AUDIT_BYTESTope único de volcado en disco (request, response, sse.txt raw). Buffer en memoria se deriva internamente.52428800
ThinkingPROXY_UNREDACT_THINKINGRemueve el flag redact-thinking-2026-02-12 del header anthropic-beta para capturar contenido thinking legible.false (desactivado)
FiltradoFILTERED_TOOLSTool names a excluir del request (coma-separado). Omitir la variable = default abajo. Desactivar filtrado: FILTERED_TOOLS="".ScheduleWakeup,NotebookEdit,ExitWorktree,EnterWorktree,CronList,CronDelete,CronCreate
LogsLOG_LEVELNivel de log de Pino (consola y server/logs.jsonl).info

Auditoría por defecto. El árbol causal se escribe vía EventBus → SessionPersistence. Para SSE, los chunks se persisten en steps/MM/response/streaming/streaming.ndjson (append, un archivo por respuesta; fuente canónica de reconstrucción); sse.txt es raw dump opcional acotado por MAX_AUDIT_BYTES; el cierre del workflow persiste output/result.json. Detalle en docs/how-sse-reconstruction-works.md.

<a name="configuracion-de-hooks"></a>

Configuración de hooks

Para instalar las 13 claves de hooks de SCP en ~/.claude/settings.json (user-level) con merge selectivo que preserva configs ajenas del usuario: npm run setup:install -- --hooks. Con --dry-run para previsualizar, --force para sobrescribir configs ajenas (con backup automático), npm run setup:uninstall -- --hooks para desinstalar solo los hooks de SCP. La plantilla canónica versionada vive en configs/hooks.json.

Adicionalmente, el archivo .claude/settings.json del proyecto registra 13 claves de hooks de Claude Code (8 del lifecycle + 5 de sesión/tareas que antes bypaseaban el gateway), sobrescribiendo las entradas equivalentes del user-level (C:\Users\user\.claude\settings.json) para esas claves (mecanismo de merge de Claude Code: el proyecto tiene precedencia). Las 13 claves son:

HookMatcherComando
UserPromptSubmit—post-hook-event.ts (gateway emite toast del prompt)
PreToolUse*post-hook-event.ts (gateway decide toast AskUserQuestion)
PostToolUse*post-hook-event.ts (gateway decide toast TaskUpdate+in_progress)
PostToolUseFailure—post-hook-event.ts
SubagentStart—post-hook-event.ts (gateway emite toast "Subagente iniciado")
SubagentStop—post-hook-event.ts (gateway emite toast "Subagente terminado")
Stop—post-hook-event.ts (gateway genera toast de continuidad)
StopFailure—post-hook-event.ts (gateway emite toast con detalle del error)
SessionStart`startup\resume`post-hook-event.ts (gateway emite toast "Sesión iniciada")
SessionEnd—hooks/session-end-hook.ts (node directo, síncrono; gateway emite toast "Sesión finalizada")
PermissionRequest—post-hook-event.ts (gateway emite toast con tool_name + preview)
TaskCreated—post-hook-event.ts (gateway emite toast "Tarea creada")
TaskCompleted—post-hook-event.ts (gateway emite toast "Tarea completada")

Patrón de relay único: 12 de los 13 hooks apuntan al mismo script scripting/hooks/post-hook-event.ts — un proceso que lee stdin una vez (UTF-8) y reenvía con fetch a $ANTHROPIC_BASE_URL/hooks (default http://127.0.0.1:8787). SessionEnd es la excepción: usa scripting/hooks/session-end-hook.ts, un cliente HTTP autocontenido invocado con node directo (sin npx/tsx) para eliminar el cold-start y entregar el POST /hooks de forma síncrona dentro de la ventana de teardown; requiere Node con type-stripping nativo de TypeScript (≥ 22.18 / 23.6). El gateway (AuditHookEventHandler) es el único punto que decide qué toast emitir (estático, dinámico, condicional). Esto elimina la race condition de Windows (varios procesos leyendo el mismo stdin), elimina 3 scripts de relay (gateway-hook-notify.ts, pre-tool-use-hook-ux.ts, task-in-progress-hook-ux.ts) y reduce el instalador a reconocer los scripts gestionados. Detalle: docs/notifications.md y docs/gateway-architecture.md §18.

Notifications

El servicio de notificaciones de escritorio vive bajo src/2-services/notifications/: un puerto INotificationService (capa 1) y un adaptador DesktopNotificationAdapter (capa 2) que delega en node-notifier.notify() con un subset mínimo de opciones. El entry point CLI (src/2-services/notifications/cli.ts) acepta --event-type, --message, --title, --sound, --silent, --stdin-json, --app-id e --icon. Branding: el CLI aplica por defecto el AUMID AIAssistant.Proxy y el icono <repo>/assets/notifications/ai-assistant.png (256×256 + ICO multi-resolución). En Windows, ejecutar opcionalmente npm run notifications:register -- --install para que SnoreToast firme los toasts como "AI Assistant" en lugar de "SnoreToast" (helper idempotente, opt-in; no-op en macOS/Linux). Detalle operativo y exclusiones explícitas: docs/notifications.md. Spec canónica: openspec/specs/desktop-notifications-service/spec.md.

<a name="correlación-de-sesión-sessionid"></a>

Correlación de Sesión (SessionId)

El directorio de auditoría bajo sessions/<sessionId>/ se nombra a partir de x-cc-audit-session (prioridad 1) o x-claude-code-session-id (prioridad 2). Si ninguna está presente, el resolver devuelve _unknown y no se escribe auditoría en disco (véase docs/health-check-handling.md). Nombres alternativos: docs/advanced-configuration.md.

<a name="capas-bytes-env"></a>

Capas de Bytes y Convenciones de Logs

El sistema previene la saturación en memoria o disco ignorando la escritura si se superan los límites configurados:

  • Disco (sessions/): un solo tope operativo, MAX_AUDIT_BYTES (request, response y volcado raw sse.txt).
  • Memoria (respuestas no-SSE): buffer derivado internamente como max(MAX_AUDIT_BYTES, 100 MiB); no es variable de entorno.

Todo volcado que se trunca genera un archivo .omitted.txt documentando la omisión. Detalle de constantes internas: docs/advanced-configuration.md. El proxy utiliza Fastify Logger (LOG_LEVEL) para consola y server/logs.jsonl.

[!TIP] Certificados SSL corporativos: si tu organización intercepta tráfico HTTPS, configura la variable de entorno estándar de Node.js NODE_EXTRA_CA_CERTS con la ruta a un archivo PEM que contenga los certificados raíz adicionales. Esta variable es gestionada directamente por Node.js, no por el proxy.


🛠 UX de Desarrollo (Workflow)

Instrucciones de Inicio Rápido

  1. Instalar dependencias: npm install
  2. Hook de changelog (opcional): bash scripting/changelog/install-changelog-hook instala el hook git post-commit que mantiene CHANGELOG.md sincronizado tras cada commit. El hook no se versiona — reinstalar tras cada clone.
  3. Configurar proveedor (opcional): npm run configure:provider (asistente interactivo para configurar API keys y modelos de diferentes proveedores).
  4. Integraciones Claude Code (opcional): npm run setup:install instala statusline y hooks en ~/.claude/settings.json en un único paso (admite --dry-run, --force, flags --statusline/--hooks). Para desinstalar: npm run setup:uninstall. Ver docs/notifications.md y docs/router-statusline.md.
  5. Referencia multi-agente (opcional): npm run create:agents-reference (Crea hardlink AGENTS.md → CLAUDE.md para compatibilidad con otros agentes de código).
  6. Modo Desarrollo: npm run dev (Carga configs/.env mediante flag nativo de Node v22.9+; v24 LTS recomendado).
  7. Compilación: npm run build (Genera /dist optimizado).
  8. Referencia de scripts: npm run help (muestra todos los scripts disponibles con descripciones).
  9. Limpieza: npm run clean:dist (purga dist/), npm run clean:modules (purga node_modules/). Purga completa del entorno de desarrollo del proxy (auditoría ./sessions/ y logs ./server/): npm run clean:all — sustituye el antiguo slash /router-clean-slate de Claude Code Router (CCR), que operaba sobre ~/.claude y ya no aplica. Selectiva: npm run clean:sessions o npm run clean:logs.

Gestión de sesiones Claude Code

CLI npm para el historial de Claude Code en ~/.claude (listar, archivar, restaurar, eliminar y sanitizar bloques thinking inválidos tras usar el proxy). No confundir con npm run clean:sessions, que purga la carpeta ./sessions/ de auditoría del proxy.

ScriptDescripción
sessions:listLista sesiones del proyecto (cwd)
sessions:archiveArchiva a ~/.claude/archived-sessions/
sessions:restoreRestaura una sesión archivada
sessions:deleteElimina permanentemente (requiere --force)
sessions:list-archivedLista archivadas
sessions:sanitize:scanDetecta thinking blocks con firma inválida
sessions:sanitizeSanitiza una sesión (npm run … -- <id>)
sessions:sanitize:allSanitiza en lote (requiere -- --force)

Detalle, layout en disco y ejemplos: scripting/sessions/README.md.

Para una guía detallada de onboarding, consultar docs/how-to-start.md.

<a name="enrutamiento-de-proveedores"></a>

📡 Enrutamiento de Proveedores

El directorio routing/providers/ contiene la configuración de los diferentes proveedores de modelos LLM soportados:

routing/providers/
├── anthropic/           # AUTH_METHOD: oauth
│   ├── config.json      # 
Source 2 files
hooks/register.js 801 lines
1// ─────────────────────────────────────────────────────────────────────────────
2// Mod: transcript-plus
3//
4// Añade una marca de tiempo (h:mm:ss a.m. / p.m.) con icono 🕐 dentro de un
5// recuadro box-drawing sobre cada render site del transcript: UserMessage,
6// AssistantMessage, ToolUse, ToolResult, ToolGroup, CommandOutput,
7// AskUserQuestion, Spinner y TurnDuration.
8//
9// Estrategia unificada (tree-wrapping):
10//
11//   Todos los componentes del transcript pasan por el mismo handler
12//   `withTimestamp` (sin distinción entre componentes con o sin prop `text`).
13//   Se llama a `next(e)` para obtener el árbol RenderElement del engine
14//   (`{ type: 'engine', ref: 0 }`), y se envuelve en un Box exterior sin
15//   borde (paddingTop: 1, la separación entre mensajes) que contiene:
16//     1. Un Box con borderStyle: 'round' que envuelve el árbol del engine,
17//        dentro de un Box con marginTop: -1 que absorbe el margen superior
18//        propio del engine (si no, queda una fila vacía bajo el header)
19//        y de un espaciador de alto 0 que fija el ancho mínimo del recuadro
20//     2. Un Box absoluto (top: 1, left: 2, right: 2) con el Text del header,
21//        que se superpone sobre el borde superior:  ╭─ 🕐 hora | ComponentType ─╮
22//     3. Cuando su dato es definitivo, un Box absoluto (bottom: 0, left: 2,
23//        right: 2) con la duración del trabajo que representa el bloque,
24//        alineada a la derecha sobre el borde inferior:  ╰──── 🔧 1m 05s ─╯
25//
26//   Regla de hora: el header muestra el inicio del trabajo que mide el
27//   footer (header + footer = fin). Footer por tipo, con el icono de quien
28//   hizo el trabajo (🧠 modelo, 🔧 herramienta o comando, 👤 usuario, 🪝 hooks):
29//     UserMessage       🪝  prompt.submit → turn.start (hooks UserPromptSubmit)
30//     AssistantMessage  🧠  generación del bloque de texto, thinking previo incluido
31//     ToolUse           🧠  escritura de la llamada por el modelo; mientras su
32//                           tool.call corre, añade `· 🔧 n s…` (counter de
33//                           ejecución, que comparte con el ToolGroup el reloj
34//                           de scheduleClock) y al terminar vuelve a solo 🧠
35//     ToolResult        🔧  intervalo de tool.call (permiso incluido); 👤 en AskUserQuestion
36//     ToolGroup         🔧  desde el inicio de su primera tool.call hasta el fin
37//                           de la última, con la generación del modelo entre
38//                           llamadas incluida (header + footer = fin);
39//                           mientras el grupo sigue abierto, un contador que avanza cada segundo
40//                           (`$.state` transcript-plus.second, declarado en
41//                           types/index.d.ts)
42//     CommandOutput     🔧  intervalo de command.run
43//   Las filas sin trabajo medido (notificaciones, mensajes de otros agentes,
44//   prompt inicial de un subagente) llevan la hora en que se crearon.
45//
46//   Fijación por evento: toda hora y duración se fija en el evento que la
47//   produce, nunca en el render, y una vez fijada no cambia. Las filas del
48//   transcript (UserMessage, AssistantMessage, CommandOutput, TurnDuration)
49//   se fijan en session.append bajo su uuid, que es el requestId de su
50//   componente; las llamadas en tool.call; los bloques del modelo se
51//   registran en turn.step y los turnos en turn.start / turn.step /
52//   turn.complete. Todo se registra por agente, también en subagentes, y lo
53//   no consumido se poda tras una hora.
54//
55//   Vistas tardías: un bloque que se dibuja más tarde (grupo expandido,
56//   ctrl+o, vista de un subagente en curso, terminado o en segundo plano,
57//   sesión reanudada) solo lee lo fijado y muestra las mismas cifras que la
58//   vista en vivo. La vista ctrl+o se deduce de `isExpanded` de UserMessage:
59//   en ella AssistantMessage no trae margen superior propio.
60//
61//   Horas y duraciones se persisten en `$.store` (un bucket por día, 30 días
62//   de retención), solo para las filas que se dibujan como componente
63//   (DRAWN_DOORS) y las claves de llamadas, así que una sesión reanudada
64//   (--continue / --resume) muestra las mismas que la original.
65//
66//   El header no puede ir dentro del Box con borde: ahí los offsets se miden
67//   desde el interior del borde y lo que cae sobre la fila del borde no se
68//   pinta. Como hermano en el Box exterior, top: 1 (tras el paddingTop) es la
69//   fila de `╭───╮`.
70//
71//   El marco (╭─╮ │ ╰─╯) lo dibuja borderStyle al ancho del engine, que se
72//   adapta al viewport; no se calcula ningún borde a mano. El espaciador
73//   (un Text de espacios con wrap: 'truncate' en un Box height: 0) ensancha
74//   los recuadros angostos hasta caber header y footer, y se encoge con el
75//   viewport; si aun así no caben, ambos se truncan con `…`.
76//
77//   AskUserQuestion es la excepción: su ToolUse se dibuja vacío mientras
78//   espera, así que se deja sin envolver; y el engine rechaza `position`
79//   alrededor del diálogo, así que este lleva solo el header, en flujo y sin
80//   recuadro ni footer (su espera la mide el 👤 del ToolResult).
81//
82//   NOTA: No se usa la prop `width` en el Box — el render engine rechaza
83//   "engine node under a Box with prop width", cayendo al rendering original
84//   sin el timestamp. Con borderStyle y sin width, el Box auto-dimensiona su
85//   border al ancho del engine content (viewport width), generando líneas
86//   verticales │ en los laterales de forma dinámica. borderStyle: 'round'
87//   es aceptado y funciona correctamente.
88//
89//   Spinner y TurnDuration usan handlers dedicados (withSpinner,
90//   withTurnDuration) que modifican props.suffix y props.word directamente,
91//   ya que no necesitan tree-wrapping: el Spinner muestra el inicio del
92//   turno en curso de su agente (sin persistir) y TurnDuration el fin del
93//   turno (persistido).
94//
95// Uso:
96//   claude --plugin-dir ./mods/transcript-plus    (o vía CLAUDE_CODE_PLUGIN_DIRS)
97//
98// Requiere Claude Code v2.1.287 o posterior (mods habilitados).
99// ─────────────────────────────────────────────────────────────────────────────
100
101// Formatea un instante (epoch ms) en formato 12h con a.m. / p.m.
102// 0h → 12 a.m., 13h → 1 p.m., 12h → 12 p.m.
103function formatClockTime(ms) {
104  const date = new Date(ms)
105  let h = date.getHours()
106  const m = String(date.getMinutes()).padStart(2, '0')
107  const s = String(date.getSeconds()).padStart(2, '0')
108  const suffix = h >= 12 ? 'p.m.' : 'a.m.'
109  h = h % 12 || 12    // 0 -> 12, 13 -> 1
110  return `${h}:${m}:${s} ${suffix}`
111}
112
113const pad2 = n => String(n).padStart(2, '0')
114
115// ── Registro de bloques y persistencia ──────────────────────────────────────
116//
117// Cada bloque del transcript tiene una clave estable (ver RESOLVER) y una
118// entrada `{ time, duration?, fromEvent? }` en ms. Hora y duración las fija el
119// evento que las produce (`pinTiming`, marca `fromEvent`); el render solo crea una
120// entrada con su propia hora cuando no hay ninguna (bloques sin evento de
121// origen), y un evento posterior la corrige. Las entradas persistidas viven
122// en `$.store` agrupadas por día local (`days/AAAA-MM-DD` → `{ [blockKey]: { h, d? } }`)
123// para que una sesión reanudada muestre las mismas horas y footers; los días
124// con más de RETENTION_DAYS se borran al cargar.
125
126const RETENTION_DAYS = 30
127const DAY_PREFIX = 'days/'
128
129const blocks = new Map()
130let storeLoaded
131const pendingKeys = new Set()
132let flushTimer
133
134// Día local AAAA-MM-DD de un instante en epoch ms.
135function dayOf(ms) {
136  const f = new Date(ms)
137  return `${f.getFullYear()}-${pad2(f.getMonth() + 1)}-${pad2(f.getDate())}`
138}
139
140// Carga el store una sola vez por carga del módulo: borra los días vencidos
141// y vuelca el resto en `blocks` sin pisar lo que ya haya en memoria.
142function loadStore($) {
143  storeLoaded ??= (async () => {
144    const cutoff = dayOf(Date.now() - RETENTION_DAYS * 86_400_000)
145    for (const key of await $.store.keys()) {
146      if (!key.startsWith(DAY_PREFIX)) continue
147      if (key.slice(DAY_PREFIX.length) < cutoff) {
148        await $.store.delete(key)
149        continue
150      }
151      for (const [blockKey, { h, d }] of Object.entries((await $.store.get(key)) ?? {})) {
152        if (!blocks.has(blockKey) || !blocks.get(blockKey).fromEvent) {
153          blocks.set(blockKey, { time: h, duration: d, fromEvent: true })
154        }
155      }
156    }
157  })()
158  return storeLoaded
159}
160
161// Devuelve la entrada de `blockKey`, creándola con la hora del render si no
162// existe (bloque sin evento de origen). No se persiste: lo persistido sale
163// siempre de un evento. Requiere el store ya cargado.
164function entryFor(blockKey) {
165  let b = blocks.get(blockKey)
166  if (!b) {
167    b = { time: Date.now() }
168    blocks.set(blockKey, b)
169  }
170  return b
171}
172
173// Fija hora y duración de un bloque desde el evento que las produce, en el
174// momento en que ocurre: el bloque puede dibujarse mucho después (al expandir
175// un ToolGroup, en ctrl+o, en la vista de un subagente). Corrige la hora de
176// una entrada creada antes por un render; una entrada de evento no cambia de
177// hora y su duración, una vez fijada, no se toca.
178function pinTiming($, blockKey, { time, duration, persist }) {
179  const b = blocks.get(blockKey)
180  if (b?.fromEvent) {
181    if (b.duration !== undefined || duration === undefined) return
182    b.duration = duration
183  } else {
184    blocks.set(blockKey, { time, duration, fromEvent: true })
185  }
186  if (persist) void loadStore($).then(() => markForSave($, blockKey))
187}
188
189// Marca la clave para el próximo volcado, agrupando escrituras en un solo
190// temporizador pendiente.
191function markForSave($, blockKey) {
192  pendingKeys.add(blockKey)
193  flushTimer ??= $.clock.after(1000, () => flush($))
194}
195
196// Escribe las claves pendientes en el bucket del día de su hora. Relee cada
197// bucket y lo fusiona antes de escribir, para no pisar otra sesión concurrente.
198async function flush($) {
199  flushTimer = undefined
200  const byDay = new Map()
201  for (const blockKey of pendingKeys) {
202    const { time, duration } = blocks.get(blockKey)
203    const day = DAY_PREFIX + dayOf(time)
204    if (!byDay.has(day)) byDay.set(day, {})
205    byDay.get(day)[blockKey] = { h: time, d: duration }
206  }
207  pendingKeys.clear()
208  for (const [day, entries] of byDay) {
209    await $.store.set(day, { ...((await $.store.get(day)) ?? {}), ...entries })
210  }
211}
212
213// Formatea ms como `<0.1s`, `2.1s` (un decimal bajo 10 s), `45s`, `1m 05s`
214// o `1h 02m 05s`.
215function formatDuration(ms) {
216  if (ms < 100) return '<0.1s'
217  if (ms < 9950) return `${(ms / 1000).toFixed(1)}s`
218  const total = Math.round(ms / 1000)
219  const h = Math.floor(total / 3600)
220  const m = Math.floor((total % 3600) / 60)
221  const s = total % 60
222  if (h) return `${h}h ${pad2(m)}m ${pad2(s)}s`
223  if (m) return `${m}m ${pad2(s)}s`
224  return `${s}s`
225}
226
227// Contador de un bloque en curso: segundos enteros y `…` (`7s…`, `1m 05s…`).
228function formatRunning(ms) {
229  const s = Math.floor(ms / 1000)
230  return (s < 10 ? `${s}s` : formatDuration(s * 1000)) + '…'
231}
232
233// ── Hooks de ciclo de vida ──────────────────────────────────────────────────
234//
235// Fijan hora y duración de cada bloque en el evento que las produce, para
236// todos los agentes (principal y subagentes), nunca desde la cadencia de
237// renders. Los registros de abajo guardan solo lo que aún no se ha fijado:
238// lo consumido se borra y lo que sobra se poda por antigüedad. Son de
239// observación y fail-open: no modifican `e`, chunks ni resultados, y si
240// fallan el engine los salta y el turno sigue igual.
241
242// Agente de un evento: el subagente que lo emite, o el loop principal.
243const MAIN = 'main'
244const agentOf = e => e.agentId ?? MAIN
245
246// Lo no consumido de más de PRUNE_MS se descarta.
247const PRUNE_MS = 3_600_000
248
249// Filas de `session.append` que se dibujan como componente del transcript y
250// por tanto se fijan y persisten; el resto (adjuntos, contexto de hooks…) se
251// ignora.
252const DRAWN_DOORS = new Set(['prompt', 'delivery', 'response', 'command', 'notice'])
253
254// Hora de envío del prompt aún sin turno (UserMessage con requestId placeholder).
255let pendingSubmit
256// Prompt principal en curso: `{ submittedAt, rowKey?, turnStart? }` desde prompt.submit
257// hasta que su fila (rowKey) y turn.start (turnStart) hayan llegado.
258let currentPrompt
259// Content blocks `text` y `tool` del modelo aún sin consumir, por agente.
260const modelBlocks = new Map()
261// Inicio de cada tool.call en curso, por tool_use_id.
262const runningCalls = new Map()
263// ToolGroups con contador en vivo, por clave `g:`: `{ start, seen }`, con
264// la hora de inicio del grupo y la del último dibujo (el contador del ToolUse
265// sale de runningCalls).
266const counters = new Map()
267// Próximo tic del reloj de los contadores: `{ timer, at }` (epoch ms).
268let clock
269// Retraso tras el cruce de segundo, para que el redibujo ya lo vea cruzado.
270const TICK_MARGIN_MS = 20
271
272// Un grupo abierto que no se dibuja en este plazo (terminó o su vista se
273// cerró mientras no se dibujaba) deja de mantener el reloj.
274const COUNTER_EXPIRY_MS = 3000
275
276// Programa el próximo tic en el primer instante en que algún contador cruza
277// un segundo entero desde su inicio: el de un ToolUse (inicio de su llamada) o
278// el de un ToolGroup abierto (inicio del grupo, aunque ninguna herramienta
279// corra: el modelo puede estar generando la siguiente llamada). Cada número
280// aparece justo al cumplirse, sin redibujar más de una vez por segundo y
281// contador. Se reprograma si llega un cruce anterior (withTimestamp la invoca
282// al dibujar un bloque suscrito) y se apaga sin llamadas ni grupos abiertos.
283function scheduleClock($) {
284  const now = Date.now()
285  for (const [blockKey, c] of counters) {
286    if (c.seen < now - COUNTER_EXPIRY_MS) counters.delete(blockKey)
287  }
288  if (!runningCalls.size && !counters.size) {
289    clock?.timer.cancel()
290    clock = undefined
291    return
292  }
293  const starts = [...runningCalls.values(), ...[...counters.values()].map(c => c.start)]
294  const at = Math.min(...starts.map(h => h + (Math.floor((now - h) / 1000) + 1) * 1000)) + TICK_MARGIN_MS
295  if (clock && clock.at <= at) return
296  clock?.timer.cancel()
297  clock = {
298    at,
299    timer: $.clock.after(at - now, () => {
300      clock = undefined
301      void $.state.set({ plugin: 'transcript-plus', key: 'second' }, Date.now())
302      scheduleClock($)
303    }),
304  }
305}
306// Slash commands terminados sin fila de salida: `{ command, start, end }`.
307let commands = []
308// Comando cuya fila eco ya llegó, a la espera de su fila de salida.
309let commandAwaitingOutput
310// Turno en curso de cada agente: `{ turnId, start }`.
311const turns = new Map()
312// Última tool.call de cada agente mientras puede seguir una nueva: la borran
313// el siguiente bloque de texto y el fin del turno. El ToolGroup que la
314// contiene es la cola del agente y aún puede recibir llamadas.
315const lastCall = new Map()
316
317// Descarta los bloques del modelo y comandos no consumidos de más de PRUNE_MS.
318function prune() {
319  const cutoff = Date.now() - PRUNE_MS
320  for (const [agent, list] of modelBlocks) {
321    const alive = list.filter(b => b.last >= cutoff)
322    if (alive.length) modelBlocks.set(agent, alive)
323    else modelBlocks.delete(agent)
324  }
325  commands = commands.filter(c => c.end >= cutoff)
326}
327
328async function onSubmit($, e, next) {
329  pendingSubmit = Date.now()
330  currentPrompt = { submittedAt: pendingSubmit }
331  return next(e)
332}
333
334// 🪝 de un UserMessage: prompt.submit → turn.start. Lo fija el último en
335// llegar de su fila y turn.start; la hora (envío) se fija ya con la fila.
336function completePrompt($) {
337  const { submittedAt, rowKey, turnStart } = currentPrompt
338  if (rowKey === undefined) return
339  pinTiming($, rowKey, { time: submittedAt, duration: turnStart === undefined ? undefined : turnStart - submittedAt, persist: true })
340  if (turnStart !== undefined) currentPrompt = undefined
341}
342
343async function onTurnStart($, e, next) {
344  const now = Date.now()
345  turns.set(MAIN, { turnId: e.turnId, start: now })
346  pendingSubmit = undefined
347  if (currentPrompt) {
348    currentPrompt.turnStart = now
349    const drawn = currentPrompt.rowKey !== undefined
350    completePrompt($)
351    // Su UserMessage ya se dibujó con la fila y el engine no lo redibuja
352    // solo: se pide un redibujado para que muestre el 🪝 recién fijado.
353    if (drawn) $.ui.invalidate('ui.render')
354  }
355  return next(e)
356}
357
358async function onTurnComplete($, e, next) {
359  turns.delete(agentOf(e))
360  lastCall.delete(agentOf(e))
361  // Cierra el ToolGroup que seguía abierto esperando otra llamada.
362  void $.state.set({ plugin: 'transcript-plus', key: 'second' }, Date.now())
363  return next(e)
364}
365
366const BLOCK_KINDS = new Set(['text', 'thinking', 'tool', 'input'])
367
368// Registra cada content block de la respuesta con su primer y último chunk,
369// en el registro de su agente. `attributedStart` es el fin del bloque visible
370// anterior del mismo paso (o el inicio del paso): así el thinking previo
371// cuenta en el bloque que sigue. Un subagente no emite turn.start: su turno
372// empieza con el primer paso de un turnId nuevo.
373async function* onStep($, e, next) {
374  const agent = agentOf(e)
375  const stepStart = Date.now()
376  if (turns.get(agent)?.turnId !== e.turnId) turns.set(agent, { turnId: e.turnId, start: stepStart })
377  const byIndex = new Map()
378  let previous
379  const stream = next(e)[Symbol.asyncIterator]()
380  for (;;) {
381    const { value: chunk, done } = await stream.next()
382    if (done) {
383      prune()
384      return chunk
385    }
386    if (BLOCK_KINDS.has(chunk.kind)) {
387      const now = Date.now()
388      let b = byIndex.get(chunk.index)
389      if (!b) {
390        b = { kind: chunk.kind, text: '', first: now, last: now, attributedStart: previous?.last ?? stepStart }
391        byIndex.set(chunk.index, b)
392        if (b.kind !== 'thinking') {
393          previous = b
394          // Un bloque de texto cierra el ToolGroup abierto de su agente.
395          if (b.kind === 'text') {
396            lastCall.delete(agent)
397            void $.state.set({ plugin: 'transcript-plus', key: 'second' }, now)
398          }
399          // La lista se crea aquí y no al empezar el paso: prune() borra
400          // las listas vacías, también las de pasos de otros agentes en curso.
401          if (!modelBlocks.has(agent)) modelBlocks.set(agent, [])
402          modelBlocks.get(agent).push(b)
403        }
404      }
405      b.last = now
406      if (chunk.kind === 'tool') b.toolId = chunk.id
407      if (chunk.kind === 'text') b.text += chunk.text
408    }
409    yield chunk
410  }
411}
412
413// Saca del registro de `agent` el primer bloque que cumpla `predicate`.
414function consumeBlock(agent, predicate) {
415  const list = modelBlocks.get(agent)
416  const i = list?.findIndex(predicate) ?? -1
417  return i < 0 ? undefined : list.splice(i, 1)[0]
418}
419
420// Fija el ToolUse al arrancar (🧠 escritura de la llamada: el bloque del
421// modelo ya está cerrado) y el ToolResult al terminar (🔧 ejecución, permiso
422// incluido), en cualquier agente. Al arrancar y al terminar escribe `second`
423// una vez para que el contador 🔧 del ToolUse aparezca y se retire en el
424// momento, sin esperar al tic siguiente.
425async function onToolCall($, e, next) {
426  const start = Date.now()
427  runningCalls.set(e.tool_use_id, start)
428  lastCall.set(agentOf(e), e.tool_use_id)
429  scheduleClock($)
430  const b = consumeBlock(agentOf(e), b => b.toolId === e.tool_use_id)
431  if (b) pinTiming($, `u:${e.tool_use_id}`, { time: b.attributedStart, duration: b.last - b.attributedStart, persist: true })
432  void $.state.set({ plugin: 'transcript-plus', key: 'second' }, start)
433  try {
434    return await next(e)
435  } finally {
436    runningCalls.delete(e.tool_use_id)
437    pinTiming($, `r:${e.tool_use_id}`, { time: start, duration: Date.now() - start, persist: true })
438    void $.state.set({ plugin: 'transcript-plus', key: 'second' }, Date.now())
439  }
440}
441
442async function onCommandRun($, e, next) {
443  const start = Date.now()
444  try {
445    return await next(e)
446  } finally {
447    commands.push({ command: e.command, start, end: Date.now() })
448    prune()
449  }
450}
451
452const textOf = message => message.content.filter(b => b.type === 'text').map(b => b.text).join('')
453
454// Fija cada fila dibujada del transcript bajo su uuid (el requestId de su
455// componente), antes de `next`, porque el engine puede dibujarla mientras se
456// guarda:
457//   prompt principal  hora = envío; el 🪝 lo completa turn.start
458//   response con texto  hora y 🧠 de su bloque de texto, que se consume
459//   command stdout  hora y 🔧 del comando nombrado en la fila eco anterior
460//   resto (prompt de subagente o notificación, delivery, turn_duration)
461//     hora = la de la fila, sin duración
462async function onAppend($, e, next) {
463  if (!DRAWN_DOORS.has(e.door)) return next(e)
464  const now = Date.now()
465  const text = textOf(e.message)
466  const row = { time: now, persist: true }
467  switch (e.door) {
468    case 'prompt':
469      if (!e.agentId && currentPrompt && currentPrompt.rowKey === undefined) {
470        currentPrompt.rowKey = e.uuid
471        completePrompt($)
472      } else {
473        pinTiming($, e.uuid, row)
474      }
475      break
476    case 'response': {
477      if (!text.trim()) break
478      const b = consumeBlock(agentOf(e), b => b.kind === 'text' && b.text.trim() === text.trim())
479      pinTiming($, e.uuid, b ? { time: b.attributedStart, duration: b.last - b.attributedStart, persist: true } : row)
480      break
481    }
482    case 'command': {
483      const name = /<command-name>\/?([^<]+)<\/command-name>/.exec(text)?.[1]
484      if (name !== undefined) {
485        const i = commands.findLastIndex(c => c.command === name)
486        commandAwaitingOutput = i < 0 ? undefined : commands.splice(i, 1)[0]
487      } else if (/<local-command-std(out|err)>/.test(text)) {
488        const c = commandAwaitingOutput
489        commandAwaitingOutput = undefined
490        pinTiming($, e.uuid, c ? { time: c.start, duration: c.end - c.start, persist: true } : row)
491      }
492      break
493    }
494    case 'notice':
495      if (e.message.name === 'turn_duration') pinTiming($, e.uuid, row)
496      break
497    default:
498      pinTiming($, e.uuid, row)
499  }
500  return next(e)
501}
502
503// ── Footers por componente ──────────────────────────────────────────────────
504
505// Vista ctrl+o activa: al conmutarla, el engine redibuja todos los
506// UserMessage (que traen `isExpanded`) antes que los AssistantMessage.
507let expandedView = false
508
509// Un grupo cuya llamada es la última de su agente sigue abierto aunque todas
510// hayan terminado: el modelo puede estar generando la siguiente. Es
511// provisional: si llega texto o termina el turno, el grupo se cierra en el fin
512// de su última llamada; si llega una llamada que el engine no agrupa con él,
513// deja de ser la última del agente y también se cierra.
514function awaitsAnotherCall(calls) {
515  const ids = new Set(calls.map(c => c.tool_use_id))
516  return [...lastCall.values()].some(id => ids.has(id))
517}
518
519// ToolGroup: hora = inicio de la primera tool.call y cifra = tiempo hasta el
520// fin de la última, con la generación del modelo entre llamadas incluida. Una
521// sola magnitud en vivo y al terminar: mientras el grupo sigue abierto (alguna
522// llamada en curso o `isRunning` aunque su tool.call no haya arrancado, o el
523// modelo generando la siguiente: awaitsAnotherCall) cuenta hasta ahora
524// (`counter`), avanzado por el reloj al cumplirse cada segundo
525// (scheduleClock); al cerrarse, el fin de la última sale de las
526// entradas `r:` y se guarda bajo `g:` (el grupo puede crecer: se recalcula y
527// se vuelve a guardar si cambia). Sin entradas `r:` (fuera de la retención
528// del store), lo guardado bajo `g:`. `toSave` pide a withTimestamp
529// persistir la clave recalculada.
530function group(calls) {
531  const blockKey = `g:${calls[0].tool_use_id}`
532  const now = Date.now()
533  const rs = calls.map(c => blocks.get(`r:${c.tool_use_id}`))
534  if (calls.some(c => c.isRunning || runningCalls.has(c.tool_use_id)) || awaitsAnotherCall(calls)) {
535    const starts = calls
536      .map((c, i) => runningCalls.get(c.tool_use_id) ?? (rs[i]?.duration !== undefined ? rs[i].time : undefined))
537      .filter(h => h !== undefined)
538    const time = starts.length ? Math.min(...starts) : entryFor(blockKey).time
539    counters.set(blockKey, { start: time, seen: now })
540    return { time, counter: now - time, subscribed: true }
541  }
542  counters.delete(blockKey)
543  if (!rs.every(r => r?.duration !== undefined)) return entryFor(blockKey)
544  const time = Math.min(...rs.map(r => r.time))
545  const duration = Math.max(...rs.map(r => r.time + r.duration)) - time
546  const g = blocks.get(blockKey)
547  if (g?.time === time && g.duration === duration) return g
548  blocks.set(blockKey, { time, duration, fromEvent: true })
549  return { time, duration, toSave: blockKey }
550}
551
552// Por componente: `{ time, duration?, icon }` leídos de lo fijado (claves:
553// requestId en UserMessage, AssistantMessage y CommandOutput, que es el uuid
554// de su fila; `u:` / `r:` / `g:` + tool_use_id en ToolUse, ToolResult y
555// ToolGroup, cuyo requestId cambia a mitad de vida). Casos especiales:
556// `unwrapped` (ToolUse de AskUserQuestion; ToolUse y ToolResult de
557// SubagentHandback, que el engine dibuja vacíos), `dialog` (AskUserQuestion)
558// y `noMargin` (componentes sin margen superior propio).
559const NO_CONTENT = new Set(['SubagentHandback'])
560
561const RESOLVER = {
562  // 🪝 prompt.submit → turn.start (hooks UserPromptSubmit); hora = envío.
563  // Sin turno aún (placeholder), la hora de envío pendiente.
564  UserMessage: e => e.requestId === 'placeholder'
565    ? { time: pendingSubmit ?? Date.now() }
566    : { ...entryFor(e.requestId), icon: '🪝' },
567  // 🧠 generación del bloque de texto, thinking previo incluido. En ctrl+o
568  // no trae margen superior propio.
569  AssistantMessage: e => ({ ...entryFor(e.requestId), icon: '🧠', noMargin: expandedView }),
570  // 🧠 escritura de la llamada; la fija onToolCall al arrancar tool.call.
571  // Mientras su llamada corre, `counter` es el tiempo desde el inicio de
572  // tool.call y el footer añade `🔧 n s…` a la cifra 🧠; `subscribed` mantiene la instancia
573  // atenta al reloj hasta que tool.call termina (r: con duración), también
574  // antes de que arranque.
575  ToolUse: e => e.props.tool === 'AskUserQuestion' || NO_CONTENT.has(e.props.tool)
576    ? { unwrapped: true }
577    : {
578        ...entryFor(`u:${e.props.tool_use_id}`),
579        icon: '🧠',
580        counter: runningCalls.has(e.props.tool_use_id) ? Date.now() - runningCalls.get(e.props.tool_use_id) : undefined,
581        subscribed: blocks.get(`r:${e.props.tool_use_id}`)?.duration === undefined,
582      },
583  // 🔧 intervalo de tool.call (permiso incluido), fijado al terminar; 👤 en
584  // AskUserQuestion. El de Agent no trae margen superior propio.
585  ToolResult: e => NO_CONTENT.has(e.props.tool) ? { unwrapped: true } : {
586    ...entryFor(`r:${e.props.tool_use_id}`),
587    icon: e.props.tool === 'AskUserQuestion' ? '👤' : '🔧',
588    noMargin: e.props.tool === 'Agent',
589  },
590  // 🔧 inicio de la primera tool.call hasta el fin de la última.
591  ToolGroup: e => ({ ...group(e.props.calls), icon: '🔧' }),
592  // 🔧 intervalo de command.run. Su salida no trae margen superior propio.
593  CommandOutput: e => ({ ...entryFor(e.requestId), icon: '🔧', noMargin: true }),
594  // Diálogo: solo header, con el inicio de su tool.call; el espaciador hace
595  // que el engine rechace el árbol.
596  AskUserQuestion: e => ({
597    time: runningCalls.get(e.requestId) ?? entryFor(`r:${e.requestId}`).time,
598    dialog: true,
599  }),
600}
601
602// Handler unificado para todos los render sites del transcript.
603//
604// Tree-wrapping: en lugar de modificar props.text (que no todos los
605// componentes tienen), se obtiene el árbol del engine via next(e) y se envuelve
606// en un Box con flexDirection: 'column' y borderStyle: 'round'. El border
607// genera automáticamente los caracteres box-drawing en todos los lados:
608//   ╭─...─╮  ← borde superior
609//   │ ... │  ← líneas verticales en los laterales
610//   ╰─...─╯  ← borde inferior
611//
612// Ese Box va dentro de un Box exterior sin borde (paddingTop: 1 separa un
613// mensaje del anterior), cuyo segundo hijo es el timestamp en un Box con
614// position: 'absolute' (top: 1, left: 2, right: 2),
615// que se pinta sobre la fila del borde superior tras `╭─`. Con `right` el
616// Box queda acotado al ancho del recuadro y `wrap: 'truncate'` recorta el
617// header con `…` cuando el viewport es más angosto que el header:
618//   🕐 hora | ComponentType
619//
620// NOTA sobre `width`: el render engine RECHAZA la prop `width` en un Box que
621// contiene un nodo engine ("engine node under a Box with prop width"). Sin
622// `width`, el Box auto-dimensiona su border al ancho del engine content, que ya
623// renderiza al ancho del viewport. Las líneas verticales │ cubren todo el
624// ancho disponible de forma dinámica.
625//
626// El margen superior que el engine da a cada mensaje se absorbe con un Box
627// marginTop: -1 alrededor del árbol, para que no quede una fila vacía entre
628// el header y el contenido (los componentes `noMargin` no lo traen y no lo
629// absorben: CommandOutput, el ToolResult de Agent y AssistantMessage en ctrl+o).
630//
631// La hora y el footer de cada bloque los da RESOLVER, que solo lee lo fijado
632// por los hooks de ciclo de vida (o el store en sesiones reanudadas). Lo
633// único que se decide al dibujar es el recuadro, la vista (ctrl+o) y, en un
634// ToolGroup, ocultar el footer mientras alguna llamada sigue en curso.
635//
636// Visualmente:
637//
638//   ╭─ 🕐 2:23:05 p.m. | ToolGroup ─────╮
639//   │  Read 1 file, ran 1 shell command │
640//   ╰─────────────────────────── 🔧 7s ─╯
641async function withTimestamp($, e, next) {
642  if (e.component === 'UserMessage') expandedView = e.props.isExpanded
643  await loadStore($)
644  const b = RESOLVER[e.component](e)
645  if (b.unwrapped) return next(e)
646  if (b.toSave) markForSave($, b.toSave)
647  // Leer el segundo del reloj suscribe esta instancia: solo los bloques en
648  // curso se redibujan en cada tick.
649  if (b.subscribed) {
650    await $.state.get({ plugin: 'transcript-plus', key: 'second' })
651    scheduleClock($)
652  }
653
654  // Árbol del engine: el contenido original del componente
655  const tree = await next(e)
656
657  // Header con el timestamp, superpuesto sobre la línea del borde superior.
658  // El Box con borderStyle: 'round' envuelve el engine y genera el marco;
659  // el header es su hermano posterior en el Box exterior, en un Box absoluto
660  // que se pinta encima del borde superior tras `╭─`.
661  const headerText = ` 🕐 ${formatClockTime(b.time)} | ${e.component} `
662
663  // El engine rechaza `position` alrededor del diálogo, y dentro de un borde
664  // sus reglas horizontales (de ancho del viewport) se parten en dos filas:
665  // el header va en flujo sobre el diálogo, sin recuadro.
666  if (b.dialog) {
667    return {
668      type: 'Box',
669      props: { flexDirection: 'column', paddingTop: 1 },
670      children: [
671        { type: 'Text', props: { wrap: 'truncate' }, children: [headerText] },
672        tree,
673      ],
674    }
675  }
676
677  // Footer con la duración del bloque una vez definitiva; si no, el borde
678  // inferior queda liso.
679  // Con contador en curso: la cifra ya fijada (🧠 en el ToolUse) y `🔧 n s…`.
680  const figures = []
681  if (b.counter !== undefined) {
682    if (b.duration !== undefined) figures.push(`${b.icon} ${formatDuration(b.duration)}`)
683    figures.push(`🔧 ${formatRunning(b.counter)}`)
684  } else if (b.duration !== undefined) {
685    figures.push(`${b.icon} ${formatDuration(b.duration)}`)
686  }
687  const footerText = figures.length ? ` ${figures.join(' · ')} ` : ''
688
689  // Espaciador de alto 0: obliga al recuadro a ser al menos tan ancho como
690  // header y footer (+2 por `─` a cada lado) sin usar `width`. Como Text en
691  // flujo con wrap: 'truncate', se encoge si el viewport es más angosto.
692  const minWidth = Math.max(headerText.length, footerText.length) + 2
693
694  return {
695    type: 'Box',
696    props: { flexDirection: 'column', paddingTop: 1 },
697    children: [
698      {
699        type: 'Box',
700        props: {
701          flexDirection: 'column',
702          borderStyle: 'round',
703        },
704        children: [
705          {
706            type: 'Box',
707            props: { flexDirection: 'column', marginTop: b.noMargin ? 0 : -1 },
708            children: [tree],
709          },
710          {
711            type: 'Box',
712            props: { height: 0, overflow: 'hidden' },
713            children: [
714              { type: 'Text', props: { wrap: 'truncate' }, children: [' '.repeat(minWidth)] },
715            ],
716          },
717        ],
718      },
719      {
720        type: 'Box',
721        props: { position: 'absolute', top: 1, left: 2, right: 2 },
722        children: [
723          { type: 'Text', props: { wrap: 'truncate' }, children: [headerText] },
724        ],
725      },
726      ...(footerText ? [{
727        type: 'Box',
728        props: { position: 'absolute', bottom: 0, left: 2, right: 2, justifyContent: 'flex-end' },
729        children: [
730          { type: 'Text', props: { wrap: 'truncate' }, children: [footerText] },
731        ],
732      }] : []),
733    ],
734  }
735}
736
737// Handler para el Spinner ("Thinking...").
738// Antepone a la palabra (y al mensaje que la sustituye, si lo hay) el inicio
739// del turno en curso de su agente (requestId: el agentId de un subagente, o
740// el id de sesión en el principal); sin turno registrado, lo deja intacto.
741// No se persiste.
742// Ejemplo: "🕐 2:23:05 p.m. · Thinking…"
743async function withSpinner($, e, next) {
744  const turn = turns.get(e.requestId) ?? turns.get(MAIN)
745  if (!turn) return next(e)
746  const prefix = `🕐 ${formatClockTime(turn.start)} · `
747  return next({
748    ...e,
749    props: {
750      ...e.props,
751      word: prefix + e.props.word,
752      message: e.props.message === null ? null : prefix + e.props.message,
753    },
754  })
755}
756
757// Handler para TurnDuration ("Completed in 15.3s").
758// Añade al word el fin del turno: la hora de su fila (requestId), fijada y
759// persistida por onAppend.
760// Ejemplo: "🕐 2:23:20 p.m. · Completed in"
761async function withTurnDuration($, e, next) {
762  await loadStore($)
763  const { time } = entryFor(e.requestId)
764  return next({
765    ...e,
766    props: {
767      ...e.props,
768      word: `🕐 ${formatClockTime(time)} · ${e.props.word}`,
769    },
770  })
771}
772
773// Claude Code llama a `register` una vez al cargar el mod.
774export function register(on) {
775  // Todos los tipos de mensaje en el transcript — tree-wrapping unificado
776  for (const component of [
777    'UserMessage',        // Mensajes del usuario
778    'AssistantMessage',   // Respuestas de Claude
779    'ToolUse',            // Llamada a una herramienta
780    'ToolResult',         // Resultado de una herramienta
781    'ToolGroup',          // Grupo colapsado de tool calls
782    'CommandOutput',      // Salida de comando shell
783    'AskUserQuestion',    // Diálogo de pregunta de Claude
784  ]) {
785    on('ui.render', { component }, withTimestamp)
786  }
787
788  // Status lines del terminal
789  on('ui.render', { component: 'Spinner' }, withSpinner)
790  on('ui.render', { component: 'TurnDuration' }, withTurnDuration)
791
792  // Ciclo de vida: fuente de los tiempos de los footers
793  on('prompt.submit', onSubmit)
794  on('turn.start', onTurnStart)
795  on('turn.step', onStep)
796  on('tool.call', onToolCall)
797  on('command.run', onCommandRun)
798  on('session.append', onAppend)
799  on('turn.complete', onTurnComplete)
800}
801
types/index.d.ts 8 lines
1// Contrato de transcript-plus: el valor de sesión que avanza los contadores
2// en curso (epoch ms del último tick del reloj).
3declare module 'claude-code' {
4  interface PluginState {
5    'transcript-plus': { second: number }
6  }
7}
8