Delegate to Google Antigravity CLI (agy) as an autonomous subagent: planning, adversarial review, evidence-based audits, cited web research and session…

A dual-host plugin that integrates Google Antigravity CLI (agy) with Claude Code and OpenAI Codex as an autonomous subagent and pair-programming partner.
Delegate deep reasoning, architectural planning, TDD implementation, adversarial code reviews and cited web research to Antigravity, running directly in your terminal. Claude Code remains fully supported; Codex uses the same skills and MCP server through its native plugin manifest.
/lagrange:fanout)/lagrange:watch)cast_agent)alma)/lagrange:usage)voice-chat/)/lagrange:research)1. Install - two commands inside Claude Code, any platform:
/plugin marketplace add KZvilla/lagrange-agent-runtime
/plugin install lagrange@kzvilla-lagrange
2. Restart Claude Code. /reload-plugins picks up commands, agents and skills, but the MCP tool schemas of a running session are the ones registered at startup - a restart is what makes agy_run and friends appear.
3. Try it:
/lagrange:run Analiza este proyecto y describí la arquitectura
/lagrange:review
/lagrange:summary
/lagrange:narrate
/lagrange:research "Latest patterns for Claude Code plugins in 2026"
/lagrange:usage
Until the first dual-host release is tagged, install from a local clone:
git clone https://github.com/KZvilla/lagrange-agent-runtime.git
codex plugin marketplace add /absolute/path/to/lagrange-agent-runtime
codex plugin add lagrange@kzvilla-lagrange-codex
Start a new Codex thread after installing so it discovers the plugin's skills and MCP tools. Ask it to use $agy-cli or describe the delegation in natural language; Claude-only /lagrange:* commands do not apply in Codex. The plugin keeps its current state under ~/.claude/ during the MVP so Claude Code and Codex do not split agent memory or configuration.
| Feature | Description | |
|---|---|---|
| 🤖 | Autonomous Subagent | Claude spins up Antigravity to execute complex tasks, multi-step refactors, and test suites |
| 🔀 | Concurrent Fan-Out | Runs parallel Antigravity subagents across isolated git worktrees with disjoint-file safety checks (/lagrange:fanout) |
| 👁️ | Lagrange Watch | Inspect the local Lagrange inventory by source, plus live fan-out progress, diffs and stop controls (/lagrange:watch) |
| 🧠 | Dual Model Intelligence | Combines Claude with Gemini models (3.8 / 3.7 Flash, 3.1 Pro) with configurable reasoning effort |
| 🎙️ | Voice Checkpoint Narration | Zero-Claude-token spoken status updates with declarative voice routing, independent Souls, and text-only degradation |
| 🗣️ | Real-Time Voice Mode | Full-duplex spoken conversation with barge-in, mic capture, Silero VAD and independent Soul/acoustic routing through local Voicebox or OmniVoice providers |
| 📋 | Anti-Compaction Session Summary | Analyzes raw JSONL session logs with Gemini (1M-2M context) to generate persistent, structured Markdown docs before context degrades |
| 🌐 | Cited Web Research | Leverages Antigravity's native web search and synthesis capabilities that Claude Code lacks out of the box |
| 📱 | Telegram Bridge & Remote Control | Control tasks from your phone, approve plans, receive voice notes, and launch claude --remote-control sessions |
| 🛡️ | Granular Permissions | ALLOW / DENY capabilities, forbidden paths, forbidden commands, and sandbox isolation |
| ⏱️ | Robust Timeouts | Auto-injects --print-timeout (15m default, 20m for reviews, 25m for audits) to prevent premature drops |
| 📊 | Live Telemetry | Token usage, thinking tokens, context caching savings, and context window saturation |
| 🔄 | Multi-Turn Continuity | conversation_id enables back-and-forth iteration with full workspace memory |
| ⚙️ | Flexible Config | Per-prompt, per-project JSON, or environment variables |
| ⚡ | Zero Dependencies | Lightweight stdio MCP server in pure Node.js |
| Requirement | Details |
|---|---|
| Node.js | ≥ 20.12 for the Telegram bridge (it loads .env with process.loadEnvFile); ≥ 18 is enough if you only use the agy_* MCP tools |
| Antigravity CLI | agy or agy.exe installed and on your PATH (Install guide) |
| Claude Code | Active Claude Code terminal session |
| Google API Key | Configured for Antigravity (GEMINI_API_KEY or agy auth login) |
| Command | Description |
|---|---|
/lagrange:run <prompt> | Delegate any task to Antigravity (read + write) |
/lagrange:plan <task> | Generate an architectural plan (read-only, no file changes) |
/lagrange:fanout [plan] | Run atomic tasks in parallel, one Antigravity subagent per isolated git worktree |
/lagrange:watch [slug] | Open the local Lagrange inventory; with a slug, open that fan-out directly |
/lagrange:review [target] | Adversarial code review on staged/unstaged diffs or specific files |
/lagrange:audit [target] | Heavyweight, evidence-based adversarial audit (Mode 1: Code vs Plan, Mode 2: Plan vs Repo) |
/lagrange:summary [focus] | Generate structured session summary from Claude Code's raw JSONL logs (full, decisions, changes, debugging) |
/lagrange:narrate [voice/lang] | Narrate the latest task/checkpoint with an explicit profile or the configured voice_setup route |
/lagrange:voices [lang] | Inspect live/cached profiles, configured roles, languages, and service health without starting providers |
/lagrange:research <topic> | Conduct deep web research with cited sources and structured insights |
/lagrange:usage | Display token telemetry, context saturation, and quota health |
/lagrange:bridge | Diagnose the Telegram bridge: daemon state, which copy of the code each half runs, credentials and shared state |
/lagrange:setup [track] | Guided setup for the optional pieces — Voicebox, Telegram notifications, the bidirectional daemon (voicebox, telegram, daemon) |
Tip: You can also ask Claude naturally — "Delegale a agy que resuma esta sesión", "¿Qué voces tengo disponibles?" o "Cuando termines, ejecuta la narración con Cloud Finch" — and it will pick the right tool automatically.
Twenty-three tools exposed via the MCP server — eleven agy_* tools (the ones whose result Antigravity produces, or that report on it), seven Lagrange tools without a prefix (your host adds the server's: lagrange_say in opencode, mcp__plugin_lagrange_lagrange__say in Claude Code), four telegram_* bridge tools, and cast_agent:
| Tool | Mode | Default Timeout | Description |
|---|---|---|---|
agy_run | read + write | 15m | Execute a full subagent session with optional permission guardrails |
agy_fanout | read + write | 15m/subagent | Concurrent fan-out: validates the tasks are disjoint in files, one worktree + branch each, batched with a concurrency cap and quota backoff |
agy_lote | isolated (containers) | per task | Confined batch: each atomic task runs in its own Docker container, its commit is tested in a no-network runner and audited by a different model; nothing is merged automatically. With motor: "claude@<account>", Claude Code writes the tasks in the same container — see Confined batches written by Claude |
agy_plan | isolated (container) | 15m | Step-by-step architectural / implementation plan over a read-only snapshot of the working tree — see Read-only isolation |
agy_review | isolated (container) | 20m | Adversarial code review on git diffs or specific files, over the same snapshot |
agy_audit | isolated (container) | 25m | Rigorous adversarial audit with severity rubric (BLOCKER, MAJOR, MINOR), over the same snapshot; always forces sandbox=false |
agy_research | no-edit (prompt) | 20m | Deep web research with cited sources — requires the network capability, errors out if denied |
agy_session_summary | no-edit (prompt) | 15m | Parse session JSONL and generate structured summary doc with Gemini |
agy_voice_stream | conversational | persistent (no fixed timeout) | Manage a long-lived, streaming agy.exe process for low-latency voice chat ("Modo Charla") — the backend behind voice-chat/ |
narrate | audio/text | 3m | Update of the latest checkpoint through the configured voice route, with text-only preservation when audio is unavailable; it writes the script from the session log |
say | audio TTS | — (3m with polish) | Speak a specific text you already have. Sanitized locally by default (markdown, paths, URLs, emoji stripped; secrets redacted); polish: true has Gemini condense it first |
narrate_voices | read-only | — | Inspect live/cached profiles, setup state, languages, roles, and service health; never starts a provider or loads a model |
voice_model | GPU memory | — | Start Voicebox headless (or OmniVoice with engine: "omnivoice"), and pin / release / unload the TTS model in VRAM across both (status is read-only) |
agy_usage | — | — | Session token telemetry, context window saturation, model limits, quota health |
agy_status | — | — | Binary path, CLI version, active model/effort defaults, permission policies |
set_config | — | — | Persist model, effort, timeout, permissions, the versioned voice_setup block, or the voice of a session identity (identidad_voz) |
telegram_notify | outbound | — | Push a notification (with optional file attachment) to your phone — see Telegram Bridge Setup |
telegram_ask | Human-in-the-Loop | 5m | Ask a question with tappable choice buttons and block until you answer on your phone |
telegram_send_voice | outbound audio | — | Send an audio file (or the latest Voicebox generation) as a native voice note |
cast_agent | read-only by default | 15m | Cast a persistent, SKILL-bound agent that keeps its identity, thread and accumulated criteria across sessions — see Persistent SKILL-Bound Agents |
telegram_bridge_status | read-only | — | Diagnose the bridge: daemon state, which copy of the code each half runs, where credentials and shared state resolve — /lagrange:bridge |
recall | read-only | — | Read this project's Claude Code memory from another Claude account on this machine; never writes — see Recall |
mensaje | local, via the daemon | up to 10m (esperar) | Messages between Claude Code sessions on this machine or on other nodes: list them, send, read, reply, with an optional wait for the answer — see Messages between sessions |
alma | local files | — | Manage Souls independently from acoustic profiles: list, inspect, explicitly seed and prune identity/memory files; install the tool-less lagrange-alma agent — see Souls |
agy_run — Full Parameters| Parameter | Type | Default | Description |
|---|---|---|---|
prompt | string | required | Task instructions for Antigravity |
model | string | "gemini-3.8-flash" | Gemini model to use |
effort | string | "high" | Reasoning effort: "low", "medium", "high" |
mode | string | "accept-edits" | "accept-edits" (read+write) or "plan" (no edits requested — not enforced, see below) |
permissions | object | — | Granular ALLOW/DENY policies (see below) |
conversation_id | string | — | Resume a previous conversation |
continue_session | boolean | — | Continue the most recent conversation (-c) |
timeout_minutes | number | 15 | Max runtime in minutes |
cwd | string | — | Requested project directory. The absolute path is used for the agy process and framed as the default Cwd of run_command; this is guidance, not confinement |
dangerously_skip_permissions | boolean | true | Run headlessly without interactive prompts |
| Field | Type | Default | Description |
|---|---|---|---|
allow | string[] | ["read", "edit", "commands", "network"] | Capabilities explicitly allowed |
deny | string[] | [] | Capabilities blocked. Denying "edit" forces --mode plan |
deny_paths | string[] | [".env*", "**/*.key", "**/*.pem"] | Paths forbidden from access |
deny_commands | string[] | ["git push*", "git reset --hard*", "npm publish*", "rm -rf /*"] | Shell commands prohibited |
sandbox | boolean | false | Enables native terminal sandbox (--sandbox) |
Denying "network" tells the subagent not to search or fetch URLs (a prompt guardrail), and makes agy_research fail with an explicit error instead of answering from memory.
Scope: the policy applies to every delegating tool — agy_run plus the no-edit ones (agy_plan, agy_review, agy_audit, agy_research, agy_session_summary). The no-edit tools always tell the subagent not to edit, whatever the policy says, and run it with --mode plan; commands, network, deny_paths and deny_commands travel with that instruction. sandbox also applies except to agy_audit, which always forces it off because the Windows implementation is actively harmful for long headless audits. Each tool's output footer prints the policy it ran under.
agy_plan, agy_review and agy_audit run inside the same Docker boundary as the confined batches (agy_lote): a container on an --internal network whose only way out is the allowlist proxy, with a decoy token (the real one lives in the proxy), a read-only root, no MCP servers and none of your home directory. What the subagent sees:
/trabajo, a read-only snapshot of the working tree: tracked and untracked files as they are on disk, uncommitted changes included. Gitignored files and files matching deny_paths are not copied, so in this mode deny_paths is a real exclusion, not a request. The snapshot is built without writing to git: git ls-files lists, Node copies; nothing touches your index or .git/objects, and git diff/status run with --attr-source set to the empty tree so no .gitattributes filter runs..git. The branch, git status, the last 20 commits and the diffs (uncommitted, and against the merge-base with origin/HEAD/main) are files in /trabajo/.lagrange-auditoria/, with deny_paths hunks removed.conversation_id works across calls (measured: a second container resumed the thread). Threads last 24 hours; a thread started on the host cannot be resumed in the container. Pass plans and specs inline: host paths do not exist inside.It cannot write your repository, run your tests or touch host processes. It also cannot run the test suite (no node_modules, read-only disk): run the gates yourself first.
Mode: readonly_isolation in .claude/antigravity.json (or set_config), and isolation: "container" | "host" per call.
readonly_isolation | Behavior |
|---|---|
"auto" (default) | Container when the batch infrastructure is installed and healthy (npm run lotes -- imagenes, npm run lotes -- login). The first time it works, a marker is written and auto never falls back to the host again: a missing image or a stopped Docker is an error, not a silent downgrade. Before that, it runs on the host with a ⚠️ warning at the top of the output. |
"container" | Always the container; error if it is not available. isolation: "host" is refused. |
"host" | As before phase 2: agy on the host, where it can run commands and write files. |
The output footer says where each call ran (Isolation: container … or Isolation: host — reason). Setup adds a token refresh, a proxy and a network per call (about half a minute).
Still on the host: agy_research (it needs to reach arbitrary sites, and the proxy only allows exact hosts by design) and agy_session_summary (its input is your own session log). They keep the before/after git status check below.
What changed while it ran (SEC-020). agy_plan, agy_review, agy_audit and agy_research take a git status snapshot of the repository that contains cwd before and after the run, and their output lists every difference as a transition (clean → .M, ?? → gone, a moved HEAD…). Nothing is reverted or deleted. The check only sees that repository: gitignored paths, writes elsewhere and killed processes are invisible to it, and a change you make yourself during the run shows up too. These tools carry no MCP read-only annotation, so your client may ask for approval on each call.
[!IMPORTANT] How these are enforced, and how far that goes (on the host). Only three things reach the CLI as real flags:
--mode plan,--sandboxand--dangerously-skip-permissions. Everything else —allow,deny,deny_paths,deny_commands— is injected as natural-language guardrails at the top of the subagent's prompt. They shape behavior reliably in practice, but they are instructions to a model, not a sandbox: treat them as hygiene and blast-radius reduction, not as a security boundary against a determined or malfunctioning agent.mode: "plan"is not a read-only boundary either: every delegating tool also passes--dangerously-skip-permissions, and with it plan mode still runs shell commands. In practice that has meant an audit that ran the test suite despitedeny_commands: ["node*"], wrote a 64 KBdiff.diffinto the audited worktree despitedeny: ["edit"], and, earlier, one that killed everynodeprocess on the machine. The only real containment is a container that sees nothing but a copy of the project — the executor of the confined batches, whichagy_plan,agy_reviewandagy_auditnow use (see Read-only isolation). A git worktree is not one: agy writes to absolute paths outside it.
sandbox: trueis narrower than it sounds, and on Windows it is actively harmful. Its own help text says "terminal restrictions", and that is exactly what it is: measured onagyv1.1.26, it blocks shell reads, shell writes andcurl, while the native tools walk straight past it — the subagent still writes files, still reads absolute paths outside its workspace, and still fetches URLs. Worse, it mounts a jail over the working directory, so acwdyou passed is ignored and writes land in the main repository instead; it triggers a UAC elevation prompt, which rules out headless or concurrent use; and it leaves a mount that outlives the process. Full evidence indocs/future-implementations/subagentes-concurrentes-agy.md. A git worktree viacwd(whatagy_fanoutdoes, without exposingsandbox) keeps concurrent subagents from colliding in the main checkout, but it does not confine them either: agy writes to absolute paths outside it.
Audit deadlines have three owners.
agy_reviewdefaults to a 20-minute CLI deadline and a 21-minute process watchdog;agy_audituses 25/26 minutes. The confined batch auditor also uses 25/26 minutes, but runs behind the persistent batch state after the web request returns. An MCP host may impose a shorter transport deadline (some cut calls near 300 seconds). Lagrange honorsnotifications/cancelledand terminates the process tree; a client that silently drops its pending request cannot be inferred from a quiet JSON-mode model. In that case raise the host deadline or use an existing persistent/background path instead of retrying blindly.
When agy runs out of quota, is not installed or is down, Lagrange can retry with a secondary Claude account (motores.cuentas, FEAT-085) through the claude engine. It is off by default and global only: with it on, those texts — including a full session transcript for agy_session_summary — and soul chats go to that account.
{
"motores": { "cuentas": { "trabajo": { "configDir": "~/.claude-work" } } },
"fallback_agy": "claude@trabajo"
}
Set it in ~/.claude/antigravity.json or with set_config (scope: "global"; a project config cannot turn it on). The account must be listed in motores.cuentas; otherwise the value is ignored with a warning.
| Work | Model | Effort |
|---|---|---|
Texts: persona rewrite, say polish, narrate script, agy_session_summary (and its strict review) | Haiku 5.5 | low |
| Voice-chat consolidation | Haiku 5.5 | default (medium) |
| Soul chat | Haiku 5.5 | medium |
| Read-only cast | Sonnet | medium |
Resets in … is remembered and shared by the MCP, the bot and the consolidator, so agy is not retried until then), agy missing, or agy unavailable. A timeout, an invalid model or a cancellation never switch engines.claude@<account> role. Texts, souls and consolidation run with the sin-tools profile (no tools, --safe-mode --strict-mcp-config); texts use a neutral system prompt instead of the soul's voice. Casts use lectura (Read/Grep/Glob); a read/write agent has no fallback. Without current isolation probes for that account the request has no fallback (the probes start in the background), and the account's 5-hour quota brake applies: it never moves to another account.motores.roles never falls back. A soul or agent keeps a separate thread per engine: the fallback continues its claude@<account> thread, or starts one.🎭 En personaje, escrito por Claude · trabajo (agy sin cuota hasta …) — and usage is recorded under claude@<account>. The web console shows agy → Claude · <account> (fallback) while the quota whooks/mods.tsx 1636 lines1import { atom, read, update } from 'claude-code'
2import type { Register, EngineInterface } from 'claude-code'
3import type { FotoPanel, FanoutPanel, BandejaBanda, MetaPanel, RedPanel, CuotaPanel, LoteAviso } from '../types'
4import { filasDeFoto, textoDeFoto, textoDeMetas, avisosDeRed } from './panel-texto.ts'
5import type { AvisoRed } from './panel-texto.ts'
6import { validarGuardas, guardaQueFrena, guardasVigentes, textoDeFreno } from './guardas.ts'
7import { FOCOS, leerArgs, ramaDeHead, gitdirDe, promptDeResumen, validarResumen, archivoDeResumen, frontmatter, pieDeCosto, textoDeEstimacion } from './resumen-texto.ts'
8import type { Foco, MetaResumen } from './resumen-texto.ts'
9import type { Guarda } from './guardas.ts'
10import { esToolDeAgy, cierreDe, hayAlgo, filasDeBanda } from './banda-texto.ts'
11import type { LlamadaAgy, CierreAgy, Tono } from './banda-texto.ts'
12import { PLAZO_GATES_MS, nuevaCorrida, leerArgGates, procesarLinea, partirLineas, finPorCodigo, bloqueDeGates, lineaDeGates, avanceDeGates } from './gates-texto.ts'
13import type { CorridaGates, FinGates } from './gates-texto.ts'
14import { reconocer, motivoDe, unir, normalizar, mismaRuta, rutasDeWorktrees, leerStatus, esLink, tieneComodin } from './vista-previa.ts'
15import type { Caso, CasoWorktree, CasoBorrado, CasoPush, Hallazgo } from './vista-previa.ts'
16import { identidadDeConfig, identidadesIguales, sufijoConIdentidad } from './identidad.ts'
17import type { Identidad } from './identidad.ts'
18import { nuevoHandoff, medirContexto, descartar, empezar, terminar, vigente, hayAviso, filaDeHandoff, empezarCompactacion, terminarCompactacion, instruccionesDeCompactacion } from './handoff-texto.ts'
19import type { Handoff, FilaHandoff } from './handoff-texto.ts'
20import { textoFinal, visibles, filasDeMensaje, remitente, avisoParaClaude, bloqueDeRespuestas, pedidoDeRecall, filaDeNovedad, novedadesDe } from './bandeja-texto.ts'
21import { nuevoTurno, abrirTool, cerrarTool, contarPaso, cerrarTurno, textoDeTurno, TURNOS_GUARDADOS } from './turno-texto.ts'
22import type { TurnoEnCurso, TurnoCerrado } from './turno-texto.ts'
23import { leerVoz, mensajeDeVoz, frases, fraseEn, filaDeSubtitulo, esToolDeVoz } from './voz-texto.ts'
24import type { VozEnCurso } from './voz-texto.ts'
25import { clasificar, sumarPaso, sumarSpawn, leerMedicion, textoDeMedicion, MEDICION_VACIA } from './haiku-medicion.ts'
26import type { Medicion } from './haiku-medicion.ts'
27import { avisosFanout, avisosLotes, avisosCuota, avisosMensajes, tiposDe } from './avisos-fondo.ts'
28import type { Aviso, TipoAviso, EstadoFanout, EstadoLotes, EstadoCuota, EstadoMensajes } from './avisos-fondo.ts'
29import { modelosDelPedido, modeloPorDefecto, restantes, bajo, pregunta, decision, SEGUIR, CANCELAR, cuentasClaudeOfrecibles, opcionClaude } from './cuota-previa.ts'
30
31/**
32 * Los mods de Lagrange para Claude Code, en un solo módulo: el kit admite uno
33 * por plugin, un solo `session.start` sin matcher, y `$` no puede pasar a una
34 * función importada de otro archivo. Lo que no usa `$` vive aparte
35 * (`panel-texto.ts`).
36 *
37 * FEAT-100 — El aviso del buzón de `mensaje`. Los hooks de `buzon.js` avisan
38 * bloqueando el Stop, y al modelo le llega como "Stop hook blocking error".
39 * Esto vigila el buzón de la sesión y, cuando llega algo nuevo, encola el aviso
40 * con `$.prompt.submit`: entra marcado como mensaje del plugin y nunca se mete
41 * en un turno en curso. Mientras late, `buzon.js stop` calla y `espera` no avisa;
42 * si el mod se cae, el latido se vence y los hooks vuelven a cargo. Solo el
43 * aviso: el texto sale únicamente por la tool `mensaje`. Nunca
44 * `$.session.append`, que le llega al modelo como un mensaje del usuario (S3).
45 *
46 * FEAT-101 — El panel de Lagrange (comando `lagrange-panel`): fan-out, cuota y
47 * versiones en un panel lateral, y la línea del fan-out en la status line
48 * mientras corre uno. Datos de `hooks/panel.js`; sin fan-out y con el panel
49 * cerrado, solo un `fs.list` cada 5 s. Si la status line ya corre
50 * `fanout-statusline.js`, no pone el status (saldría dos veces).
51 *
52 * BE-093 — La cuota de Claude que mide la sesión (`session.measure`) va a
53 * `antigravity-usage.json` con `panel.js cuota-sesion`: sin esto solo la
54 * anotaba un `claude -p`, y el panel y el freno leían datos de días.
55 *
56 * FEAT-109 — La banda de agy sobre el prompt: las llamadas de agy en curso con
57 * su reloj, el cierre de cada una (veredicto) por 20 s y las tareas del fan-out
58 * con su paso. Redibuja con el tick del panel; no agrega timers.
59 *
60 * FEAT-114 — `/lagrange-gates`: corre `scripts/gates.mjs` sin turno de Claude y
61 * muestra el avance en la sección «Gates» del panel. Solo existe donde está ese
62 * script. El veredicto es el código de salida del proceso; el resultado no
63 * entra a la conversación (solo una línea en `/lagrange-panel`).
64 *
65 * FEAT-112 — La vista previa del daño: antes de cuatro comandos destructivos
66 * (quitar un worktree, borrar recursivo, push forzado, matar node) mide qué
67 * tocarían y, si hay daño, pregunta con el prompt de permisos del motor
68 * (`classic.PreToolUse` → `{ ask }`, que pregunta también en modo auto). Si no
69 * hay daño, pasa sin preguntar. Las guardas (`tool.call`) niegan antes.
70 *
71 * FEAT-123 — La identidad de la cuenta (`identidad_sesion`: Spica, Epikouros)
72 * al final del spinner. Sin color: `Spinner` no lo expone; el color va en la
73 * statusline (`fanout-statusline.js`). Sin configuración, nada cambia.
74 *
75 * FEAT-118 — El freno de contexto: al cruzar el 70 % y el 85 % de la ventana
76 * de compactación, una fila en la banda con «[h] guardar handoff» (corre
77 * `generarResumen` con foco handoff, lo mismo que `/lagrange-resumen handoff si`)
78 * y «[x] ahora no», solo entre turnos. Una vez por umbral y por ciclo de
79 * compactación; sin dígitos de atajo (un «1» suelto lanzaría el fork).
80 *
81 * FEAT-111 — Antes de un `agy_run`, `agy_fanout` o `agy_lote` (lanzar), si la
82 * cuota guardada del grupo de sus modelos tiene menos del 20 % (dato de menos
83 * de 30 min), pregunta «Seguir / Cancelar» con `$.ui.ask`. Sin interfaz
84 * (`claude -p`) o sin dato, pasa. Va dentro del hook de FEAT-109, antes de
85 * anotar la llamada: una cancelación no deja fila en la banda.
86 *
87 * FEAT-115 — «Banda primero»: un mensaje de otra sesión aparece en la banda
88 * (quién y el texto, para el usuario) sin despertar a Claude, con «[r]
89 * responder» (un Input; sale por `buzon.js mod-responder` con un rótulo
90 * informativo), «[c] pasar a Claude» (el aviso de siempre, sin el texto) y
91 * «[l] más tarde» (queda para `mensaje leer`). Lo respondido se le cuenta a
92 * Claude en el próximo prompt del usuario. Con el mod vivo, los hooks
93 * `prompt`/`stop`/`espera` de `buzon.js` callan.
94 *
95 * FEAT-116 — Al arrancar, si otra cuenta modificó notas de memoria de este
96 * proyecto desde la última marca (`$.store`, «recall-visto»), una fila con
97 * «[t] traer» (le pide a Claude el recall, sin nombres de archivo) y «[n]
98 * ahora no». Una vez por sesión; nada se copia solo.
99 *
100 * FEAT-126 — `/meta`: metas del proyecto (fecha, conteo, condición; riesgo
101 * opcional), compartidas entre cuentas en la base de conocimiento. Los
102 * comandos corren cada 5 min por `hooks/metas.js` SOLO si esta cuenta los
103 * aprobó (hashes en `$.store` «metas-permitidos»): el archivo es compartido.
104 * Sección «Metas» del panel (desde `panel.js`) y un toast por transición.
105 *
106 * FEAT-122 — La línea de tiempo del turno: cada tool del loop principal (sin
107 * `agentId`) con su inicio y duración, los requests, los tokens (sumados por
108 * `turn.complete`) y el costo (lo que sumó `cost.usd` de la sesión). Los
109 * últimos 5 en una variable del módulo; sección «Último turno» del panel y
110 * `/turno` con el detalle.
111 */
112
113// ----------------------------------------------------------------- buzón
114
115type Ubicacion = { sesion: string; jsonl: string; mod: string }
116
117const TICK_BUZON_MS = 3000
118const LATIR_CADA_TICKS = 3
119
120async function pedirBuzon($: EngineInterface, modo: 'mod-ubicar' | 'mod-mensajes'): Promise<Record<string, unknown> | null> {
121 try {
122 const r = await $.process.run(['node', `${$.plugin.root}/hooks/buzon.js`, modo])
123 if (r.exitCode !== 0) return null
124 return JSON.parse(r.stdout)
125 } catch {
126 return null
127 }
128}
129
130async function huella($: EngineInterface, ruta: string): Promise<string> {
131 try {
132 const st = await $.fs.stat(ruta)
133 return `${st.mtimeMs}:${st.size}`
134 } catch {
135 return 'ausente'
136 }
137}
138
139async function latir($: EngineInterface, u: Ubicacion): Promise<void> {
140 try { await $.fs.write(u.mod, JSON.stringify({ ts: await $.clock.now() })) } catch {}
141}
142
143/** FEAT-115 — Los pendientes sin entregar van a la banda; no se despierta a Claude. */
144async function traerMensajes($: EngineInterface): Promise<void> {
145 const r = await pedirBuzon($, 'mod-mensajes')
146 if (!r || !Array.isArray(r.mensajes)) return
147 const mensajes = r.mensajes as BandejaBanda['mensajes']
148 await update($, bandeja, (b) => ({ ...b, mensajes }))
149 $.ui.invalidate('ui.render')
150 // FEAT-135 — El primer pedido (lo que ya esperaba) es la línea de base.
151 const { listos } = await read($, bandeja)
152 const r2 = avisosMensajes(estadoMensajesAv, mensajes, listos, remitente)
153 estadoMensajesAv = r2.estado
154 avisar($, r2.avisos)
155}
156
157/** FEAT-115 — Despacha un mensaje de la banda (pasado a Claude, para más tarde o respondido). */
158async function despachar($: EngineInterface, id: string, extra: Partial<BandejaBanda> = {}): Promise<void> {
159 await update($, bandeja, (b) => ({ ...b, ...extra, listos: [...b.listos.filter((x) => x !== id), id].slice(-200), respondiendo: null }))
160 $.ui.invalidate('ui.render')
161}
162
163async function responderDesdeLaBanda($: EngineInterface, id: string, texto: string): Promise<void> {
164 const b = await read($, bandeja)
165 const m = b.mensajes.find((x) => x.id === id)
166 if (!m || !texto.trim()) return
167 let r: { ok?: boolean; error?: string } = {}
168 try {
169 const out = await $.process.run(['node', `${$.plugin.root}/hooks/buzon.js`, 'mod-responder'], { stdin: JSON.stringify({ id, texto }) })
170 r = JSON.parse(out.stdout)
171 } catch {
172 r = { ok: false, error: 'No se pudo correr buzon.js' }
173 }
174 if (!r.ok) {
175 $.ui.toast(`No se envió: ${r.error || 'error desconocido'}`)
176 return
177 }
178 const nota = { de: remitente(m), id, texto: texto.trim().slice(0, 2000) }
179 await despachar($, id, { notas: [...b.notas, nota].slice(-10) })
180 $.ui.toast(`Respuesta enviada a ${remitente(m)}`)
181}
182
183/** FEAT-116 — Una vez por sesión: lo que otra cuenta anotó de este proyecto desde la última marca. */
184async function iniciarRecall($: EngineInterface): Promise<void> {
185 if ((await read($, bandeja)).recallMirado) return
186 await update($, bandeja, (b) => ({ ...b, recallMirado: true }))
187 const raiz = normalizar(await $.session.root())
188 const marcas = ((await $.store.get('recall-visto')) ?? {}) as Record<string, Record<string, number>>
189 const out = await $.process.run(['node', `${$.plugin.root}/hooks/recall-novedades.js`], {
190 stdin: JSON.stringify({ cwd: raiz, desde: marcas[raiz.toLowerCase()] ?? {} }),
191 env: { CLAUDECODE: '1' }
192 })
193 if (out.exitCode !== 0) return
194 const novedades = novedadesDe(JSON.parse(out.stdout))
195 if (!novedades.length) return
196 await update($, bandeja, (b) => ({ ...b, novedades }))
197 $.ui.invalidate('ui.render')
198}
199
200/** FEAT-116 — «traer» y «ahora no» guardan la misma marca: no se vuelve a avisar hasta que haya algo más nuevo. */
201async function marcarRecall($: EngineInterface, cuenta: string, traer: boolean): Promise<void> {
202 const b = await read($, bandeja)
203 const n = b.novedades.find((x) => x.cuenta === cuenta)
204 if (!n) return
205 const raiz = normalizar(await $.session.root()).toLowerCase()
206 const marcas = ((await $.store.get('recall-visto')) ?? {}) as Record<string, Record<string, number>>
207 await $.store.set('recall-visto', { ...marcas, [raiz]: { ...(marcas[raiz] ?? {}), [cuenta]: n.hasta } })
208 await update($, bandeja, (x) => ({ ...x, novedades: x.novedades.filter((y) => y.cuenta !== cuenta) }))
209 $.ui.invalidate('ui.render')
210 const pedido = traer ? pedidoDeRecall(n) : null
211 if (pedido) await $.prompt.submit({ text: pedido })
212}
213
214// ----------------------------------------------------------------- turno (FEAT-122)
215
216// Por turnId: aunque el motor dice que un subagente no dispara turn.start, uno que llegara no pisa al principal.
217// Las tools del loop principal van al más viejo abierto (el principal); se descartan los de más de 6 h.
218const turnosAbiertos = new Map<string, TurnoEnCurso>()
219const TURNO_VIEJO_MS = 6 * 60 * 60 * 1000
220let turnosCerrados: TurnoCerrado[] = []
221
222async function costoSesion($: EngineInterface): Promise<number | null> {
223 try {
224 const u = await $.session.usage()
225 return typeof u.cost?.usd === 'number' ? u.cost.usd : null
226 } catch {
227 return null
228 }
229}
230
231// ----------------------------------------------------------------- metas (FEAT-126)
232
233const MEDIR_METAS_MS = 5 * 60 * 1000
234let midiendoMetas = false
235let metasPendientes = false
236
237type RespuestaMetas = { ok?: boolean; error?: string; metas?: MetaPanel[]; transiciones?: Array<{ nombre: string; tipo: string }>; hashes?: string[]; huerfanos?: string[]; meta?: MetaPanel }
238
239async function metasPermitidos($: EngineInterface): Promise<string[]> {
240 const v = await $.store.get('metas-permitidos').catch(() => undefined)
241 return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []
242}
243
244async function pedirMetas($: EngineInterface, cuerpo: Record<string, unknown>): Promise<RespuestaMetas> {
245 try {
246 const raiz = raizSesion || (await $.session.root())
247 const r = await $.process.run(['node', `${$.plugin.root}/hooks/metas.js`], { stdin: JSON.stringify({ cwd: raiz, ...cuerpo }), timeoutMs: 5 * 60 * 1000 })
248 return JSON.parse(r.stdout) as RespuestaMetas
249 } catch (err) {
250 return { ok: false, error: String((err as Error)?.message ?? err) }
251 }
252}
253
254/** Mide (una a la vez) y avisa cada transición con un toast. Nunca lanza. */
255async function medirMetas($: EngineInterface): Promise<MetaPanel[] | null> {
256 if (midiendoMetas) return null
257 midiendoMetas = true
258 try {
259 const r = await pedirMetas($, { accion: 'medir', permitidos: await metasPermitidos($) })
260 if (!r.ok || !r.metas) return null
261 metasPendientes = r.metas.some((m) => !m.estado.cumplida)
262 for (const t of r.transiciones ?? []) $.ui.toast(t.tipo === 'cumplida' ? `🎯 Meta «${t.nombre}» cumplida` : `⚠️ Meta «${t.nombre}» en riesgo`)
263 if (r.transiciones?.length) void actual?.refrescar().catch(() => {})
264 return r.metas
265 } catch {
266 return null
267 } finally {
268 midiendoMetas = false
269 }
270}
271
272async function iniciarMetas($: EngineInterface): Promise<void> {
273 await $.command.register({ name: 'meta', description: 'Metas del proyecto con progreso: /meta (lista), /meta fecha|conteo|condicion …, /meta aprobar|borrar <id>, /meta revisar, /meta ayuda' })
274 await medirMetas($)
275 $.clock.every(MEDIR_METAS_MS, () => { if (metasPendientes) void medirMetas($).catch(() => {}) })
276}
277
278const AYUDA_META = [
279 'Metas del proyecto (compartidas entre tus cuentas):',
280 ' /meta fecha "P3" 2026-10-09T05:37Z',
281 ' /meta conteo "Major" 20 -- git rev-list --count main..next/v1',
282 ' /meta condicion "Gates verdes" -- node scripts/gates.mjs --quick',
283 ' … cualquiera admite al final: --riesgo <comando> (si sale distinto de 0, la meta queda en riesgo)',
284 ' /meta (lista) · /meta revisar (mide ya) · /meta aprobar <id> · /meta borrar <id>',
285 'Los comandos corren sin shell, en la raíz del proyecto, cada 5 min, y solo si esta cuenta los aprobó.'
286].join(String.fromCharCode(10))
287
288async function comandoMeta($: EngineInterface, args: string): Promise<{ text: string }> {
289 const ahora = await $.clock.now()
290 const texto = args.trim()
291 const verbo = texto.split(/\s+/)[0] ?? ''
292 const resto = texto.slice(verbo.length).trim()
293 const permitidos = await metasPermitidos($)
294 if (verbo === 'ayuda') return { text: AYUDA_META }
295 if (verbo === 'fecha' || verbo === 'conteo' || verbo === 'condicion') {
296 const r = await pedirMetas($, { accion: 'crear', args: texto })
297 if (!r.ok || !r.meta) return { text: `No se creó: ${r.error ?? 'error desconocido'}` }
298 await $.store.set('metas-permitidos', [...new Set([...permitidos, ...(r.hashes ?? [])])])
299 metasPendientes = true
300 void medirMetas($).then(() => actual?.refrescar()).catch(() => {})
301 return { text: `Meta ${r.meta.id} «${r.meta.nombre}» creada${r.hashes?.length ? '; su comando quedó aprobado en esta cuenta' : ''}.` }
302 }
303 if (verbo === 'borrar') {
304 const r = await pedirMetas($, { accion: 'borrar', id: resto })
305 if (!r.ok) return { text: `No se borró: ${r.error ?? 'error desconocido'}` }
306 const fuera = new Set(r.huerfanos ?? [])
307 await $.store.set('metas-permitidos', permitidos.filter((h) => !fuera.has(h)))
308 void actual?.refrescar().catch(() => {})
309 return { text: `Meta ${resto} borrada.` }
310 }
311 if (verbo === 'aprobar') {
312 const r = await pedirMetas($, { accion: 'listar' })
313 const m = r.metas?.find((x) => x.id === resto)
314 if (!m) return { text: `No hay una meta ${resto} en este proyecto.` }
315 const nuevos = m.hashes.filter((h) => !permitidos.includes(h))
316 if (!nuevos.length) return { text: `La meta ${m.id} ya está aprobada en esta cuenta.` }
317 const cmds = [m.medir, m.riesgo].filter((a): a is string[] => Array.isArray(a)).map((a) => a.join(' ')).join(' · ')
318 let respuesta = ''
319 try {
320 respuesta = await $.ui.ask(`La meta «${m.nombre}» corre «${cmds}» cada 5 min en ${raizSesion}. ¿Aprobar en esta cuenta?`, { options: ['Aprobar', 'No'], header: 'Meta' })
321 } catch {
322 return { text: 'Sin respuesta: la meta sigue sin aprobar.' }
323 }
324 if (respuesta !== 'Aprobar') return { text: 'La meta sigue sin aprobar.' }
325 await $.store.set('metas-permitidos', [...new Set([...permitidos, ...nuevos])])
326 metasPendientes = true
327 void medirMetas($).then(() => actual?.refrescar()).catch(() => {})
328 return { text: `Meta ${m.id} aprobada en esta cuenta.` }
329 }
330 if (verbo === 'revisar') {
331 const metas = await medirMetas($)
332 return { text: metas ? textoDeMetas(metas, ahora, await metasPermitidos($)) : 'Hay una medición en curso; probá en un rato.' }
333 }
334 if (verbo === '') {
335 const r = await pedirMetas($, { accion: 'listar' })
336 return { text: r.ok ? textoDeMetas(r.metas ?? [], ahora, permitidos) : `No se pudieron leer: ${r.error}` }
337 }
338 return { text: AYUDA_META }
339}
340
341// BE-110 — El MCP da de alta la sesión (y escribe el puntero) 500 ms después de arrancar, y session.start
342// puede llegar antes: sin puntero, se reintenta (cada 5 s los primeros 2 min, después cada 60 s).
343const REINTENTO_RAPIDO_MS = 5000
344const REINTENTO_LENTO_MS = 60 * 1000
345const VENTANA_RAPIDA_MS = 2 * 60 * 1000
346
347async function ubicarBuzon($: EngineInterface): Promise<Ubicacion | null> {
348 const u = await pedirBuzon($, 'mod-ubicar')
349 return u && typeof u.sesion === 'string' && typeof u.jsonl === 'string' && typeof u.mod === 'string' ? (u as Ubicacion) : null
350}
351
352/** Arranca la vigilancia del buzón (FEAT-100); si la sesión todavía no tiene puntero, la busca de nuevo (BE-110). */
353async function iniciarBuzon($: EngineInterface): Promise<void> {
354 const ubicada = await ubicarBuzon($)
355 if (ubicada) return vigilarBuzon($, ubicada)
356 const desde = await $.clock.now()
357 let buscando = false
358 let encontrada = false
359 let ultimo = desde
360 $.clock.every(REINTENTO_RAPIDO_MS, () => {
361 if (buscando || encontrada) return
362 buscando = true
363 void (async () => {
364 const ahora = await $.clock.now()
365 if (ahora - desde > VENTANA_RAPIDA_MS && ahora - ultimo < REINTENTO_LENTO_MS) return
366 ultimo = ahora
367 const u = await ubicarBuzon($)
368 if (u) {
369 encontrada = true
370 await vigilarBuzon($, u)
371 }
372 })().catch(() => {}).finally(() => { buscando = false })
373 })
374}
375
376async function vigilarBuzon($: EngineInterface, ubicacion: Ubicacion): Promise<void> {
377 await latir($, ubicacion)
378 let anterior = await huella($, ubicacion.jsonl)
379 let ticks = 0
380 let ocupado = false
381 // Lo que ya esperaba antes de que el mod cargara.
382 void traerMensajes($).catch(() => {})
383 $.clock.every(TICK_BUZON_MS, () => {
384 if (ocupado) return
385 ocupado = true
386 void (async () => {
387 ticks += 1
388 if (ticks % LATIR_CADA_TICKS === 0) await latir($, ubicacion)
389 const vista = await huella($, ubicacion.jsonl)
390 if (vista !== anterior) {
391 anterior = vista
392 await traerMensajes($)
393 }
394 })().catch(() => {}).finally(() => { ocupado = false })
395 })
396}
397
398// ----------------------------------------------------------------- panel
399
400const PANE = 'lagrange'
401const TICK_PANEL_MS = 5000
402const RECIENTE_MS = 10 * 60 * 1000
403const REFRESCO_FANOUT_MS = 30 * 1000
404const REFRESCO_FOTO_MS = 60 * 1000
405
406const foto = atom({ plugin: 'lagrange', key: 'foto' } as const, null as FotoPanel | null)
407// FEAT-115/116 — De la sesión: sobrevive a una recarga del mod (los ids despachados y la nota pendiente incluidos).
408const BANDEJA_VACIA: BandejaBanda = { mensajes: [], listos: [], respondiendo: null, notas: [], novedades: [], recallMirado: false }
409const bandeja = atom({ plugin: 'lagrange', key: 'bandeja' } as const, BANDEJA_VACIA)
410// BE-111 — Lo tecleado en el Input de la banda: cada redibujo lo vuelve a poner como `value` (si no, un redibujo
411// pisaba lo escrito), y al enviar gana lo más completo. `bandaId` es el requestId de la banda, para darle el foco.
412let borrador = ''
413let bandaId: string | null = null
414
415type Sesion = {
416 abierto: boolean
417 refrescar: () => Promise<void>
418}
419
420// La sesión vigente: la reinicia cada `session.start` (también al recargar el módulo).
421let actual: Sesion | null = null
422
423async function pedirPanel($: EngineInterface, modo: 'fanout' | 'foto', root: string): Promise<Partial<FotoPanel> | null> {
424 try {
425 const r = await $.process.run(['node', `${$.plugin.root}/hooks/panel.js`, modo, root])
426 if (r.exitCode !== 0) return null
427 return JSON.parse(r.stdout)
428 } catch {
429 return null
430 }
431}
432
433/** Arranca el comando, el status y el refresco del panel (FEAT-101). */
434async function iniciarPanel($: EngineInterface): Promise<void> {
435 await $.command.register({ name: 'lagrange-panel', description: 'Panel de Lagrange: fan-out, cuota, versiones, agentes, almas, programaciones, guardas y worktrees huérfanos' })
436 let statusHabilitado = true
437 try {
438 const comando = (await $.settings.read())?.statusLine?.command
439 if (typeof comando === 'string' && comando.includes('fanout-statusline')) statusHabilitado = false
440 } catch {}
441 const root = await $.session.root()
442 let ocupado = false
443 let statusMostrado = false
444 let hayFanout = false
445 let ultimaHuella = ''
446 let ultimaCorrida = 0
447 let ultimaFoto = 0
448 const sesion: Sesion = { abierto: false, refrescar: async () => {} }
449
450 const aplicarFanout = async (fan: FanoutPanel | null) => {
451 hayFanout = Boolean(fan)
452 fanoutBanda = fan
453 void avisarFanout($, fan).catch(() => {})
454 // FEAT-109 — Sin esto la banda mostraba el fan-out del tick anterior.
455 $.ui.invalidate('ui.render')
456 if (statusHabilitado) {
457 if (fan?.linea) { await $.ui.status(fan.linea); statusMostrado = true }
458 else if (statusMostrado) { await $.ui.status(undefined); statusMostrado = false }
459 }
460 if (sesion.abierto) await update($, foto, (f) => (f ? { ...f, fanout: fan } : f))
461 }
462
463 sesion.refrescar = async () => {
464 const r = await pedirPanel($, 'foto', root)
465 ultimaFoto = await $.clock.now()
466 if (!r) return
467 // FEAT-105 — Cada clave a mano: lo que no esté acá se pierde en el refresco.
468 const nueva: FotoPanel = {
469 fanout: r.fanout ?? null, cuota: r.cuota ?? null, versiones: r.versiones ?? null,
470 agentes: r.agentes ?? null, almas: r.almas ?? null, programaciones: r.programaciones ?? null, worktrees: r.worktrees ?? null,
471 // BE-109 — FEAT-126 agregó la sección y no la clave: sin esto, las metas nunca llegaban al panel.
472 metas: r.metas ?? null,
473 // FEAT-121 — La red (mismo cuidado que BE-109: sin la clave, el bloque queda en «sin datos»).
474 red: r.red ?? null
475 }
476 await update($, foto, () => nueva)
477 await aplicarFanout(nueva.fanout)
478 }
479 actual = sesion
480
481 $.clock.every(TICK_PANEL_MS, () => {
482 // FEAT-109 — El reloj de la banda avanza con este tick; una vez más al vaciarse, para borrarla.
483 void (async () => {
484 const ahora = await $.clock.now()
485 if (bandaDibujada || bandaViva(ahora) || gatesCorriendo() || hayAviso(handoff, ahora)) $.ui.invalidate('ui.render')
486 })().catch(() => {})
487 if (ocupado) return
488 ocupado = true
489 void (async () => {
490 const ahora = await $.clock.now()
491 if (sesion.abierto && ahora - ultimaFoto >= REFRESCO_FOTO_MS) {
492 await sesion.refrescar()
493 return
494 }
495 let lista: Array<{ name: string; mtimeMs: number }> = []
496 try { lista = await $.fs.list(`${root}/.claude/worktrees`) } catch {}
497 const recientes = lista.filter((x) => x.name.startsWith('.fanout-status-') && x.name.endsWith('.json') && ahora - x.mtimeMs < RECIENTE_MS)
498 if (!recientes.length) {
499 if (hayFanout) await aplicarFanout(null)
500 return
501 }
502 // FEAT-109 — El avance fino va a los .agy-progress: sin ellos el paso se congelaba 30 s.
503 const progreso = lista.filter((x) => x.name.startsWith('.agy-progress-') && x.name.endsWith('.jsonl') && ahora - x.mtimeMs < RECIENTE_MS)
504 const huellaFanout = [...recientes, ...progreso].map((x) => `${x.name}:${x.mtimeMs}`).sort().join('|')
505 if (huellaFanout === ultimaHuella && ahora - ultimaCorrida < REFRESCO_FANOUT_MS) return
506 ultimaHuella = huellaFanout
507 ultimaCorrida = ahora
508 const r = await pedirPanel($, 'fanout', root)
509 if (r) await aplicarFanout(r.fanout ?? null)
510 })().catch(() => {}).finally(() => { ocupado = false })
511 })
512}
513
514// ----------------------------------------------------------------- banda (FEAT-109)
515
516// Una sola fuente de verdad, en el módulo: un reload la pierde y la banda vuelve
517// con la próxima llamada. Nada en `$.state`.
518const llamadas = new Map<string, LlamadaAgy>()
519let cierres: CierreAgy[] = []
520let fanoutBanda: FanoutPanel | null = null
521let bandaDibujada = false
522
523// ----------------------------------------------------------------- voz
524
525// FEAT-119/120 — Un `say`/`narrate` con `local_playback` en curso y lo que el MCP avisó que suena
526// (`buzones/voz-<claudePid>.json`, ubicado con `buzon.js mod-voz`: no depende del daemon).
527const TICK_VOZ_MS = 1000
528let rutaVoz: string | null = null
529let vocesEnCurso = 0
530let vozActual: VozEnCurso | null = null
531let frasesVoz: string[] = []
532let fraseVoz = -1
533
534async function iniciarVoz($: EngineInterface): Promise<void> {
535 try {
536 const r = await $.process.run(['node', `${$.plugin.root}/hooks/buzon.js`, 'mod-voz'])
537 const v = r.exitCode === 0 ? (JSON.parse(r.stdout) as { voz?: unknown }).voz : null
538 rutaVoz = typeof v === 'string' && v ? v : null
539 } catch {
540 rutaVoz = null
541 }
542}
543
544// ----------------------------------------------------------------- FEAT-121
545
546/**
547 * Avisos de la red con toasts: un nodo que se cae, el daemon que deja de
548 * responder, un nodo con otra versión. Cada aviso sale una vez cuando aparece
549 * y, si se arregla solo (un nodo que vuelve), otra al irse. Los datos son los
550 * de la sección «Red» del panel (`panel.js red`); un error al leer no decide.
551 */
552const PRIMERA_RED_MS = 20_000
553const TICK_RED_MS = 5 * 60_000
554let avisosRed: Map<string, AvisoRed> | null = null
555let tickRed: { cancel(): void } | null = null
556
557async function mirarRed($: EngineInterface): Promise<void> {
558 const root = raizSesion || (await $.session.root())
559 const r = await $.process.run(['node', `${$.plugin.root}/hooks/panel.js`, 'red', root ?? '.'])
560 if (r.exitCode !== 0) return
561 const red = (JSON.parse(r.stdout) as { red?: RedPanel | null }).red
562 if (red == null) return
563 const nuevos = new Map(avisosDeRed(red).map((a) => [a.clave, a] as const))
564 const previos = avisosRed
565 avisosRed = nuevos
566 for (const [clave, a] of nuevos) if (!previos?.has(clave)) $.ui.toast(a.texto, { timeoutMs: 10_000 })
567 if (previos) for (const [clave, a] of previos) if (!nuevos.has(clave) && a.recuperado) $.ui.toast(a.recuperado, { timeoutMs: 6_000 })
568}
569
570function iniciarRed($: EngineInterface): void {
571 avisosRed = null
572 $.clock.after(PRIMERA_RED_MS, () => { void mirarRed($).catch(() => {}) })
573 if (!tickRed) tickRed = $.clock.every(TICK_RED_MS, () => { void mirarRed($).catch(() => {}) })
574}
575
576// ----------------------------------------------------------------- FEAT-135
577
578/**
579 * Avisos de fondo con toasts (lógica en `avisos-fondo.ts`): un fan-out o un
580 * lote que termina, una cuota que se libera (o la propia que cruza el 90 %) y
581 * un mensaje nuevo en la banda. El fan-out y los mensajes usan sus ticks; los
582 * lotes y la cuota, `panel.js avisos` cada minuto, aunque el panel esté
583 * cerrado. Lo que esta sesión está esperando no se avisa: llega por la tool.
584 * Hasta el primer `panel.js avisos` no se sabe qué tipos quiere el usuario
585 * (`background_toasts`), así que no sale ninguno.
586 */
587const PRIMEROS_AVISOS_MS = 20_000
588const TICK_AVISOS_MS = 60_000
589const PROPIA_MS = 2 * 60_000
590const TOOLS_CON_FIN = ['agy_fanout', 'agy_lote'] as const
591const finesPropios = new Map<string, number>()
592let tiposAviso: Set<TipoAviso> | null = null
593let estadoFanoutAv: EstadoFanout | undefined
594let estadoLotesAv: EstadoLotes | undefined
595let estadoCuotaAv: EstadoCuota | undefined
596let estadoMensajesAv: EstadoMensajes | undefined
597let tickAvisos: { cancel(): void } | null = null
598let mirandoAvisos = false
599
600function avisar($: EngineInterface, avisos: Aviso[]): void {
601 if (!tiposAviso) return
602 for (const a of avisos) if (tiposAviso.has(a.tipo)) $.ui.toast(a.texto, { timeoutMs: a.timeoutMs })
603}
604
605function esPropia(tool: (typeof TOOLS_CON_FIN)[number], ahora: number): boolean {
606 for (const l of llamadas.values()) if (l.tool.endsWith(tool)) return true
607 const fin = finesPropios.get(tool)
608 return fin !== undefined && ahora - fin < PROPIA_MS
609}
610
611async function avisarFanout($: EngineInterface, fan: FanoutPanel | null): Promise<void> {
612 const r = avisosFanout(estadoFanoutAv, fan, esPropia('agy_fanout', await $.clock.now()))
613 estadoFanoutAv = r.estado
614 avisar($, r.avisos)
615}
616
617async function mirarAvisos($: EngineInterface): Promise<void> {
618 if (mirandoAvisos) return
619 mirandoAvisos = true
620 try {
621 const root = raizSesion || (await $.session.root())
622 const r = await $.process.run(['node', `${$.plugin.root}/hooks/panel.js`, 'avisos', root ?? '.'])
623 if (r.exitCode !== 0) return
624 const d = JSON.parse(r.stdout) as { lotes?: LoteAviso[] | null; cuota?: CuotaPanel | null; propia?: string | null; tipos?: unknown }
625 tiposAviso = tiposDe(d.tipos)
626 const ahora = await $.clock.now()
627 if (Array.isArray(d.lotes)) {
628 const x = avisosLotes(estadoLotesAv, d.lotes, esPropia('agy_lote', ahora))
629 estadoLotesAv = x.estado
630 avisar($, x.avisos)
631 }
632 // Sin dato no decide: una línea de base vacía haría avisar de golpe lo que ya estaba alto.
633 if (d.cuota && typeof d.cuota === 'object') {
634 const x = avisosCuota(estadoCuotaAv, d.cuota, ahora, d.propia ?? null)
635 estadoCuotaAv = x.estado
636 avisar($, x.avisos)
637 }
638 } finally {
639 mirandoAvisos = false
640 }
641}
642
643function iniciarAvisos($: EngineInterface): void {
644 tiposAviso = null
645 estadoFanoutAv = estadoLotesAv = estadoCuotaAv = estadoMensajesAv = undefined
646 $.clock.after(PRIMEROS_AVISOS_MS, () => { void mirarAvisos($).catch(() => {}) })
647 if (!tickAvisos) tickAvisos = $.clock.every(TICK_AVISOS_MS, () => { void mirarAvisos($).catch(() => {}) })
648}
649
650// ----------------------------------------------------------------- FEAT-117
651
652/**
653 * Fase 1: medir qué pasos podrían ir a Haiku 5.5 (`haiku-medicion.ts`), sin
654 * desviar ninguno. Se acumula en `$.store` (entre sesiones) y se guarda al
655 * cerrar cada turno del loop principal. `/lagrange-haiku` lo muestra.
656 */
657const CLAVE_MEDICION = 'medicion-haiku'
658let medicion: Medicion = MEDICION_VACIA
659let medicionSucia = false
660// Las tools que pidió el último paso de cada loop (`principal` o el id del subagente).
661const pedidasPorLoop = new Map<string, string[]>()
662// El tipo de cada subagente, por su id (lo da `agent.spawn`).
663const tipoDeAgente = new Map<string, string>()
664
665async function medirPaso($: EngineInterface, e: { agentId?: string; index: number }, r: { toolUses?: ReadonlyArray<{ name: string }>; usage?: unknown } | undefined): Promise<void> {
666 const loop = e.agentId ?? 'principal'
667 const previas = e.index === 0 ? null : (pedidasPorLoop.get(loop) ?? null)
668 const pedidas = (r?.toolUses ?? []).map((u) => u.name)
669 pedidasPorLoop.set(loop, pedidas)
670 const clase = clasificar({ tipoAgente: e.agentId ? tipoDeAgente.get(e.agentId) ?? null : null, enSubagente: Boolean(e.agentId), previas, pedidas })
671 medicion = sumarPaso(medicion, clase, (r?.usage ?? null) as never, await $.clock.now())
672 medicionSucia = true
673}
674
675async function guardarMedicion($: EngineInterface): Promise<void> {
676 if (!medicionSucia) return
677 medicionSucia = false
678 await $.store.set(CLAVE_MEDICION, medicion)
679}
680
681// El tick vive solo mientras suena una voz: arranca con la primera y se cancela con la última.
682let tickVoz: { cancel(): void } | null = null
683
684function empezarVoz($: EngineInterface): void {
685 vocesEnCurso += 1
686 vozActual = null
687 if (!tickVoz) tickVoz = $.clock.every(TICK_VOZ_MS, () => { void mirarVoz($).catch(() => {}) })
688 $.ui.invalidate('ui.render')
689}
690
691/**
692 * Con una voz en curso: lee el aviso hasta encontrarlo (una vez por llamada) y después cuenta la frase
693 * con el reloj. Redibuja solo si cambió la voz o la frase.
694 */
695async function mirarVoz($: EngineInterface): Promise<void> {
696 if (vocesEnCurso === 0) return
697 const ahora = await $.clock.now()
698 if (!vozActual && rutaVoz) {
699 let crudo: string | null = null
700 try { crudo = await $.fs.read(rutaVoz) } catch {}
701 if (vocesEnCurso === 0) return
702 const v = leerVoz(crudo, ahora)
703 if (v) {
704 vozActual = v
705 frasesVoz = frases(v.texto)
706 fraseVoz = -1
707 }
708 }
709 if (vozActual && vozActual.hasta < ahora) {
710 vozActual = null
711 frasesVoz = []
712 }
713 const i = vozActual ? fraseEn(frasesVoz, ahora - vozActual.desde, vozActual.duracionMs) : -1
714 if (i !== fraseVoz) {
715 fraseVoz = i
716 $.ui.invalidate('ui.render')
717 }
718}
719
720function terminarVoz($: EngineInterface): void {
721 vocesEnCurso = Math.max(0, vocesEnCurso - 1)
722 if (vocesEnCurso > 0) return
723 try { tickVoz?.cancel() } catch {}
724 tickVoz = null
725 vozActual = null
726 frasesVoz = []
727 fraseVoz = -1
728 $.ui.invalidate('ui.render')
729}
730
731/** FEAT-120 — La fila de subtítulos, o `null`. */
732function subtituloActual(): string | null {
733 return vozActual && fraseVoz >= 0 && frasesVoz[fraseVoz] ? filaDeSubtitulo(vozActual, frasesVoz[fraseVoz]) : null
734}
735let contadorLlamadas = 0
736
737const COLOR_DE_TONO: Record<Tono, string | undefined> = { normal: undefined, ok: 'green', error: 'red', tenue: undefined }
738
739function bandaViva(ahora: number): boolean {
740 return hayAlgo({ llamadas: [...llamadas.values()], cierres, fanout: fanoutBanda, ahora })
741}
742
743/** Cierra una llamada: la saca de las en curso y deja su cierre. Nunca lanza. */
744async function cerrarLlamada($: EngineInterface, clave: string, salida: { texto?: unknown; fallo: boolean }): Promise<void> {
745 try {
746 const l = llamadas.get(clave)
747 llamadas.delete(clave)
748 if (!l) return
749 const ahora = await $.clock.now()
750 cierres = [...cierres.filter((c) => ahora < c.hasta), cierreDe(l.tool, salida, l.desde, ahora)]
751 // FEAT-135 — Lo que termine de esta tool en los próximos minutos ya llegó como resultado.
752 for (const t of TOOLS_CON_FIN) if (l.tool.endsWith(t)) finesPropios.set(t, ahora)
753 $.ui.invalidate('ui.render')
754 } catch {}
755}
756
757// ----------------------------------------------------------------- gates (FEAT-114)
758
759// La corrida en curso o la última: variables del módulo, como la banda. Un reload la pierde.
760let gates: CorridaGates | null = null
761let gatesStream: AsyncGenerator<unknown, unknown> | null = null
762let gatesPlazo: { cancel: () => void } | null = null
763let gatesRaiz = ''
764
765function gatesCorriendo(): boolean {
766 return gates !== null && gates.fin === null
767}
768
769/** FEAT-114 — Registra `lagrange-gates` solo si la raíz tiene `scripts/gates.mjs`. */
770async function iniciarGates($: EngineInterface): Promise<void> {
771 gatesRaiz = await $.session.root()
772 if (!(await $.fs.exists(`${gatesRaiz}/scripts/gates.mjs`))) return
773 await $.command.register({ name: 'lagrange-gates', description: 'Corre las puertas (scripts/gates.mjs) sin turno de Claude; el avance en el panel de Lagrange. quick: sin el bridge; detener: corta la corrida.' })
774}
775
776/** Cierra la corrida una sola vez: libera la bandera, avisa y redibuja. Nunca lanza. */
777async function terminarGates($: EngineInterface, c: CorridaGates, fin: FinGates): Promise<void> {
778 try {
779 if (c.fin) return
780 c.fin = fin
781 gatesPlazo?.cancel()
782 gatesPlazo = null
783 if (gates === c) gatesStream = null
784 $.ui.toast(`Gates: ${lineaDeGates(c, await $.clock.now())}`)
785 $.ui.invalidate('ui.render')
786 } catch {}
787}
788
789/** Corta a pedido o por plazo: la bandera se libera al momento y el stream se cierra (mata al hijo). */
790async function cortarGates($: EngineInterface, c: CorridaGates, estado: 'cortada' | 'detenida'): Promise<void> {
791 const stream = gates === c ? gatesStream : null
792 await terminarGates($, c, { estado, duracionMs: Math.max(0, (await $.clock.now()) - c.desde), code: null })
793 try { void stream?.return(undefined) } catch {}
794}
795
796/** El hijo: lee las líneas a medida que llegan; el veredicto sale de `stream.result`. */
797async function correrGates($: EngineInterface, c: CorridaGates, argv: string[]): Promise<void> {
798 let recibio = false
799 try {
800 const stream = $.process.spawn({ argv, cwd: gatesRaiz })
801 gatesStream = stream as AsyncGenerator<unknown, unknown>
802 gatesPlazo = $.clock.after(PLAZO_GATES_MS, () => { void cortarGates($, c, 'cortada') })
803 let resto = ''
804 for await (const trozo of stream) {
805 recibio = true
806 if (c.fin) break
807 if (trozo.stream !== 'stdout') continue
808 const p = partirLineas(resto, trozo.text)
809 resto = p.resto
810 const ahora = await $.clock.now()
811 for (const l of p.lineas) procesarLinea(c, l, ahora)
812 $.ui.invalidate('ui.render')
813 }
814 if (c.fin) return
815 if (resto) procesarLinea(c, resto, await $.clock.now())
816 const r = await stream.result
817 await terminarGates($, c, finPorCodigo(r.code, c.desde, await $.clock.now()))
818 } catch {
819 const ahora = await $.clock.now().catch(() => c.desde)
820 await terminarGates($, c, recibio ? finPorCodigo(null, c.desde, ahora) : { estado: 'no-arranco', duracionMs: Math.max(0, ahora - c.desde), code: null })
821 }
822}
823
824// ----------------------------------------------------------------- vista previa (FEAT-112)
825
826const MEDIR_MS = 5000
827const MAX_SUELTAS = 200
828
829/** git de solo lectura, con plazo; rechaza si no termina a tiempo (el caso queda «sin medir»). */
830async function gitLectura($: EngineInterface, dir: string, args: string[]): Promise<{ code: number; out: string }> {
831 const r = await $.process.run(['git', '-C', dir, ...args], { timeoutMs: MEDIR_MS })
832 return { code: r.exitCode, out: r.stdout }
833}
834
835/** Adónde llega una ruta de verdad (links seguidos), o la ruta misma si no se puede saber. */
836async function realDe($: EngineInterface, ruta: string): Promise<string> {
837 try { return normalizar((await $.fs.stat(ruta, { resolve: true })).realPath ?? ruta) } catch { return ruta }
838}
839
840/** De las entradas sueltas (ignoradas o sin seguimiento) de `top`, las que son links. Rutas absolutas. */
841async function linksEn($: EngineInterface, top: string, sueltas: string[]): Promise<Array<{ ruta: string; destino: string | null }>> {
842 const topReal = await realDe($, top)
843 const links: Array<{ ruta: string; destino: string | null }> = []
844 // Más de las que se pueden mirar: no se trunca en silencio, el caso queda «sin medir» (pregunta igual).
845 if (sueltas.length > MAX_SUELTAS) throw new Error('demasiadas entradas')
846 for (const s of sueltas) {
847 const abs = unir(top, s)
848 try {
849 const st = await $.fs.stat(abs, { resolve: true })
850 if (esLink(unir(topReal, s), st)) links.push({ ruta: abs, destino: st.realPath ? normalizar(st.realPath) : null })
851 } catch {}
852 }
853 return links
854}
855
856async function medirWorktree($: EngineInterface, base: string, c: CasoWorktree): Promise<Hallazgo | null> {
857 const lista = await gitLectura($, base, ['worktree', 'list', '--porcelain'])
858 const pedida = unir(base, c.ruta)
859 const rutas = lista.code === 0 ? rutasDeWorktrees(lista.out) : []
860 const fin = '/' + normalizar(c.ruta).toLowerCase()
861 let ruta = rutas.find((r) => mismaRuta(r, pedida)) ?? rutas.find((r) => normalizar(r).toLowerCase().endsWith(fin)) ?? null
862 if (!ruta) {
863 // Sin worktree registrado: si la carpeta existe se mide igual; si no, no hay nada que quitar.
864 try { if ((await $.fs.stat(pedida)).kind !== 'dir') return null } catch { return null }
865 ruta = pedida
866 }
867 const st = await gitLectura($, ruta, ['status', '--porcelain', '--ignored'])
868 if (st.code !== 0) throw new Error('status')
869 const { sueltas, cambios } = leerStatus(st.out)
870 const links = await linksEn($, ruta, sueltas)
871 return links.length || (c.force && cambios > 0) ? { tipo: 'worktree', ruta, links, cambios, force: c.force } : null
872}
873
874async function medirBorrado($: EngineInterface, base: string, c: CasoBorrado): Promise<Hallazgo | null> {
875 const links: Array<{ ruta: string; destino: string | null }> = []
876 const versionados: Array<{ ruta: string; n: number }> = []
877 for (const r of c.rutas) {
878 if (tieneComodin(r)) throw new Error('comodín')
879 const abs = unir(base, r)
880 let st
881 try { st = await $.fs.stat(abs, { resolve: true }) } catch { continue }
882 const corte = abs.lastIndexOf('/')
883 const padreReal = await realDe($, corte > 0 ? abs.slice(0, corte) : '/')
884 if (esLink(unir(padreReal, abs.slice(corte + 1)), st)) { links.push({ ruta: abs, destino: st.realPath ? normalizar(st.realPath) : null }); continue }
885 // Desde la raíz: sirve para archivos y carpetas. Fuera de un repo, git sale con 128: sin versionados.
886 const ls = await gitLectura($, base, ['ls-files', '-z', '--', abs])
887 if (ls.code === 0) {
888 const n = ls.out.split('\0').filter(Boolean).length
889 if (n) versionados.push({ ruta: abs, n })
890 }
891 if (st.kind === 'dir') {
892 const top = await gitLectura($, abs, ['rev-parse', '--show-toplevel'])
893 if (top.code === 0) {
894 const t = normalizar(top.out.trim())
895 const s = await gitLectura($, t, ['status', '--porcelain', '--ignored', '--', abs])
896 if (s.code === 0) links.push(...(await linksEn($, t, leerStatus(s.out).sueltas)).filter((l) => !mismaRuta(l.ruta, abs)))
897 }
898 }
899 }
900 return links.length || versionados.length ? { tipo: 'borrado', links, versionados } : null
901}
902
903async function medirPush($: EngineInterface, base: string, c: CasoPush): Promise<Hallazgo | null> {
904 let remoto = c.remoto
905 let rama = c.destino
906 if (!rama || rama === 'HEAD') rama = (await gitLectura($, base, ['rev-parse', '--abbrev-ref', 'HEAD'])).out.trim()
907 if (!remoto) {
908 const up = await gitLectura($, base, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])
909 remoto = up.code === 0 ? up.out.trim().split('/')[0] : 'origin'
910 }
911 const ref = `${remoto}/${rama}`
912 // Rama sin copia remota (nueva): no hay commits que perder.
913 if ((await gitLectura($, base, ['rev-parse', '--verify', '--quiet', `refs/remotes/${ref}`])).code !== 0) return null
914 const desde = c.origen && c.origen !== 'HEAD' ? c.origen : 'HEAD'
915 const n = await gitLectura($, base, ['rev-list', '--count', `${desde}..${ref}`])
916 if (n.code !== 0) throw new Error('rev-list')
917 const commits = Number(n.out.trim())
918 return commits > 0 ? { tipo: 'push', ref, commits } : null
919}
920
921const ETIQUETA: Record<Caso['tipo'], string> = { worktree: 'git worktree remove', borrado: 'borrado recursivo', push: 'git push forzado', node: 'matar node' }
922
923/** Mide cada caso; una medición que falla o se pasa de plazo deja «sin medir» (pregunta igual). */
924async function medirCasos($: EngineInterface, casos: Caso[], raiz: string): Promise<Hallazgo[]> {
925 const hallazgos: Hallazgo[] = []
926 for (const c of casos) {
927 try {
928 if (c.tipo === 'node') { hallazgos.push({ tipo: 'node' }); continue }
929 const base = c.dir ? unir(raiz, c.dir) : raiz
930 const h = c.tipo === 'worktree' ? await medirWorktree($, base, c) : c.tipo === 'borrado' ? await medirBorrado($, base, c) : await medirPush($, base, c)
931 if (h) hallazgos.push(h)
932 } catch {
933 hallazgos.push({ tipo: 'sin-medir', comando: ETIQUETA[c.tipo] })
934 }
935 }
936 return hallazgos
937}
938
939// ----------------------------------------------------------------- guardas
940
941const RECARGA_GUARDAS_MS = 10_000
942
943// Las reglas vigentes y la raíz de la sesión: las reinicia cada `session.start`.
944// `tool.call` solo lee esto, en memoria: nada de I/O por comando.
945let guardas: Guarda[] = []
946let raizSesion = ''
947
948/** FEAT-102 — Carga las guardas de `~/.claude/antigravity.json` y las recarga si el archivo cambia. */
949async function iniciarGuardas($: EngineInterface): Promise<void> {
950 guardas = []
951 raizSesion = await $.session.root()
952 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
953 if (!home) return
954 const ruta = `${home}/.claude/antigravity.json`
955 let visto = -1
956 let avisoRoto = false
957 let ocupado = false
958 const cargar = async () => {
959 let mtime: number
960 try { mtime = (await $.fs.stat(ruta)).mtimeMs } catch { guardas = []; visto = -1; return }
961 if (mtime === visto) return
962 visto = mtime
963 let datos: unknown
964 try {
965 datos = JSON.parse(await $.fs.read(ruta))
966 } catch {
967 guardas = []
968 if (!avisoRoto) { avisoRoto = true; await $.ui.log('lagrange: ~/.claude/antigravity.json no se pudo leer; guardas apagadas') }
969 return
970 }
971 avisoRoto = false
972 const lista = datos && typeof datos === 'object' ? (datos as Record<string, unknown>).guardas : undefined
973 guardas = validarGuardas(lista).guardas
974 }
975 await cargar()
976 $.clock.every(RECARGA_GUARDAS_MS, () => {
977 if (ocupado) return
978 ocupado = true
979 void cargar().catch(() => {}).finally(() => { ocupado = false })
980 })
981}
982
983// ----------------------------------------------------------------- identidad (FEAT-123)
984
985// La de esta cuenta, o `null`: la reinicia cada `session.start`.
986let identidad: Identidad | null = null
987
988/** Por su lado, no dentro de las guardas: si una falla, la otra sigue. Solo presentación: sin avisos. */
989async function iniciarIdentidad($: EngineInterface): Promise<void> {
990 identidad = null
991 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
992 if (!home) return
993 const configDir = await $.env.get('CLAUDE_CONFIG_DIR')
994 const ruta = `${home}/.claude/antigravity.json`
995 let visto = -1
996 let ocupado = false
997 const cargar = async () => {
998 let nueva: Identidad | null = null
999 try {
1000 const mtime = (await $.fs.stat(ruta)).mtimeMs
1001 if (mtime === visto) return
1002 visto = mtime
1003 nueva = identidadDeConfig(JSON.parse(await $.fs.read(ruta)), { configDir, home })
1004 } catch {
1005 visto = -1
1006 }
1007 if (identidadesIguales(nueva, identidad)) return
1008 identidad = nueva
1009 $.ui.invalidate('ui.render')
1010 }
1011 await cargar()
1012 $.clock.every(RECARGA_GUARDAS_MS, () => {
1013 if (ocupado) return
1014 ocupado = true
1015 void cargar().catch(() => {}).finally(() => { ocupado = false })
1016 })
1017}
1018
1019// ----------------------------------------------------------------- handoff (FEAT-118)
1020
1021const VENTANA_VIGENCIA_MS = 10 * 60_000
1022const COLOR_DE_HANDOFF: Record<FilaHandoff['tono'], string | undefined> = { aviso: 'yellow', urgente: 'red', normal: undefined, ok: 'green', error: 'red' }
1023
1024// Lo reinician `session.start` y `session.end` (un `/clear` no dispara `session.start`).
1025let handoff: Handoff = nuevoHandoff()
1026let ventanaCompactacion: { tokens: number; en: number } | null = null
1027let ventanaPidiendo = false
1028let ultimoContexto: { tokens?: number; window: number } | null = null
1029let homeHandoff = ''
1030let sembrandoEn = -Infinity
1031
1032function reiniciarHandoff(): void {
1033 sembrandoEn = -Infinity
1034 handoff = nuevoHandoff()
1035 ventanaCompactacion = null
1036 ultimoContexto = null
1037}
1038
1039/** Decide con la ventana de compactación si ya está, o con la del modelo mientras tanto (avisa tarde, nunca de más). */
1040function aplicarContexto($: EngineInterface): void {
1041 if (!ultimoContexto) return
1042 handoff = medirContexto(handoff, ultimoContexto.tokens, ventanaCompactacion?.tokens ?? ultimoContexto.window)
1043 $.ui.invalidate('ui.render')
1044}
1045
1046/** La ventana de compactación, en segundo plano: un pedido en vuelo, vigente 10 min. Al llegar, recalcula. */
1047async function pedirVentana($: EngineInterface): Promise<void> {
1048 const ahora = await $.clock.now()
1049 if (ventanaPidiendo || (ventanaCompactacion && ahora - ventanaCompactacion.en < VENTANA_VIGENCIA_MS)) return
1050 ventanaPidiendo = true
1051 try {
1052 const raw = (await $.session.usage({ breakdown: 'summary' }))?.context?.breakdown?.rawMaxTokens
1053 if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) ventanaCompactacion = { tokens: raw, en: ahora }
1054 } catch {} finally {
1055 ventanaPidiendo = false
1056 }
1057 aplicarContexto($)
1058}
1059
1060/**
1061 * FEAT-146 — Sin medición todavía (sesión retomada, mod recargado: `session.measure` llega recién tras un turno),
1062 * la banda siembra el contexto con `$.session.usage()` simple, que no gasta. Un pedido a la vez, cada 30 s a lo sumo.
1063 */
1064const SEMBRAR_CADA_MS = 30_000
1065async function sembrarContexto($: EngineInterface, ahora: number): Promise<void> {
1066 if (ultimoContexto || ahora - sembrandoEn < SEMBRAR_CADA_MS) return
1067 sembrandoEn = ahora
1068 const c = (await $.session.usage())?.context
1069 if (ultimoContexto || typeof c?.tokens !== 'number' || typeof c?.window !== 'number') return
1070 ultimoContexto = { tokens: c.tokens, window: c.window }
1071 aplicarContexto($)
1072 await pedirVentana($)
1073}
1074
1075/** BE-122 — qué hacer cuando `$.model.fork` responde `nothing-to-fork`. */
1076const NADA_QUE_FORKEAR = 'esta sesión todavía no respondió en este proceso (nueva, tras /clear o retomada): mandá un mensaje y reintentá, o pedí agy_session_summary'
1077
1078/**
1079 * FEAT-103 — `/lagrange-resumen`: sin `si` solo estima; con `si` hace el fork y guarda a disco. A la conversación
1080 * vuelve la ruta y el costo, nunca el documento. Fuera del hook porque también la usa el botón de FEAT-118:
1081 * `$.command.run` desde el mod no pasa por los hooks del propio plugin.
1082 */
1083async function generarResumen($: EngineInterface, args: string): Promise<{ text: string }> {
1084 try {
1085 const { foco, valido, confirmado } = leerArgs(args)
1086 if (!valido) return { text: `Foco desconocido: ${foco}. Válidos: ${FOCOS.join(', ')}.` }
1087 const modelo = await $.session.model()
1088 if (!confirmado) {
1089 const uso = await $.session.usage()
1090 return { text: textoDeEstimacion(foco, uso?.context?.tokens, modelo) }
1091 }
1092 const root = await $.session.root()
1093 const meta: MetaResumen = {
1094 sessionId: await $.session.id(),
1095 proyecto: root,
1096 rama: await leerRama($, root),
1097 modelo,
1098 inicio: (await $.session.usage())?.startedAt ?? null,
1099 fin: await $.clock.now()
1100 }
1101 const f = await $.model.fork({ prompt: promptDeResumen(foco as Foco, meta) })
1102 const pie = pieDeCosto((f as { usage?: Parameters<typeof pieDeCosto>[0] }).usage)
1103 if (!f.isAnswered) {
1104 const motivo = (f as { reason?: string }).reason
1105 // BE-122 — el fork repite la última request del hilo principal en este proceso, no lee el transcript: en una
1106 // sesión nueva, tras /clear o retomada, no hay nada que repetir hasta que termine un turno.
1107 const dicho = motivo === 'nothing-to-fork' ? NADA_QUE_FORKEAR : `el fork no respondió (${motivo})`
1108 return { text: `No se generó el resumen: ${dicho}.\n${pie}` }
1109 }
1110 const texto = (f as { text: string }).text
1111 const v = validarResumen(texto)
1112 if (!v.ok) return { text: `No se guardó el resumen: ${v.motivo}.\n${pie}` }
1113 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
1114 if (!home) return { text: `No se guardó el resumen: no se encontró la carpeta del usuario.\n${pie}` }
1115 const ruta = archivoDeResumen(home, meta)
1116 await $.fs.write(ruta, frontmatter(meta) + texto.trim() + '\n')
1117 return { text: `Resumen (${foco}) guardado en ${ruta}\n${pie}` }
1118 } catch (err) {
1119 return { text: `No se pudo generar el resumen: ${err instanceof Error ? err.name : 'error'}.` }
1120 }
1121}
1122
1123/** [h]: corre el resumen de handoff y deja el resultado en la fila. Nunca se queda en «generando». */
1124async function guardarHandoff($: EngineInterface): Promise<void> {
1125 if (handoff.fase === 'generando') return
1126 handoff = empezar(handoff, await $.clock.now())
1127 $.ui.invalidate('ui.render')
1128 let texto = ''
1129 try {
1130 texto = (await generarResumen($, 'handoff si')).text
1131 } catch {
1132 texto = 'No se pudo generar el handoff.'
1133 }
1134 handoff = terminar(handoff, texto, await $.clock.now(), homeHandoff)
1135 $.ui.invalidate('ui.render')
1136}
1137
1138/** FEAT-146 — [k]: compacta como `/compact`, con instrucciones; nunca se queda en «compactando». Nunca se llama sola. */
1139async function compactar($: EngineInterface): Promise<void> {
1140 if (handoff.fase === 'generando' || handoff.fase === 'compactando') return
1141 handoff = empezarCompactacion(handoff, await $.clock.now())
1142 $.ui.invalidate('ui.render')
1143 let r: { resultado?: unknown; error?: unknown }
1144 try {
1145 r = { resultado: await $.session.compact({ instructions: instruccionesDeCompactacion(handoff.ruta) }) }
1146 } catch (error) {
1147 r = { error: error ?? new Error('compact') }
1148 }
1149 handoff = terminarCompactacion(handoff, r, await $.clock.now())
1150 $.ui.invalidate('ui.render')
1151}
1152
1153// ----------------------------------------------------------------- cuota
1154
1155/** FEAT-111 — Un JSON del disco, o `null` si falta o no se puede leer. */
1156async function leerJson($: EngineInterface, ruta: string): Promise<unknown> {
1157 try { return JSON.parse(await $.fs.read(ruta)) } catch { return null }
1158}
1159
1160/** FEAT-111 — `{ deny }` si el usuario cancela por cuota baja; `null` para seguir. Nunca lanza. */
1161async function frenoDeCuota($: EngineInterface, tool: unknown, input: unknown): Promise<{ deny: string } | null> {
1162 try {
1163 const homes = [await $.env.get('USERPROFILE').catch(() => undefined), await $.env.get('HOME').catch(() => undefined)].filter((h): h is string => Boolean(h))
1164 if (!homes.length) return null
1165 const raiz = raizSesion || (await $.session.root())
1166 let global: unknown = null
1167 let uso: unknown = null
1168 for (const h of homes) {
1169 global ??= await leerJson($, `${h}/.claude/antigravity.json`)
1170 uso ??= await leerJson($, `${h}/.claude/antigravity-usage.json`)
1171 }
1172 const porDefecto = modeloPorDefecto(raiz ? await leerJson($, `${raiz}/.claude/antigravity.json`) : null, global, await $.env.get('AGY_MODEL').catch(() => undefined))
1173 const pedido = modelosDelPedido(tool, input, porDefecto)
1174 if (!pedido) return null
1175 const ahora = await $.clock.now()
1176 const x = bajo(pedido.modelos, restantes(uso, ahora))
1177 if (!x) return null
1178 // FEAT-131 — Las sondas del lote con Claude, donde las guarda el bridge (bridgeDataDirPath).
1179 let cuentas: string[] = []
1180 if (pedido.tool === 'agy_lote') {
1181 const explicito = await $.env.get('TELEGRAM_BRIDGE_DATA_DIR').catch(() => undefined)
1182 const local = await $.env.get('LOCALAPPDATA').catch(() => undefined)
1183 const dir = explicito && explicito.trim() ? explicito.trim() : (local ? `${local}/antigravity-telegram-bridge` : null)
1184 cuentas = cuentasClaudeOfrecibles(pedido.tool, dir ? await leerJson($, `${dir}/lotes-sondas-claude.json`) : null, uso, ahora)
1185 }
1186 let respuesta: string
1187 try {
1188 respuesta = await $.ui.ask(pregunta(x, pedido.tool, pedido.modelos.length), { options: [SEGUIR, ...cuentas.map(opcionClaude), CANCELAR], header: 'Cuota agy' })
1189 } catch {
1190 return null // Sin nadie a quién preguntar (claude -p) o descartado: no se retiene nada.
1191 }
1192 const d = decision(respuesta, x, cuentas)
1193 return 'deny' in d ? d : null
1194 } catch {
1195 return null
1196 }
1197}
1198
1199type Ventana = { kind: string; percentUsed: number; resetsAt?: string }
1200hooks/panel-texto.ts 341 lines1import type { FotoPanel, VentanaCuota, MetaPanel, RedPanel } from '../types'
2import type { Guarda } from './guardas.ts'
3import { cabecera, tiras, porTipo, TOPE_FILAS_PANEL } from './turno-texto.ts'
4import type { TurnoCerrado } from './turno-texto.ts'
5
6/**
7 * FEAT-101 — La foto del panel en filas, sin `$`: la dibuja el Pane y la
8 * devuelve el comando `lagrange-panel`. Las cuotas guardan la fracción usada
9 * (agy la invierte desde "% restante" en `cuota-agy.js`).
10 *
11 * FEAT-106 — Cada fila es una lista de segmentos con estilo. El Pane los dibuja
12 * con color; `textoDeFoto` concatena el texto (vuelve a la conversación: nada
13 * de ANSI). Un segmento `soloTexto` (el emoji de la cuota) va solo al texto.
14 */
15
16export type Segmento = { texto: string; color?: string; tenue?: boolean; negrita?: boolean; soloTexto?: boolean }
17export type Bloque = { titulo: string; filas: Segmento[][] }
18
19const s = (texto: string, estilo: Omit<Segmento, 'texto'> = {}): Segmento => ({ texto, ...estilo })
20const tenue = (texto: string): Segmento => s(texto, { tenue: true })
21const fila = (texto: string): Segmento[] => [s(texto)]
22const filaTenue = (texto: string): Segmento[] => [tenue(texto)]
23
24const fecha = (v: string | null | undefined) => (typeof v === 'string' && !Number.isNaN(Date.parse(v)) ? Date.parse(v) : null)
25
26// BE-092 — Una ventana que ya pasó su reinicio no tiene porcentaje que valga, y
27// cada fila dice de cuándo es el dato.
28function hace(ms: number): string {
29 const min = Math.floor(Math.max(0, ms) / 60_000)
30 if (min < 60) return `${min} min`
31 const h = Math.floor(min / 60)
32 return h < 48 ? `${h} h` : `${Math.floor(h / 24)} d`
33}
34
35// FEAT-106 — Los umbrales de la statusline (`statusline-base.js`): verde < 50,
36// amarillo < 75, ámbar < 90, rojo ≥ 90. Nombres de Ink o hex.
37const CELDAS = 10
38const VIEJO_MS = 6 * 60 * 60 * 1000
39
40function colorDe(p: number): string {
41 if (p >= 90) return 'red'
42 if (p >= 75) return '#ff8700'
43 if (p >= 50) return 'yellow'
44 return 'green'
45}
46
47function emojiDe(p: number | null): string {
48 if (p === null) return '⚪'
49 if (p >= 90) return '🔴'
50 if (p >= 75) return '🟠'
51 if (p >= 50) return '🟡'
52 return '🟢'
53}
54
55function barra(p: number): string {
56 const llenas = Math.max(0, Math.min(CELDAS, Math.round(p / 10)))
57 return '█'.repeat(llenas) + '░'.repeat(CELDAS - llenas)
58}
59
60/** La celda de una ventana (lo que va después de `5h `): `████░░░░░░ 40%`, `░░░░░░░░░░ reiniciada` o `—`. Con el % que vale, o `null`. */
61function ventana(frac: number | null | undefined, resetea: string | null | undefined, ahora: number): Celda {
62 const r = fecha(resetea)
63 if (r !== null && ahora >= r) return { celda: [tenue(`${barra(0)} reiniciada`)], pct: null, resetea: null }
64 if (typeof frac !== 'number') return { celda: [tenue('—')], pct: null, resetea: null }
65 const p = Math.round(frac * 100)
66 const color = colorDe(p)
67 return { celda: [s(barra(p), { color }), s(' '), s(`${p}%`, { color })], pct: p, resetea: r }
68}
69
70/**
71 * FEAT-124 — El pronóstico: con la ventana de más uso en ≥ 75 % y su reinicio por venir,
72 * «· despeja HH:MM» (o «el DD/MM» a más de 24 h), del color de su punto. En un empate,
73 * el reinicio más tardío: recién ahí se despeja. Sin pct (reiniciada o sin dato), nada.
74 */
75export function pronostico(ventanas: readonly Celda[], ahora: number): Segmento | null {
76 // El mismo máximo que decide el punto de la fila: si esa ventana no tiene reinicio conocido, no se pronostica con otra.
77 const conPct = ventanas.filter((v): v is Celda & { pct: number } => v.pct !== null)
78 if (!conPct.length) return null
79 const peor = Math.max(...conPct.map((v) => v.pct))
80 if (peor < 75) return null
81 const reinicios = conPct.filter((v) => v.pct === peor && v.resetea !== null && v.resetea > ahora).map((v) => v.resetea as number)
82 if (!reinicios.length) return null
83 const r = Math.max(...reinicios)
84 const d = new Date(r)
85 const dos = (n: number) => String(n).padStart(2, '0')
86 const cuando = r - ahora > 24 * 60 * 60 * 1000 ? `el ${dos(d.getDate())}/${dos(d.getMonth() + 1)}` : `${dos(d.getHours())}:${dos(d.getMinutes())}`
87 // Dos espacios, como «(visto hace …)»: sin ese texto, no queda pegado al porcentaje de 7d.
88 return s(` · despeja ${cuando}`, { color: colorDe(peor) })
89}
90
91const anchoDe = (celda: Segmento[]) => celda.reduce((n, x) => n + x.texto.length, 0)
92
93/** La celda más el relleno (sin estilo) hasta el ancho de su columna (§8). */
94function rellenar(celda: Segmento[], ancho: number): Segmento[] {
95 const falta = ancho - anchoDe(celda)
96 return falta > 0 ? [...celda, s(' '.repeat(falta))] : celda
97}
98
99type Celda = { celda: Segmento[]; pct: number | null; resetea: number | null }
100type Celdas = { cinco: Celda; siete: Celda }
101type Anchos = { nombre: number; cinco: number; siete: number }
102
103/** Una fila de cuota: emoji (solo texto), nombre rellenado, las dos ventanas en columna y de cuándo es el dato. */
104function filaCuota(nombre: string, { cinco, siete }: Celdas, anchos: Anchos, ahora: number, vistoEn: string | null | undefined): Segmento[] {
105 const usos = [cinco.pct, siete.pct].filter((x): x is number => x !== null)
106 const peor = usos.length ? Math.max(...usos) : null
107 const visto = fecha(vistoEn)
108 const segs: Segmento[] = [
109 s(`${emojiDe(peor)} `, { soloTexto: true }), s(`${nombre.padEnd(anchos.nombre)} `),
110 s('5h '), ...rellenar(cinco.celda, anchos.cinco), s(' · '),
111 // Sin relleno al final de la fila: la celda de 7d solo se rellena si sigue "(visto hace …)".
112 s('7d '), ...(visto !== null ? rellenar(siete.celda, anchos.siete) : siete.celda)
113 ]
114 if (visto !== null) {
115 const edad = ahora - visto
116 // El amarillo reemplaza al tenue: combinados, en la terminal se lee mal.
117 segs.push(s(' '), edad > VIEJO_MS ? s(`(visto hace ${hace(edad)})`, { color: 'yellow' }) : tenue(`(visto hace ${hace(edad)})`))
118 }
119 const p = pronostico([cinco, siete], ahora)
120 if (p) segs.push(p)
121 return segs
122}
123
124// FEAT-105 — "vence en 25 min" / "vence en 3 h" / "vence en 2 d".
125function dentroDe(ms: number): string {
126 const min = Math.max(1, Math.ceil(ms / 60_000))
127 if (min < 60) return `${min} min`
128 const h = Math.floor(min / 60)
129 return h < 48 ? `${h} h` : `${Math.floor(h / 24)} d`
130}
131
132function cuando(iso: string): string {
133 const d = new Date(iso)
134 return `${d.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' })} ${d.toLocaleTimeString('es-AR', { hour: '2-digit', minute: '2-digit', hour12: false })}`
135}
136
137// BE-102 — Cómo se muestra cada grupo de cuota de agy; la clave (`grupos.claude_gpt` en uso-agy.js) no cambia.
138// El mismo mapa vive en mcp-server/lib/statusline-lagrange.js (CommonJS): si se agrega un grupo, en los dos.
139// Un grupo nuevo sale con su clave tal cual hasta que se lo nombre acá.
140const NOMBRE_GRUPO_AGY: Record<string, string> = { claude_gpt: 'claude/gpt' }
141
142type Extra = { guardas?: Guarda[]; metasPermitidos?: readonly string[]; turno?: TurnoCerrado | null }
143
144// ----------------------------------------------------------------- FEAT-126 metas
145
146/** Cuánto va, de 0 a 1: el tiempo transcurrido, el conteo o la condición. */
147export function progresoDeMeta(m: MetaPanel, ahora: number): number {
148 if (m.estado.cumplida) return 1
149 if (m.tipo === 'fecha') {
150 const ini = Date.parse(m.creada), fin = Date.parse(m.fin ?? '')
151 return Number.isFinite(ini) && Number.isFinite(fin) && fin > ini ? Math.max(0, Math.min(1, (ahora - ini) / (fin - ini))) : 0
152 }
153 if (m.tipo === 'conteo') return m.objetivo && m.estado.valor !== null ? Math.max(0, Math.min(1, m.estado.valor / m.objetivo)) : 0
154 return 0
155}
156
157/** «faltan 3 d 4 h», «7/20», «pendiente»; «cumplida» al llegar. */
158export function detalleDeMeta(m: MetaPanel, ahora: number): string {
159 if (m.estado.cumplida) return 'cumplida'
160 if (m.tipo === 'conteo') return `${m.estado.valor ?? '?'}/${m.objetivo}`
161 if (m.tipo === 'condicion') return 'pendiente'
162 const falta = Date.parse(m.fin ?? '') - ahora
163 if (!Number.isFinite(falta)) return '?'
164 const h = Math.max(0, Math.floor(falta / 3_600_000))
165 return h >= 24 ? `faltan ${Math.floor(h / 24)} d ${h % 24} h` : `faltan ${h} h ${Math.max(0, Math.floor((falta % 3_600_000) / 60_000))} min`
166}
167
168/** Sin aprobar: tiene comandos y alguno no está entre los que aprobó esta cuenta. */
169export function metaSinAprobar(m: MetaPanel, permitidos: readonly string[]): boolean {
170 return m.hashes.some((h) => !permitidos.includes(h))
171}
172
173export function filaDeMeta(m: MetaPanel, ahora: number, permitidos: readonly string[]): Segmento[] {
174 const p = progresoDeMeta(m, ahora)
175 const color = m.estado.cumplida ? 'green' : m.estado.enRiesgo ? 'yellow' : undefined
176 const segs: Segmento[] = [s(`${m.nombre} `, { negrita: true }), s(barra(p * 100), { color: color ?? 'cyan' }), s(` ${detalleDeMeta(m, ahora)}`, { color })]
177 if (m.estado.enRiesgo && !m.estado.cumplida) segs.push(s(' ⚠ en riesgo', { color: 'yellow' }))
178 if (!m.estado.cumplida && metaSinAprobar(m, permitidos)) segs.push(tenue(` · sin aprobar (/meta aprobar ${m.id})`))
179 else if (m.estado.error && !m.estado.cumplida) segs.push(tenue(` · ${m.estado.error}`))
180 return segs
181}
182
183/** Lo que responde `/meta`: cada meta con su id y sus comandos. */
184export function textoDeMetas(metas: readonly MetaPanel[], ahora: number, permitidos: readonly string[]): string {
185 if (!metas.length) return 'No hay metas en este proyecto. Creá una con /meta fecha|conteo|condicion … (ver /meta ayuda).'
186 return metas.map((m) => {
187 const linea = filaDeMeta(m, ahora, permitidos).map((x) => x.texto).join('')
188 const cmds = [m.medir ? ` mide: ${m.medir.join(' ')}` : null, m.riesgo ? ` riesgo: ${m.riesgo.join(' ')}` : null].filter(Boolean)
189 return [`${m.id} · ${linea}`, ...cmds].join('\n')
190 }).join('\n')
191}
192
193/**
194 * FEAT-121 — La red: este daemon, cada nodo con su estado y su versión (la que
195 * informa su daemon, nunca el nombre de la carpeta de una sesión), y las colas.
196 */
197export function filasDeRed(r: RedPanel | null | undefined, ahora: number): Segmento[][] {
198 if (r == null) return [filaTenue('sin datos')]
199 if (r.estado === 'sin-enlace') return [[s('el daemon no responde', { color: 'red' }), tenue(' (sin enlace local)')]]
200 const local = r.local
201 const filas: Segmento[][] = [[s(local.nombre, { color: 'magenta', negrita: true }), tenue(` · ${local.rol} · ${local.version ?? '?'}${local.desde ? ` · desde ${cuando(local.desde)}` : ''}`)]]
202 if (r.servidor) {
203 filas.push(r.servidor.conectado
204 ? [s('● ', { color: 'green' }), s('servidor de la red'), tenue(' · conectado')]
205 : [s('○ ', { color: 'red' }), s('servidor de la red'), s(` · ${r.servidor.estado ?? 'desconectado'}`, { color: 'red' })])
206 }
207 for (const n of r.nodos) {
208 const otra = Boolean(n.version && local.version && n.version !== local.version)
209 const visto = fecha(n.ultimaConexion)
210 filas.push([
211 s(n.conectado ? '● ' : '○ ', { color: n.conectado ? 'green' : 'red' }),
212 s(n.nombre, { color: 'magenta' }),
213 s(` · ${n.version ?? '?'}`, otra ? { color: 'yellow' } : {}),
214 ...(n.conectado ? [] : [s(` · desconectado${visto ? ` (visto hace ${hace(ahora - visto)})` : ''}`, { color: 'red' })])
215 ])
216 }
217 const colas = r.carriles.map((c) => s(`${c.carril} ${c.enCurso ? '▶' : ''}${c.enCola}`, c.enCola > 0 ? { color: 'yellow' } : { tenue: !c.enCurso }))
218 if (colas.length) filas.push(colas.flatMap((x, i) => (i ? [tenue(' · '), x] : [x])))
219 return filas
220}
221
222/**
223 * FEAT-121 — Lo que merece un toast. Cada aviso tiene una clave estable: el
224 * mod avisa una vez cuando aparece y otra cuando se va (si `recuperable`).
225 */
226export type AvisoRed = { clave: string; texto: string; recuperado: string | null }
227export function avisosDeRed(r: RedPanel | null | undefined): AvisoRed[] {
228 if (r == null) return []
229 if (r.estado === 'sin-enlace') return [{ clave: 'daemon', texto: '🔌 El daemon de Lagrange no responde', recuperado: '🔌 El daemon de Lagrange volvió' }]
230 const out: AvisoRed[] = []
231 if (r.servidor && !r.servidor.conectado) out.push({ clave: 'servidor', texto: `🌐 ${r.local.nombre} perdió la conexión con el servidor de la red`, recuperado: `🌐 ${r.local.nombre} volvió a conectarse al servidor` })
232 for (const n of r.nodos) {
233 if (!n.conectado) out.push({ clave: `nodo:${n.nombre}`, texto: `🌐 El nodo ${n.nombre} está desconectado`, recuperado: `🌐 El nodo ${n.nombre} volvió` })
234 else if (n.version && r.local.version && n.version !== r.local.version) {
235 out.push({ clave: `version:${n.nombre}:${n.version}`, texto: `🌐 ${n.nombre} corre Lagrange ${n.version} y este daemon ${r.local.version}`, recuperado: null })
236 }
237 }
238 return out
239}
240
241/** Las secciones de FEAT-105. Las guardas llegan ya filtradas: solo motivo y vencimiento, nunca la secuencia ni la raíz. */
242function seccionesNuevas(f: FotoPanel | null, ahora: number, { guardas, metasPermitidos, turno }: Extra): Bloque[] {
243 const a = f?.agentes
244 const agentes: Segmento[][] = a == null
245 ? [filaTenue('sin datos')]
246 : a.estado === 'sin-enlace'
247 ? [filaTenue('daemon sin enlace')]
248 : a.sesiones.length
249 ? [
250 ...a.sesiones.map((x) => [
251 s(x.nodo, { color: 'magenta' }), s(`/${x.nombre} · ${x.proyecto ?? '?'} · desde ${cuando(x.desde)}`),
252 ...(x.silenciada ? [tenue(' (no recibe)')] : [])
253 ]),
254 ...(a.aviso ? [[s(`⚠ ${a.aviso}`, { color: 'yellow' })]] : [])
255 ]
256 : [filaTenue('ninguna sesión registrada')]
257 const al = f?.almas
258 const numero = (n: number) => (n > 0 ? s(String(n), { color: 'yellow' }) : s(String(n)))
259 const almas: Segmento[][] = al
260 ? [
261 [numero(al.pendientes), s(' pendientes de consolidar · '), numero(al.cuarentena), s(' en cuarentena')],
262 // FEAT-127 — Las activas en las últimas 24 h: solo nombre, superficie y cuándo.
263 ...(al.recientes ?? []).map((x) => [s(x.nombre, { negrita: true }), tenue(`${x.superficie ? ` · ${x.superficie}` : ''} · hace ${hace(ahora - x.ts)}`)])
264 ]
265 : [filaTenue('sin datos')]
266 const p = f?.programaciones
267 const programaciones: Segmento[][] = p
268 ? [...p.proximas.map((x) => [s(cuando(x.proxima)), s(` · ${x.titulo}`)]), [tenue(`${p.activas} activas · ${p.pausadas} pausadas`)]]
269 : [filaTenue('sin datos')]
270 // BE-101 — Las idénticas (mismo motivo y mismo vencimiento) en una fila con su cuenta. Campo a campo,
271 // sin clave de texto: un motivo puede tener `:`. En el orden de la primera de cada grupo.
272 const grupos: Array<{ g: Guarda; n: number }> = []
273 for (const g of guardas ?? []) {
274 const igual = grupos.find((x) => x.g.motivo === g.motivo && x.g.vence === g.vence)
275 if (igual) igual.n += 1
276 else grupos.push({ g, n: 1 })
277 }
278 const filasGuardas: Segmento[][] = grupos.length
279 ? grupos.map(({ g, n }) => [s(g.motivo, { color: 'yellow' }), tenue(` · ${g.vence === null ? 'sin vencimiento' : `vence en ${dentroDe(g.vence - ahora)}`}${n > 1 ? ` · ${n} reglas` : ''}`)])
280 : [filaTenue('ninguna')]
281 const bloques: Bloque[] = [
282 // FEAT-121 — La red antes que las sesiones: un nodo caído explica una sesión que falta.
283 { titulo: 'Red', filas: filasDeRed(f?.red, ahora) },
284 { titulo: 'Agentes', filas: agentes },
285 { titulo: 'Almas', filas: almas },
286 { titulo: 'Programaciones', filas: programaciones },
287 { titulo: 'Guardas', filas: filasGuardas }
288 ]
289 if (f?.metas?.length) bloques.push({ titulo: 'Metas', filas: f.metas.map((m) => filaDeMeta(m, ahora, metasPermitidos ?? [])) })
290 // FEAT-122 — El último turno del loop principal: el detalle completo, con /turno.
291 if (turno) bloques.push({ titulo: 'Último turno', filas: [fila(cabecera(turno)), ...tiras(turno, TOPE_FILAS_PANEL).map((x) => [s(x.texto, x.error ? { color: 'red' } : {})]), [tenue(porTipo(turno).join(' · ') || 'sin tools')]] })
292 const w = f?.worktrees
293 if (w && w.length) bloques.push({ titulo: 'Worktrees huérfanos', filas: w.map((x) => [s(x.nombre, { color: 'yellow' }), ...(x.vacia ? [tenue(' (vacía)')] : [])]) })
294 return bloques
295}
296
297export function filasDeFoto(f: FotoPanel | null, ahora: number, extra: Extra = {}): Bloque[] {
298 const fan = f?.fanout
299 const fanout: Segmento[][] = fan
300 ? [fila(fan.linea ?? `fan-out ${fan.slug ?? ''}`), ...fan.tareas.map((t) => fila(` ${t.estado} · ${t.id}`))]
301 : [filaTenue('sin fan-out en curso')]
302
303 const c = f?.cuota
304 const entradas: Array<{ nombre: string; v: VentanaCuota; vistoEn: string | null | undefined }> = []
305 if (c?.antigravity) for (const [g, v] of Object.entries(c.antigravity.grupos)) entradas.push({ nombre: `agy ${NOMBRE_GRUPO_AGY[g] ?? g}`, v, vistoEn: c.antigravity.vistoEn })
306 if (c?.claude) entradas.push({ nombre: 'claude', v: c.claude, vistoEn: c.claude.vistoEn })
307 if (c?.claudePorCuenta) for (const [cuenta, v] of Object.entries(c.claudePorCuenta)) entradas.push({ nombre: `claude@${cuenta}`, v, vistoEn: v.vistoEn })
308 // §8 — Dos pasadas: primero todas las celdas, para medir el ancho de cada columna; después las filas.
309 const celdas: Celdas[] = entradas.map((e) => ({ cinco: ventana(e.v.ventana5h, e.v.resetea5h, ahora), siete: ventana(e.v.ventana7d, e.v.resetea7d, ahora) }))
310 const anchos: Anchos = {
311 nombre: entradas.reduce((m, e) => Math.max(m, e.nombre.length), 0),
312 cinco: celdas.reduce((m, c) => Math.max(m, anchoDe(c.cinco.celda)), 0),
313 siete: celdas.reduce((m, c) => Math.max(m, anchoDe(c.siete.celda)), 0)
314 }
315 const cuota = entradas.map((e, i) => filaCuota(e.nombre, celdas[i], anchos, ahora, e.vistoEn))
316
317 const v = f?.versiones
318 const versiones: Segmento[][] = v
319 ? [
320 fila(`esta copia: ${v.propia ?? '?'}`),
321 ...v.cuentas.map((x) => [
322 s(`${x.cuenta}${x.propia ? ' (esta sesión)' : ''}: ${x.version ?? x.estado}`),
323 ...(x.desactualizada ? [s(' ⚠ desactualizada', { color: 'red' })] : [])
324 ])
325 ]
326 : []
327 return [
328 { titulo: 'Fan-out', filas: fanout },
329 { titulo: 'Cuota', filas: cuota.length ? cuota : [filaTenue('sin datos')] },
330 { titulo: 'Versiones', filas: versiones.length ? versiones : [filaTenue('sin datos')] },
331 ...seccionesNuevas(f, ahora, extra)
332 ]
333}
334
335/** Para la conversación: el texto de los segmentos (con el emoji), sin estilos. */
336export function textoDeFoto(f: FotoPanel | null, ahora: number, extra: Extra = {}): string {
337 return filasDeFoto(f, ahora, extra)
338 .map((b) => [`**${b.titulo}**`, ...b.filas.map((segs) => segs.map((x) => x.texto).join(''))].join('\n'))
339 .join('\n\n')
340}
341hooks/guardas.ts 120 lines1/**
2 * FEAT-102 — Las guardas de Lagrange, sin `$`: validar las reglas de
3 * `~/.claude/antigravity.json` (clave `guardas`) y decidir si una frena un
4 * comando de las tools Bash o PowerShell de Claude.
5 *
6 * Sin regex (una regex del usuario puede colgar la sesión): una regla es una
7 * secuencia de palabras enteras que tiene que aparecer contigua en el comando
8 * normalizado. La palabra `*` acepta cero o más palabras en el medio. Es un
9 * freno de buena fe, no una barrera: un comando ofuscado puede no coincidir.
10 */
11
12export type Guarda = { secuencia: string[]; motivo: string; vence: number | null; raiz: string | null }
13
14const MAX_PALABRAS = 8
15const MAX_LARGO_PALABRA = 80
16const MAX_MOTIVO = 200
17
18/** Minúsculas, sin comillas, backticks ni barras invertidas, partido en palabras por espacios y separadores de shell. */
19export function palabrasDe(texto: string): string[] {
20 return String(texto)
21 .toLowerCase()
22 .replace(/["'`\\]/g, '')
23 .split(/[\s;|&()<>]+/)
24 .filter(Boolean)
25}
26
27/** La carpeta para comparar: sin mayúsculas, `\` como `/` y sin barra final. */
28export function normalizarRaiz(ruta: string): string {
29 return String(ruta).toLowerCase().replace(/\\/g, '/').replace(/\/+$/, '')
30}
31
32/** Las reglas válidas de `guardas`. Una inválida se descarta; si no es un array, ninguna. */
33export function validarGuardas(lista: unknown): { guardas: Guarda[]; descartadas: number } {
34 if (!Array.isArray(lista)) return { guardas: [], descartadas: 0 }
35 const guardas: Guarda[] = []
36 let descartadas = 0
37 for (const r of lista) {
38 const g = validarUna(r)
39 if (g) guardas.push(g)
40 else descartadas += 1
41 }
42 return { guardas, descartadas }
43}
44
45function validarUna(r: unknown): Guarda | null {
46 if (!r || typeof r !== 'object') return null
47 const o = r as Record<string, unknown>
48 if (!Array.isArray(o.secuencia) || !o.secuencia.every((p) => typeof p === 'string')) return null
49 // "git push" en una sola palabra se parte: no se descarta en silencio.
50 const secuencia = (o.secuencia as string[]).flatMap((p) => p.trim().toLowerCase().split(/\s+/)).filter(Boolean)
51 if (!secuencia.length || secuencia.length > MAX_PALABRAS) return null
52 if (secuencia.some((p) => p.length > MAX_LARGO_PALABRA)) return null
53 if (secuencia.every((p) => p === '*')) return null
54 if (typeof o.motivo !== 'string' || !o.motivo.trim() || o.motivo.length > MAX_MOTIVO) return null
55 let vence: number | null = null
56 if (o.vence !== undefined) {
57 if (typeof o.vence !== 'string') return null
58 vence = Date.parse(o.vence)
59 if (!Number.isFinite(vence)) return null
60 }
61 let raiz: string | null = null
62 if (o.raiz !== undefined) {
63 if (typeof o.raiz !== 'string' || !o.raiz.trim()) return null
64 raiz = normalizarRaiz(o.raiz)
65 }
66 return { secuencia, motivo: o.motivo.trim(), vence, raiz }
67}
68
69/** ¿La secuencia aparece contigua en las palabras, con `*` como cero o más palabras? */
70export function coincide(secuencia: string[], palabras: string[]): boolean {
71 // Tramos sin `*`: cada uno contiguo, en orden, con huecos libres entre tramos.
72 const tramos: string[][] = []
73 let actual: string[] = []
74 for (const p of secuencia) {
75 if (p === '*') { if (actual.length) tramos.push(actual); actual = [] }
76 else actual.push(p)
77 }
78 if (actual.length) tramos.push(actual)
79 let desde = 0
80 for (const tramo of tramos) {
81 let hallado = -1
82 for (let i = desde; i + tramo.length <= palabras.length; i++) {
83 let ok = true
84 for (let j = 0; j < tramo.length; j++) {
85 if (palabras[i + j] !== tramo[j]) { ok = false; break }
86 }
87 if (ok) { hallado = i; break }
88 }
89 if (hallado < 0) return false
90 desde = hallado + tramo.length
91 }
92 return true
93}
94
95/** La primera guarda vigente que frena el comando en esta raíz, o `null`. */
96export function guardaQueFrena(guardas: Guarda[], { comando, raiz, ahora }: { comando: string; raiz: string; ahora: number }): Guarda | null {
97 const palabras = palabrasDe(comando)
98 const aqui = normalizarRaiz(raiz)
99 for (const g of guardas) {
100 if (g.vence !== null && ahora >= g.vence) continue
101 if (g.raiz !== null && g.raiz !== aqui) continue
102 if (coincide(g.secuencia, palabras)) return g
103 }
104 return null
105}
106
107/**
108 * FEAT-105 — Las guardas que valen en esta raíz ahora (el criterio de
109 * `guardaQueFrena`): sin vencer, y de esta raíz o sin raíz. Para el panel.
110 */
111export function guardasVigentes(guardas: Guarda[], { raiz, ahora }: { raiz: string; ahora: number }): Guarda[] {
112 const aqui = normalizarRaiz(raiz)
113 return guardas.filter((g) => (g.vence === null || ahora < g.vence) && (g.raiz === null || g.raiz === aqui))
114}
115
116/** El texto que ve el modelo: el motivo, sin la secuencia ni rutas. */
117export function textoDeFreno(g: Guarda): string {
118 return `Lagrange · guarda: ${g.motivo}. Si hace falta igual, pedíselo al usuario.`
119}
120hooks/resumen-texto.ts 149 lines1/**
2 * FEAT-103 — `/lagrange-resumen`, sin `$`: argumentos, el prompt para
3 * `$.model.fork`, la validación y el archivo.
4 *
5 * El prompt es propio, no el de `mcp-server/summary-doc.js`: aquel trabaja
6 * sobre un transcript preprocesado ("Final State", "Derived Facts"), y con el
7 * fork el transcript es esta misma conversación. Los metadatos los pone el mod,
8 * así el modelo no los inventa.
9 */
10
11export const FOCOS = ['full', 'decisions', 'changes', 'debugging', 'handoff'] as const
12export type Foco = (typeof FOCOS)[number]
13
14export type MetaResumen = {
15 sessionId: string
16 proyecto: string
17 rama: string | null
18 modelo: string
19 inicio: number | null
20 fin: number
21}
22
23/** `decisions si` → foco y confirmación; acepta `si` o `sí`. */
24export function leerArgs(args: string): { foco: string; valido: boolean; confirmado: boolean } {
25 const partes = String(args || '').trim().split(/\s+/).filter(Boolean)
26 const confirmado = partes.some((p) => /^s[ií]$/i.test(p))
27 const resto = partes.filter((p) => !/^s[ií]$/i.test(p))
28 const foco = (resto[0] || 'full').toLowerCase()
29 return { foco, valido: (FOCOS as readonly string[]).includes(foco), confirmado }
30}
31
32/** La rama de un `.git/HEAD` (`ref: refs/heads/<rama>`), o `null` si está desacoplado o no se entiende. */
33export function ramaDeHead(texto: string): string | null {
34 const m = /^ref:\s*refs\/heads\/(.+)\s*$/m.exec(String(texto || ''))
35 return m ? m[1].trim() : null
36}
37
38/** En un worktree, `.git` es un archivo `gitdir: <ruta>`: la ruta, o `null`. */
39export function gitdirDe(texto: string): string | null {
40 const m = /^gitdir:\s*(.+)\s*$/m.exec(String(texto || ''))
41 return m ? m[1].trim() : null
42}
43
44const iso = (ms: number | null) => (typeof ms === 'number' && Number.isFinite(ms) ? new Date(ms).toISOString() : '[no disponible]')
45
46const SECCIONES: Record<Foco, string> = {
47 full: 'Cubrí todas las secciones por igual.',
48 decisions: 'Profundizá en "Decisiones": razones, alternativas descartadas y compromisos.',
49 changes: 'Profundizá en "Cambios": cada archivo con lo que cambió y por qué.',
50 debugging: 'Profundizá en "Problemas y resoluciones": cada error con su causa raíz.',
51 handoff: 'Es un traspaso: lo que necesita una sesión NUEVA, sin acceso a esta conversación, para seguir el trabajo.'
52}
53
54export function promptDeResumen(foco: Foco, meta: MetaResumen): string {
55 const datos = [
56 '## Datos de la sesión (verificados por el plugin; copialos tal cual, no los cambies)',
57 `- Session id: ${meta.sessionId || '[no disponible]'}`,
58 `- Proyecto: ${meta.proyecto || '[no disponible]'}`,
59 `- Rama: ${meta.rama || '[no disponible]'}`,
60 `- Modelo: ${meta.modelo || '[no disponible]'}`,
61 `- Inicio: ${iso(meta.inicio)}`,
62 `- Fin: ${iso(meta.fin)}`
63 ].join('\n')
64 const secciones = foco === 'handoff'
65 ? [
66 '### 1. Objetivo y estado', '### 2. Qué se hizo (con archivos y commits que aparezcan en la conversación)',
67 '### 3. Decisiones y por qué', '### 4. Qué falta, en orden', '### 5. Trampas conocidas',
68 '### 6. Cómo verificar dónde quedó', '### 7. Prompt para iniciar la sesión nueva'
69 ]
70 : [
71 '### 1. Resumen ejecutivo', '### 2. Decisiones', '### 3. Cambios', '### 4. Problemas y resoluciones',
72 '### 5. Estado actual y próximos pasos', '### 6. Contexto para continuar'
73 ]
74 return [
75 'Sin usar herramientas: escribí un documento de resumen de ESTA conversación. El transcript es la conversación misma;',
76 'el estado final es el de los últimos turnos, no uno intermedio.',
77 '',
78 datos,
79 '',
80 '## Secciones (en este orden, como encabezados markdown)',
81 ...secciones,
82 '',
83 `## Foco: ${SECCIONES[foco]}`,
84 '',
85 '## Reglas',
86 '- Respondé el documento ENTERO como texto, empezando por un título `#`. Nada de archivos, enlaces ni rutas a otro lado.',
87 '- No inventes SHAs, versiones ni cantidades: solo los que aparecen en la conversación. Si algo no está claro, "[no claro]".',
88 '- Distinguí lo medido (salida de un comando) de lo estimado.',
89 '- Citá archivos concretos, con líneas si la conversación las tiene.',
90 '- Escribí en el idioma en que habló el usuario. Máximo 400 líneas.'
91 ].join('\n')
92}
93
94const MIN_LONGITUD = 400
95
96/** Como `validarDocumento` de `summary-doc.js`: un documento real es largo y tiene varios encabezados. */
97export function validarResumen(texto: string): { ok: true } | { ok: false; motivo: string } {
98 const t = String(texto || '').trim()
99 if (!t) return { ok: false, motivo: 'la respuesta vino vacía' }
100 if (t.length < MIN_LONGITUD) return { ok: false, motivo: `tiene ${t.length} caracteres, menos de ${MIN_LONGITUD}` }
101 const encabezados = (t.match(/^#{1,4}\s+\S/gm) || []).length
102 if (encabezados < 2) return { ok: false, motivo: `tiene ${encabezados} encabezado(s); un resumen tiene varios` }
103 return { ok: true }
104}
105
106/** `<home>/.claude/session-summaries/<fecha>-<id8>-fork.md`, con el id saneado como en `saveSummary`. */
107export function archivoDeResumen(home: string, meta: MetaResumen): string {
108 const fecha = new Date(meta.fin).toISOString().slice(0, 10)
109 const id = (meta.sessionId || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 8) || 'unknown'
110 return `${home.replace(/[\\/]+$/, '')}/.claude/session-summaries/${fecha}-${id}-fork.md`
111}
112
113/** El frontmatter de `saveSummary` (index.js), con quién lo hizo y el modelo. */
114export function frontmatter(meta: MetaResumen): string {
115 const limpio = (v: string | null) => String(v ?? 'unknown').replace(/\\/g, '/').replace(/"/g, "'")
116 return [
117 '---',
118 `session_id: "${limpio(meta.sessionId)}"`,
119 'host: "claude"',
120 `project: "${limpio(meta.proyecto)}"`,
121 `branch: "${limpio(meta.rama)}"`,
122 `date: "${new Date(meta.fin).toISOString().slice(0, 10)}"`,
123 `start_time: "${meta.inicio ? new Date(meta.inicio).toISOString() : 'unknown'}"`,
124 `end_time: "${new Date(meta.fin).toISOString()}"`,
125 'summarized_by: "lagrange-resumen (model.fork)"',
126 `model: "${limpio(meta.modelo)}"`,
127 '---',
128 ''
129 ].join('\n')
130}
131
132type Uso = { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number } | null | undefined
133
134/** El pie de costo: siempre, también cuando el fork no respondió. */
135export function pieDeCosto(uso: Uso): string {
136 const n = (v: number | undefined) => (typeof v === 'number' ? v : 0)
137 const nuevos = n(uso?.input_tokens) + n(uso?.cache_creation_input_tokens)
138 return `— $.model.fork: ${n(uso?.cache_read_input_tokens)} tokens leídos de caché, ${nuevos} nuevos, ${n(uso?.output_tokens)} de salida.`
139}
140
141export function textoDeEstimacion(foco: string, tokens: number | undefined, modelo: string): string {
142 const cuanto = typeof tokens === 'number' && Number.isFinite(tokens) ? `~${tokens}` : '[no disponible]'
143 return [
144 `/lagrange-resumen relee toda esta conversación con ${modelo || 'el modelo de la sesión'}: ${cuanto} tokens de contexto`,
145 '(en la sonda S10, una sesión larga leyó 616.675 de caché). En sesiones largas, agy_session_summary es más barato.',
146 `Para seguir: /lagrange-resumen ${foco} si`
147 ].join('\n')
148}
149hooks/banda-texto.ts 108 lines1/**
2 * FEAT-109 — La banda de agy sobre el prompt, sin `$`: qué tools son de agy,
3 * el veredicto de una auditoría y las filas que se dibujan.
4 *
5 * De las tools unitarias (`agy_run`, `agy_audit`…) solo se sabe que corren y
6 * desde cuándo: no dejan nada en disco. Del fan-out y los lotes, lo que trae
7 * `panel.js fanout` (estado, modelo, paso y tiempos por tarea).
8 */
9
10import type { FanoutPanel } from '../types'
11
12export type LlamadaAgy = { tool: string; desde: number }
13
14export type ResultadoAgy = 'PASS' | 'PASS WITH RESERVATIONS' | 'FAIL' | 'terminó' | 'error'
15
16export type CierreAgy = { tool: string; resultado: ResultadoAgy; duracionMs: number; hasta: number }
17
18export type Tono = 'normal' | 'ok' | 'error' | 'tenue'
19
20export type FilaBanda = { texto: string; tono: Tono }
21
22/** Cuánto queda la línea de cierre después de terminar una llamada. */
23export const CIERRE_MS = 20_000
24
25const MAX_TAREAS = 6
26
27// El nombre del servidor MCP cambia según cómo se cargó el plugin
28// (`plugin_lagrange_lagrange`, `lagrange-dev`): se mira solo el final.
29const TOOL_AGY = /^mcp__[^_].*__agy_(run|audit|plan|review|research|fanout|lote)$/
30
31// La misma regex que `parsearVeredicto` en `mcp-server/lotes/auditor.js`, que el
32// mod no puede importar (CommonJS con Node). `test/banda-texto.test.js` las compara.
33const VEREDICTO = /^## Verdict:\s*(PASS WITH RESERVATIONS|PASS|FAIL)\s*$/mi
34
35export function esToolDeAgy(nombre: unknown): boolean {
36 return typeof nombre === 'string' && TOOL_AGY.test(nombre)
37}
38
39/** `mcp__plugin_lagrange_lagrange__agy_audit` → `agy_audit`. */
40export function nombreCorto(tool: string): string {
41 const i = tool.lastIndexOf('__')
42 return i >= 0 ? tool.slice(i + 2) : tool
43}
44
45/** El veredicto del texto de una auditoría, o `null` si no lo trae. */
46export function veredictoDe(texto: unknown): 'PASS' | 'PASS WITH RESERVATIONS' | 'FAIL' | null {
47 const m = VEREDICTO.exec(String(texto ?? ''))
48 return m ? (m[1].toUpperCase() as 'PASS' | 'PASS WITH RESERVATIONS' | 'FAIL') : null
49}
50
51/** El cierre de una llamada que terminó: veredicto, «terminó» o «error». */
52export function cierreDe(tool: string, { texto, fallo }: { texto?: unknown; fallo: boolean }, desde: number, ahora: number): CierreAgy {
53 const resultado: ResultadoAgy = fallo ? 'error' : veredictoDe(texto) ?? 'terminó'
54 return { tool, resultado, duracionMs: Math.max(0, ahora - desde), hasta: ahora + CIERRE_MS }
55}
56
57/** `48s`, `3m12s`, `1h05m`. */
58export function duracion(ms: number): string {
59 const s = Math.max(0, Math.floor(ms / 1000))
60 if (s < 60) return `${s}s`
61 const m = Math.floor(s / 60)
62 if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
63 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
64}
65
66function tonoDe(resultado: string): Tono {
67 if (resultado === 'PASS' || resultado === 'PASS WITH RESERVATIONS' || resultado === 'ok') return 'ok'
68 if (resultado === 'FAIL' || resultado === 'error') return 'error'
69 return 'normal'
70}
71
72function tiempoDeTarea(t: { estado: string; inicio?: string | null; fin?: string | null }, ahora: number): string | null {
73 const inicio = t.inicio ? Date.parse(t.inicio) : NaN
74 if (!Number.isFinite(inicio)) return null
75 const fin = t.fin ? Date.parse(t.fin) : NaN
76 if (Number.isFinite(fin)) return duracion(fin - inicio)
77 return t.estado === 'corriendo' || t.estado === 'reintentando' ? duracion(ahora - inicio) : null
78}
79
80/** ¿Hay algo que dibujar? Sin esto la banda devuelve `next(e)`. */
81export function hayAlgo({ llamadas, cierres, fanout, ahora }: { llamadas: LlamadaAgy[]; cierres: CierreAgy[]; fanout: FanoutPanel | null; ahora: number }): boolean {
82 return llamadas.length > 0 || cierres.some((c) => ahora < c.hasta) || Boolean(fanout && !fanout.terminado && fanout.tareas.length)
83}
84
85/**
86 * Las filas de la banda: llamadas en curso, cierres vigentes y el fan-out en
87 * curso. Sin contadores en el encabezado del fan-out: ya los da la status line.
88 */
89export function filasDeBanda({ llamadas, cierres, fanout, ahora, maxFilas }: { llamadas: LlamadaAgy[]; cierres: CierreAgy[]; fanout: FanoutPanel | null; ahora: number; maxFilas?: number }): FilaBanda[] {
90 const filas: FilaBanda[] = []
91 for (const l of llamadas) filas.push({ texto: `${nombreCorto(l.tool)} · ${duracion(ahora - l.desde)}`, tono: 'normal' })
92 for (const c of cierres) {
93 if (ahora >= c.hasta) continue
94 filas.push({ texto: `${nombreCorto(c.tool)} · ${c.resultado} · ${duracion(c.duracionMs)}`, tono: tonoDe(c.resultado) })
95 }
96 if (fanout && !fanout.terminado && fanout.tareas.length) {
97 filas.push({ texto: `fanout ${fanout.slug ?? ''}`.trim(), tono: 'normal' })
98 const ancho = Math.max(...fanout.tareas.slice(0, MAX_TAREAS).map((t) => t.id.length))
99 for (const t of fanout.tareas.slice(0, MAX_TAREAS)) {
100 const partes = [t.id.padEnd(ancho), t.estado.padEnd(10), t.modelo, t.paso, tiempoDeTarea(t, ahora)].filter((x): x is string => Boolean(x))
101 filas.push({ texto: ` ${partes.join(' ').trimEnd()}`, tono: t.estado === 'pendiente' ? 'tenue' : tonoDe(t.estado) })
102 }
103 const resto = fanout.tareas.length - MAX_TAREAS
104 if (resto > 0) filas.push({ texto: ` +${resto}`, tono: 'tenue' })
105 }
106 return maxFilas !== undefined && maxFilas >= 0 ? filas.slice(0, maxFilas) : filas
107}
108hooks/gates-texto.ts 131 lines1/**
2 * FEAT-114 — `/lagrange-gates`, sin `$`: leer lo que imprime `scripts/gates.mjs`
3 * y armar la sección «Gates» del panel y su línea de resumen.
4 *
5 * El veredicto lo da el código de salida del proceso, nunca el texto: esto solo
6 * cuenta las líneas para mostrar el avance. La cola de las rotas va al pane y
7 * nunca a la línea de texto, que es la que lee el modelo en `/lagrange-panel`.
8 */
9
10import type { Bloque, Segmento } from './panel-texto.ts'
11import { duracion } from './banda-texto.ts'
12
13export type ModoGates = 'todas' | 'rápidas'
14
15export type FinGates = {
16 estado: 'ok' | 'rotas' | 'cortada' | 'detenida' | 'no-arranco'
17 duracionMs: number
18 code: number | null
19}
20
21export type CorridaGates = {
22 modo: ModoGates
23 desde: number
24 /** Cuándo llegó la última línea PASS/FAIL: la puerta en curso arrancó ahí. */
25 ultimaLinea: number
26 puertas: Array<{ nombre: string; ok: boolean }>
27 /** La línea `N/M puertas en verde` de gates.mjs, si llegó. */
28 total: { verdes: number; total: number } | null
29 /** Las últimas líneas de cada rota (lo que gates.mjs imprime tras `=== nombre (exit N) ===`). */
30 cola: string[]
31 enCola: boolean
32 fin: FinGates | null
33}
34
35/** Una corrida se corta si pasa de esto; las puertas tardan ~6 min. */
36export const PLAZO_GATES_MS = 20 * 60 * 1000
37
38const MAX_COLA = 40
39const MAX_LARGO_LINEA = 300
40
41export function nuevaCorrida(modo: ModoGates, desde: number): CorridaGates {
42 return { modo, desde, ultimaLinea: desde, puertas: [], total: null, cola: [], enCola: false, fin: null }
43}
44
45/** El argumento del comando: el modo, `detener` o inválido. */
46export function leerArgGates(args: unknown): ModoGates | 'detener' | null {
47 const a = String(args ?? '').trim().toLowerCase()
48 if (a === '') return 'todas'
49 if (a === 'quick') return 'rápidas'
50 if (a === 'detener') return 'detener'
51 return null
52}
53
54/** Aplica una línea completa de la salida de gates.mjs a la corrida. */
55export function procesarLinea(c: CorridaGates, linea: string, ahora: number): void {
56 const l = linea.replace(/\r$/, '')
57 const m = /^(PASS|FAIL) {2}(.+)$/.exec(l)
58 if (m && !c.enCola) {
59 c.puertas.push({ nombre: m[2].trim(), ok: m[1] === 'PASS' })
60 c.ultimaLinea = ahora
61 return
62 }
63 const t = /^(\d+)\/(\d+) puertas en verde/.exec(l)
64 if (t && !c.enCola) {
65 c.total = { verdes: Number(t[1]), total: Number(t[2]) }
66 return
67 }
68 if (/^=== .+ \(exit \d+\) ===$/.test(l)) c.enCola = true
69 if (c.enCola && c.cola.length < MAX_COLA) c.cola.push(l.length > MAX_LARGO_LINEA ? `${l.slice(0, MAX_LARGO_LINEA)}…` : l)
70}
71
72/**
73 * Parte un trozo en líneas completas: devuelve las líneas y el resto sin salto,
74 * que se junta con el próximo trozo (spawn entrega lo que llega, no líneas).
75 */
76export function partirLineas(resto: string, trozo: string): { lineas: string[]; resto: string } {
77 const todo = resto + trozo
78 const partes = todo.split('\n')
79 return { lineas: partes.slice(0, -1), resto: partes[partes.length - 1] }
80}
81
82/** El fin por código de salida: 0 es verde, cualquier otro o una señal (`null`) es rotas. */
83export function finPorCodigo(code: number | null, desde: number, ahora: number): FinGates {
84 return { estado: code === 0 ? 'ok' : 'rotas', duracionMs: Math.max(0, ahora - desde), code }
85}
86
87// La línea `N/M puertas en verde` de gates.mjs manda sobre lo contado: es su propio resumen.
88function cuenta(c: CorridaGates): { verdes: number; total: number } {
89 return c.total ?? { verdes: c.puertas.filter((p) => p.ok).length, total: c.puertas.length }
90}
91
92function resumen(c: CorridaGates): { texto: string; color?: string } | null {
93 if (!c.fin) return null
94 const { verdes, total } = cuenta(c)
95 switch (c.fin.estado) {
96 case 'ok': return { texto: `${verdes}/${total} en verde`, color: 'green' }
97 case 'rotas': {
98 const rotas = total - verdes
99 // Manda el código: si el texto dice todo verde pero salió distinto de 0, se dice el código.
100 return { texto: rotas > 0 ? `${verdes}/${total} · ${rotas} ${rotas === 1 ? 'rota' : 'rotas'}` : `${verdes}/${total} · salió con código ${c.fin.code ?? 'señal'}`, color: 'red' }
101 }
102 case 'cortada': return { texto: 'cortadas por plazo', color: 'red' }
103 case 'detenida': return { texto: 'detenidas', color: 'yellow' }
104 case 'no-arranco': return { texto: 'No se pudo lanzar gates.mjs', color: 'red' }
105 }
106}
107
108/** La sección «Gates» del pane: avance, resumen y, si hubo rotas, su cola. */
109export function bloqueDeGates(c: CorridaGates, ahora: number): Bloque {
110 const filas: Segmento[][] = []
111 const lleva = c.fin ? c.fin.duracionMs : ahora - c.desde
112 filas.push([{ texto: `${c.modo} · ${duracion(lleva)}`, tenue: true }])
113 for (const p of c.puertas) filas.push([{ texto: p.ok ? '✔ ' : '✘ ', color: p.ok ? 'green' : 'red' }, { texto: p.nombre }])
114 if (!c.fin) filas.push([{ texto: `… puerta ${c.puertas.length + 1} · ${duracion(ahora - c.ultimaLinea)}`, tenue: true }])
115 const r = resumen(c)
116 if (r) filas.push([{ texto: r.texto, color: r.color, negrita: true }])
117 if (c.fin && c.fin.estado !== 'ok') for (const l of c.cola) filas.push([{ texto: l, tenue: true }])
118 return { titulo: 'Gates', filas }
119}
120
121/** El avance de una corrida en curso, para la respuesta del comando. */
122export function avanceDeGates(c: CorridaGates, ahora: number): string {
123 return `(${c.modo}): ${c.puertas.length} terminadas, ${duracion(ahora - c.desde)}`
124}
125
126/** Una sola línea, sin la cola: la que va al texto de `/lagrange-panel` y al toast. */
127export function lineaDeGates(c: CorridaGates, ahora: number): string {
128 if (!c.fin) return `corriendo (${c.modo}): ${c.puertas.length} terminadas, ${duracion(ahora - c.desde)}`
129 return `${resumen(c)?.texto ?? ''} (${c.modo}) · ${duracion(c.fin.duracionMs)}`
130}
131hooks/vista-previa.ts 258 lines1/**
2 * FEAT-112 — La vista previa del daño, sin `$`: reconocer los comandos
3 * destructivos que se miden antes de correr, leer sus rutas y armar el motivo
4 * que muestra el prompt de permisos del motor (`classic.PreToolUse` → `{ ask }`).
5 *
6 * Como las guardas (`guardas.ts`), es un freno de buena fe: no sigue `cd`, ni
7 * variables, ni subshells. Solo separa por comillas y por `;` `&&` `||` `|`, y
8 * entiende `git -C <dir>` para saber dónde medir.
9 */
10
11import { palabrasDe } from './guardas.ts'
12
13export type CasoWorktree = { tipo: 'worktree'; dir: string | null; ruta: string; force: boolean }
14export type CasoBorrado = { tipo: 'borrado'; dir: string | null; rutas: string[] }
15export type CasoPush = { tipo: 'push'; dir: string | null; remoto: string | null; origen: string | null; destino: string | null }
16export type CasoNode = { tipo: 'node' }
17export type Caso = CasoWorktree | CasoBorrado | CasoPush | CasoNode
18
19/** Lo que encontró la medición; un caso sin daño no deja hallazgo y el comando pasa. */
20export type Hallazgo =
21 | { tipo: 'worktree'; ruta: string; links: Array<{ ruta: string; destino: string | null }>; cambios: number; force: boolean }
22 | { tipo: 'borrado'; links: Array<{ ruta: string; destino: string | null }>; versionados: Array<{ ruta: string; n: number }> }
23 | { tipo: 'push'; ref: string; commits: number }
24 | { tipo: 'node' }
25 | { tipo: 'sin-medir'; comando: string }
26
27const MAX_LINEAS = 12
28
29/**
30 * Parte un comando en segmentos (por `;` `&&` `||` `|` fuera de comillas) y cada
31 * segmento en palabras, respetando comillas simples y dobles. La barra invertida
32 * no escapa: en PowerShell y en rutas de Windows es un carácter más.
33 */
34export function separar(comando: string): string[][] {
35 const segmentos: string[][] = []
36 let palabras: string[] = []
37 let actual = ''
38 let hay = false
39 let comilla: string | null = null
40 const cerrarPalabra = () => { if (hay) palabras.push(actual); actual = ''; hay = false }
41 const cerrarSegmento = () => { cerrarPalabra(); if (palabras.length) segmentos.push(palabras); palabras = [] }
42 const s = String(comando)
43 for (let i = 0; i < s.length; i++) {
44 const ch = s[i]
45 if (comilla) {
46 if (ch === comilla) comilla = null
47 else actual += ch
48 continue
49 }
50 if (ch === '"' || ch === "'") { comilla = ch; hay = true; continue }
51 if (ch === ';' || ch === '\n') { cerrarSegmento(); continue }
52 if ((ch === '&' || ch === '|') && s[i + 1] === ch) { cerrarSegmento(); i++; continue }
53 if (ch === '|') { cerrarSegmento(); continue }
54 if (/\s/.test(ch)) { cerrarPalabra(); continue }
55 actual += ch
56 hay = true
57 }
58 cerrarSegmento()
59 return segmentos
60}
61
62const esOpcion = (p: string) => p.startsWith('-') && p.length > 1
63
64/** Los argumentos que siguen a `git` sin sus opciones globales; `-C <dir>` se guarda aparte. */
65function partirGit(seg: string[]): { dir: string | null; resto: string[] } | null {
66 const i = seg.findIndex((p) => p.toLowerCase() === 'git')
67 if (i < 0) return null
68 let dir: string | null = null
69 let j = i + 1
70 while (j < seg.length && esOpcion(seg[j])) {
71 if (seg[j] === '-C' && j + 1 < seg.length) { dir = seg[j + 1]; j += 2; continue }
72 // `-c clave=valor` y otras opciones globales con valor pegado o separado.
73 if (seg[j] === '-c' && j + 1 < seg.length) { j += 2; continue }
74 j += 1
75 }
76 return { dir, resto: seg.slice(j) }
77}
78
79function casoGit(seg: string[]): Caso | null {
80 const g = partirGit(seg)
81 if (!g) return null
82 const [sub, ...args] = g.resto
83 if (!sub) return null
84 if (sub.toLowerCase() === 'worktree' && args[0]?.toLowerCase() === 'remove') {
85 const resto = args.slice(1)
86 const ruta = resto.find((p) => !esOpcion(p))
87 if (!ruta) return null
88 const force = resto.some((p) => p === '--force' || p === '-f' || /^-f+$/.test(p))
89 return { tipo: 'worktree', dir: g.dir, ruta, force }
90 }
91 return null
92}
93
94/** `git push` forzado: `--force`/`-f` fuerza todos los refspecs; si no, solo los que empiezan con `+`. */
95function casosPush(seg: string[]): CasoPush[] {
96 const g = partirGit(seg)
97 if (!g || g.resto[0]?.toLowerCase() !== 'push') return []
98 const args = g.resto.slice(1)
99 const lease = args.some((p) => p.startsWith('--force-with-lease'))
100 const forzado = args.some((p) => p === '--force' || p === '-f')
101 if (lease && !forzado) return []
102 const posicionales = args.filter((p) => !esOpcion(p))
103 // Sin remoto (`git push +main`), el primer posicional ya es un refspec.
104 const remoto = posicionales[0] && !posicionales[0].startsWith('+') ? posicionales[0] : null
105 const refspecs = remoto ? posicionales.slice(1) : posicionales
106 const caso = (r: string | null): CasoPush => {
107 const limpio = (r ?? '').replace(/^\+/, '')
108 const [origen, destino] = limpio ? (limpio.includes(':') ? limpio.split(':') : [limpio, limpio]) : [null, null]
109 return { tipo: 'push', dir: g.dir, remoto, origen: origen || null, destino: destino || null }
110 }
111 if (forzado) return refspecs.length ? refspecs.map(caso) : [caso(null)]
112 return refspecs.filter((r) => r.startsWith('+')).map(caso)
113}
114
115function casoBorrado(seg: string[]): Caso | null {
116 const cmd = (seg[0] ?? '').toLowerCase()
117 const args = seg.slice(1)
118 if (cmd === 'rm') {
119 const recursivo = args.some((p) => p === '--recursive' || (/^-[a-zA-Z]+$/.test(p) && /[rR]/.test(p)))
120 const rutas = args.filter((p) => !esOpcion(p))
121 return recursivo && rutas.length ? { tipo: 'borrado', dir: null, rutas } : null
122 }
123 if (cmd === 'remove-item' || cmd === 'ri' || cmd === 'del' || cmd === 'erase' || cmd === 'rd' || cmd === 'rmdir') {
124 const bajos = args.map((p) => p.toLowerCase())
125 const recursivo = bajos.some((p) => p === '-recurse' || p === '-r' || p === '/s')
126 if (!recursivo) return null
127 const rutas: string[] = []
128 for (let i = 0; i < args.length; i++) {
129 const p = args[i]
130 const b = bajos[i]
131 if (b === '-path' || b === '-literalpath') { if (args[i + 1]) rutas.push(args[i + 1]); i++; continue }
132 // Modificadores de cmd (`/s`, `/q`): una barra y una letra. `/var/log` es una ruta.
133 if (esOpcion(p) || /^\/[a-zA-Z]$/.test(p)) continue
134 rutas.push(p)
135 }
136 // PowerShell: `Remove-Item a, b` llega como `a,` `b`.
137 const limpias = rutas.flatMap((r) => r.split(',')).map((r) => r.trim()).filter(Boolean)
138 return limpias.length ? { tipo: 'borrado', dir: null, rutas: limpias } : null
139 }
140 return null
141}
142
143/** ¿El comando entero mata `node` en general? Se mira sin segmentar: `Get-Process node | Stop-Process` cruza un pipe. */
144export function mataNode(comando: string): boolean {
145 const w = palabrasDe(comando)
146 const tiene = (x: string) => w.includes(x)
147 const nodeNombre = w.some((p) => p === 'node' || p === 'node.exe')
148 if (!nodeNombre) return false
149 // `Stop-Process node`, `-Name`, `-ProcessName`; `kill` y `spps` son alias en PowerShell. `-Id` mata un PID: no es «todos».
150 if ((tiene('stop-process') || tiene('spps') || tiene('kill')) && !tiene('-id')) return true
151 if (tiene('taskkill') && (tiene('/im') || tiene('-im'))) return true
152 if (tiene('pkill') || tiene('killall')) return true
153 if (tiene('get-process') && (tiene('stop-process') || tiene('kill'))) return true
154 return false
155}
156
157/** Los casos del comando, en orden. Vacío: el comando pasa sin medir. */
158export function reconocer(comando: string): Caso[] {
159 const casos: Caso[] = []
160 for (const seg of separar(comando)) {
161 const c = casoGit(seg) ?? casoBorrado(seg)
162 if (c) casos.push(c)
163 casos.push(...casosPush(seg))
164 }
165 if (mataNode(comando)) casos.push({ tipo: 'node' })
166 return casos
167}
168
169/** ¿La ruta tiene comodines del shell? No se puede medir: se pregunta con «no se pudo medir». */
170export function tieneComodin(ruta: string): boolean {
171 return /[*?[]/.test(ruta)
172}
173
174/** Barras normales, sin barra final; para comparar, en minúsculas. */
175export function normalizar(ruta: string): string {
176 return String(ruta).replace(/\\/g, '/').replace(/\/+$/, '').replace(/\/\.(?=\/|$)/g, '')
177}
178
179export function mismaRuta(a: string, b: string): boolean {
180 return normalizar(a).toLowerCase() === normalizar(b).toLowerCase()
181}
182
183export function esAbsoluta(ruta: string): boolean {
184 return /^[a-zA-Z]:[\\/]/.test(ruta) || ruta.startsWith('/') || ruta.startsWith('\\\\')
185}
186
187/** La ruta absoluta de `ruta` vista desde `base`. */
188export function unir(base: string, ruta: string): string {
189 return normalizar(esAbsoluta(ruta) ? ruta : `${normalizar(base)}/${ruta}`)
190}
191
192/** `ruta` relativa a `raiz` si está adentro; si no, tal cual. */
193export function relativa(raiz: string, ruta: string): string {
194 const r = normalizar(raiz), p = normalizar(ruta)
195 return p.toLowerCase().startsWith(`${r.toLowerCase()}/`) ? p.slice(r.length + 1) : p
196}
197
198/** Las rutas de `git worktree list --porcelain`. */
199export function rutasDeWorktrees(salida: string): string[] {
200 return salida.split(/\r?\n/).filter((l) => l.startsWith('worktree ')).map((l) => normalizar(l.slice('worktree '.length)))
201}
202
203/** Las entradas de `git status --porcelain --ignored`: las ignoradas y sin seguimiento (candidatas a link) y los cambios. */
204export function leerStatus(salida: string): { sueltas: string[]; cambios: number } {
205 const sueltas: string[] = []
206 let cambios = 0
207 for (const l of salida.split(/\r?\n/)) {
208 if (l.length < 4) continue
209 const xy = l.slice(0, 2)
210 let ruta = l.slice(3)
211 if (ruta.startsWith('"') && ruta.endsWith('"')) ruta = ruta.slice(1, -1)
212 if (xy === '!!' || xy === '??') sueltas.push(ruta.replace(/\/$/, ''))
213 if (xy !== '!!') cambios += 1
214 }
215 return { sueltas, cambios }
216}
217
218/** ¿Es un link? `isLink`, o adónde llega cuando no es donde dice estar (una junction que el motor no marcara). */
219export function esLink(ruta: string, st: { isLink?: boolean; realPath?: string }): boolean {
220 if (st.isLink) return true
221 return typeof st.realPath === 'string' && !mismaRuta(st.realPath, ruta)
222}
223
224function linea(l: { ruta: string; destino: string | null }): string {
225 return l.destino ? `${l.ruta} → ${l.destino}` : l.ruta
226}
227
228/** El motivo del prompt: una cabecera y una o más líneas por hallazgo, con tope. */
229export function motivoDe(hallazgos: Hallazgo[], raiz: string): string {
230 const lineas: string[] = ['Lagrange · vista previa del daño']
231 for (const h of hallazgos) {
232 switch (h.tipo) {
233 case 'worktree': {
234 lineas.push(`git worktree remove${h.force ? ' --force' : ''} ${relativa(raiz, h.ruta)}`)
235 for (const l of h.links) lineas.push(`⚠ contiene un link: ${linea({ ruta: relativa(h.ruta, l.ruta), destino: l.destino ? relativa(raiz, l.destino) : null })}${h.force ? ' — --force borra a través de él' : ''}`)
236 if (h.force && h.cambios > 0) lineas.push(`${h.cambios} ${h.cambios === 1 ? 'archivo sin commitear se perdería' : 'archivos sin commitear se perderían'}`)
237 break
238 }
239 case 'borrado': {
240 lineas.push('borrado recursivo:')
241 for (const l of h.links) lineas.push(`⚠ link: ${linea({ ruta: relativa(raiz, l.ruta), destino: l.destino ? relativa(raiz, l.destino) : null })}`)
242 for (const v of h.versionados) lineas.push(`${relativa(raiz, v.ruta)}: ${v.n} ${v.n === 1 ? 'archivo versionado' : 'archivos versionados'}`)
243 break
244 }
245 case 'push':
246 lineas.push(`git push forzado: ${h.commits} ${h.commits === 1 ? 'commit' : 'commits'} de ${h.ref} se perderían (según la copia local, sin fetch)`)
247 break
248 case 'node':
249 lineas.push('mata todos los node: esta sesión de Claude Code, los MCP de Lagrange, el daemon del bridge y el registrador de P3')
250 break
251 case 'sin-medir':
252 lineas.push(`${h.comando}: no se pudo medir el alcance`)
253 break
254 }
255 }
256 return (lineas.length > MAX_LINEAS ? [...lineas.slice(0, MAX_LINEAS - 1), `… y ${lineas.length - MAX_LINEAS + 1} líneas más`] : lineas).join('\n')
257}
258hooks/identidad.ts 87 lines1/**
2 * FEAT-123 — Qué cuenta de Claude es esta sesión y con qué nombre y emblema se
3 * muestra (`identidad_sesion` del antigravity.json global).
4 *
5 * Copia de `mcp-server/lib/identidad-sesion.js` (el mod no puede importar CJS).
6 * Las dos corren la tabla `test/fixtures/identidad-casos.mjs`: si cambia una,
7 * cambia la otra.
8 */
9
10export const PRINCIPAL = 'principal'
11const MAX_NOMBRE = 24
12const MAX_EMBLEMA = 2
13const CONTROLES = /[\u0000-\u001f\u007f-\u009f]/
14
15export type Identidad = { nombre: string; emblema: string | null; color: string | number | null }
16
17const esObjeto = (v: unknown): v is Record<string, unknown> => Boolean(v) && typeof v === 'object' && !Array.isArray(v)
18
19/** `~` inicial al home, barras normales, sin barra final, en minúsculas: solo para comparar. */
20function normalizar(ruta: string, home: string): string {
21 let r = ruta.trim()
22 if (r === '~' || /^~[\\/]/.test(r)) r = home + r.slice(1)
23 return r.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
24}
25
26/** `principal`, el nombre de una cuenta de `motores.cuentas`, o `null` si el dir no es de ninguna. */
27export function resolverCuenta({ configDir, home, cuentas }: { configDir: unknown; home: string; cuentas: unknown }): string | null {
28 const dir = typeof configDir === 'string' ? configDir.trim() : ''
29 if (!dir) return PRINCIPAL
30 const propio = normalizar(dir, home)
31 if (propio === normalizar(`${home}/.claude`, home)) return PRINCIPAL
32 for (const [nombre, entrada] of Object.entries(esObjeto(cuentas) ? cuentas : {})) {
33 if (nombre === PRINCIPAL || !esObjeto(entrada) || typeof entrada.configDir !== 'string' || !entrada.configDir.trim()) continue
34 if (normalizar(entrada.configDir, home) === propio) return nombre
35 }
36 return null
37}
38
39function grafemas(texto: string): number {
40 if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
41 return [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(texto)].length
42 }
43 return [...texto].length
44}
45
46/** `{ nombre, emblema, color }` o `null`. Sin nombre válido no hay identidad; un emblema inválido se descarta solo. */
47export function validarIdentidad(crudo: unknown): Identidad | null {
48 if (!esObjeto(crudo) || typeof crudo.nombre !== 'string') return null
49 const nombre = crudo.nombre.trim()
50 if (!nombre || nombre.length > MAX_NOMBRE || CONTROLES.test(nombre)) return null
51 let emblema: string | null = null
52 if (typeof crudo.emblema === 'string') {
53 const e = crudo.emblema.trim()
54 if (e && !CONTROLES.test(e) && grafemas(e) <= MAX_EMBLEMA) emblema = e
55 }
56 const color = typeof crudo.color === 'string' || typeof crudo.color === 'number' ? crudo.color : null
57 return { nombre, emblema, color }
58}
59
60/** La identidad de esta sesión según la config global, o `null`. Nunca tira. */
61export function identidadDeConfig(config: unknown, { configDir, home }: { configDir: unknown; home: string }): Identidad | null {
62 try {
63 if (!esObjeto(config) || !esObjeto(config.identidad_sesion)) return null
64 const cuentas = esObjeto(config.motores) ? config.motores.cuentas : undefined
65 const cuenta = resolverCuenta({ configDir, home, cuentas })
66 if (!cuenta || !Object.prototype.hasOwnProperty.call(config.identidad_sesion, cuenta)) return null
67 return validarIdentidad(config.identidad_sesion[cuenta])
68 } catch {
69 return null
70 }
71}
72
73/** Lo que se muestra: `✦ Spica` (dos espacios: algunos emblemas, como ☘, se dibujan anchos y se pegan al nombre), o solo el nombre. */
74export function etiquetaDe(identidad: Identidad): string {
75 return identidad.emblema ? `${identidad.emblema} ${identidad.nombre}` : identidad.nombre
76}
77
78/** El `suffix` del spinner con la identidad al final; el separador va siempre, pegado a la palabra. */
79export function sufijoConIdentidad(previo: unknown, identidad: Identidad): string {
80 const etiqueta = etiquetaDe(identidad)
81 return typeof previo === 'string' && previo ? `${previo} · ${etiqueta}` : ` · ${etiqueta}`
82}
83
84export function identidadesIguales(a: Identidad | null, b: Identidad | null): boolean {
85 return a === b || (!!a && !!b && a.nombre === b.nombre && a.emblema === b.emblema && a.color === b.color)
86}
87hooks/handoff-texto.ts 197 lines1/**
2 * FEAT-118 — El freno de contexto: cuándo avisar que conviene un handoff, el
3 * estado de la fila de la banda y su texto. Puro: `mods.tsx` pone las
4 * mediciones, el reloj y el botón.
5 *
6 * El porcentaje es contra la ventana de compactación (la del autocompact), no
7 * contra la del modelo: contra esa, un 80 % puede llegar tarde.
8 *
9 * FEAT-146 — Con ventanas grandes avisa en tokens fijos (268k / 536k / 804k) y
10 * la fila suma «compactar» (`$.session.compact`). Nunca compacta sola.
11 */
12
13import { duracion } from './banda-texto.ts'
14import { tokensCortos } from './turno-texto.ts'
15
16export const UMBRALES = [70, 85] as const
17/** Por debajo de esto (tras compactar) los umbrales se rearman: un aviso por ciclo de compactación. */
18export const REARME = 40
19/** FEAT-146 — Los cortes en tokens; solo cuentan los menores que la ventana. */
20export const UMBRALES_TOKENS = [268_000, 536_000, 804_000] as const
21/** FEAT-146 — Con una ventana mayor que esto, modo tokens (entran al menos dos cortes). */
22export const VENTANA_MODO_TOKENS = 536_000
23/** FEAT-146 — El rearme en tokens, con margen bajo el primer corte para que una oscilación no reavise. */
24export const REARME_TOKENS = 200_000
25/** Lo que se ve «Handoff guardado» o el error. */
26export const VISIBLE_MS = 20_000
27const EXITO = 'Resumen (handoff) guardado en '
28
29export type FaseHandoff = 'quieto' | 'generando' | 'listo' | 'error' | 'compactando' | 'compactado'
30export type ModoContexto = 'pct' | 'tokens'
31
32export type Handoff = {
33 /** Umbrales ya avisados en este ciclo (porcentajes o tokens, según `modo`). */
34 disparados: number[]
35 /** El umbral del aviso visible, o `null` (sin aviso o descartado). */
36 aviso: number | null
37 /** El último porcentaje medido. */
38 pct: number | null
39 /** FEAT-146 — Cómo se mide; cambiarlo vacía `disparados`. */
40 modo: ModoContexto
41 /** FEAT-146 — Los últimos tokens medidos. */
42 tokens: number | null
43 /** FEAT-146 — Cuántos cortes en tokens entran en la ventana («corte n de m»). */
44 cortes: number
45 /** FEAT-146 — La ruta del último handoff guardado en este ciclo, para las instrucciones de compactar. */
46 ruta: string | null
47 /** FEAT-146 — Qué dejó la fila en `error`: si fue el handoff, todavía se puede compactar. */
48 accion: 'handoff' | 'compactar' | null
49 fase: FaseHandoff
50 /** Inicio de `generando` o `compactando`. */
51 desde: number
52 /** El texto de `listo`, `compactado` o `error`. */
53 texto: string
54 /** Cuándo vence `listo`, `compactado` o `error`. */
55 hasta: number
56}
57
58export type AccionesHandoff = { guardar: boolean; compactar: boolean; descartar: boolean }
59export type FilaHandoff = { texto: string; tono: 'aviso' | 'urgente' | 'normal' | 'ok' | 'error'; acciones: AccionesHandoff }
60
61const NINGUNA: AccionesHandoff = { guardar: false, compactar: false, descartar: false }
62const TODAS: AccionesHandoff = { guardar: true, compactar: true, descartar: true }
63
64export function nuevoHandoff(): Handoff {
65 return { disparados: [], aviso: null, pct: null, modo: 'pct', tokens: null, cortes: 0, ruta: null, accion: null, fase: 'quieto', desde: 0, texto: '', hasta: 0 }
66}
67
68/** Porcentaje entero de `tokens` sobre `ventana`, o `null` sin datos. */
69export function pctDe(tokens: unknown, ventana: unknown): number | null {
70 if (typeof tokens !== 'number' || typeof ventana !== 'number' || !Number.isFinite(tokens) || !Number.isFinite(ventana) || tokens < 0 || ventana <= 0) return null
71 return Math.round((tokens / ventana) * 100)
72}
73
74/**
75 * Una medición nueva. Cruzar un umbral no avisado lo avisa (el más alto
76 * cruzado, marcando también los de abajo); bajar de `REARME` rearma todo y
77 * quita el aviso. Nada más cambia.
78 */
79export function medir(h: Handoff, pct: number | null): Handoff {
80 if (pct === null) return h
81 if (pct < REARME) return { ...h, disparados: [], aviso: null, ruta: null, pct }
82 const cruzados = UMBRALES.filter((u) => pct >= u)
83 const nuevo = cruzados.filter((u) => !h.disparados.includes(u))
84 if (!nuevo.length) return { ...h, pct }
85 return { ...h, pct, disparados: [...new Set([...h.disparados, ...cruzados])], aviso: Math.max(...nuevo) }
86}
87
88function numeroValido(n: unknown): n is number {
89 return typeof n === 'number' && Number.isFinite(n) && n >= 0
90}
91
92/** FEAT-146 — Los cortes que entran en `ventana`. */
93export function cortesDe(ventana: number): number[] {
94 return UMBRALES_TOKENS.filter((u) => u < ventana)
95}
96
97/** FEAT-146 — `medir` en tokens: los mismos avisos, con los cortes que entran y rearme bajo `REARME_TOKENS`. */
98export function medirTokens(h: Handoff, tokens: unknown, ventana: unknown): Handoff {
99 if (!numeroValido(tokens) || !numeroValido(ventana) || ventana <= 0) return h
100 const cortes = cortesDe(ventana)
101 const pct = pctDe(tokens, ventana)
102 if (tokens < REARME_TOKENS) return { ...h, disparados: [], aviso: null, ruta: null, pct, tokens, cortes: cortes.length }
103 const cruzados = cortes.filter((u) => tokens >= u)
104 const nuevo = cruzados.filter((u) => !h.disparados.includes(u))
105 const base = { ...h, pct, tokens, cortes: cortes.length }
106 if (!nuevo.length) return base
107 return { ...base, disparados: [...new Set([...h.disparados, ...cruzados])], aviso: Math.max(...nuevo) }
108}
109
110/** FEAT-146 — Elige el modo por la ventana y mide; cambiar de modo vacía lo avisado. */
111export function medirContexto(h: Handoff, tokens: unknown, ventana: unknown): Handoff {
112 if (!numeroValido(tokens) || !numeroValido(ventana) || ventana <= 0) return h
113 const modo: ModoContexto = ventana > VENTANA_MODO_TOKENS ? 'tokens' : 'pct'
114 const previo = modo === h.modo ? h : { ...h, modo, disparados: [], aviso: null }
115 return modo === 'tokens' ? medirTokens(previo, tokens, ventana) : { ...medir(previo, pctDe(tokens, ventana)), tokens, cortes: 0 }
116}
117
118/** «Ahora no»: oculta el aviso; vuelve con el próximo umbral o el próximo ciclo. */
119export function descartar(h: Handoff): Handoff {
120 return { ...h, aviso: null }
121}
122
123export function empezar(h: Handoff, ahora: number): Handoff {
124 return { ...h, aviso: null, fase: 'generando', desde: ahora, texto: '', accion: 'handoff' }
125}
126
127/** `~` en lugar del home, para que la fila no muestre la carpeta del usuario entera. */
128export function conTilde(ruta: string, home: string): string {
129 const r = ruta.replace(/\\/g, '/')
130 const h = home.replace(/\\/g, '/').replace(/\/+$/, '')
131 return h && r.toLowerCase().startsWith(h.toLowerCase() + '/') ? '~' + r.slice(h.length) : r
132}
133
134/** El fin de `/lagrange-resumen handoff si`: de su texto, solo la primera línea (el pie de costo no va). */
135export function terminar(h: Handoff, texto: string, ahora: number, home: string): Handoff {
136 const primera = String(texto ?? '').split(/\r?\n/)[0].trim()
137 const ok = primera.startsWith(EXITO)
138 const ruta = ok ? primera.slice(EXITO.length).trim() : null
139 const mostrado = ruta !== null ? `Handoff guardado en ${conTilde(ruta, home)}` : primera || 'No se generó el handoff.'
140 return { ...h, fase: ok ? 'listo' : 'error', texto: mostrado, hasta: ahora + VISIBLE_MS, ruta: ruta ?? h.ruta }
141}
142
143/** FEAT-146 — Lo que la compactación debe conservar; con handoff guardado, que cite su ruta. */
144export function instruccionesDeCompactacion(ruta: string | null): string {
145 const base = 'Conservá el estado de la tarea en curso: objetivo, decisiones tomadas y por qué, archivos tocados, pendientes y próximo paso concreto.'
146 return ruta ? `${base} El handoff completo está en ${ruta}; citá esa ruta en el resumen para poder releerlo.` : base
147}
148
149export function empezarCompactacion(h: Handoff, ahora: number): Handoff {
150 return { ...h, aviso: null, fase: 'compactando', desde: ahora, texto: '', accion: 'compactar' }
151}
152
153/** FEAT-146 — El fin de `$.session.compact`: el resultado (compactado o `skip`) o la excepción. */
154export function terminarCompactacion(h: Handoff, r: { resultado?: unknown; error?: unknown }, ahora: number): Handoff {
155 const hasta = ahora + VISIBLE_MS
156 if ('error' in r && r.error !== undefined) {
157 const nombre = r.error instanceof Error ? r.error.name : 'error'
158 return { ...h, fase: 'error', texto: `No se pudo compactar: ${nombre}`, hasta }
159 }
160 const res = (r.resultado ?? {}) as { skip?: unknown; tokensBefore?: unknown; tokensAfter?: unknown }
161 if (typeof res.skip === 'string') return { ...h, fase: 'error', texto: `Compactación cancelada: ${res.skip}`, hasta }
162 const cifras = numeroValido(res.tokensBefore) && numeroValido(res.tokensAfter) ? `: ${tokensCortos(res.tokensBefore)} → ${tokensCortos(res.tokensAfter)} tokens` : '.'
163 return { ...h, fase: 'compactado', texto: `Compactado${cifras}`, hasta }
164}
165
166const CON_VENCIMIENTO: FaseHandoff[] = ['listo', 'error', 'compactado']
167
168/** `listo`, `compactado` y `error` vencidos vuelven a `quieto`. */
169export function vigente(h: Handoff, ahora: number): Handoff {
170 return CON_VENCIMIENTO.includes(h.fase) && ahora >= h.hasta ? { ...h, fase: 'quieto', texto: '' } : h
171}
172
173/** ¿Hay fila de handoff que dibujar (y reloj que hacer avanzar)? */
174export function hayAviso(h: Handoff, ahora: number): boolean {
175 if (h.fase === 'generando' || h.fase === 'compactando') return true
176 if (CON_VENCIMIENTO.includes(h.fase)) return ahora < h.hasta
177 return h.aviso !== null
178}
179
180export function filaDeHandoff(h: Handoff, ahora: number): FilaHandoff | null {
181 if (h.fase === 'generando') return { texto: `Generando handoff… ${duracion(ahora - h.desde)}`, tono: 'normal', acciones: NINGUNA }
182 if (h.fase === 'compactando') return { texto: `Compactando… ${duracion(ahora - h.desde)}`, tono: 'normal', acciones: NINGUNA }
183 if (CON_VENCIMIENTO.includes(h.fase) && ahora < h.hasta) {
184 const tono = h.fase === 'error' ? 'error' : 'ok'
185 // Tras el handoff, salga bien o mal, queda compactar; tras compactar, nada.
186 const compactar = h.fase === 'listo' || (h.fase === 'error' && h.accion === 'handoff')
187 return { texto: h.texto, tono, acciones: compactar ? { ...NINGUNA, compactar: true } : NINGUNA }
188 }
189 if (h.aviso === null) return null
190 if (h.modo === 'tokens') {
191 const n = UMBRALES_TOKENS.indexOf(h.aviso as (typeof UMBRALES_TOKENS)[number]) + 1
192 const urgente = h.aviso >= UMBRALES_TOKENS[UMBRALES_TOKENS.length - 1]
193 return { texto: `Contexto: ${tokensCortos(h.tokens ?? h.aviso)} tokens (corte ${n} de ${h.cortes})`, tono: urgente ? 'urgente' : 'aviso', acciones: TODAS }
194 }
195 return { texto: `Contexto: ${h.pct ?? h.aviso} % hasta compactar`, tono: h.aviso >= 85 ? 'urgente' : 'aviso', acciones: TODAS }
196}
197hooks/bandeja-texto.ts 87 lines1/**
2 * FEAT-115 + FEAT-116 — El buzón y el recall en la banda, sin `$`: qué
3 * mensajes se ven, cómo se dibujan, y los textos que el plugin manda a Claude.
4 *
5 * El texto de un mensaje de otro agente se le muestra al USUARIO en la banda;
6 * a Claude nunca le llega por acá: con «pasar a Claude» recibe el mismo aviso
7 * de siempre (quién y cuántos) y lo lee con `mensaje leer`, dentro de su sobre.
8 */
9
10import type { MensajeBanda, NotaRespuesta, NovedadBanda } from '../types'
11export type { MensajeBanda, NotaRespuesta, NovedadBanda }
12
13/** BE-111 — El texto a enviar: el de onSubmit, salvo que lo guardado por onInput lo extienda (se perdió el final). */
14export function textoFinal(enviado: string, guardado: string): string {
15 return guardado.length > enviado.length && guardado.startsWith(enviado) ? guardado : enviado
16}
17
18/** Lo que se ve en la banda: los pendientes que el usuario no despachó en esta sesión. */
19export function visibles(mensajes: readonly MensajeBanda[], listos: readonly string[]): MensajeBanda[] {
20 const fuera = new Set(listos)
21 return mensajes.filter((m) => m && typeof m.id === 'string' && m.id && !fuera.has(m.id))
22}
23
24/** `nodo/nombre`, o solo el nombre en el nodo local. */
25export function remitente(m: MensajeBanda): string {
26 return m.de.nodo && m.de.nodo !== 'local' ? `${m.de.nodo}/${m.de.nombre}` : m.de.nombre || 'otro agente'
27}
28
29const MAX_FILAS_TEXTO = 3
30
31/** Las filas del primer mensaje: la cabecera y hasta tres líneas del texto. */
32export function filasDeMensaje(m: MensajeBanda, mas: number): string[] {
33 const lineas = m.texto.split('\n').map((l) => l.trim()).filter(Boolean)
34 const cuerpo = lineas.slice(0, MAX_FILAS_TEXTO)
35 if (lineas.length > MAX_FILAS_TEXTO) cuerpo[MAX_FILAS_TEXTO - 1] = `${cuerpo[MAX_FILAS_TEXTO - 1]} …`
36 const resto = mas > 0 ? ` (+${mas} más)` : ''
37 return [`✉ ${remitente(m)} · de otro agente${resto}`, ...cuerpo.map((l) => ` ${l}`)]
38}
39
40// El mismo saneo que `textoAviso` de `mcp-server/lib/buzones.js`: lo que diga otro nodo no entra crudo.
41const limpio = (v: unknown) => String(v ?? '').replace(/[^A-Za-z0-9._/-]/g, '').slice(0, 81)
42
43/** «Pasar a Claude»: el aviso de siempre, sin el texto. */
44export function avisoParaClaude(m: MensajeBanda): string {
45 return `📨 Tenés 1 mensaje de otros agentes (de ${limpio(m.de.nodo)}/${limpio(m.de.nombre)}). Leelo con la herramienta \`mensaje\`, accion: leer.`
46}
47
48/**
49 * Lo que se antepone al próximo prompt del usuario después de responder desde
50 * la banda: el texto es del propio usuario. Vacío si no hay notas.
51 */
52export function bloqueDeRespuestas(notas: readonly NotaRespuesta[]): string {
53 if (!notas.length) return ''
54 // El remitente y el id vienen de otro agente: saneados como en el aviso; el texto es del propio usuario.
55 const filas = notas.map((n) => `- A ${limpio(n.de)} (mensaje ${limpio(n.id)}) le respondí desde la banda: «${n.texto}»`)
56 return `[Mientras tanto, desde la banda de Lagrange]\n${filas.join('\n')}\nLos mensajes originales están en \`mensaje\` (accion: leer, todos: true).\n\n`
57}
58
59const CUENTA = /^[\w-]{1,40}$/
60
61/** FEAT-116 — El pedido de «traer»: solo la cuenta (validada) y cuántas notas. Sin nombres de archivo. */
62export function pedidoDeRecall(n: NovedadBanda): string | null {
63 if (!CUENTA.test(n.cuenta)) return null
64 const cuantas = `${n.cantidad} nota${n.cantidad === 1 ? '' : 's'}`
65 return `Traé con la tool \`recall\` (desde: "${n.cuenta}") la memoria de esta cuenta para este proyecto: tiene ${cuantas} nuevas o cambiadas. Seguí la skill \`recall\`: compará con tu memoria, verificá contra el código y guardá solo lo que sirva, adaptado.`
66}
67
68/** FEAT-116 — La fila de la banda. */
69export function filaDeNovedad(n: NovedadBanda): string {
70 return `📚 ${n.nombre} tiene ${n.cantidad} nota${n.cantidad === 1 ? '' : 's'} nueva${n.cantidad === 1 ? '' : 's'} de este proyecto`
71}
72
73/** FEAT-116 — Del JSON de `recall-novedades.js` a filas: cantidad y la marca a guardar (el mtime más nuevo). */
74export function novedadesDe(salida: unknown): NovedadBanda[] {
75 const cuentas = (salida as { cuentas?: unknown } | null)?.cuentas
76 if (!Array.isArray(cuentas)) return []
77 const out: NovedadBanda[] = []
78 for (const c of cuentas) {
79 const notas = Array.isArray(c?.notas) ? c.notas.filter((x: { mtimeMs?: unknown }) => typeof x?.mtimeMs === 'number') : []
80 if (typeof c?.cuenta !== 'string' || !CUENTA.test(c.cuenta) || !notas.length) continue
81 const nombre = typeof c.nombre === 'string' && c.nombre.trim() ? c.nombre.trim().slice(0, 40) : c.cuenta
82 const total = Number.isInteger(c.total) && c.total >= notas.length ? c.total : notas.length
83 out.push({ cuenta: c.cuenta, nombre, cantidad: total, hasta: Math.max(...notas.map((x: { mtimeMs: number }) => x.mtimeMs)) })
84 }
85 return out
86}
87hooks/turno-texto.ts 150 lines1/**
2 * FEAT-122 — La línea de tiempo del turno, sin `$`: qué se anota de cada tool
3 * y de cada request, y cómo se dibuja (una tira por tool, el resumen por tipo
4 * y la cabecera con tiempo, requests, tokens y costo).
5 *
6 * Solo el loop principal: la tool de un subagente no se cuenta aparte (su
7 * tiempo es el de la tool `Agent` que lo lanzó). El costo es el del turno
8 * entero (lo que sumó la sesión entre el inicio y el fin): repartirlo por tool
9 * sería inventarlo.
10 */
11import { nombreCorto, duracion } from './banda-texto.ts'
12
13// Locales: la línea de tiempo vive en una variable del mod (UI efímera), no en `$.state`.
14export type ToolTurno = { nombre: string; desdeMs: number; duracionMs: number; error: boolean }
15export type Tokens = { entrada: number; salida: number; cacheLeida: number; cacheEscrita: number }
16export type TurnoCerrado = {
17 turnId: string
18 fin: number
19 duracionMs: number
20 interrumpido: boolean
21 requests: number
22 tokens: Tokens
23 costo: number | null
24 tools: ToolTurno[]
25 extra: number
26}
27
28export const TOPE_TOOLS = 200
29export const TOPE_FILAS = 25
30export const TOPE_FILAS_PANEL = 8
31export const TURNOS_GUARDADOS = 5
32const ANCHO_BARRA = 40
33const ANCHO_NOMBRE = 16
34
35export type TurnoEnCurso = {
36 turnId: string
37 desde: number
38 costoInicial: number | null
39 tools: ToolTurno[]
40 abiertas: Record<string, { nombre: string; desde: number }>
41 extra: number
42 requests: number
43}
44
45export function nuevoTurno(turnId: string, desde: number, costoInicial: number | null): TurnoEnCurso {
46 return { turnId, desde, costoInicial, tools: [], abiertas: {}, extra: 0, requests: 0 }
47}
48
49export function abrirTool(t: TurnoEnCurso, clave: string, tool: string, ahora: number): void {
50 t.abiertas[clave] = { nombre: nombreCorto(tool), desde: ahora }
51}
52
53export function cerrarTool(t: TurnoEnCurso, clave: string, ahora: number, error: boolean): void {
54 const a = t.abiertas[clave]
55 if (!a) return
56 delete t.abiertas[clave]
57 if (t.tools.length >= TOPE_TOOLS) { t.extra += 1; return }
58 t.tools.push({ nombre: a.nombre, desdeMs: Math.max(0, a.desde - t.desde), duracionMs: Math.max(0, ahora - a.desde), error })
59}
60
61type Uso = { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number } | null | undefined
62
63/** Solo cuenta: los tokens los trae sumados `turn.complete`. */
64export function contarPaso(t: TurnoEnCurso): void {
65 t.requests += 1
66}
67
68export function tokensDe(uso: Uso): Tokens {
69 return {
70 entrada: Number(uso?.input_tokens) || 0,
71 salida: Number(uso?.output_tokens) || 0,
72 cacheLeida: Number(uso?.cache_read_input_tokens) || 0,
73 cacheEscrita: Number(uso?.cache_creation_input_tokens) || 0
74 }
75}
76
77/** Cierra el turno: las tools que quedaron abiertas (interrumpidas) cuentan hasta el fin. */
78export function cerrarTurno(t: TurnoEnCurso, { durationMs, interrumpido, costoFinal, ahora, uso }: { durationMs: number; interrumpido: boolean; costoFinal: number | null; ahora: number; uso: Uso }): TurnoCerrado {
79 for (const clave of Object.keys(t.abiertas)) cerrarTool(t, clave, ahora, true)
80 const costo = t.costoInicial !== null && costoFinal !== null ? Math.max(0, costoFinal - t.costoInicial) : null
81 return { turnId: t.turnId, fin: ahora, duracionMs: Math.max(0, durationMs), interrumpido, requests: t.requests, tokens: tokensDe(uso), costo, tools: t.tools, extra: t.extra }
82}
83
84export function tokensCortos(n: number): string {
85 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace('.', ',')}M`
86 if (n >= 1000) return `${Math.round(n / 1000)}k`
87 return String(n)
88}
89
90export function cabecera(t: TurnoCerrado): string {
91 const total = t.tokens.entrada + t.tokens.salida + t.tokens.cacheLeida + t.tokens.cacheEscrita
92 const costo = t.costo === null ? '—' : `$${t.costo.toFixed(2).replace('.', ',')}`
93 const req = `${t.requests} request${t.requests === 1 ? '' : 's'}`
94 return `Turno de ${duracion(t.duracionMs)} · ${req} · ${tokensCortos(total)} tokens (cache ${tokensCortos(t.tokens.cacheLeida)}) · ${costo}${t.interrumpido ? ' (interrumpido)' : ''}`
95}
96
97/** `░░░████░░░`: dónde empezó y cuánto duró, sobre el largo del turno; al menos una celda. */
98export function barra(desdeMs: number, duracionMs: number, totalMs: number, ancho = ANCHO_BARRA): string {
99 const total = Math.max(1, totalMs)
100 const ini = Math.min(ancho - 1, Math.floor((desdeMs / total) * ancho))
101 const largo = Math.max(1, Math.min(ancho - ini, Math.round((duracionMs / total) * ancho)))
102 return '░'.repeat(ini) + '█'.repeat(largo) + '░'.repeat(ancho - ini - largo)
103}
104
105export type FilaTira = { texto: string; error: boolean }
106
107/** Una tira por tool en orden de inicio; con más de 25, las 25 más largas y `+N más`. */
108export function tiras(t: TurnoCerrado, tope = TOPE_FILAS): FilaTira[] {
109 const total = Math.max(t.duracionMs, ...t.tools.map((x) => x.desdeMs + x.duracionMs))
110 let elegidas = t.tools
111 let fuera: ToolTurno[] = []
112 if (t.tools.length > tope) {
113 const largas = new Set([...t.tools].sort((a, b) => b.duracionMs - a.duracionMs).slice(0, tope))
114 elegidas = t.tools.filter((x) => largas.has(x))
115 fuera = t.tools.filter((x) => !largas.has(x))
116 }
117 // En orden de inicio: `tools` queda en orden de cierre (se anota al terminar cada una).
118 elegidas = [...elegidas].sort((a, b) => a.desdeMs - b.desdeMs)
119 const filas = elegidas.map((x) => ({ texto: `${x.nombre.slice(0, ANCHO_NOMBRE).padEnd(ANCHO_NOMBRE)} ${barra(x.desdeMs, x.duracionMs, total)} ${duracion(x.duracionMs)}`, error: x.error }))
120 const resto = fuera.length + t.extra
121 if (resto > 0) filas.push({ texto: `+${resto} más (${duracion(fuera.reduce((s, x) => s + x.duracionMs, 0))}${t.extra ? ', sin detalle' : ''})`, error: false })
122 return filas
123}
124
125/** `Bash ×7 · 41s`, por tiempo total. */
126export function porTipo(t: TurnoCerrado): string[] {
127 const m = new Map<string, { n: number; ms: number }>()
128 for (const x of t.tools) {
129 const v = m.get(x.nombre) ?? { n: 0, ms: 0 }
130 v.n += 1
131 v.ms += x.duracionMs
132 m.set(x.nombre, v)
133 }
134 return [...m.entries()].sort((a, b) => b[1].ms - a[1].ms).map(([n, v]) => `${n} ×${v.n} · ${duracion(v.ms)}`)
135}
136
137/** Los anteriores, del más nuevo al más viejo, en una línea. */
138export function anteriores(turnos: readonly TurnoCerrado[]): string {
139 return turnos.slice(0, -1).reverse().map((x) => duracion(x.duracionMs)).join(' · ')
140}
141
142/** Lo que responde `/turno`. */
143export function textoDeTurno(turnos: readonly TurnoCerrado[]): string {
144 const t = turnos[turnos.length - 1]
145 // BE-108 — El historial vive en el mod: una recarga de plugins lo vacía, y un comando con / no es un turno.
146 if (!t) return 'Todavía no hay turnos cerrados desde que cargó el mod (la recarga de plugins lo vacía; un comando con / no es un turno).'
147 const previos = anteriores(turnos)
148 return [cabecera(t), ...tiras(t).map((f) => (f.error ? `${f.texto} ✗` : f.texto)), '', `Por tipo: ${porTipo(t).join(' · ') || 'sin tools'}`, ...(previos ? [`Anteriores: ${previos}`] : [])].join(String.fromCharCode(10))
149}
150hooks/voz-texto.ts 87 lines1/**
2 * FEAT-119 + FEAT-120 — La voz en la terminal, sin `$`: qué dice el spinner
3 * mientras suena una voz y qué frase va en la banda.
4 *
5 * El MCP escribe `buzones/<sesion>.voz` justo antes de reproducir y lo borra
6 * al terminar (`mcp-server/lib/voz-en-curso.js`). Acá solo se lee: el texto es
7 * lo que se está diciendo en voz alta, ya saneado por el MCP; igual se le
8 * quitan los caracteres de control antes de dibujarlo. Nunca llega al modelo.
9 */
10
11export type VozEnCurso = { voz: string; texto: string; desde: number; duracionMs: number; hasta: number }
12
13const CONTROLES = /\u001b\[[0-9;?]*[ -\/]*[@-~]|[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g
14const MAX_VOZ = 40
15const MAX_TEXTO = 8000
16export const MAX_FRASE = 140
17
18const limpio = (v: unknown, tope: number) => String(v ?? '').replace(CONTROLES, '').trim().slice(0, tope)
19
20/** Del JSON crudo del `.voz` a algo dibujable; `null` si no sirve o ya venció. */
21export function leerVoz(crudo: string | null | undefined, ahora: number): VozEnCurso | null {
22 if (!crudo) return null
23 let j: Record<string, unknown>
24 try { j = JSON.parse(crudo) } catch { return null }
25 if (!j || typeof j !== 'object') return null
26 const desde = Number(j.desde)
27 const duracionMs = Number(j.duracionMs)
28 const hasta = Number(j.hasta)
29 if (![desde, duracionMs, hasta].every(Number.isFinite) || duracionMs <= 0 || hasta < ahora) return null
30 const texto = limpio(j.texto, MAX_TEXTO)
31 if (!texto) return null
32 return { voz: limpio(j.voz, MAX_VOZ) || 'La voz', texto, desde, duracionMs, hasta }
33}
34
35/** FEAT-119 — El `message` del spinner, o `null` para dejar el del motor. */
36export function mensajeDeVoz(enCurso: boolean, voz: VozEnCurso | null): string | null {
37 if (voz) return `🔊 ${voz.voz} está hablando`
38 return enCurso ? '🎙 preparando la voz' : null
39}
40
41/** Corta una frase larga por la última coma o espacio antes del tope. */
42function partir(frase: string): string[] {
43 const out: string[] = []
44 let resto = frase
45 while (resto.length > MAX_FRASE) {
46 const ventana = resto.slice(0, MAX_FRASE)
47 const coma = ventana.lastIndexOf(',')
48 const espacio = ventana.lastIndexOf(' ')
49 const corte = coma > MAX_FRASE / 2 ? coma + 1 : espacio > MAX_FRASE / 2 ? espacio : MAX_FRASE
50 out.push(resto.slice(0, corte).trim())
51 resto = resto.slice(corte).trim()
52 }
53 if (resto) out.push(resto)
54 return out
55}
56
57/** FEAT-120 — Las frases del texto: por `.`, `!`, `?`, `…` y saltos de línea; ninguna pasa de MAX_FRASE. */
58export function frases(texto: string): string[] {
59 const crudas = texto.split(/(?<=[.!?…])\s+|\n+/).map((f) => f.replace(/\s+/g, ' ').trim()).filter(Boolean)
60 return crudas.flatMap(partir)
61}
62
63/** FEAT-120 — El índice de la frase que suena: cada frase pesa por sus caracteres sobre la duración. */
64export function fraseEn(lista: readonly string[], transcurridoMs: number, duracionMs: number): number {
65 if (!lista.length) return -1
66 const total = lista.reduce((n, f) => n + f.length, 0)
67 const avance = Math.min(1, Math.max(0, transcurridoMs / duracionMs)) * total
68 let acumulado = 0
69 for (let i = 0; i < lista.length; i++) {
70 acumulado += lista[i].length
71 if (avance < acumulado) return i
72 }
73 return lista.length - 1
74}
75
76/** FEAT-120 — La fila de la banda. */
77export function filaDeSubtitulo(voz: VozEnCurso, frase: string): string {
78 return `🔊 ${voz.voz}: «${frase}»`
79}
80
81const TOOL_VOZ = /^mcp__[^_].*__(say|narrate)$/
82
83/** `say`/`narrate` de un MCP de Lagrange (instalado o de desarrollo). */
84export function esToolDeVoz(nombre: unknown): boolean {
85 return typeof nombre === 'string' && TOOL_VOZ.test(nombre)
86}
87