SLOPSHOPPER

lagrange

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

newpanebandspinnerguardcommand
★ 1v1.13.1MITupdated 2026-10-09KZvilla/lagrange-agent-runtime
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lagrange
│ ┃ Lagrange ✕ › fix the failing auth test and add an audit log call │ ┃ Fan-out │ ┃ sin fan-out en curso ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Cuota ⏺ Update(src/auth.ts) │ ┃ sin datos ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Versiones ⎿ 3 pass, 1 fail │ ┃ sin datos │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Red │ ┃ sin datos ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Agentes › /lagrange-panel │ ┃ sin datos ⎿ lagrange: **Fan-out** │ ┃ ⎿ lagrange: sin fan-out en curso │ ┃ Almas ⎿ lagrange: │ ┃ sin datos ⎿ lagrange: **Cuota** │ ┃ ⎿ lagrange: sin datos │ ┃ Programaciones ⎿ lagrange: │ ┃ sin datos │ ┃ │ ┃ Guardas │ ┃ ninguna │ ┃ │ ┃ Último turno ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Lagrange
Fan-out sin fan-out en curso Cuota sin datos Versiones sin datos Red sin datos Agentes sin datos Almas sin datos Programaciones sin datos Guardas ninguna Último turno Turno de 42s · 0 requests · 99k tokens (cache 91k) · $0,00 Read █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0s Grep █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0s Edit █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0s Write █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0s Write █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0s Bash █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0s Bash █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0s Bash █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0s +1 más (0s) Bash ×4 · 0s · Write ×2 · 0s · Read ×1 · 0s · Grep ×1 · 0s …
README

Lagrange — Antigravity for Claude Code and Codex

Version Platform Dependencies Node License

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.


📑 Table of Contents


⚡ Quick Start

Claude Code

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

Codex

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.


🚀 Features

FeatureDescription
🤖Autonomous SubagentClaude spins up Antigravity to execute complex tasks, multi-step refactors, and test suites
🔀Concurrent Fan-OutRuns parallel Antigravity subagents across isolated git worktrees with disjoint-file safety checks (/lagrange:fanout)
👁️Lagrange WatchInspect the local Lagrange inventory by source, plus live fan-out progress, diffs and stop controls (/lagrange:watch)
🧠Dual Model IntelligenceCombines Claude with Gemini models (3.8 / 3.7 Flash, 3.1 Pro) with configurable reasoning effort
🎙️Voice Checkpoint NarrationZero-Claude-token spoken status updates with declarative voice routing, independent Souls, and text-only degradation
🗣️Real-Time Voice ModeFull-duplex spoken conversation with barge-in, mic capture, Silero VAD and independent Soul/acoustic routing through local Voicebox or OmniVoice providers
📋Anti-Compaction Session SummaryAnalyzes raw JSONL session logs with Gemini (1M-2M context) to generate persistent, structured Markdown docs before context degrades
🌐Cited Web ResearchLeverages Antigravity's native web search and synthesis capabilities that Claude Code lacks out of the box
📱Telegram Bridge & Remote ControlControl tasks from your phone, approve plans, receive voice notes, and launch claude --remote-control sessions
🛡️Granular PermissionsALLOW / DENY capabilities, forbidden paths, forbidden commands, and sandbox isolation
⏱️Robust TimeoutsAuto-injects --print-timeout (15m default, 20m for reviews, 25m for audits) to prevent premature drops
📊Live TelemetryToken usage, thinking tokens, context caching savings, and context window saturation
🔄Multi-Turn Continuityconversation_id enables back-and-forth iteration with full workspace memory
⚙️Flexible ConfigPer-prompt, per-project JSON, or environment variables
⚡Zero DependenciesLightweight stdio MCP server in pure Node.js

📋 Prerequisites

RequirementDetails
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 CLIagy or agy.exe installed and on your PATH (Install guide)
Claude CodeActive Claude Code terminal session
Google API KeyConfigured for Antigravity (GEMINI_API_KEY or agy auth login)

🛠️ Slash Commands

CommandDescription
/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:usageDisplay token telemetry, context saturation, and quota health
/lagrange:bridgeDiagnose 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.


🔧 MCP Tools Reference

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:

ToolModeDefault TimeoutDescription
agy_runread + write15mExecute a full subagent session with optional permission guardrails
agy_fanoutread + write15m/subagentConcurrent fan-out: validates the tasks are disjoint in files, one worktree + branch each, batched with a concurrency cap and quota backoff
agy_loteisolated (containers)per taskConfined 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_planisolated (container)15mStep-by-step architectural / implementation plan over a read-only snapshot of the working tree — see Read-only isolation
agy_reviewisolated (container)20mAdversarial code review on git diffs or specific files, over the same snapshot
agy_auditisolated (container)25mRigorous adversarial audit with severity rubric (BLOCKER, MAJOR, MINOR), over the same snapshot; always forces sandbox=false
agy_researchno-edit (prompt)20mDeep web research with cited sources — requires the network capability, errors out if denied
agy_session_summaryno-edit (prompt)15mParse session JSONL and generate structured summary doc with Gemini
agy_voice_streamconversationalpersistent (no fixed timeout)Manage a long-lived, streaming agy.exe process for low-latency voice chat ("Modo Charla") — the backend behind voice-chat/
narrateaudio/text3mUpdate 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
sayaudio 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_voicesread-only—Inspect live/cached profiles, setup state, languages, roles, and service health; never starts a provider or loads a model
voice_modelGPU 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_notifyoutbound—Push a notification (with optional file attachment) to your phone — see Telegram Bridge Setup
telegram_askHuman-in-the-Loop5mAsk a question with tappable choice buttons and block until you answer on your phone
telegram_send_voiceoutbound audio—Send an audio file (or the latest Voicebox generation) as a native voice note
cast_agentread-only by default15mCast a persistent, SKILL-bound agent that keeps its identity, thread and accumulated criteria across sessions — see Persistent SKILL-Bound Agents
telegram_bridge_statusread-only—Diagnose the bridge: daemon state, which copy of the code each half runs, where credentials and shared state resolve — /lagrange:bridge
recallread-only—Read this project's Claude Code memory from another Claude account on this machine; never writes — see Recall
mensajelocal, via the daemonup 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
almalocal 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

ParameterTypeDefaultDescription
promptstringrequiredTask instructions for Antigravity
modelstring"gemini-3.8-flash"Gemini model to use
effortstring"high"Reasoning effort: "low", "medium", "high"
modestring"accept-edits""accept-edits" (read+write) or "plan" (no edits requested — not enforced, see below)
permissionsobject—Granular ALLOW/DENY policies (see below)
conversation_idstring—Resume a previous conversation
continue_sessionboolean—Continue the most recent conversation (-c)
timeout_minutesnumber15Max runtime in minutes
cwdstring—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_permissionsbooleantrueRun headlessly without interactive prompts

🛡️ Granular Permissions System (ALLOW / DENY)

Permission Fields

FieldTypeDefaultDescription
allowstring[]["read", "edit", "commands", "network"]Capabilities explicitly allowed
denystring[][]Capabilities blocked. Denying "edit" forces --mode plan
deny_pathsstring[][".env*", "**/*.key", "**/*.pem"]Paths forbidden from access
deny_commandsstring[]["git push*", "git reset --hard*", "npm publish*", "rm -rf /*"]Shell commands prohibited
sandboxbooleanfalseEnables 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.

Read-only isolation (SEC-020)

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.
  • No .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.
  • One Docker volume per thread for agy's state, so 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_isolationBehavior
"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, --sandbox and --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 despite deny_commands: ["node*"], wrote a 64 KB diff.diff into the audited worktree despite deny: ["edit"], and, earlier, one that killed every node process 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, which agy_plan, agy_review and agy_audit now use (see Read-only isolation). A git worktree is not one: agy writes to absolute paths outside it.

sandbox: true is 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 on agy v1.1.26, it blocks shell reads, shell writes and curl, 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 a cwd you 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 in docs/future-implementations/subagentes-concurrentes-agy.md. A git worktree via cwd (what agy_fanout does, without exposing sandbox) 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_review defaults to a 20-minute CLI deadline and a 21-minute process watchdog; agy_audit uses 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 honors notifications/cancelled and 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.

Fallback to a secondary Claude account when agy cannot run (FEAT-097)

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.

WorkModelEffort
Texts: persona rewrite, say polish, narrate script, agy_session_summary (and its strict review)Haiku 5.5low
Voice-chat consolidationHaiku 5.5default (medium)
Soul chatHaiku 5.5medium
Read-only castSonnetmedium
  • agy stays first. The account only answers when agy cannot: quota (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.
  • Same isolation as any 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.
  • Only roles without a fixed engine. A role set in 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.
  • Output, soul journal and provenance name the real writer — 🎭 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 w
Source 16 files
hooks/mods.tsx 1636 lines
1import { 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 }
1200
hooks/panel-texto.ts 341 lines
1import 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}
341
hooks/guardas.ts 120 lines
1/**
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}
120
hooks/resumen-texto.ts 149 lines
1/**
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}
149
hooks/banda-texto.ts 108 lines
1/**
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}
108
hooks/gates-texto.ts 131 lines
1/**
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}
131
hooks/vista-previa.ts 258 lines
1/**
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}
258
hooks/identidad.ts 87 lines
1/**
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}
87
hooks/handoff-texto.ts 197 lines
1/**
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}
197
hooks/bandeja-texto.ts 87 lines
1/**
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}
87
hooks/turno-texto.ts 150 lines
1/**
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}
150
hooks/voz-texto.ts 87 lines
1/**
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