17 Commits

Author SHA1 Message Date
egutierrez eb8dbf66a1 feat(infra): auto-commit con 88 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 00:16:46 +02:00
egutierrez 6bc97df5c0 Merge quick/orquestador-command: /orquestador + grupo orchestration (launch_claude_agent_kitty, list_claude_agents) 2026-06-08 21:15:16 +02:00
egutierrez e769836b0d feat(pipelines): auto-commit con 1 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 11:33:13 +02:00
egutierrez 93756fbd0c chore: auto-commit (1 archivos)
- .claude/settings.local.json

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 11:28:02 +02:00
egutierrez 0a6d1b8d17 feat(infra): auto-commit con 6 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 01:57:00 +02:00
egutierrez 82f1f1bd58 feat(infra): parse_unibus_health — healthz del cluster unibus → []PromSample
Función del grupo fleet-metrics que convierte la respuesta JSON del endpoint /healthz
de un nodo unibus (membershipd) en series Prometheus (unibus_up, unibus_status_ok,
unibus_posture_enforce/acl/tls/cluster, unibus_store_kv) con labels node/instance.
Pura de transformación (impure solo por el error de unmarshal). La consume el daemon
unibus_exporter del project fleet_monitoring. Con tests golden/edge/error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:26:15 +02:00
egutierrez 9a9b876400 feat(commands): /orquestador — modo de coordinacion de Claudes secundarios en kitty
Nuevo slash command que codifica el modo orquestador: el Claude principal
descompone una tarea grande y lanza Claudes secundarios interactivos, cada uno
en su propia terminal kitty con un prompt autonomo inyectado y aislamiento git
impuesto (worktree / sub-repo / scope disjunto). El humano habla solo con el
orquestador, ve a los secundarios en sus terminales y puede saltar a cualquiera.

El cuerpo cubre los 8 pasos del ciclo (descomponer, lanzar, aislar, prompt,
seguir, no pkill, integrar, kitty vs Agent tool), la plantilla del comando de
lanzamiento, la tabla de seguimiento de la flota, las reglas de aislamiento, los
anti-patrones y un ejemplo end-to-end. Referencia las funciones del registry
launch_claude_agent_kitty_bash_infra, list_claude_agents_bash_infra y
reboot_all_claudes_bash_infra (grupo orchestration). Deja explicita la diferencia
con fn-orquestador / autopilot (Agent tool en sandbox no-interactivo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:32:47 +02:00
egutierrez 5c253a26e2 feat(infra): grupo orchestration — launch_claude_agent_kitty + list_claude_agents
Dos funciones bash para la mecanica del modo orquestador (Claudes secundarios
interactivos en kitty):

- launch_claude_agent_kitty(title, directory, prompt_file): lanza un Claude Code
  secundario en su propia terminal kitty con un prompt autonomo inyectado y
  --dangerously-skip-permissions, detached (setsid nohup ... disown) para
  sobrevivir al cierre de la terminal padre.
- list_claude_agents([--json] [--exclude-current]): lista la flota de Claudes
  vivos cruzando pgrep -x claude con ~/.claude/sessions/<PID>.json (con
  validacion anti-PID-reciclado por procStart), reportando PID, sessionId, cwd,
  status, etime y KITTY_PID. Reusa la logica de descubrimiento de
  reboot_all_claudes_bash_infra.

Tag de grupo de capacidad: orchestration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:32:33 +02:00
egutierrez 10bfb846a8 ahora si funciona
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 16:23:52 +02:00
Egutierrez d996542f88 feat(infra): grupo fleet-metrics — collect_host_metrics, format_prom_exposition, push_prom_remote, push_loki_stream, collect_battery_metrics + tipo PromSample (gopsutil; Android-safe: sin exec/pidfd, procesos via /proc) 2026-06-07 14:25:45 +02:00
egutierrez 8742cb25be feat(browser): auto-commit con 60 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 11:42:31 +02:00
egutierrez 37aacfcfa9 feat(browser): chrome_launch ReuseExisting — guarda anti-duplicado de Chrome
Añade el campo ReuseExisting a ChromeLaunchOpts. Con ReuseExisting=true, si el
puerto CDP ya responde a una conexión TCP, ChromeLaunch NO lanza un Chrome nuevo
y devuelve (0, nil) para que el caller se adjunte al existente. Evita acumular
procesos chromium duplicados en el mismo puerto (cada uno ~789 MiB RSS), causa
del leak de RAM del browser_mcp.

Extrae el sondeo de puerto a dialCDP/cdpPortResponds (net.Dial con timeout), que
waitCDPReady ahora reutiliza en su bucle. Tests sin Chrome real (TestCdpPortResponds,
TestChromeLaunchReuseExisting) usando un net.Listener local como puerto ocupado.
Bump a 1.4.0 + growth log + gotchas en el .md (pid 0 = no es nuestro, no matar).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:06:45 +02:00
egutierrez 029dbf57bd feat(core): auto-commit con 10 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 13:20:36 +02:00
egutierrez 3f6b652f3f chore(agents): subir los 6 agentes fn de sonnet a opus
Los agentes del ciclo reactivo (constructor, executor, recopilador,
analizador, mejorador, orquestador) corrian con model: sonnet. Se suben
todos a model: opus para mejorar la calidad del codigo generado y del
razonamiento durante el ciclo CONSTRUIR -> EJECUTAR -> RECOPILAR ->
ANALIZAR -> MEJORAR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:17:46 +02:00
egutierrez 5b10b419a2 feat(browser): auto-commit con 44 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 12:49:54 +02:00
egutierrez e2c073b8b7 feat(browser): set_chrome_profile_appearance v1.1.0 — color tiñe el tema del navegador
Antes --color solo escribía los campos de color en Local State (info_cache), que
únicamente tiñen el círculo del avatar en el selector de perfiles. Ahora --color
aplica además el tema del navegador (toolbar, frame/bordes, barra de pestañas y
omnibox), que es lo que permite identificar un perfil de un vistazo.

El tema vive en el Preferences del perfil, no en Local State. La función ahora
escribe browser.theme.user_color2 (SkColor ARGB con signo), browser_color_variant
y is_grayscale2, y fuerza extensions.theme.system_theme=0. Escribe también las
claves legacy sin sufijo "2" por compatibilidad de versiones. Nuevo flag
--variant <0..4> (default 3 vibrant) para la intensidad del tinte. Backup y
validación del Preferences con el mismo patrón que Local State.

Claves verificadas empíricamente con captura de pantalla en Chromium 148: un
perfil lanzado con estas claves muestra la toolbar y el frame teñidos del color.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:12:37 +02:00
egutierrez 25054ff64e feat(browser): set_chrome_profile_appearance — avatar + color de perfiles Chrome
Nueva función Bash del dominio browser para personalizar la apariencia de un
perfil Chrome/Chromium y diferenciarlo de un vistazo. Edita
`profile.info_cache.<perfil>` en el Local State del user-data-dir:

- `--avatar <N>`: avatar built-in de Chrome (índice 0..55) vía
  `avatar_icon = chrome://theme/IDR_PROFILE_AVATAR_<N>`. Camino robusto.
- `--avatar <ruta.png>`: avatar custom best-effort (copia la imagen al perfil y
  marca `is_using_default_avatar=false`); ver gotchas del .md.
- `--color <#rrggbb>`: color del perfil. Convierte el hex a int32 con signo en
  formato ARGB (0xAARRGGBB) y lo aplica a `profile_highlight_color`,
  `profile_color_seed` y `default_avatar_fill_color`.

Sigue el patrón de create/delete_chrome_profile: backup del Local State antes de
escribir, validación del JSON resultante con restauración del backup si queda
inválido, guard de SingletonLock (chromium debe estar cerrado), idempotente y
con --dry-run. No crea perfiles (eso es create_chrome_profile); requiere que el
perfil ya exista. Probada con --avatar 26 --color #1f6feb y casos edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:57:12 +02:00
293 changed files with 24297 additions and 480 deletions
+6 -2
View File
@@ -23,7 +23,9 @@ Cualquier decision tecnica que choque con estos objetivos esta mal priorizada. E
**Sub-repos:** cada app, cada analysis y **cada project** es su propio repo Gitea en `dataforge/<basename>` con branch `master` (ver ADR 0002). `apps/*`, `analysis/*` y `projects/*` estan en el `.gitignore` del repo padre — el codigo de cada app vive en `apps/<name>/.git/`. Cada `projects/<name>/` es a su vez un sub-repo que versiona solo sus docs de nivel-project (`project.md`, `CONVENTIONS.md`, ...) con un `.gitignore` interno que excluye `apps/*/` y `analysis/*/` (sub-repos hijos). Ver `.claude/rules/projects.md`. Los slash commands `/full-git-push` y `/full-git-pull` orquestan push/pull/clone de fn_registry + todos los sub-repos + `fn sync`. `/full-git-push` auto-inicializa apps/analyses sin `.git` via `ensure_repo_synced_bash_infra`. Los `vaults/` y `subrepos/` NO entran en este flujo. **Gotcha worktrees**: si creas una app nueva dentro de un git worktree del repo padre, haz `git init` dentro de `apps/<name>/` ANTES de limpiar el worktree, sino el codigo se pierde (apps/* gitignored). **REGLA DURA**: el repo padre NUNCA trackea contenido de artefactos hijos (apps/analysis/projects) — solo `.gitkeep`. Nada de `git add -f` sobre esos paths: deja el padre permanentemente dirty (doble-tracking). Auditoria + fix en `.claude/rules/apps_subrepo.md`. Ver `.claude/rules/apps_subrepo.md`.
**Artefactos:** termino paraguas para apps, analysis, vaults, projects y playgrounds — todo lo que NO es codigo reutilizable. Usa "artefacto" cuando una afirmacion aplica a varios tipos a la vez para no repetir la lista. Ver `.claude/rules/artefactos.md` y `.claude/rules/playgrounds.md`.
**Artefactos:** termino paraguas para apps, analysis, vaults, projects, playgrounds y reports — todo lo que NO es codigo reutilizable. Usa "artefacto" cuando una afirmacion aplica a varios tipos a la vez para no repetir la lista. Ver `.claude/rules/artefactos.md`, `.claude/rules/playgrounds.md` y `.claude/rules/reports.md`.
**Reports:** reportes de trabajo (entregable de una tarea: resumen + cambios + verificacion con evidencia + gaps). Son **artefacto local**: viven en `reports/` o `projects/<p>/reports/`, estan gitignored (salvo `reports/.gitkeep`), NO suben a Gitea ni se versionan en el padre y NO se indexan — igual que los vaults/playgrounds. Compartir = pasar la ruta del `.md`. Convencion + plantilla en `.claude/rules/reports.md`. Decision: ADR 0006.
**Reglas y convenciones:** ver `.claude/rules/INDEX.md`
@@ -148,7 +150,7 @@ Cualquier `SELECT ... FROM functions/types/apps/proposals WHERE ...` plano se ha
**functions** — columnas: `id, name, kind, lang, domain, version, purity, signature, description, tags, uses_functions, uses_types, returns, returns_optional, error_type, imports, example, tested, tests, test_file_path, file_path, created_at, updated_at, props, emits, has_state, framework, variant, notes, documentation, code, content_hash, source_repo, source_license, source_file, params_schema`
- `params_schema`: JSON con semántica de inputs/outputs. Formato: `{"params":[{"name":"x","desc":"..."}],"output":"..."}`. Buscable via FTS5.
- Enums: `kind`(function|pipeline|component) `purity`(pure|impure) `lang`(go|py|bash|ps)
- Dominios: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser
- Dominios: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser, obsidian
**types** — columnas: `id, name, lang, domain, version, algebraic, definition, description, tags, uses_types, file_path, created_at, updated_at, examples, notes, documentation, code, content_hash, source_repo, source_license, source_file`
- Enums: `algebraic`(product|sum)
@@ -231,6 +233,8 @@ fn-registry/
docs/ # Specs de diseño
docs/templates/ # Plantillas de frontmatter
temp/ # Workspace efimero — pruebas, APIs, prototipos (gitignored, no indexado)
reports/ # Reportes de trabajo (artefacto local: gitignored salvo .gitkeep, no Gitea, no indexado)
projects/*/reports/ # Reportes de un proyecto concreto (mismo trato: gitignored, local)
<artefacto>/playground/ # Prototipo rapido dentro de un artefacto padre (analysis/app/proyecto). No se indexa
```
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-analizador
description: "Agente analizador (Fase 4) del ciclo reactivo. Lee `e2e_checks` declarados en app.md, ejecuta la suite via `e2e_run_checks_go_infra`, evalua assertions activas, calcula drift de metricas vs historico, persiste resultado en `e2e_runs` de operations.db y devuelve veredicto caveman pass/fail. NO modifica codigo ni propone fixes — eso es trabajo de fn-mejorador (Fase 5)."
model: sonnet
model: opus
tools: Read, Write, Bash, Glob, Grep, Edit
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-constructor
description: "Agente constructor (Fase 1) del ciclo reactivo. Construye funciones, tests y tipos en Go, Python, TypeScript y Bash para fn_registry."
model: sonnet
model: opus
tools: Read, Write, Bash, Glob, Grep, Edit
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-executor
description: "Agente ejecutor (Fase 2) del ciclo reactivo. Prepara apps, ejecuta pipelines/funciones Go y Python, y registra ejecuciones en operations.db."
model: sonnet
model: opus
tools: Read, Write, Bash, Glob, Grep, Edit
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-mejorador
description: "Agente mejorador (Fase 5) del ciclo reactivo. Lee resultados fallidos de fn-analizador desde `e2e_runs`/`assertion_results`, busca contexto en el registry, y crea proposals con evidencia trazable. NO modifica codigo: solo abre proposals para que un humano (o el bucle autonomo del issue 0069) decida."
model: sonnet
model: opus
tools: Read, Bash, Grep, Glob
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-orquestador
description: "Meta-orquestador (Fase 6) del ciclo reactivo. Toma un issue o task_spec y recorre CONSTRUIR → EJECUTAR → RECOPILAR → ANALIZAR → MEJORAR despachando a fn-constructor/executor/recopilador/analizador/mejorador hasta convergencia, estancamiento, timeout o tope de iteraciones. Trabaja SIEMPRE en rama sandbox `auto/<issue>`, NUNCA mergea a master, persiste progreso en `task_runs`. Issue 0069."
model: sonnet
model: opus
tools: Read, Write, Bash, Glob, Grep, Edit
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: fn-recopilador
description: "Agente recopilador (Fase 3) del ciclo reactivo. Audita operations.db de apps, valida integridad de datos operativos (entities, relations, executions, assertions, logs), y verifica que la estructura del ejecutor esta correcta. Modo extra `design-e2e <app_id>`: propone bloque `e2e_checks` para que la fase 4 (fn-analizador) pueda validar la app sin iteracion humana."
model: sonnet
model: opus
tools: Read, Write, Bash, Glob, Grep, Edit
---
+159
View File
@@ -0,0 +1,159 @@
---
description: "Modo launcher: das ordenes en lenguaje natural y Claude responde SOLO con la procedencia (registry/bash/heredoc) + el comando exacto, y lo ejecuta. Agiliza el lanzamiento de comandos y audita en vivo el Reg % (uso real de funciones del registry)."
---
# /modo_launcher — lanzamiento rápido registry-first
Activa un **modo de comportamiento** persistente. Mientras estás dentro, el usuario da órdenes en lenguaje natural y Claude responde con el **mínimo absoluto**: la procedencia del comando + el comando exacto + por qué, y lo ejecuta. Sin prosa, sin explicaciones largas, sin preámbulos.
El objetivo es doble:
1. **Agilizar** el lanzamiento de comandos (cero verborrea entre orden y ejecución).
2. **Auditar en vivo** que de verdad pasamos por funciones del registry antes que por bash inline — sube `Reg %` (objetivo 1 del Norte) y expone gaps reutilizables (objetivo 3).
## Activación
Al invocar `/modo_launcher` entras en **MODO LAUNCHER**. El modo permanece activo en todos los turnos siguientes hasta que el usuario escriba `salir` o `fin launcher`. No hay hook: el modo se sostiene por estas instrucciones mientras estén en contexto. Si el comportamiento se diluye tras muchos turnos, el usuario puede re-invocar `/modo_launcher` para reanclarlo.
Al entrar, responde con una sola línea de confirmación y queda a la espera:
```
MODO LAUNCHER activo. Da ordenes. 'salir' para terminar.
```
## Comportamiento por orden (regla dura)
Para CADA orden del usuario mientras el modo esté activo:
1. **Registry-first.** Mapea la orden a una capacidad y busca primero en el registry vía FTS (`mcp__registry__fn_search`) o reconoce un ID conocido. Las funciones del registry SIEMPRE tienen prioridad sobre bash inline.
2. **Clasifica la procedencia** según la taxonomía de abajo.
3. **Ejecuta directo.** Identificado el comando, ejecútalo sin pedir permiso — salvo que sea destructivo (ver guarda).
4. **Responde en el formato fijo** (abajo), con la salida cruda del comando. Nada más.
## Formato de respuesta (OBLIGATORIO en cada orden)
```
FUENTE: <etiqueta>
CMD: <comando exacto>
WHY: <razón: match FTS, ID conocido, o "sin función → bash">
──────────
<salida cruda del comando>
```
- `FUENTE` es una de las etiquetas de la taxonomía.
- `CMD` es el comando literal lanzado (forma `./fn run <id> [args]` para legibilidad aunque la ejecución real vaya por MCP).
- `WHY` es una línea: qué match de búsqueda o qué ID justifica esa elección. Si fue un gap, dilo.
- Tras la regla `──────────`, la salida cruda. Cero comentario después salvo que el usuario pregunte.
## Taxonomía de procedencia
| Etiqueta | Qué es | Cómo se ejecuta |
|---|---|---|
| `registry-run` | Ejecutar UNA función o pipeline del registry | `mcp__registry__fn_run <id> [args]` (preferido); fallback `./fn run <id> [args]` |
| `registry-mcp` | Inspeccionar el registro (buscar, ver, código, deps, dominios) | `mcp__registry__fn_search` / `fn_show` / `fn_code` / `fn_uses` / `fn_list_domains` |
| `heredoc` | Componer N funciones con lógica intermedia (loops, dispatch) | Heredoc `python/.venv/bin/python3 - <<'PY' ... PY` importando del registry |
| `bash` | Comando shell puro: no existe función que lo cubra | Bash directo |
| `gap` | No hay función Y el patrón parece reutilizable | Ejecuta el bash equivalente y marca el candidato (ver abajo) |
### Preferencia de ejecución para `registry-run`
- Usa `mcp__registry__fn_run` cuando esté disponible (queda registrado en `call_monitor`, alimenta el bucle reactivo).
- Si el MCP `fn_run` no está habilitado (requiere `--enable-run`), cae a `./fn run <id>` por terminal. La línea `CMD` muestra siempre la forma `./fn run <id>` por legibilidad.
## Gaps: orden sin función en el registry
Cuando una orden no tenga función que la cubra:
1. Ejecuta el bash equivalente (`FUENTE: bash`).
2. Si el patrón parece **reutilizable** (firma genérica, se repetiría en otras tareas, ≥5 líneas de lógica), añade tras la salida UNA línea:
```
CANDIDATO → <nombre_propuesto>_<lang>_<domain>: <1 frase de qué haría>
```
No lances `fn-constructor` automáticamente dentro del modo (rompería el ritmo de lanzamiento). Solo marca. El usuario decide al salir si promueve los candidatos.
## Guarda de comandos destructivos
Ejecuta directo SALVO que el comando sea irreversible o de alto impacto. En esos casos, NO ejecutes: muestra el bloque con `FUENTE`/`CMD`/`WHY` y añade `⚠ DESTRUCTIVO — confirma con 'ok'` en vez de la salida. Espera el `ok` explícito del usuario antes de lanzar.
Patrones que exigen confirmación:
- `rm -rf`, borrado de archivos versionados, `> archivo` sobre archivos trackeados.
- `git push --force`, `git reset --hard`, `git clean`, borrado de ramas.
- SQL `DROP`, `DELETE` sin `WHERE`, `TRUNCATE`, borrar cualquier `.db`.
- `deploy`, `systemctl stop/restart/disable` de services, `fn sync` (escribe en el servidor).
- `kill -9` masivo, `format`, `mkfs`, `dd`, cambios en `fstab`.
Para todo lo demás (lecturas, búsquedas, `fn run` de funciones puras o idempotentes, `git status/add/commit`, listados), ejecuta directo.
## Salida del modo
Cuando el usuario escriba `salir` o `fin launcher`, cierra el modo con un resumen caveman de una tabla:
```
=== fin MODO LAUNCHER ===
ordenes: N
registry: X (run A / mcp B)
bash: Y
gaps: Z → [lista de candidatos marcados]
Reg %: X/(X+Y) de las ordenes ejecutables golpearon el registry
```
Si hubo candidatos a función (`gap`), recuérdalos y pregunta si promover alguno vía `fn-constructor`.
## Reglas duras del modo
- **Registry-first siempre.** Nunca escribas bash inline para lógica que ya es función del registry. Busca antes de teclear.
- **Cero prosa fuera del bloque.** Nada de "Claro, voy a…". Solo el bloque `FUENTE/CMD/WHY/salida`.
- **Una orden = un bloque.** Si la orden necesita varios pasos, encadénalos en un solo `CMD` o numera los bloques, pero mantén el formato.
- **No inventes IDs.** Si no encuentras función por búsqueda, es `gap`, no un `registry-run` falso.
- **El modo no exime de las reglas del repo** (`registry_calls.md`, `purity.md`, etc.). Solo cambia el estilo de respuesta.
## Ejemplo de sesión
```
tú: /modo_launcher
yo: MODO LAUNCHER activo. Da ordenes. 'salir' para terminar.
tú: busca funciones de slice
yo: FUENTE: registry-mcp
CMD: mcp__registry__fn_search query="slice"
WHY: inspección directa del registro
──────────
filter_slice_go_core — Filtra slice por predicado
chunk_slice_go_core — Parte slice en lotes de N
...
tú: mata lo que escuche en el puerto 8484
yo: FUENTE: registry-run
CMD: ./fn run port_kill_bash_infra 8484
WHY: match FTS 'port kill' → port_kill_bash_infra
──────────
killed pid 33120 on :8484
tú: enséñame el uso de disco de home
yo: FUENTE: bash
CMD: du -sh /home/enmanuel/* | sort -rh | head
WHY: sin función → utilidad de sistema puntual
──────────
12G /home/enmanuel/fn_registry
...
CANDIDATO → disk_usage_top_bash_shell: top-N directorios por tamaño en una ruta
tú: salir
yo: === fin MODO LAUNCHER ===
ordenes: 3
registry: 2 (run 1 / mcp 1)
bash: 1
gaps: 1 → disk_usage_top_bash_shell
Reg %: 2/3 (67%)
1 candidato marcado. ¿Promuevo disk_usage_top_bash_shell vía fn-constructor?
```
## Relación con otras reglas
- `registry_calls.md` — el modo es una capa de estilo sobre los tres patrones canónicos (inspect / run / compose).
- `registry_first.md` — el modo materializa "buscar antes de escribir" en cada orden.
- `function_growth_and_self_docs.md` — los candidatos marcados alimentan la promoción de patrones inline a funciones.
- `kiss.md` — sin hook, sin estado en disco: el modo vive solo en estas instrucciones.
+279
View File
@@ -0,0 +1,279 @@
---
name: orquestador
description: "Modo orquestador: el Claude principal NO hace el trabajo pesado — descompone la tarea y lanza Claudes SECUNDARIOS interactivos, cada uno en su propia terminal kitty con un prompt autonomo y aislamiento git impuesto. El humano habla solo con el orquestador, ve a los secundarios en sus kitties y puede saltar a cualquiera. El orquestador sigue la flota, lee sus reports e integra. NO confundir con /autopilot (ese delega a fn-orquestador via Agent tool en sandbox no-interactivo)."
---
# /orquestador — coordinar Claudes secundarios interactivos en kitty
Activa un **modo de comportamiento** persistente. Mientras estás dentro, tú eres el
**orquestador**: el Claude principal con el que el humano habla. Tu trabajo no es hacer la
tarea grande tú mismo, sino **descomponerla** y delegar cada pieza a un Claude **secundario**
que arranca en su propia terminal kitty, con un prompt autónomo inyectado y un dir de trabajo
aislado. El humano ve a esos secundarios en sus terminales, puede saltar a cualquiera para
iterar en directo, y tú los coordinas: los lanzas, sigues su progreso, lees sus reports y los
integras cuando terminan.
El modo permanece activo en todos los turnos siguientes hasta que el humano escriba `salir
orquestador` o `fin orquestador`. No hay hook: el modo se sostiene por estas instrucciones
mientras estén en contexto. Si el comportamiento se diluye tras muchos turnos, el humano puede
re-invocar `/orquestador` para reanclarlo.
Al entrar, responde con una sola línea de confirmación y queda a la espera de la tarea grande:
```
MODO ORQUESTADOR activo. Dame la tarea grande; la descompongo y lanzo secundarios. 'fin orquestador' para terminar.
```
## Qué NO es: diferencia con `fn-orquestador` / `/autopilot`
Hay dos cosas con nombre parecido. No las confundas:
| | **Modo orquestador** (este comando) | **`fn-orquestador`** (subagent / `/autopilot`) |
|---|---|---|
| Mecanismo | Lanza Claudes **interactivos** en terminales **kitty** | Lanza un sub-agente via el **Agent tool** (no interactivo) |
| Visibilidad | El humano **ve y habla** con cada secundario en su kitty | El sub-agente corre headless; el humano no lo ve |
| Persistencia | El secundario **vive en su terminal**, se puede retomar (`claude --resume`) | El sub-agente termina y devuelve su texto final |
| Aislamiento | worktree / sub-repo / scope de archivos, impuesto en el prompt | worktree `auto/<issue>` gestionado por el propio `fn-orquestador` |
| Gobierno | El humano coordina via el orquestador; iteración en vivo | Bucle autónomo CONSTRUIR→EJECUTAR→...→MEJORAR hasta converger, PR draft |
| Regla de referencia | esta página | `.claude/rules/autonomous_loop.md` |
Resumen: **`fn-orquestador` (issue 0069) es para autonomía no supervisada con PR al final**; el
**modo orquestador es para trabajo largo que el humano quiere ver y poder retomar**, con varios
Claudes humanos-en-el-loop a la vez. Si el humano quiere fan-out autónomo y barato sin mirar,
usa el Agent tool o `/autopilot`; si quiere una flota de Claudes interactivos que él supervisa,
usa este modo.
## El ciclo del orquestador (8 pasos)
### 1. Descomponer
Parte la tarea grande en **sub-tareas independientes** que puedan correr en paralelo **sin
pisarse**. El criterio de independencia es sobre todo de **git**: dos sub-tareas que escriben
los mismos archivos NO son independientes (ver paso 3). Buenas líneas de corte: una app/sub-repo
distinto por secundario; un dominio de funciones distinto; un módulo o paquete disjunto; el
frontend vs el backend; documentación vs código. Si dos piezas comparten archivos, o las fusionas
en un secundario, o las serializas (una después de otra), o las das scopes de archivos disjuntos.
### 2. Lanzar cada secundario
Comando canónico de lanzamiento (memoria `lanzar-agentes-skip-permissions`), **siempre** con
`--dangerously-skip-permissions` porque los secundarios trabajan autónomos y desatendidos y los
prompts de permiso en cada Bash los atascarían:
```bash
setsid nohup kitty --title "<PROYECTO> · <subtarea>" --directory <dir-aislado> \
zsh -ic 'claude --dangerously-skip-permissions "$(cat /tmp/orq_<slug>.md)"; exec zsh' \
>/tmp/orq_<slug>_kitty.log 2>&1 & disown
```
`setsid nohup ... & disown` hace que la kitty sobreviva al cierre de la terminal padre. El
`zsh -ic '...; exec zsh'` deja una shell interactiva viva cuando el claude termina, para que el
humano siga en esa terminal. El log de `/tmp/orq_<slug>_kitty.log` es donde se ve el arranque.
**Prefiere la función del registry** en vez de teclear el one-liner a mano (registry-first,
queda en telemetría):
```bash
./fn run launch_claude_agent_kitty "<PROYECTO> · <subtarea>" <dir-aislado> /tmp/orq_<slug>.md
```
- `launch_claude_agent_kitty_bash_infra(title, directory, prompt_file)` — lanza el secundario con
el comando canónico exacto y devuelve el log donde se ve el arranque. Valida que el dir y el
prompt_file existan y que kitty esté instalado.
### 3. Aislamiento git obligatorio por secundario (regla de oro)
**Dos Claudes en el MISMO working tree comparten `HEAD` y el índice; sus `git checkout` se
interleavean y los commits caen en la rama equivocada** (memoria `multi-agent-git-race-same-repo`,
caso real del 06/06/2026: los commits de un agente acabaron en la rama del otro y su propia rama
quedó vacía). Por eso **cada secundario trabaja en un espacio aislado**, y el orquestador elige
cuál y se lo **impone** en el prompt del secundario:
| Opción | Cómo | Cuándo |
|---|---|---|
| **(a) Sub-repo Gitea propio** | El secundario trabaja dentro de `apps/<x>/`, `analysis/<x>/`, `projects/<p>/...` — cada uno tiene su `.git` independiente (regla `apps_subrepo.md`) | Cuando las sub-tareas caen en apps/analyses/projects distintos. Es el aislamiento natural del monorepo. |
| **(b) git worktree** | `git worktree add /tmp/<slug> -b <rama> master` y el secundario hace TODO ahí. Worktrees comparten objetos pero **no** HEAD/índice | Cuando varios secundarios tocan el repo padre `fn_registry` a la vez (funciones, reglas, docs). |
| **(c) Scope de archivos disjunto** | Mismo working tree pero cada secundario commitea **solo sus paths**: `git add <paths-específicos>`, **nunca** `git add -A` | Último recurso, solo si los scopes están garantizados disjuntos y no hay `git checkout` de rama de por medio. Frágil; prefiere (a) o (b). |
Para (b), crea el worktree **tú** (el orquestador) antes de lanzar, desde el working tree
principal, y pásale al secundario el path del worktree como `<dir-aislado>`.
### 4. El prompt de cada secundario
Lo escribes tú en `/tmp/orq_<slug>.md` antes de lanzar. El secundario **no ve este historial**;
el prompt debe ser **autocontenido**. Incluye SIEMPRE:
1. **Objetivo claro** — qué construir/arreglar, acotado y verificable.
2. **Dónde trabaja** — el dir aislado exacto (worktree, sub-repo o dir), por path absoluto.
3. **Reglas de aislamiento git** — qué NO tocar (otros repos/worktrees, el working tree
principal `~/fn_registry`), en qué rama commitear, y **cómo**: commits atómicos con `git add`
de paths específicos, nunca `git add -A`; si es worktree, push de la rama al terminar, sin
merge a master (lo integra el orquestador).
4. **Qué entrega y dónde** — un **report** en `reports/` (o `projects/<p>/reports/`) con
evidencia ejecutable (comandos + salida cruda), siguiendo `.claude/rules/reports.md` y
`.claude/rules/dod_quality.md`. Reports son artefacto local gitignored: se escriben, no se
commitean.
5. **Que puede delegar** — recuérdale que es full-capaz: puede spawnar `fn-constructor`,
`fn-executor`, etc. via el Agent tool, y debe seguir registry-first (`registry_calls.md`,
`delegation.md`).
6. **La coletilla**: *"reporta tu progreso en esta terminal"* — para que el humano que mire la
kitty vea el estado sin abrir el report.
Mira `/tmp/unibus_agent_*.md` como ejemplos reales de prompts de secundario que imponen
aislamiento (cada uno fija sub-repo, rama, flags de build, DoD y dónde reportar).
### 5. Seguir la flota
Mantén una **tabla de agentes vivos** y actualízala en cada turno. La fuente de verdad del
mapeo PID→sessionId→cwd son los archivos `~/.claude/sessions/<PID>.json` (memoria
`claude-session-pid-mapping`). Usa la función del registry para listarla:
```bash
./fn run list_claude_agents # tabla: PID, STATUS, ETIME, KITTY, SELF, SESSION_ID, CWD
./fn run list_claude_agents --json # para parsear y decidir
```
- `list_claude_agents_bash_infra([--json] [--exclude-current])` — cruza `pgrep -x claude` con los
`sessions/<PID>.json` (con validación anti-PID-reciclado), marca tu propia sesión como `SELF`,
y reporta cwd + sessionId de cada secundario (para retomar con `claude --resume <sessionId>`).
Tu tabla de seguimiento, una fila por secundario:
| slug | título kitty | PID | cwd / dir aislado | rama | log | report | estado |
|---|---|---|---|---|---|---|---|
| docs | fn_registry · docs | 3637133 | /tmp/orq_docs_wt | orq/docs | /tmp/orq_docs_kitty.log | reports/00NN-…-docs.md | en curso |
Cuando un secundario parezca terminado, confirma: ¿pusheó la rama? ¿escribió el report? Lee el
report (`reports/`), revisa los commits de su rama (`git -C <dir> log --oneline`).
### 6. NUNCA `pkill`/`killall` sobre claude
Un `pkill claude` o `killall claude` **te mata a ti mismo** (el orquestador) junto con la flota.
Para parar un secundario:
- **Kill por PID exacto** del secundario (lo tienes en la tabla / `list_claude_agents`):
`kill <PID>` (o `kill <KITTY_PID>` para cerrar su ventana). Verifica que NO es tu `SELF`.
- **`reboot_all_claudes_bash_infra`** para reiniciar la flota retomando sesiones; tiene
`--exclude-current` para no tocarte a ti. Es dry-run por defecto; `--go` para ejecutar.
### 7. Integrar
Cuando un secundario termina (rama pusheada + report verde):
1. **Revisa** su diff y su report. Si el report no trae evidencia ejecutable o falla la DoD,
devuélvele trabajo (el humano puede saltar a su kitty, o tú le mandas otro prompt).
2. **Mergea si procede** desde el **working tree principal** (ahí suele estar `master`
checked-out): `git -C ~/fn_registry merge --no-ff <rama>` para apps con TBD, o el flujo que
corresponda al sub-repo. Para funciones nuevas del registry padre, sus archivos viajan en la
rama y el merge los lleva a master.
3. **Informa al humano** y **resume el estado de la flota** en cada turno: quién terminó, quién
sigue, qué se integró, qué falta.
### 8. kitty vs Agent tool — cuándo cada uno
- **kitty (este modo)**: trabajo **largo e interactivo** que el humano quiere **ver** y poder
**retomar** — implementar una feature de horas, depurar en vivo, una sesión que evoluciona.
- **Agent tool directo**: fan-out **acotado y no interactivo** — buscar en el codebase, crear
una función con `fn-constructor`, auditar N apps con `fn-recopilador`. Más barato, sin
terminal, sin supervisión humana. Para esto NO lances kitty: usa `Agent(...)` y ya.
Regla práctica: si el humano va a querer hablar con ello o mirarlo trabajar → kitty. Si es una
sub-tarea que devuelve un resultado y se acabó → Agent tool.
## Reglas duras del modo
- **El orquestador no hace el trabajo pesado.** Descompone, lanza, sigue, integra. Si te
encuentras escribiendo tú la feature, párate: ¿no debería ser un secundario?
- **Cada secundario, su aislamiento.** Nunca lances dos secundarios sobre el mismo working tree
sin worktrees/sub-repos/scopes disjuntos. Es la causa nº1 de commits perdidos.
- **El prompt del secundario lleva SIEMPRE las reglas de aislamiento.** Un prompt sin "trabaja
aquí, no toques aquello, commitea así" es un secundario que contaminará otro repo.
- **Nunca `git add -A` en un secundario** salvo que su dir aislado sea exclusivamente suyo
(worktree/sub-repo). En scope compartido, paths específicos.
- **Nunca `pkill`/`killall claude`.** Kill por PID exacto o `reboot_all_claudes --exclude-current`.
- **El humano habla contigo.** Tú resumes la flota; no le hagas perseguir 5 terminales.
## Anti-patrones
| Anti-patrón | Por qué es malo | En su lugar |
|---|---|---|
| `pkill claude` para parar la flota | Te mata a ti (el orquestador) también | Kill por PID exacto / `reboot_all_claudes --exclude-current` |
| Dos secundarios en el mismo working tree | Comparten HEAD/índice → commits dispersos, ramas vacías | worktree / sub-repo / scope disjunto por secundario |
| Prompt de secundario sin reglas de aislamiento | El secundario contamina el repo padre u otro worktree | El prompt fija dir, qué NO tocar, rama y cómo commitear |
| `git add -A` en scope compartido | Arrastra cambios de otra sub-tarea al commit | `git add <paths-específicos>` |
| Lanzar kitty para un fan-out trivial | Caro y sin supervisión que aporte | Agent tool directo (`fn-constructor`, `Explore`, …) |
| Hacer tú la feature "porque es rápido" | Pierdes el sentido del modo; el humano no lo ve evolucionar | Descompón y lanza un secundario |
| Lanzar sin `--dangerously-skip-permissions` | El secundario se atasca pidiendo permiso en cada Bash | Siempre `--dangerously-skip-permissions` (riesgo asumido) |
| Mergear desde el dir del secundario | Master suele estar en el working tree principal; colisión de HEAD | Mergear desde `~/fn_registry` |
## Funciones del registry que usa este modo (grupo `orchestration`)
| Función | Para qué |
|---|---|
| `launch_claude_agent_kitty_bash_infra` | Lanzar un secundario en kitty con prompt autónomo + `--dangerously-skip-permissions` |
| `list_claude_agents_bash_infra` | Listar la flota de Claudes vivos (PID, sessionId, cwd, status, kitty) para seguirla |
| `reboot_all_claudes_bash_infra` | Reiniciar/parar la flota retomando sesiones; `--exclude-current` para no tocarte |
## Ejemplo end-to-end
Tarea grande: *"añade un endpoint `/api/health` al backend de la app `kanban` y, en paralelo,
documenta el grupo de capacidad `deploy` en `docs/capabilities/deploy.md`"*. Dos piezas
independientes: una toca el sub-repo `apps/kanban` (su propio `.git`), la otra toca el repo
padre `fn_registry` (docs). Aislamiento natural distinto para cada una.
```bash
# 1. Descomponer → 2 secundarios independientes:
# A) health endpoint → sub-repo apps/kanban (aislamiento (a))
# B) doc capability → worktree del padre (aislamiento (b))
# 2. Preparar aislamiento de B (worktree del padre; A ya está aislado por su sub-repo):
git -C ~/fn_registry worktree add /tmp/orq_capdoc -b orq/cap-deploy master
# 3. Escribir los prompts autónomos (autocontenidos, con reglas de aislamiento):
# /tmp/orq_health.md → "trabaja en apps/kanban (sub-repo propio), rama issue/health,
# commits atómicos de tus paths, push al terminar, report en reports/. No toques el
# repo padre. Reporta tu progreso en esta terminal."
# /tmp/orq_capdoc.md → "trabaja SOLO en /tmp/orq_capdoc (worktree), rama orq/cap-deploy,
# toca solo docs/capabilities/deploy.md, git add de ese path, push al terminar, report
# en reports/. No toques ~/fn_registry. Reporta tu progreso en esta terminal."
# 4. Lanzar ambos secundarios (cada uno su kitty, su dir aislado):
./fn run launch_claude_agent_kitty "kanban · health endpoint" \
~/fn_registry/apps/kanban /tmp/orq_health.md
./fn run launch_claude_agent_kitty "fn_registry · doc deploy" \
/tmp/orq_capdoc /tmp/orq_capdoc.md
# 5. Seguir la flota (cada turno):
./fn run list_claude_agents
# → tabla con los 2 secundarios vivos (PID, cwd, sessionId, status) + tu SELF.
# Lee /tmp/orq_*_kitty.log para el arranque; cuando terminen, lee sus reports/.
# 7. Integrar (desde el working tree principal):
git -C ~/fn_registry/apps/kanban merge --no-ff issue/health # sub-repo de la app
git -C ~/fn_registry merge --no-ff orq/cap-deploy # repo padre (la doc)
git -C ~/fn_registry worktree remove /tmp/orq_capdoc # limpiar worktree
# Resumen al humano: A integrado (endpoint + test verde), B integrado (doc),
# flota vacía. Tarea grande hecha.
```
## Salida del modo
Cuando el humano escriba `salir orquestador` o `fin orquestador`, cierra con un resumen de la
flota: secundarios lanzados, cuáles terminaron e integraste, cuáles siguen vivos (con su kitty
para que el humano decida), y los reports generados. Si quedan secundarios vivos, recuérdale que
`list_claude_agents` los lista y que para pararlos es kill por PID exacto, nunca `pkill`.
## Relación con otras reglas
- `.claude/rules/autonomous_loop.md``fn-orquestador` (Agent tool, sandbox no-interactivo). Es
lo que este modo **no** es; tenlas claras separadas.
- `.claude/rules/apps_subrepo.md` — apps/analyses/projects son sub-repos Gitea (`apps/*`
gitignored): el aislamiento natural (opción (a)) y el gotcha de `git init` antes de limpiar un
worktree con una app nueva dentro.
- `.claude/rules/reports.md` + `.claude/rules/dod_quality.md` — qué entrega cada secundario:
report con evidencia ejecutable + gaps.
- `.claude/rules/delegation.md` + `.claude/rules/registry_calls.md` — los secundarios siguen
registry-first y delegan a `fn-constructor` igual que tú.
- Memorias: `lanzar-agentes-skip-permissions`, `multi-agent-git-race-same-repo`,
`claude-session-pid-mapping`, `prefiere-kitty-terminal`.
+2
View File
@@ -40,3 +40,5 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
| 33 | [project_commands.md](project_commands.md) | Slash commands por project (`.claude/commands/<project>/`) expuestos via symlink. Desde fn_registry: `/<project>:foo`. Desde el project: `/foo`. Sin colision. |
| 34 | [dod_quality.md](dod_quality.md) | DoD Quality Triada: Mecanica + Cobertura (golden + edge + error path con evidencia ejecutable) + Vida util validada (>=7 dias uso real). Cierra anti-criterios contra checkbox vago. Aplica a `dev/flows/` y issues user-facing. |
| 35 | [llm_invocation.md](llm_invocation.md) | Invocacion de LLM: SIEMPRE `ask_llm` (grupo `claude-direct`, API directa, arranque 0), NUNCA `claude -p` (lento, cold start). One-shot/streaming/tool-loop + legacy `claude_stream_go_core` deprecado. |
| 36 | [reports.md](reports.md) | Reports: reportes de trabajo como artefacto local (entregable de tarea con evidencia). Gitignored salvo `.gitkeep`, NO suben a Gitea ni se indexan (como vaults+playgrounds). Viven en `reports/` o `projects/<p>/reports/`. Convencion + plantilla. ADR 0006. |
| 37 | [flow_replay.md](flow_replay.md) | Flow replay: guardar un flujo web (login, reiniciar server, formulario) como funcion del registry. Patron grabar→destilar→reproducir con jerarquia HTTP puro > headless chromium > visible humanizado. Empieza por Nivel 1. Seguridad: HAR sensible, secrets a pass, acciones con efecto exigen confirmacion. Grupo `flow-replay`. Issue 0087. |
+4 -1
View File
@@ -1,6 +1,6 @@
## Artefactos: termino colectivo
**"Artefacto"** es el termino paraguas para todo lo que vive en el registry pero NO es codigo reutilizable de `functions/` o `types/`. Sirve para no repetir "apps, analysis, vaults, projects, playgrounds" cada vez.
**"Artefacto"** es el termino paraguas para todo lo que vive en el registry pero NO es codigo reutilizable de `functions/` o `types/`. Sirve para no repetir "apps, analysis, vaults, projects, playgrounds, reports" cada vez.
Tipos de artefacto:
@@ -11,6 +11,7 @@ Tipos de artefacto:
| **vault** | `projects/<p>/vaults/<v>` (symlink) | tabla `vaults` | no (datos fuera del repo) |
| **project** | `projects/<p>/` | tabla `projects` | no (vive dentro de fn_registry) |
| **playground** | `<artefacto_padre>/playground/` | NO se indexa | no (vive dentro del padre) |
| **report** | `reports/`, `projects/<p>/reports/` | NO se indexa | no (local, gitignored, no sube a Gitea — como vaults) |
Caracteristicas comunes de los artefactos:
- NO son codigo reutilizable. La reutilizacion vive en `functions/`.
@@ -18,6 +19,8 @@ Caracteristicas comunes de los artefactos:
- `pc_locations` los unifica via `entity_type` (app, analysis, project, vault).
- Pueden importar funciones del registry; el registry NUNCA importa de un artefacto.
**Reports** son el caso mas ligero: artefacto local (gitignored salvo `reports/.gitkeep`), NO sube a Gitea ni se versiona en el padre (como los vaults), NO se indexa (como los playgrounds). Convencion en [[reports]]. Pueden vivir sueltos en `reports/` o dentro de un proyecto en `projects/<p>/reports/`.
### Cuando usar el termino
Usa "artefacto" cuando hablas de varios tipos a la vez o cuando la afirmacion aplica a todos:
+76
View File
@@ -0,0 +1,76 @@
## Flow replay: guardar un flujo web como función reproducible
Cuando una acción web se hace **más de una vez** (login en un panel, reiniciar un servidor
desde su consola, rellenar un formulario recurrente, descargar un export), deja de hacerse a
mano: se **graba una vez y se promueve a función del registry**. Es la doctrina del issue 0087
aplicada a la navegación — el registry crece convirtiendo secuencias repetidas en operaciones
de un solo paso, no inflando funciones existentes.
Grupo de capacidad: `flow-replay`. Página madre: `docs/capabilities/flow-replay.md`. Graba con
el grupo `web-proxy`; destila y reproduce con `flow-replay`.
### El patrón: grabar → destilar → reproducir
1. **Grabar** (una vez, con browser + proxy): `web_proxy` ON, haces la acción a mano,
exportas el tramo a HAR (`query_mitm_flows --har`).
2. **Destilar**: `har_filter_flows_py_cybersecurity` (quita ruido) →
`har_extract_calls_py_cybersecurity` (call specs reproducibles).
3. **Reproducir**, en esta jerarquía de preferencia (de barato a caro):
| Nivel | Mecanismo | Cuándo |
|---|---|---|
| **1 — HTTP puro** | `http_replay_sequence_py_infra` | **Por defecto.** Rápido, headless, scriptable. La mayoría de paneles admin funcionan con cookie de sesión + requests. |
| **2 — headless chromium** | action recipe (reutiliza `cdp_extract_recipe` + `cdp_save_storage_state`) | Token dinámico firmado en cliente, challenge JS obligatorio, WAF con fingerprint. |
| **3 — chromium visible + humanizado** | `cdp_click_xy_human`, `cdp_move_mouse_human` | Headless detectado/bloqueado. Último recurso. |
**Empieza SIEMPRE por el Nivel 1.** Solo baja de nivel cuando el anterior demuestre no
reproducir el efecto. Construir el runner de Nivel 2/3 por adelantado, sin un caso que lo
exija, es especular (KISS): se monta cuando un flujo real falle en HTTP puro.
### Flujo de autoría (cómo guardar una función-acción nueva)
1. Grabar el flujo y exportar el HAR del tramo.
2. `har_filter_flows` + `har_extract_calls` → boceto de la secuencia. El agente **lee** el
HAR (es texto) e identifica los 2-4 requests que producen el efecto (auth + acción +
confirmación), descartando el resto.
3. Parametrizar: marcar los valores variables (ids, tokens) como `{{param}}`; definir las
reglas `extract` para los tokens que una respuesta genera y otro request consume.
4. Validar el replay con `http_replay_sequence`. Si reproduce el efecto sin navegador → Nivel 1.
5. **Promover a función del registry**: delegar a `fn-constructor` una función-acción nombrada
con verbo (`reboot_vps_server_<panel>`, `login_<panel>`, `export_<panel>_report`) que
internamente llama a `http_replay_sequence` con su secuencia fija, recibe los parámetros
del caller y resuelve los secretos desde `pass`/vault. Tag de grupo `flow-replay` + el
dominio que toque (infra, cybersecurity, …). `fn index` + usar en el mismo turno.
### Reglas duras de seguridad
- **El HAR es un secreto**: lleva cookies/tokens en crudo. Gitignored, no subir a Gitea, no
indexar, borrar tras destilar. El output de `har_extract_calls` también, hasta sustituir por
`{{param}}`.
- **Secretos a `pass`/vault**, jamás hardcodeados en la función-acción.
- **Replay con efectos = peligroso.** Una acción destructiva o irreversible (reiniciar, borrar,
pagar, enviar) NUNCA se reproduce a ciegas: la función-acción exige confirmación o un flag
explícito (`confirm=True` / `--yes`) antes de disparar.
- `http_replay_sequence` usa `verify_tls=True` y sigue redirects por defecto; la extracción
JSON es dot-path simple, no JSONPath completo.
### Anti-patrones
| Anti-patrón | Por qué es malo | Sustituir por |
|---|---|---|
| Repetir el flujo a mano cada vez | No capitaliza; lento; propenso a error | Grabar una vez → función-acción |
| Reescribir requests inline en un heredoc/app cada vez | Reinvento, sin telemetría | Función-acción que llama `http_replay_sequence` |
| Empezar por chromium headless "por si acaso" | Más caro y frágil que HTTP puro | Nivel 1 primero, bajar solo si falla |
| Hardcodear cookie/token del HAR en el código | Secreto filtrado + caduca | `{{param}}` desde `pass`/vault |
| Commitear el HAR o el output crudo de extract | Filtración de credenciales | Tratar como secreto, gitignored |
| Replay ciego de un POST destructivo | Daño irreversible | Confirmación / flag explícito |
### Relación con otras reglas
- [[registry_first]] — buscar/reutilizar antes de escribir; la función-acción se delega a
`fn-constructor`, no se escribe inline.
- [[function_growth_and_self_docs]] — el registry crece por promoción de composiciones
repetidas a funciones one-shot (issue 0087); esto es ese patrón para la navegación.
- [[registry_calls]] — invocar las funciones del grupo por los patrones canónicos (MCP /
`fn run` / heredoc que importa).
- Grupo `web-proxy` (`docs/capabilities/web-proxy.md`) — la captura que alimenta la Fase 0.
+1 -1
View File
@@ -13,7 +13,7 @@ IDs: `{name}_{lang}_{domain}` (ej: `filter_slice_go_core`). Predictibilidad alta
Lista no exhaustiva pero cubre la mayoria. Anadir aqui (y al validator en `apps/registry_mcp/naming.go`) cuando se introduzca un verbo nuevo recurrente.
`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, start, stop, kill, restart, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate`
`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, relaunch, start, stop, kill, restart, reboot, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate`
### Excepciones
+78
View File
@@ -0,0 +1,78 @@
## Reports: reportes de trabajo como artefacto local
Un **report** es el entregable escrito de una tarea no trivial: qué se hizo, cómo se verificó y qué quedó pendiente, en formato copiable de un vistazo. Sirve para conservar el resultado fuera del chat y compartirlo rápido pasando la ruta del archivo.
Un report es un **artefacto** (ver `artefactos.md`), no documentación del registry. En consecuencia:
- **NO se versiona en el git del padre `fn_registry`** ni en ningún sub-repo: `reports/*` está en el `.gitignore` (solo el marcador `reports/.gitkeep` se versiona). Igual que los **vaults**.
- **NO sube a Gitea**: un report no tiene repo propio. Vive local en la máquina que lo generó. Compartir = pasar la ruta o copiar el contenido, no `git push`.
- **NO se indexa en `registry.db`**: no hay tabla `reports` ni schema. KISS — son texto plano efímero, como los `playgrounds`.
### Qué NO es un report
| Es | Va a |
|---|---|
| Decisión de diseño (qué se decidió y por qué) | `docs/adr/` (versionado) |
| Norma operativa / convención | `.claude/rules/` (versionado) |
| Bitácora cronológica libre | `docs/diary/` (versionado) |
| **Resultado de una tarea concreta + su evidencia** | **`reports/` (artefacto local, NO versionado)** |
Si durante el trabajo aparece una decisión de diseño, esa decisión va a `docs/adr/` y el report solo la referencia.
### Ubicación
Como cualquier artefacto, un report puede vivir en dos sitios:
| Ubicación | Para qué |
|---|---|
| `reports/` (raíz) | Reportes que no pertenecen a ningún proyecto |
| `projects/<p>/reports/` | Reportes del trabajo de un proyecto concreto |
Ambas rutas están gitignored (`reports/*`, `projects/*/reports/`). Se pueden crear subcarpetas bajo `reports/` para agrupar (`reports/browser/`, `reports/audits/`, …).
### Convención de nombre
```
NNNN-YYYY-MM-DD-slug-corto.md
```
- `NNNN` — número incremental de 4 dígitos por carpeta (0001, 0002, …). Referencia corta ("report 0003").
- `YYYY-MM-DD` — fecha del trabajo (ISO en el nombre; en el cuerpo, fechas en formato europeo DD/MM/AAAA).
- `slug-corto` — kebab-case descriptivo. Ej: `browser-domain-audit-fixes`.
### Plantilla mínima
```markdown
# Report NNNN — Título
- **Fecha:** DD/MM/AAAA
- **Autor:** (agente/humano)
- **Ámbito:** (dominio/app/módulo tocado)
- **Estado:** done | parcial | bloqueado
## Resumen
Qué se hizo y el resultado, en 2-4 líneas.
## Cambios
Tabla o lista de lo tocado/creado, con el porqué.
## Verificación
Comandos ejecutados + salida cruda (build/test/vet/e2e). Sin "verde" sin evidencia.
## Gaps / pendientes
Lo que NO se cubrió y por qué (honesto: requiere Chrome, scope, etc.).
```
### Reglas
- **Cuándo escribir uno**: auditorías, tandas de fixes con verificación, refactors, investigaciones — cualquier trabajo cuyo resumen pedirías "para compartir rápido". Un fix de una línea NO necesita report; basta el commit.
- **Evidencia ejecutable obligatoria**: cada "pasa" lleva su comando/salida. Nada de smoke "no petó". Alineado con `dod_quality.md`.
- **Honestidad sobre gaps**: declarar siempre qué quedó sin cubrir.
- **Índice opcional**: si una carpeta de reports acumula muchos, mantener un `INDEX.md` local (también gitignored) ayuda a navegar; no es obligatorio.
### Relación con otras reglas y ADRs
- [[artefactos]] — report es un tipo de artefacto (no código reutilizable, ciclo de vida propio).
- [[playgrounds]] — mismo espíritu (artefacto local no indexado); el playground es prototipo de código, el report es resultado escrito.
- [[dod_quality]] — los reports heredan su exigencia de evidencia + gaps.
- ADR 0006 (`docs/adr/0006-reports-folder.md`) — decisión que crea la carpeta `reports/`.
+2 -1
View File
@@ -2,7 +2,8 @@
"permissions": {
"allow": [
"Bash(CGO_ENABLED=1 go test *)",
"Bash(sqlite3 *)"
"Bash(sqlite3 *)",
"Read(//home/enmanuel/.claude/**)"
]
},
"enabledMcpjsonServers": [
+7
View File
@@ -46,6 +46,13 @@ projects/*/
vaults/*/
!vaults/vault.yaml
# Reports — artefacto local: reportes de trabajo. Como los vaults, NO suben a
# Gitea ni se versionan en el padre (solo el marcador .gitkeep). Conviven en
# reports/ (raíz) o projects/<p>/reports/. Convención: .claude/rules/reports.md
reports/*
!reports/.gitkeep
projects/*/reports/
# Node / pnpm
**/node_modules/
@@ -0,0 +1,100 @@
---
name: set_chrome_profile_appearance
kind: function
lang: bash
domain: browser
version: "1.1.0"
purity: impure
signature: "set_chrome_profile_appearance --user-data-dir <dir> --profile <dir-name> [--avatar <N|ruta.png>] [--color <#rrggbb>] [--variant <0..4>] [--dry-run]"
description: "Personaliza la apariencia visual de un perfil Chrome/Chromium existente: asigna un avatar built-in (índice 0..55) o una imagen PNG/JPG custom, y/o un color de acento (hex #rrggbb). Con --color aplica el tinte tanto al círculo del avatar en Local State (profile_highlight_color, profile_color_seed, default_avatar_fill_color) como al tema completo del navegador en el Preferences del perfil (browser.theme.user_color2, browser_color_variant, extensions.theme.system_theme), tiñendo toolbar, frame, barra de pestañas y omnibox. Requiere que Chromium esté cerrado sobre el user-data-dir. Hace backup de Local State y Preferences antes de escribir y valida el JSON resultante."
tags: [navegator, chromium, profile, browser, cdp, scraping, appearance, avatar, color]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/browser/set_chrome_profile_appearance.sh"
params:
- name: --user-data-dir
desc: "Raíz del user-data-dir de Chrome/Chromium donde vive el perfil. El directorio y Local State deben existir. Obligatorio."
- name: --profile
desc: "Nombre de la carpeta del perfil dentro de user-data-dir, por ejemplo: Default, Automation, \"Profile 1\". El perfil debe existir previamente en info_cache de Local State. Obligatorio."
- name: --avatar
desc: "Índice entero 0..55 del avatar built-in de Chrome (56 avatares: animales, objetos, personas) o ruta absoluta/relativa a un archivo PNG/JPG para avatar custom. Con índice: sets avatar_icon=IDR_PROFILE_AVATAR_<N> e is_using_default_avatar=true. Con imagen: copia el archivo al perfil como 'Google Profile Picture.png' y sets is_using_default_avatar=false. Opcional; al menos uno de --avatar o --color debe darse."
- name: --color
desc: "Color de acento del perfil en hex #rrggbb, con o sin el '#' inicial. Se convierte a int32 con signo en formato ARGB 0xFFRRGGBB. Aplica el color en dos lugares: (1) Local State info_cache (profile_highlight_color, profile_color_seed, default_avatar_fill_color) para el círculo del avatar; (2) Preferences del perfil (browser.theme.user_color2 + browser_color_variant + extensions.theme.system_theme=0) para teñir toolbar, frame, barra de pestañas y omnibox. Opcional; al menos uno de --avatar o --color debe darse."
- name: --variant
desc: "Intensidad del tema de color aplicado al navegador (browser_color_variant). Entero 0..4: 0=system, 1=tonal_spot, 2=neutral, 3=vibrant (default), 4=expressive. Valores más altos dan tintes más saturados e identificables. Solo tiene efecto cuando se usa --color. Opcional."
- name: --dry-run
desc: "Describe las acciones que se ejecutarían (campos a modificar en Local State y Preferences, conversión de color, ruta del Preferences) sin escribir nada ni verificar si Chromium está corriendo. Emite JSON de resultado con dry_run:true."
output: "JSON en stdout con los campos resultantes del perfil: {\"profile\":\"<dir>\",\"avatar_icon\":\"...\",\"is_using_default_avatar\":true|false,\"profile_highlight_color\":<int>,\"profile_color_seed\":<int>,\"default_avatar_fill_color\":<int>,\"theme_applied\":true|false,\"variant\":<int>,\"preferences_path\":\"...\",\"browser_theme_user_color2\":<int>,\"browser_theme_color_variant\":<int>,\"extensions_theme_system_theme\":<int>,\"backup\":\"Local State.bak.YYYYMMDD\"}. En dry-run: {\"profile\":\"...\",\"avatar_applied\":true|false,\"color_applied\":true|false,\"theme_applied\":true|false,\"variant\":<int>,\"dry_run\":true}. Mensajes de diagnóstico a stderr. Exit 0 en éxito."
---
## Ejemplo
```bash
source $HOME/fn_registry/bash/functions/browser/set_chrome_profile_appearance.sh
# Asignar avatar #30 y tinte verde a toolbar/frame/omnibox del perfil Automation
# (verde #16a34a tiñe toda la chrome del navegador, no solo el círculo del avatar)
set_chrome_profile_appearance \
--user-data-dir ~/.config/chromium-cdp \
--profile Automation \
--avatar 30 \
--color "#16a34a"
# Salida JSON incluye: theme_applied:true, variant:3, browser_theme_user_color2:-15293622
# Color con intensidad personalizada (expressive = máxima saturación)
set_chrome_profile_appearance \
--user-data-dir ~/.config/chromium-cdp \
--profile Scraping \
--color "#1f6feb" \
--variant 4
# Solo cambiar avatar (no toca Preferences del perfil)
set_chrome_profile_appearance \
--user-data-dir ~/.config/chromium-cdp \
--profile "Profile 1" \
--avatar 5
# Dry-run: ver qué se aplicaría en Local State y Preferences sin escribir
set_chrome_profile_appearance \
--user-data-dir ~/.config/chromium-cdp \
--profile Automation \
--avatar 30 \
--color "#16a34a" \
--dry-run
```
## Cuando usarla
Úsala para diferenciar visualmente los perfiles de un user-data-dir de automatización — un color y avatar distintos por perfil hacen inmediata la identificación en el selector de Chrome Y en la chrome del navegador (toolbar/frame visible mientras navega). Ejecútala justo después de `create_chrome_profile` (con `--no-launch`) o como paso independiente de personalización batch antes de lanzar sesiones CDP. Si solo quieres teñir el círculo del avatar (sin el tema), basta esta función; si quieres el tinte completo del navegador (lo más identificable), pasa `--color`.
## Gotchas
- **Chromium debe estar cerrado**: Chrome reescribe `Local State` y `Preferences` completos desde memoria al cerrar; si se ejecuta mientras hay un proceso chromium vivo sobre el mismo user-data-dir, Chrome sobreescribirá los cambios al salir. La función detecta esto con `pgrep -x chromium` filtrando por cmdline y sale con exit 2 antes de modificar nada. Usa `pkill -TERM chromium` para cerrar y espera unos segundos.
- **El tema se escribe en Preferences del perfil, distinto de Local State**: los cambios de color al avatar van en `<user-data-dir>/Local State` (global a todos los perfiles); los cambios de tema del navegador van en `<user-data-dir>/<profile_dir>/Preferences` (específico de cada perfil). La función hace backup de ambos archivos por separado antes de tocarlos.
- **El perfil debe existir en info_cache**: esta función personaliza perfiles existentes; no los crea. Usa `create_chrome_profile` primero (con `--no-launch` basta para que aparezca en Local State) y luego `set_chrome_profile_appearance`.
- **color es int32 con signo en ARGB**: Chrome almacena el color como entero con signo de 32 bits en formato `0xAARRGGBB`. Un color como `#16a34a` (verde) da ARGB `0xFF16A34A` → signed int32 `-15293622`. La función hace la conversión internamente; tú pasas siempre hex `#rrggbb`.
- **En modo oscuro del sistema el tinte sale más apagado**: en temas oscuros del sistema el color se mezcla con el fondo oscuro y queda menos saturado. Para compensar, usa `--variant 3` (vibrant, default) o `--variant 4` (expressive); valores bajos como 1 o 2 pueden resultar casi imperceptibles en modo oscuro.
- **`extensions.theme.system_theme` se fuerza a 0**: si el perfil usaba el tema GTK del sistema (`system_theme=1`), el GTK puede ignorar el `user_color`. Esta función lo fuerza a 0 (tema propio de Chrome) para que el `user_color2` tenga efecto. Si quieres devolver el perfil al tema del sistema, tendrás que resetear `system_theme` manualmente.
- **Avatar custom (imagen) es best-effort**: el campo `gaia_picture_file_name` y `is_using_default_avatar=false` se aplican correctamente en Local State y la imagen se copia al directorio del perfil. Sin embargo, Chrome puede ignorar la foto de perfil en perfiles sin sesión Google activa (Chromium sin cuenta). El camino robusto y garantizado es usar el índice built-in (`--avatar 0..55`): 56 avatares (animales, objetos, personas) son más que suficientes para diferenciar perfiles de automatización.
- **Backup diario**: se crea `Local State.bak.YYYYMMDD` y `Preferences.bak.YYYYMMDD` antes de cualquier escritura. Si ya existen los backups del día no se sobreescriben. Si el JSON resultante es inválido, se restaura automáticamente el backup correspondiente.
## Exit codes
| Código | Significado |
|--------|------------|
| 0 | Éxito |
| 1 | Argumento obligatorio faltante, rango inválido o archivo de imagen no encontrado |
| 2 | Lock: hay un chromium usando el mismo user-data-dir |
| 3 | El perfil no existe en info_cache de Local State |
| 4 | Error editando Local State o Preferences (JSON inválido tras escritura, restaurado backup) |
## Capability growth log
v1.1.0 (2026-06-06) — --color ahora aplica también el tema del navegador (toolbar/frame/omnibox) escribiendo browser.theme.user_color2 + browser_color_variant en el Preferences del perfil, no solo el color del avatar en Local State. Nuevo flag --variant (0..4, default 3 vibrant). Verificado con captura en Chromium 148.
@@ -0,0 +1,426 @@
#!/usr/bin/env bash
# set_chrome_profile_appearance — personaliza la apariencia visual de un perfil
# Chrome/Chromium existente: asigna un avatar built-in (índice 0..55) o una imagen
# PNG/JPG custom, y/o un color de acento (hex #rrggbb). Edita Local State Y el
# Preferences del perfil (browser.theme.* para teñir toolbar/frame/omnibox).
set -euo pipefail
set_chrome_profile_appearance() {
# ── defaults ──────────────────────────────────────────────────────────────
local _udd=""
local _profile_dir=""
local _avatar=""
local _color=""
local _variant=3
local _dry_run=0
# ── parse args ─────────────────────────────────────────────────────────────
_usage() {
cat >&2 <<'EOF'
Usage: set_chrome_profile_appearance --user-data-dir <dir> --profile <dir-name>
[--avatar <N|ruta.png>] [--color <#rrggbb>] [--variant <0..4>] [--dry-run]
--user-data-dir Raíz del user-data-dir de Chrome/Chromium (obligatorio).
--profile Nombre de la carpeta del perfil, ej: Default, Automation,
"Profile 1" (obligatorio). El perfil debe existir.
--avatar Índice entero 0..55 del avatar built-in de Chrome, o ruta a
un archivo PNG/JPG para avatar custom (opcional).
--color Color de acento del perfil en formato hex #rrggbb, con o sin
el '#' inicial (opcional). Aplica el color tanto al círculo
del avatar (Local State) como al tema del navegador
(toolbar/frame/omnibox via Preferences del perfil).
--variant Intensidad del tema de color: 0=system, 1=tonal_spot,
2=neutral, 3=vibrant (default), 4=expressive. Solo tiene
efecto cuando se usa --color.
--dry-run Describe las acciones sin modificar nada.
Al menos uno de --avatar o --color debe indicarse.
Exit codes:
0 éxito
1 error de argumento o validación
2 lock: hay un chromium corriendo con este user-data-dir
3 el perfil no existe en info_cache de Local State
4 error editando Local State o Preferences (JSON inválido tras escritura)
EOF
return 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--user-data-dir) _udd="$2"; shift 2 ;;
--profile) _profile_dir="$2"; shift 2 ;;
--avatar) _avatar="$2"; shift 2 ;;
--color) _color="$2"; shift 2 ;;
--variant) _variant="$2"; shift 2 ;;
--dry-run) _dry_run=1; shift ;;
-h|--help) _usage; return 0 ;;
*) echo "set_chrome_profile_appearance: argumento desconocido: $1" >&2; return 1 ;;
esac
done
# ── validaciones obligatorias ──────────────────────────────────────────────
if [[ -z "$_udd" ]]; then
echo "set_chrome_profile_appearance: --user-data-dir es obligatorio" >&2
return 1
fi
if [[ -z "$_profile_dir" ]]; then
echo "set_chrome_profile_appearance: --profile es obligatorio" >&2
return 1
fi
if [[ -z "$_avatar" && -z "$_color" ]]; then
echo "set_chrome_profile_appearance: al menos --avatar o --color debe indicarse" >&2
return 1
fi
# Validar --variant
if ! [[ "$_variant" =~ ^[0-4]$ ]]; then
echo "set_chrome_profile_appearance: --variant debe ser un entero 0..4, recibido: ${_variant}" >&2
return 1
fi
# Expandir ~ en el user-data-dir
_udd="${_udd/#\~/$HOME}"
local _local_state="${_udd}/Local State"
# Verificar que user-data-dir y Local State existen
if [[ ! -d "$_udd" ]]; then
echo "set_chrome_profile_appearance: user-data-dir no encontrado: ${_udd}" >&2
return 1
fi
if [[ ! -f "$_local_state" ]]; then
echo "set_chrome_profile_appearance: Local State no encontrado: ${_local_state}" >&2
return 1
fi
# ── validar --avatar ──────────────────────────────────────────────────────
local _avatar_index=-1
local _avatar_image_path=""
if [[ -n "$_avatar" ]]; then
if [[ "$_avatar" =~ ^[0-9]+$ ]]; then
# Índice built-in
_avatar_index=$(( _avatar ))
if [[ $_avatar_index -lt 0 || $_avatar_index -gt 55 ]]; then
echo "set_chrome_profile_appearance: índice de avatar fuera de rango (0..55): ${_avatar}" >&2
return 1
fi
else
# Ruta a imagen custom
local _img_path="${_avatar/#\~/$HOME}"
if [[ ! -f "$_img_path" ]]; then
echo "set_chrome_profile_appearance: archivo de imagen no encontrado: ${_img_path}" >&2
return 1
fi
_avatar_image_path="$_img_path"
fi
fi
# ── validar --color ───────────────────────────────────────────────────────
local _color_hex=""
if [[ -n "$_color" ]]; then
_color_hex="${_color/#\#/}" # quitar # inicial si lo hay
if ! [[ "$_color_hex" =~ ^[0-9a-fA-F]{6}$ ]]; then
echo "set_chrome_profile_appearance: color hex inválido (espera rrggbb): ${_color}" >&2
return 1
fi
fi
# ── guard: ningún chromium debe tener ESTE user-data-dir abierto ──────────
# pgrep -x chromium lista solo procesos cuyo comm es exactamente "chromium",
# nunca grep/pgrep/bash. Así evitamos auto-matchear el propio script cuando
# el path del udd contiene "chromium" (p.ej. ~/.config/chromium-cdp).
if [[ $_dry_run -eq 0 ]]; then
local _p _busy=0
for _p in $(pgrep -x chromium 2>/dev/null); do
if tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_udd"; then
_busy=1; break
fi
done
if [[ $_busy -eq 1 ]]; then
echo "set_chrome_profile_appearance: hay un chromium corriendo con este user-data-dir — ciérralo primero:" >&2
echo " pkill -TERM chromium" >&2
echo " (Chrome reescribe Local State y Preferences al cerrar y pierde los cambios)" >&2
return 2
fi
fi
# ── verificar que el perfil existe en info_cache ──────────────────────────
if [[ $_dry_run -eq 0 ]]; then
local _profile_exists
_profile_exists="$(python3 -c "
import json, sys
data = json.load(open(sys.argv[1]))
ic = data.get('profile', {}).get('info_cache', {})
print('yes' if sys.argv[2] in ic else 'no')
" "$_local_state" "$_profile_dir" 2>/dev/null || echo "no")"
if [[ "$_profile_exists" != "yes" ]]; then
echo "set_chrome_profile_appearance: perfil '${_profile_dir}' no existe en info_cache de Local State" >&2
echo " Perfiles disponibles:" >&2
python3 -c "
import json, sys
data = json.load(open(sys.argv[1]))
ic = data.get('profile', {}).get('info_cache', {})
for k in ic: print(' ', k)
" "$_local_state" >&2 2>/dev/null || true
return 3
fi
fi
# ── modo dry-run ──────────────────────────────────────────────────────────
if [[ $_dry_run -eq 1 ]]; then
echo "=== set_chrome_profile_appearance DRY-RUN ===" >&2
echo " user-data-dir : ${_udd}" >&2
echo " profile : ${_profile_dir}" >&2
if [[ $_avatar_index -ge 0 ]]; then
echo " avatar : built-in #${_avatar_index} → avatar_icon=chrome://theme/IDR_PROFILE_AVATAR_${_avatar_index}" >&2
echo " is_using_default_avatar=true" >&2
elif [[ -n "$_avatar_image_path" ]]; then
local _dest_img="${_udd}/${_profile_dir}/Google Profile Picture.png"
echo " avatar : imagen custom ${_avatar_image_path}" >&2
echo " copiaría a ${_dest_img}" >&2
echo " is_using_default_avatar=false" >&2
echo " gaia_picture_file_name=Google Profile Picture.png" >&2
fi
if [[ -n "$_color_hex" ]]; then
local _signed_preview
_signed_preview="$(python3 -c "
rgb = int('${_color_hex}', 16)
argb = 0xFF000000 | rgb
signed = argb - 0x100000000 if argb >= 0x80000000 else argb
print(signed)
" 2>/dev/null || echo '?')"
echo " color : #${_color_hex} → signed int32 ${_signed_preview}" >&2
echo " Local State: profile_highlight_color, profile_color_seed, default_avatar_fill_color" >&2
echo " Preferences: browser.theme.user_color2=${_signed_preview}, browser_color_variant=${_variant}, is_grayscale2=false" >&2
echo " Preferences: extensions.theme.system_theme=0" >&2
local _prefs_path="${_udd}/${_profile_dir}/Preferences"
echo " Preferences : ${_prefs_path}" >&2
fi
echo " Local State : ${_local_state}" >&2
printf '{"profile":"%s","avatar_applied":%s,"color_applied":%s,"theme_applied":%s,"variant":%d,"dry_run":true}\n' \
"$_profile_dir" \
"$([[ -n "$_avatar" ]] && echo 'true' || echo 'false')" \
"$([[ -n "$_color_hex" ]] && echo 'true' || echo 'false')" \
"$([[ -n "$_color_hex" ]] && echo 'true' || echo 'false')" \
"$_variant"
return 0
fi
# ── backup de Local State (no sobreescribir el del mismo día) ────────────
local _today
_today="$(date +%Y%m%d)"
local _backup="${_local_state}.bak.${_today}"
if [[ ! -f "$_backup" ]]; then
cp "$_local_state" "$_backup"
fi
# ── copiar imagen custom si es necesario ──────────────────────────────────
local _copy_image_done=false
if [[ -n "$_avatar_image_path" ]]; then
local _profile_path="${_udd}/${_profile_dir}"
mkdir -p "$_profile_path"
cp "$_avatar_image_path" "${_profile_path}/Google Profile Picture.png"
_copy_image_done=true
fi
# ── editar Local State con python3 ────────────────────────────────────────
if ! python3 - \
"$_local_state" \
"$_profile_dir" \
"${_avatar_index}" \
"${_avatar_image_path}" \
"${_color_hex}" <<'PY'; then
import sys, json
ls_path = sys.argv[1]
prof_dir = sys.argv[2]
avatar_index = int(sys.argv[3]) # -1 = no cambiar avatar
avatar_img = sys.argv[4] # "" = no usar imagen
color_hex = sys.argv[5] # "" = no cambiar color
with open(ls_path, "r", encoding="utf-8") as f:
data = json.load(f)
profile_section = data.setdefault("profile", {})
info_cache = profile_section.setdefault("info_cache", {})
# El perfil debe existir (ya validado en bash, pero doble check)
if prof_dir not in info_cache:
print(f"error: perfil '{prof_dir}' no existe en info_cache", file=sys.stderr)
sys.exit(1)
entry = info_cache[prof_dir]
# ── Avatar ────────────────────────────────────────────────────────────────────
if avatar_index >= 0:
# Avatar built-in: IDR_PROFILE_AVATAR_<N>
entry["avatar_icon"] = f"chrome://theme/IDR_PROFILE_AVATAR_{avatar_index}"
entry["is_using_default_avatar"] = True
elif avatar_img:
# Avatar custom imagen: Chrome necesita gaia_picture_file_name
entry["avatar_icon"] = "chrome://theme/IDR_PROFILE_AVATAR_0"
entry["is_using_default_avatar"] = False
entry["gaia_picture_file_name"] = "Google Profile Picture.png"
# ── Color ─────────────────────────────────────────────────────────────────────
if color_hex:
rgb = int(color_hex, 16) # 0xRRGGBB
argb = 0xFF000000 | rgb # alpha=FF opaco → 0xFFRRGGBB
# Convertir a int32 con signo (Python usa enteros arbitrarios)
signed = argb - 0x100000000 if argb >= 0x80000000 else argb
entry["profile_highlight_color"] = signed
entry["profile_color_seed"] = signed
entry["default_avatar_fill_color"] = signed
with open(ls_path, "w", encoding="utf-8") as f:
json.dump(data, f, separators=(",", ":"))
PY
echo "set_chrome_profile_appearance: error editando Local State con python3" >&2
return 4
fi
# ── validar JSON de Local State tras escritura ────────────────────────────
if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$_local_state" 2>/dev/null; then
echo "set_chrome_profile_appearance: JSON inválido tras escribir Local State; restaurando backup" >&2
cp "$_backup" "$_local_state"
return 4
fi
# ── editar Preferences del perfil (browser.theme.*) si hay color ─────────
local _prefs_path="${_udd}/${_profile_dir}/Preferences"
local _prefs_backup=""
local _theme_applied=false
if [[ -n "$_color_hex" ]]; then
_theme_applied=true
# Backup de Preferences antes de escribir (mismo patrón que Local State)
if [[ -f "$_prefs_path" ]]; then
_prefs_backup="${_prefs_path}.bak.${_today}"
if [[ ! -f "$_prefs_backup" ]]; then
cp "$_prefs_path" "$_prefs_backup"
fi
fi
# Editar/crear Preferences con python3
if ! python3 - \
"$_prefs_path" \
"${_color_hex}" \
"${_variant}" <<'PY'; then
import sys, json, os
prefs_path = sys.argv[1]
color_hex = sys.argv[2]
variant = int(sys.argv[3])
# Calcular el signed int32 ARGB
rgb = int(color_hex, 16)
argb = 0xFF000000 | rgb
signed = argb - 0x100000000 if argb >= 0x80000000 else argb
# Cargar Preferences existente o arrancar desde vacío
if os.path.isfile(prefs_path):
with open(prefs_path, "r", encoding="utf-8") as f:
data = json.load(f)
else:
data = {}
# ── browser.theme.* ──────────────────────────────────────────────────────────
browser = data.setdefault("browser", {})
theme = browser.setdefault("theme", {})
# Claves modernas (sufijo "2") — verificadas en Chromium 148
theme["user_color2"] = signed
theme["browser_color_variant"] = variant
theme["is_grayscale2"] = False
# Claves legacy (sin sufijo "2") — compatibilidad con versiones anteriores
theme["user_color"] = signed
theme["color_variant"] = variant
theme["is_grayscale"] = False
# ── extensions.theme.system_theme = 0 ────────────────────────────────────────
# 0=color propio, 1=GTK, 2=Qt. Forzar 0 para que el user_color tenga efecto.
extensions = data.setdefault("extensions", {})
ext_theme = extensions.setdefault("theme", {})
ext_theme["system_theme"] = 0
# Escribir directorio si no existe (perfil recién creado sin arrancar)
os.makedirs(os.path.dirname(prefs_path), exist_ok=True)
with open(prefs_path, "w", encoding="utf-8") as f:
json.dump(data, f, separators=(",", ":"))
PY
echo "set_chrome_profile_appearance: error editando Preferences con python3" >&2
# Restaurar Preferences si teníamos backup
if [[ -n "$_prefs_backup" && -f "$_prefs_backup" ]]; then
cp "$_prefs_backup" "$_prefs_path"
elif [[ -f "$_prefs_path" ]]; then
rm -f "$_prefs_path"
fi
return 4
fi
# Validar JSON de Preferences tras escritura
if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$_prefs_path" 2>/dev/null; then
echo "set_chrome_profile_appearance: JSON inválido tras escribir Preferences; restaurando backup" >&2
if [[ -n "$_prefs_backup" && -f "$_prefs_backup" ]]; then
cp "$_prefs_backup" "$_prefs_path"
fi
return 4
fi
fi
# ── leer valores resultantes para el JSON de salida ───────────────────────
local _result_json
_result_json="$(python3 - "$_local_state" "$_profile_dir" "$_prefs_path" "$_theme_applied" "$_variant" <<'PY'
import json, sys, os
ls_path = sys.argv[1]
prof_dir = sys.argv[2]
prefs_path = sys.argv[3]
theme_applied = sys.argv[4] == "true"
variant = int(sys.argv[5])
data = json.load(open(ls_path))
entry = data.get("profile", {}).get("info_cache", {}).get(prof_dir, {})
out = {
"profile": prof_dir,
"avatar_icon": entry.get("avatar_icon", ""),
"is_using_default_avatar": entry.get("is_using_default_avatar", True),
"profile_highlight_color": entry.get("profile_highlight_color", 0),
"profile_color_seed": entry.get("profile_color_seed", 0),
"default_avatar_fill_color": entry.get("default_avatar_fill_color", 0),
"theme_applied": theme_applied,
"variant": variant,
"preferences_path": prefs_path if theme_applied else "",
"backup": "Local State.bak." + __import__("datetime").date.today().strftime("%Y%m%d"),
}
# Añadir valores de theme si se aplicó
if theme_applied and os.path.isfile(prefs_path):
try:
prefs = json.load(open(prefs_path))
bt = prefs.get("browser", {}).get("theme", {})
out["browser_theme_user_color2"] = bt.get("user_color2", 0)
out["browser_theme_color_variant"] = bt.get("browser_color_variant", 0)
out["extensions_theme_system_theme"] = prefs.get("extensions", {}).get("theme", {}).get("system_theme", -1)
except Exception:
pass
print(json.dumps(out, separators=(",",":")))
PY
)"
echo "$_result_json"
}
# ── auto-ejecución ────────────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
set_chrome_profile_appearance "$@"
fi
@@ -0,0 +1,58 @@
---
name: ensure_project_gitignore
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "ensure_project_gitignore(project_dir: string) -> void"
description: "Garantiza de forma idempotente que el .gitignore de un directorio de project contiene las lineas canonicas que excluyen del repo del project el contenido de sus sub-repos hijos (apps y analyses son repos Gitea independientes) y sus vaults (datos fuera de git). Evita el doble-tracking al hacer push del project."
tags: [git, gitignore, projects, infra]
params:
- name: project_dir
desc: "Ruta al directorio del project (p. ej. projects/aurgi). Debe existir; si no, error a stderr y return 1. El .gitignore se escribe/actualiza en <project_dir>/.gitignore."
output: "Sin salida en stdout. A stderr informa de la accion realizada: 'created' si creo el .gitignore, 'updated: anadidas N lineas' si anadio lineas faltantes, u 'ok: ya completo' si nada cambiaba. Codigo de salida 0 en exito, 1 si project_dir falta o no existe."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/ensure_project_gitignore.sh"
---
## Ejemplo
```bash
source bash/functions/infra/ensure_project_gitignore.sh
# Asegura que projects/aurgi/.gitignore excluye el contenido de sus hijos.
ensure_project_gitignore projects/aurgi
# stderr: ensure_project_gitignore: created projects/aurgi/.gitignore
# (o: updated: anadidas 2 lineas / ok: ya completo)
```
Las lineas canonicas que la funcion garantiza son:
```
apps/*/
analysis/*/
vaults/*
!vaults/.gitkeep
!vaults/vault.yaml
```
## Cuando usarla
Llamala justo despues de crear un project nuevo (`mkdir -p projects/<nombre>/{apps,analysis,vaults}`) y antes de inicializar su repo Gitea con `ensure_repo_synced`, para que el repo del project nunca trackee el contenido de sus sub-repos hijos. Tambien al adoptar un project existente que aun no tiene estas exclusiones, o como paso de saneamiento cuando `git status` del project muestra contenido de `apps/`/`analysis/` que deberia estar ignorado.
## Gotchas
- La funcion modifica el filesystem (escribe en `<project_dir>/.gitignore`): es impura. No commitea ni hace push — solo deja el `.gitignore` correcto.
- La comparacion para no duplicar es linea-exacta (`grep -Fxq`). Una linea equivalente pero con espacios extra, comentario adjunto o glob distinto (p. ej. `apps/*` sin la barra final) NO se considera presente y la canonica se anade igualmente; podrian quedar ambas formas. Mantener el `.gitignore` con las lineas canonicas tal cual evita ruido.
- Si el `.gitignore` existente no termina en salto de linea, la funcion anade uno antes de apendar para no pegar la primera linea nueva al final de la ultima existente.
- Solo gestiona las exclusiones de sub-repos hijos y vaults del nivel-project; no toca otras reglas que el `.gitignore` ya contenga ni las reordena.
- Si una linea canonica ya existia con su forma exacta, no se vuelve a anadir (idempotente): re-ejecutar es seguro.
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# ensure_project_gitignore — Garantiza de forma idempotente que el .gitignore de
# un directorio de project (projects/<nombre>/) contiene las lineas canonicas que
# excluyen del repo del project el contenido de sus sub-repos hijos (apps y
# analyses son repos Gitea independientes) y sus vaults (datos fuera de git).
#
# Esto evita que al hacer push del project se trackee por error el contenido de
# los hijos (doble-tracking). Ver .claude/rules/apps_subrepo.md y
# .claude/rules/projects.md.
#
# Uso:
# ensure_project_gitignore <project_dir>
#
# Salida:
# stdout vacio. A stderr informa de la accion realizada (created / updated / ok).
ensure_project_gitignore() {
local project_dir="$1"
if [[ -z "$project_dir" ]]; then
echo "ensure_project_gitignore: se requiere project_dir" >&2
return 1
fi
if [[ ! -d "$project_dir" ]]; then
echo "ensure_project_gitignore: directorio '$project_dir' no existe" >&2
return 1
fi
local gitignore="$project_dir/.gitignore"
# Lineas canonicas que deben estar presentes (orden de referencia).
local -a canonical=(
"apps/*/"
"analysis/*/"
"vaults/*"
"!vaults/.gitkeep"
"!vaults/vault.yaml"
)
# Caso 1: el .gitignore no existe — crearlo con el contenido canonico.
if [[ ! -f "$gitignore" ]]; then
printf '%s\n' "${canonical[@]}" > "$gitignore"
echo "ensure_project_gitignore: created $gitignore" >&2
return 0
fi
# Caso 2: existe — anadir solo las lineas que falten (comparacion linea-exacta),
# preservando el contenido y el orden existentes.
# Si el archivo no termina en newline, anadir uno antes de apendar para no
# pegar la primera linea nueva al final de la ultima existente.
if [[ -s "$gitignore" && -n "$(tail -c 1 "$gitignore")" ]]; then
printf '\n' >> "$gitignore"
fi
local line added=0
for line in "${canonical[@]}"; do
# grep -F -x: match literal de linea completa, sin interpretar metacaracteres.
if ! grep -Fxq -- "$line" "$gitignore"; then
printf '%s\n' "$line" >> "$gitignore"
added=$((added + 1))
fi
done
if [[ $added -gt 0 ]]; then
echo "ensure_project_gitignore: updated: anadidas $added lineas a $gitignore" >&2
else
echo "ensure_project_gitignore: ok: ya completo $gitignore" >&2
fi
return 0
}
# Si se invoca como script (no source), ejecutar la funcion.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
ensure_project_gitignore "$@"
fi
@@ -0,0 +1,66 @@
---
name: launch_claude_agent_kitty
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "launch_claude_agent_kitty(title: string, directory: string, prompt_file: string) -> string"
description: "Lanza un Claude Code secundario interactivo y persistente en su propia terminal kitty, con un prompt autonomo inyectado desde un archivo y --dangerously-skip-permissions. Mecanica del modo orquestador: un Claude principal descompone una tarea y lanza N secundarios, cada uno en su kitty, que el humano ve y puede retomar. La ventana sobrevive al cierre de la terminal padre (setsid nohup ... disown) y deja una shell interactiva viva cuando el claude termina (exec zsh)."
tags: [orchestration, agents, claude, kitty, agent, terminal, infra]
params:
- name: title
desc: "Titulo de la ventana kitty. Ej: 'fn_registry · subtarea X'. Tambien se sanitiza (minusculas, no-alfanumerico -> '_') para derivar el slug del archivo de log."
- name: directory
desc: "Directorio de trabajo AISLADO donde arranca el claude secundario (worktree git, sub-repo, o dir cualquiera). Debe existir; si no -> error exit 2. Usar un dir aislado: dos claudes en el mismo working tree comparten HEAD y dispersan commits."
- name: prompt_file
desc: "Ruta a un archivo .md con el prompt autonomo a inyectar (ej. /tmp/orq_<slug>.md). Debe existir y ser legible; si no -> error exit 2."
output: "Imprime en stdout el title, directory, prompt_file y la ruta del log (/tmp/orq_<slug>_kitty.log) donde se ve el arranque. Exit 0 = lanzamiento disparado; exit 2 = argumentos invalidos; exit 1 = kitty no instalado."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/launch_claude_agent_kitty.sh"
---
## Ejemplo
```bash
source bash/functions/infra/launch_claude_agent_kitty.sh
# El orquestador prepara un worktree aislado y un archivo de prompt...
git worktree add /tmp/orq_docs_wt -b orq/docs
cat > /tmp/orq_docs.md <<'PROMPT'
Eres un agente secundario. Tu tarea: revisar y mejorar la documentacion del
dominio infra del registry. Trabaja SOLO en este worktree. Reporta al terminar.
PROMPT
# ...y lanza un claude secundario en su propia kitty:
launch_claude_agent_kitty "fn_registry · docs" /tmp/orq_docs_wt /tmp/orq_docs.md
# -> abre una ventana kitty titulada "fn_registry · docs", arranca claude con
# el prompt inyectado, y deja /tmp/orq_fn_registry_docs_kitty.log con el arranque.
```
O directo via `fn run`:
```bash
./fn run launch_claude_agent_kitty "fn_registry · docs" /tmp/orq_docs_wt /tmp/orq_docs.md
```
## Cuando usarla
Cuando el orquestador quiere lanzar un Claude secundario **interactivo** en su propia terminal kitty para una sub-tarea que el humano quiere **ver y poder retomar**. A diferencia del `Agent` tool (sub-agente no interactivo, headless, cuyo output vuelve al padre y no deja terminal abierta), aqui cada secundario corre en una ventana visible que persiste: el humano observa el progreso en vivo y, cuando el claude termina, la shell sigue ahi para continuar manualmente o relanzar.
## Gotchas
- **kitty debe estar instalado.** Si `command -v kitty` falla -> exit 1 con mensaje claro. No hay fallback a otra terminal.
- **El `directory` debe ser AISLADO** (worktree git o sub-repo propio). Dos claudes apuntando al mismo working tree **comparten HEAD** y dispersan/cruzan los commits (memoria `multi-agent-git-race-same-repo`). El orquestador debe crear un worktree/clon por agente antes de llamar.
- **`--dangerously-skip-permissions` corre sin pedir confirmacion** a ninguna accion (memoria `lanzar-agentes-skip-permissions`). Es a proposito para agentes autonomos desatendidos, pero es un riesgo asumido: el secundario puede tocar el sistema sin gates. No lanzar sobre directorios sensibles.
- **El log de `/tmp/orq_<slug>_kitty.log` es donde se ve el arranque** (errores de kitty/claude al iniciar). El `<slug>` deriva del `title` sanitizado; titulos distintos que colapsen al mismo slug sobrescriben el mismo log.
- **El PID reportado no es el de kitty.** Con `setsid` el `$!` es el del proceso setsid, no el de la ventana; por eso la funcion reporta el log en vez de un PID. Para encontrar la ventana despues: `pgrep -af kitty | grep <title>`.
- **El prompt se inyecta con `"$(cat <prompt_file>)"` evaluado DENTRO de la kitty.** Si editas el `prompt_file` despues de lanzar pero antes de que la kitty arranque, el claude vera la version editada (se lee en el momento del arranque, no del lanzamiento).
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# launch_claude_agent_kitty — Lanza un Claude Code secundario interactivo y
# persistente en su propia terminal kitty, con un prompt autonomo inyectado
# desde un archivo. Es la mecanica de lanzamiento del "modo orquestador": un
# Claude principal descompone una tarea y lanza N secundarios, cada uno en su
# kitty, que el humano ve y puede retomar.
#
# Mecanismo:
# - setsid nohup kitty ... & disown -> la ventana sobrevive al cierre de la
# terminal padre (igual que reboot_all_claudes con setsid).
# - zsh -ic 'claude ...; exec zsh' -> al terminar el claude queda una shell
# interactiva viva para que el humano siga en esa terminal.
# - --dangerously-skip-permissions -> agente autonomo desatendido (sin
# confirmaciones). Riesgo asumido a proposito.
# - El prompt se inyecta con "$(cat <prompt_file>)" para no expandir nada en
# el shell del orquestador.
# - Log de arranque en /tmp/orq_<slug>_kitty.log, donde <slug> deriva del
# title (minusculas, no-alfanumerico -> '_').
set -euo pipefail
IFS=$' \t\n'
launch_claude_agent_kitty() {
# -----------------------------------------------------------------------
# Ayuda / sin argumentos.
# -----------------------------------------------------------------------
if [[ $# -eq 0 || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
cat <<'USAGE'
Uso: launch_claude_agent_kitty <title> <directory> <prompt_file>
Lanza un Claude Code secundario interactivo y persistente en su propia
terminal kitty, con el prompt del archivo <prompt_file> inyectado y
--dangerously-skip-permissions (agente autonomo desatendido).
Argumentos (los 3 obligatorios):
title Titulo de la ventana kitty. Ej: "fn_registry · subtarea X".
directory Directorio de trabajo AISLADO donde arranca el claude
secundario (worktree git, sub-repo, o dir cualquiera). Debe
existir. Usa un dir aislado: dos claudes en el mismo working
tree comparten HEAD y dispersan commits.
prompt_file Ruta a un archivo .md con el prompt autonomo a inyectar.
Debe existir y ser legible.
Ejemplo:
launch_claude_agent_kitty "fn_registry · docs" /tmp/orq_docs_wt /tmp/orq_docs.md
El log de arranque va a /tmp/orq_<slug>_kitty.log (slug derivado del title).
USAGE
return 0
fi
# -----------------------------------------------------------------------
# Validacion de argumentos.
# -----------------------------------------------------------------------
if [[ $# -ne 3 ]]; then
echo "launch_claude_agent_kitty: se requieren 3 argumentos <title> <directory> <prompt_file> (recibidos: $#). Usa -h." >&2
return 2
fi
local title="$1"
local directory="$2"
local prompt_file="$3"
if [[ -z "$title" ]]; then
echo "launch_claude_agent_kitty: <title> no puede estar vacio." >&2
return 2
fi
if [[ ! -d "$directory" ]]; then
echo "launch_claude_agent_kitty: el directorio de trabajo no existe: '$directory'." >&2
return 2
fi
if [[ ! -f "$prompt_file" ]]; then
echo "launch_claude_agent_kitty: el prompt_file no existe: '$prompt_file'." >&2
return 2
fi
if [[ ! -r "$prompt_file" ]]; then
echo "launch_claude_agent_kitty: el prompt_file no es legible: '$prompt_file'." >&2
return 2
fi
# -----------------------------------------------------------------------
# Comprobar que kitty esta instalado.
# -----------------------------------------------------------------------
if ! command -v kitty >/dev/null 2>&1; then
echo "launch_claude_agent_kitty: 'kitty' no esta instalado o no esta en el PATH." >&2
return 1
fi
# -----------------------------------------------------------------------
# Derivar el slug del title para el nombre del log.
# minusculas, todo no-alfanumerico -> '_', colapsar/recortar '_'.
# -----------------------------------------------------------------------
local slug
slug="$(printf '%s' "$title" \
| tr '[:upper:]' '[:lower:]' \
| tr -c 'a-z0-9' '_' \
| sed -E 's/_+/_/g; s/^_//; s/_$//')"
[[ -z "$slug" ]] && slug="agent"
local log="/tmp/orq_${slug}_kitty.log"
# -----------------------------------------------------------------------
# Lanzar la kitty detached. El prompt se inyecta con "$(cat <prompt_file>)"
# ya escapado para que se evalue DENTRO de la kitty, no aqui.
# exec zsh deja una shell viva cuando el claude termina.
# -----------------------------------------------------------------------
local inner
inner="claude --dangerously-skip-permissions \"\$(cat $(printf '%q' "$prompt_file"))\"; exec zsh"
setsid nohup kitty \
--title "$title" \
--directory "$directory" \
zsh -ic "$inner" \
>"$log" 2>&1 &
disown 2>/dev/null || true
# -----------------------------------------------------------------------
# Reportar. Con setsid el $! es el PID de setsid, no el de kitty; basta
# con confirmar el lanzamiento y apuntar al log donde se ve el arranque.
# -----------------------------------------------------------------------
echo "launch_claude_agent_kitty: claude secundario lanzado."
echo " title: $title"
echo " directory: $directory"
echo " prompt_file: $prompt_file"
echo " log: $log"
echo " (sigue el arranque con: tail -f $(printf '%q' "$log"))"
return 0
}
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
launch_claude_agent_kitty "$@"
fi
@@ -0,0 +1,55 @@
---
name: list_claude_agents
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "list_claude_agents([--json] [--exclude-current] [-h|--help])"
description: "Lista todas las instancias de Claude Code VIVAS cruzando pgrep -x claude con los archivos de estado por proceso ~/.claude/sessions/<PID>.json. Para cada claude vivo y validado devuelve PID, status (idle/busy), etime (tiempo de vida), KITTY_PID de su ventana kitty, sessionId y cwd. Es la herramienta de seguimiento de la flota del modo orquestador: el Claude principal ve que agentes secundarios siguen vivos, en que directorio trabajan y su sessionId para retomarlos con claude --resume."
tags: [orchestration, claude, session, fleet, kitty, infra, terminal-capture]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: "--json"
desc: "Imprime un array JSON (un objeto por agente con pid, session_id, cwd, status, etime, kitty_pid, self) en vez de la tabla legible. Pensado para que el agente parsee y decida cual retomar/parar."
- name: "--exclude-current"
desc: "Omite la propia sesion del listado. Detecta el claude propio subiendo por la cadena de ancestros de $$ hasta hallar un proceso con comm=claude. Sin esta opcion, la sesion actual se marca (columna SELF en tabla / self=true en JSON)."
- name: "-h|--help"
desc: "Muestra el uso y termina con exit 0."
output: "En modo tabla: una fila por claude vivo y validado con columnas PID, STATUS, ETIME, KITTY, SELF, SESSION_ID, CWD. En modo --json: array JSON con pid, session_id, cwd, status, etime, kitty_pid (null si no corre en kitty) y self. Si no hay claudes vivos imprime aviso (tabla) o [] (json) y exit 0. Exit 0 normal; exit 2 si flag invalido."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/list_claude_agents.sh"
notes: "Mecanismo (Claude Code 2.1.x sobre Linux + kitty): pgrep -x claude -> PIDs vivos; ~/.claude/sessions/<PID>.json -> sessionId/cwd/status/procStart (parseado con python3); validacion en tres capas: kill -0 <PID> exito, el JSON existe, y anti-PID-reciclado comparando procStart del JSON con el campo 22 de /proc/<PID>/stat (si difieren el JSON es huerfano de un PID reusado y se omite). KITTY_PID se saca del environ del proceso (tr '\\0' '\\n' < /proc/<PID>/environ | sed -n 's/^KITTY_PID=//p'). etime via ps -o etime= -p <PID>. Reusa la misma logica de descubrimiento y validacion que reboot_all_claudes_bash_infra. El codigo JSON va en python3 -c con los datos por stdin TSV (no heredoc) para no colisionar el stdin del pipe."
---
## Ejemplo
```bash
# Tabla legible de la flota de Claudes vivos (PID, status, etime, kitty, sessionId, cwd).
./fn run list_claude_agents
# Array JSON para parsear (decidir cual retomar con claude --resume <session_id>).
./fn run list_claude_agents --json
# Omitir la propia sesion (ver solo los agentes secundarios).
./fn run list_claude_agents --exclude-current
```
## Cuando usarla
Cuando el orquestador necesita ver la flota de Claudes secundarios vivos (PID, cwd, sessionId, status) para seguir su progreso o decidir cual retomar/parar. Lanzala al inicio de un ciclo de seguimiento para saber que agentes siguen activos y en que directorio trabaja cada uno; usa `--json` cuando vayas a programar la decision (filtrar por `status`, extraer `session_id` para un `claude --resume`).
## Gotchas
- **Requiere Claude Code >= 2.1.x.** Depende de que cada sesion escriba `~/.claude/sessions/<PID>.json` con los campos `sessionId`, `cwd`, `status`, `procStart`. Si una version futura cambia el formato, la funcion deja de mapear PID -> sessionId y omitira las sesiones.
- **Un JSON puede ser huerfano por PID reciclado.** El sistema operativo reusa PIDs; un `<PID>.json` viejo puede apuntar a un proceso `claude` distinto. Por eso se valida `procStart` del JSON contra el campo 22 de `/proc/<PID>/stat`; si no coincide la entrada se descarta. Sin esa validacion se reportarian agentes fantasma.
- **El titulo exacto de la ventana kitty no se recupera sin `kitty @`.** Se reporta el `KITTY_PID` (suficiente para identificar la ventana); mapearlo al titulo requeriria `kitty @ ls`, que solo funciona si el control remoto de kitty esta habilitado. KISS: se omite por defecto. Un claude que corra fuera de kitty (terminal integrado de un editor, etc.) sale con `KITTY` vacio `(none)` / `kitty_pid: null`.
- **Solo ve procesos del usuario actual.** `pgrep -x claude` y la lectura de `/proc/<PID>/{environ,stat}` solo cubren los claudes del propio usuario; no lista sesiones de otros usuarios del sistema.
- **`status` refleja el ultimo estado guardado en el JSON**, no necesariamente el instante exacto de la consulta (Claude actualiza el archivo al cambiar de estado). Pueden aparecer valores como `idle`, `busy` o `waiting`.
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env bash
# list_claude_agents — Lista todas las instancias de Claude Code VIVAS cruzando
# pgrep -x claude con los archivos de estado por proceso ~/.claude/sessions/<PID>.json.
# Para cada claude vivo y validado reporta: PID, status (idle/busy), etime (tiempo de
# vida del proceso), KITTY_PID de la ventana kitty si corre en una, sessionId y cwd.
# Es la herramienta de "seguimiento de la flota" del modo orquestador: el Claude
# principal la usa para ver que agentes secundarios siguen vivos, en que directorio
# trabajan y su sessionId (para poder retomarlos con claude --resume <sessionId>).
#
# Mecanismo (Claude Code 2.1.x sobre Linux + kitty):
# - pgrep -x claude -> PIDs de las sesiones interactivas vivas.
# - ~/.claude/sessions/<PID>.json -> mapea PID a {sessionId, cwd, status, procStart}.
# - Anti-PID-reciclado: procStart del JSON debe coincidir con el campo 22 de
# /proc/<PID>/stat; ademas kill -0 <PID> debe tener exito y el JSON debe existir.
# - KITTY_PID del environ del proceso -> ventana kitty (titulo exacto requeriria
# 'kitty @ ls'; aqui se reporta el KITTY_PID, suficiente para identificarla).
# - etime via ps -o etime= -p <PID>.
set -euo pipefail
IFS=$' \t\n'
list_claude_agents() {
local output="table" # table | json
local exclude_current=0
# -----------------------------------------------------------------------
# Parseo de argumentos
# -----------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--json)
output="json"
;;
--exclude-current)
exclude_current=1
;;
-h|--help)
cat <<'USAGE'
Uso: list_claude_agents [--json] [--exclude-current]
Lista las instancias de Claude Code vivas y validas, una fila por agente, con su
PID, status, etime (tiempo de vida), KITTY_PID, sessionId y cwd. Pensada para el
modo orquestador: ver la flota de Claudes secundarios y su sessionId para retomar
(claude --resume <sessionId>) o decidir cual parar.
Opciones:
--json Imprime un array JSON (pid, session_id, cwd, status, etime,
kitty_pid) en vez de la tabla. Util para parsear.
--exclude-current Omite la propia sesion (sube por la cadena de ancestros de
$$ hasta hallar un proceso con comm=claude). Sin esta opcion,
la sesion actual se marca con self=true / SELF en la tabla.
-h, --help Muestra esta ayuda.
Ejemplos:
list_claude_agents
list_claude_agents --json
list_claude_agents --exclude-current
USAGE
return 0
;;
*)
echo "list_claude_agents: opcion desconocida: '$1' (usa -h)" >&2
return 2
;;
esac
shift
done
# -----------------------------------------------------------------------
# Detectar el PID de la sesion actual subiendo por la cadena de ancestros
# hasta encontrar un proceso cuyo comm sea exactamente "claude".
# Se usa tanto para --exclude-current como para marcar la fila propia.
# -----------------------------------------------------------------------
local current_claude_pid=""
local walk="$$"
local guard=0
while [[ -n "$walk" && "$walk" != "0" && "$walk" != "1" ]]; do
local comm=""
comm="$(cat "/proc/$walk/comm" 2>/dev/null || true)"
if [[ "$comm" == "claude" ]]; then
current_claude_pid="$walk"
break
fi
# campo 4 de /proc/<pid>/stat es el PPID
walk="$(awk '{print $4}' "/proc/$walk/stat" 2>/dev/null || true)"
guard=$((guard + 1))
[[ "$guard" -gt 64 ]] && break
done
# -----------------------------------------------------------------------
# Recolectar las sesiones vivas y validarlas.
# -----------------------------------------------------------------------
local sessions_dir="$HOME/.claude/sessions"
local pids=""
pids="$(pgrep -x claude 2>/dev/null || true)"
if [[ -z "$pids" ]]; then
if [[ "$output" == "json" ]]; then
echo "[]"
else
echo "list_claude_agents: no hay sesiones de Claude Code vivas (pgrep -x claude vacio)."
fi
return 0
fi
# Arrays paralelos con la flota validada.
local -a a_pid a_status a_etime a_kitty a_sid a_cwd a_self
local pid
for pid in $pids; do
# Validacion 1: el proceso debe seguir vivo.
if ! kill -0 "$pid" 2>/dev/null; then
continue
fi
# Validacion 2: debe existir su JSON de sesion.
local json="$sessions_dir/$pid.json"
if [[ ! -f "$json" ]]; then
continue
fi
# Parsear el JSON con python3 (campos sessionId, cwd, status, procStart).
# Salida: lineas "clave=valor" en orden fijo.
local parsed=""
parsed="$(python3 - "$json" <<'PY' 2>/dev/null || true
import json, sys
try:
with open(sys.argv[1]) as fh:
d = json.load(fh)
except Exception:
sys.exit(0)
print("sessionId=" + str(d.get("sessionId", "")))
print("cwd=" + str(d.get("cwd", "")))
print("status=" + str(d.get("status", "")))
print("procStart=" + str(d.get("procStart", "")))
PY
)"
[[ -z "$parsed" ]] && continue
local sid cwd status proc_start_json
sid="$(printf '%s\n' "$parsed" | sed -n 's/^sessionId=//p')"
cwd="$(printf '%s\n' "$parsed" | sed -n 's/^cwd=//p')"
status="$(printf '%s\n' "$parsed" | sed -n 's/^status=//p')"
proc_start_json="$(printf '%s\n' "$parsed" | sed -n 's/^procStart=//p')"
[[ -z "$sid" ]] && continue
# Validacion 3 (anti-PID-reciclado): procStart del JSON debe coincidir
# con el campo 22 de /proc/<PID>/stat.
local proc_start_real=""
proc_start_real="$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true)"
if [[ -n "$proc_start_json" && "$proc_start_json" != "$proc_start_real" ]]; then
# JSON huerfano de un PID reciclado: omitir.
continue
fi
# Omitir la propia sesion si se pidio --exclude-current.
if [[ "$exclude_current" -eq 1 && -n "$current_claude_pid" && "$pid" == "$current_claude_pid" ]]; then
continue
fi
# KITTY_PID de la ventana kitty (vacio si claude no corre en kitty).
local kitty_pid=""
kitty_pid="$(tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | sed -n 's/^KITTY_PID=//p' | head -n1)"
# etime: tiempo transcurrido desde que arranco el proceso.
local etime=""
etime="$(ps -o etime= -p "$pid" 2>/dev/null | tr -d ' ' || true)"
# Marca de sesion propia (solo relevante cuando NO se excluye).
local self="false"
if [[ -n "$current_claude_pid" && "$pid" == "$current_claude_pid" ]]; then
self="true"
fi
a_pid+=("$pid")
a_status+=("${status:-?}")
a_etime+=("${etime:-?}")
a_kitty+=("${kitty_pid:-}")
a_sid+=("$sid")
a_cwd+=("${cwd:-?}")
a_self+=("$self")
done
local total="${#a_pid[@]}"
if [[ "$total" -eq 0 ]]; then
if [[ "$output" == "json" ]]; then
echo "[]"
else
echo "list_claude_agents: ninguna sesion valida encontrada (PIDs huerfanos, reciclados, o sin JSON)."
fi
return 0
fi
# -----------------------------------------------------------------------
# Salida JSON.
# -----------------------------------------------------------------------
if [[ "$output" == "json" ]]; then
# Delegar el escaping correcto de strings (cwd con espacios, etc.) a python3.
# El codigo python va en -c y los datos por stdin (TSV), para no colisionar
# el heredoc con el stdin del pipe.
local i
{
for ((i = 0; i < total; i++)); do
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"${a_pid[$i]}" \
"${a_sid[$i]}" \
"${a_cwd[$i]}" \
"${a_status[$i]}" \
"${a_etime[$i]}" \
"${a_kitty[$i]}" \
"${a_self[$i]}"
done
} | python3 -c '
import json, sys
out = []
for line in sys.stdin:
line = line.rstrip("\n")
if not line:
continue
pid, sid, cwd, status, etime, kitty, self_ = line.split("\t")
out.append({
"pid": int(pid) if pid.isdigit() else pid,
"session_id": sid,
"cwd": cwd,
"status": status,
"etime": etime,
"kitty_pid": (int(kitty) if kitty.isdigit() else (kitty or None)),
"self": (self_ == "true"),
})
print(json.dumps(out, indent=2))
'
return 0
fi
# -----------------------------------------------------------------------
# Salida tabla legible.
# -----------------------------------------------------------------------
echo "list_claude_agents — claudes vivos: ${total}"
echo
printf '%-8s %-7s %-12s %-9s %-6s %-38s %s\n' \
"PID" "STATUS" "ETIME" "KITTY" "SELF" "SESSION_ID" "CWD"
printf '%-8s %-7s %-12s %-9s %-6s %-38s %s\n' \
"--------" "-------" "------------" "---------" "------" \
"--------------------------------------" "---"
local i
for ((i = 0; i < total; i++)); do
local self_mark=""
[[ "${a_self[$i]}" == "true" ]] && self_mark="SELF"
printf '%-8s %-7s %-12s %-9s %-6s %-38s %s\n' \
"${a_pid[$i]}" \
"${a_status[$i]}" \
"${a_etime[$i]}" \
"${a_kitty[$i]:-(none)}" \
"${self_mark:--}" \
"${a_sid[$i]}" \
"${a_cwd[$i]}"
done
return 0
}
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
list_claude_agents "$@"
fi
@@ -0,0 +1,64 @@
---
name: reboot_all_claudes
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "reboot_all_claudes([--go|--yes] [--resume-mode resume|continue|none] [--exclude-current] [--only-idle] [-h|--help])"
description: "Cierra todas las terminales kitty con una sesion de Claude Code corriendo y las relanza retomando la misma sesion (claude --resume <sessionId>). Mapea cada PID vivo a su ~/.claude/sessions/<PID>.json para sacar sessionId, cwd y la ventana kitty. DRY-RUN por defecto; --go ejecuta de verdad de forma desacoplada."
tags: [claude, session, terminal, kitty, reboot, infra, terminal-capture]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: "--go"
desc: "Ejecuta de verdad: mata las ventanas kitty y relanza las sesiones (detached). Alias --yes. Sin esto es dry-run."
- name: "--yes"
desc: "Alias de --go."
- name: "--resume-mode <resume|continue|none>"
desc: "Estrategia de reanudacion. resume (default): claude --resume <sessionId>. continue: claude --continue. none: sesion nueva en el mismo cwd."
- name: "--exclude-current"
desc: "No cierra ni relanza la terminal desde la que se invoca. Detecta el claude propio subiendo por la cadena de PPIDs hasta hallar un ancestro con comm=claude."
- name: "--only-idle"
desc: "Omite las sesiones con status busy (no pierde el turno en vuelo). Por defecto se incluyen todas y el dry-run avisa cuales estan busy."
- name: "-h|--help"
desc: "Muestra el uso y termina."
output: "Imprime una tabla del plan (PID, KITTY_PID, status, accion, sessionId, cwd) y el comando claude exacto por sesion. En dry-run no toca nada. Con --go lanza un script desacoplado en /tmp que cierra ventanas y relanza. Exit 0 normal; exit 2 si flags invalidos."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/reboot_all_claudes.sh"
notes: "Mecanismo (Claude Code 2.1.x sobre Linux + kitty): pgrep -x claude -> PIDs vivos; ~/.claude/sessions/<PID>.json -> sessionId/cwd/status/procStart; anti-PID-reciclado comparando procStart del JSON con el campo 22 de /proc/<PID>/stat; KITTY_PID del environ -> ventana a cerrar con SIGTERM; cmdline -> flags conservados (sin argv0 ni resume previos). El relanzamiento usa setsid kitty --directory <cwd> zsh -ic 'claude ...; exec zsh'. Como la propia terminal es una victima, el plan --go se escribe a /tmp y se lanza con setsid para sobrevivir al cierre del padre."
---
## Ejemplo
```bash
# Dry-run (default seguro): ver el plan sin tocar nada.
reboot_all_claudes
# Reiniciar de verdad todas las sesiones MENOS la terminal actual.
reboot_all_claudes --go --exclude-current
# Reiniciar solo las sesiones idle (no perder turnos en vuelo), de verdad.
reboot_all_claudes --go --only-idle
# Arrancar sesiones nuevas (sin reanudar la conversacion) en cada cwd.
reboot_all_claudes --go --resume-mode none
```
## Cuando usarla
Tras actualizar Claude Code (para que todas las sesiones corran la version nueva), o cuando varias sesiones se cuelgan y quieres reiniciarlas todas de golpe retomando exactamente la conversacion donde estaba cada una. Lanza siempre primero sin flags para revisar el plan; luego repite con `--go`.
## Gotchas
- **Es impura y se auto-mata.** La terminal desde la que la invocas suele ser una de las victimas; por eso el modo `--go` escribe un script a `/tmp/reboot_all_claudes.<pid>.<ts>.sh` y lo lanza con `setsid` para que el reparenting a init garantice los relanzamientos aunque el padre muera. Usa `--exclude-current` si quieres conservar la terminal actual.
- **Sesiones `busy` pierden el turno en vuelo.** Por defecto se reinician igual y el dry-run lo avisa explicitamente. Al reanudar con `--resume` se recupera hasta el ultimo mensaje completo guardado en el `.jsonl`. Usa `--only-idle` para no tocarlas.
- **Depende de `~/.claude/sessions/<PID>.json`** (formato de Claude Code 2.1.x). Si una version futura cambia el formato, la funcion deja de mapear PID -> sessionId y omitira las sesiones.
- **Asume kitty como terminal.** Si un claude corre fuera de kitty (sin `KITTY_PID` en el environ, p.ej. terminal integrado de un editor), el fallback mata directamente el PID de claude y abre una kitty nueva en su `cwd`.
- **Anti-PID-reciclado:** valida `procStart` del JSON contra el campo 22 de `/proc/<PID>/stat`; si no coincide (o el JSON no existe, o `kill -0` falla) la sesion se omite como huerfana.
+356
View File
@@ -0,0 +1,356 @@
#!/usr/bin/env bash
# reboot_all_claudes — Cierra todas las terminales con una sesion de Claude Code
# corriendo y las relanza retomando exactamente la sesion que tenian
# (claude --resume <sessionId>). Por defecto es DRY-RUN: imprime el plan sin
# tocar nada. Usar --go para ejecutarlo de verdad.
#
# Mecanismo (Claude Code 2.1.x sobre Linux + kitty):
# - pgrep -x claude -> PIDs de las sesiones interactivas vivas.
# - ~/.claude/sessions/<PID>.json -> mapea PID a {sessionId, cwd, status, procStart}.
# - Anti-PID-reciclado: procStart del JSON debe coincidir con el campo 22 de
# /proc/<PID>/stat; ademas kill -0 <PID> debe tener exito.
# - KITTY_PID del environ del proceso -> ventana kitty a cerrar.
# - cmdline del proceso -> flags originales a conservar (sin argv0 ni resume previos).
# - Relanzamiento detached (setsid) para sobrevivir al cierre de la propia terminal.
set -euo pipefail
IFS=$' \t\n'
reboot_all_claudes() {
local mode="dry" # dry | go
local resume_mode="resume" # resume | continue | none
local exclude_current=0
local only_idle=0
# -----------------------------------------------------------------------
# Parseo de argumentos
# -----------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--go|--yes)
mode="go"
;;
--resume-mode)
shift
resume_mode="${1:-}"
case "$resume_mode" in
resume|continue|none) ;;
*)
echo "reboot_all_claudes: --resume-mode invalido: '$resume_mode' (usa resume|continue|none)" >&2
return 2
;;
esac
;;
--exclude-current)
exclude_current=1
;;
--only-idle)
only_idle=1
;;
-h|--help)
cat <<'USAGE'
Uso: reboot_all_claudes [opciones]
Cierra todas las terminales con una sesion de Claude Code corriendo y las
relanza retomando la misma sesion (claude --resume <sessionId>).
Por defecto es DRY-RUN (accion destructiva => default seguro): imprime el plan
y NO mata ni relanza nada.
Opciones:
--go, --yes Ejecuta de verdad (kills + relanzamientos detached).
--resume-mode <modo> resume (default) | continue | none.
resume -> claude --resume <sessionId>
continue -> claude --continue
none -> claude (sesion nueva en el mismo cwd)
--exclude-current No cierra ni relanza la terminal desde la que se invoca.
--only-idle Omite sesiones con status busy (no pierde turnos en vuelo).
-h, --help Muestra esta ayuda.
Ejemplos:
reboot_all_claudes # dry-run, ve el plan
reboot_all_claudes --go --exclude-current # reinicia todas menos esta terminal
USAGE
return 0
;;
*)
echo "reboot_all_claudes: opcion desconocida: '$1' (usa -h)" >&2
return 2
;;
esac
shift
done
# -----------------------------------------------------------------------
# Detectar el PID de la sesion actual subiendo por la cadena de ancestros
# hasta encontrar un proceso cuyo comm sea exactamente "claude".
# -----------------------------------------------------------------------
local current_claude_pid=""
if [[ "$exclude_current" -eq 1 ]]; then
local walk="$$"
local guard=0
while [[ -n "$walk" && "$walk" != "0" && "$walk" != "1" ]]; do
local comm=""
comm="$(cat "/proc/$walk/comm" 2>/dev/null || true)"
if [[ "$comm" == "claude" ]]; then
current_claude_pid="$walk"
break
fi
# campo 4 de /proc/<pid>/stat es el PPID
walk="$(awk '{print $4}' "/proc/$walk/stat" 2>/dev/null || true)"
guard=$((guard + 1))
[[ "$guard" -gt 64 ]] && break
done
fi
# -----------------------------------------------------------------------
# Recolectar las sesiones vivas y validarlas.
# -----------------------------------------------------------------------
local sessions_dir="$HOME/.claude/sessions"
local pids=""
pids="$(pgrep -x claude 2>/dev/null || true)"
if [[ -z "$pids" ]]; then
echo "reboot_all_claudes: no hay sesiones de Claude Code vivas (pgrep -x claude vacio)."
return 0
fi
# Arrays paralelos con el plan validado.
local -a plan_pid plan_kitty plan_status plan_cwd plan_sid plan_cmd plan_skip plan_skipreason
local pid
for pid in $pids; do
# Validacion 1: el proceso debe seguir vivo.
if ! kill -0 "$pid" 2>/dev/null; then
continue
fi
# Validacion 2: debe existir su JSON de sesion.
local json="$sessions_dir/$pid.json"
if [[ ! -f "$json" ]]; then
continue
fi
# Parsear el JSON con python3 (campos sessionId, cwd, status, procStart).
# Salida: lineas "clave=valor" en orden fijo.
local parsed=""
parsed="$(python3 - "$json" <<'PY' 2>/dev/null || true
import json, sys
try:
with open(sys.argv[1]) as fh:
d = json.load(fh)
except Exception:
sys.exit(0)
print("sessionId=" + str(d.get("sessionId", "")))
print("cwd=" + str(d.get("cwd", "")))
print("status=" + str(d.get("status", "")))
print("procStart=" + str(d.get("procStart", "")))
PY
)"
[[ -z "$parsed" ]] && continue
local sid cwd status proc_start_json
sid="$(printf '%s\n' "$parsed" | sed -n 's/^sessionId=//p')"
cwd="$(printf '%s\n' "$parsed" | sed -n 's/^cwd=//p')"
status="$(printf '%s\n' "$parsed" | sed -n 's/^status=//p')"
proc_start_json="$(printf '%s\n' "$parsed" | sed -n 's/^procStart=//p')"
[[ -z "$sid" ]] && continue
# Validacion 3 (anti-PID-reciclado): procStart del JSON debe coincidir
# con el campo 22 de /proc/<PID>/stat.
local proc_start_real=""
proc_start_real="$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true)"
if [[ -n "$proc_start_json" && "$proc_start_json" != "$proc_start_real" ]]; then
# JSON huerfano de un PID reciclado: omitir.
continue
fi
# KITTY_PID de la ventana kitty (vacio si claude no corre en kitty).
local kitty_pid=""
kitty_pid="$(tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | sed -n 's/^KITTY_PID=//p' | head -n1)"
# Flags originales: leer cmdline, descartar argv0 (claude) y cualquier
# flag de resume/continue previo para no duplicarlos.
local raw_cmd=""
raw_cmd="$(tr '\0' '\n' < "/proc/$pid/cmdline" 2>/dev/null || true)"
local -a kept_flags=()
local first=1 tok skipnext=0
while IFS= read -r tok; do
[[ -z "$tok" ]] && continue
if [[ "$first" -eq 1 ]]; then
# argv0 (la ruta o nombre de claude) — descartar.
first=0
continue
fi
if [[ "$skipnext" -eq 1 ]]; then
skipnext=0
continue
fi
case "$tok" in
--resume|--continue|-r|-c)
# Resume/continue previos: omitir (y su posible valor para --resume).
if [[ "$tok" == "--resume" || "$tok" == "-r" ]]; then
skipnext=1
fi
continue
;;
esac
kept_flags+=("$tok")
done <<< "$raw_cmd"
# Construir la estrategia de resume.
local -a launch_args=()
case "$resume_mode" in
resume) launch_args=("--resume" "$sid") ;;
continue) launch_args=("--continue") ;;
none) launch_args=() ;;
esac
launch_args+=("${kept_flags[@]}")
# Comando claude final (para mostrar y ejecutar).
local claude_cmd="claude"
local a
for a in "${launch_args[@]}"; do
claude_cmd+=" $(printf '%q' "$a")"
done
# Decidir si se omite esta sesion del plan.
local skip=0 skipreason=""
if [[ "$exclude_current" -eq 1 && -n "$current_claude_pid" && "$pid" == "$current_claude_pid" ]]; then
skip=1
skipreason="terminal actual (--exclude-current)"
elif [[ "$only_idle" -eq 1 && "$status" == "busy" ]]; then
skip=1
skipreason="busy (--only-idle)"
fi
plan_pid+=("$pid")
plan_kitty+=("${kitty_pid:-}")
plan_status+=("${status:-?}")
plan_cwd+=("${cwd:-?}")
plan_sid+=("$sid")
plan_cmd+=("$claude_cmd")
plan_skip+=("$skip")
plan_skipreason+=("$skipreason")
done
local total="${#plan_pid[@]}"
if [[ "$total" -eq 0 ]]; then
echo "reboot_all_claudes: ninguna sesion valida encontrada (todos los PIDs eran huerfanos o reciclados)."
return 0
fi
# -----------------------------------------------------------------------
# Imprimir el plan (siempre, tanto en dry-run como en --go).
# -----------------------------------------------------------------------
echo "reboot_all_claudes — modo: ${mode} resume: ${resume_mode} sesiones: ${total}"
echo
printf '%-8s %-9s %-7s %-6s %-38s %s\n' "PID" "KITTY" "STATUS" "ACCION" "SESSION_ID" "CWD"
printf '%-8s %-9s %-7s %-6s %-38s %s\n' "--------" "---------" "-------" "------" "--------------------------------------" "---"
local i busy_count=0 act_count=0
for ((i = 0; i < total; i++)); do
local accion="reinic"
if [[ "${plan_skip[$i]}" -eq 1 ]]; then
accion="OMITE"
else
act_count=$((act_count + 1))
fi
[[ "${plan_status[$i]}" == "busy" ]] && busy_count=$((busy_count + 1))
printf '%-8s %-9s %-7s %-6s %-38s %s\n' \
"${plan_pid[$i]}" \
"${plan_kitty[$i]:-(none)}" \
"${plan_status[$i]}" \
"$accion" \
"${plan_sid[$i]}" \
"${plan_cwd[$i]}"
if [[ "${plan_skip[$i]}" -eq 1 ]]; then
echo " -> omitida: ${plan_skipreason[$i]}"
else
echo " -> ${plan_cmd[$i]}"
fi
done
echo
# Aviso explicito de sesiones busy que SI se van a reiniciar.
if [[ "$only_idle" -eq 0 ]]; then
local warned=0
for ((i = 0; i < total; i++)); do
if [[ "${plan_skip[$i]}" -eq 0 && "${plan_status[$i]}" == "busy" ]]; then
if [[ "$warned" -eq 0 ]]; then
echo "AVISO: las siguientes sesiones estan BUSY y se reiniciaran; perderan el turno en vuelo"
echo " (al reanudar con --resume se recupera hasta el ultimo mensaje completo guardado):"
warned=1
fi
echo " - PID ${plan_pid[$i]} cwd=${plan_cwd[$i]}"
fi
done
[[ "$warned" -eq 1 ]] && echo
fi
# -----------------------------------------------------------------------
# DRY-RUN: parar aqui.
# -----------------------------------------------------------------------
if [[ "$mode" == "dry" ]]; then
echo "DRY-RUN: no se ha matado ni relanzado nada."
echo "Para ejecutar de verdad: reboot_all_claudes --go"
return 0
fi
if [[ "$act_count" -eq 0 ]]; then
echo "reboot_all_claudes: nada que hacer (todas las sesiones quedaron omitidas)."
return 0
fi
# -----------------------------------------------------------------------
# MODO --go: construir un script desacoplado que mata las ventanas y
# relanza las sesiones. Se ejecuta con setsid para que sobreviva al cierre
# de la propia terminal (que es una de las victimas).
# -----------------------------------------------------------------------
local ts script log
ts="$(date +%s)"
script="/tmp/reboot_all_claudes.$$.$ts.sh"
log="/tmp/reboot_all_claudes.$ts.log"
{
echo '#!/usr/bin/env bash'
echo 'set -uo pipefail'
echo '# Dar tiempo a que la terminal padre devuelva el control antes de matar.'
echo 'sleep 1'
echo
for ((i = 0; i < total; i++)); do
[[ "${plan_skip[$i]}" -eq 1 ]] && continue
local kp="${plan_kitty[$i]}"
local cp="${plan_pid[$i]}"
local cwd="${plan_cwd[$i]}"
local cmd="${plan_cmd[$i]}"
echo "# --- sesion PID ${cp} (kitty ${kp:-none}) ---"
if [[ -n "$kp" ]]; then
# Cerrar la ventana kitty limpia con SIGTERM.
echo "kill $(printf '%q' "$kp") 2>/dev/null || true"
else
# Sin kitty: matar el propio claude.
echo "kill $(printf '%q' "$cp") 2>/dev/null || true"
fi
# Relanzar en una kitty nueva, detached, en el cwd correcto.
# zsh -ic '...; exec zsh' replica el patron del usuario: al salir de
# claude queda una shell interactiva viva.
printf 'setsid kitty --directory %q zsh -ic %q </dev/null >/dev/null 2>&1 &\n' \
"$cwd" "${cmd}; exec zsh"
echo
done
echo 'exit 0'
} > "$script"
chmod +x "$script"
echo "reboot_all_claudes: lanzando plan desacoplado -> $script (log: $log)"
setsid bash "$script" </dev/null >>"$log" 2>&1 &
disown 2>/dev/null || true
echo "reboot_all_claudes: hecho. Las terminales se cerraran y reabriran en ~1s."
return 0
}
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
reboot_all_claudes "$@"
fi
+10 -3
View File
@@ -3,14 +3,15 @@ name: full_git_pull
kind: pipeline
lang: bash
domain: pipelines
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "full_git_pull() -> stdout: tabla resumen"
description: "Pull automatico de fn_registry + todos los sub-repos locales + submodules + fn sync. Descubre repos locales, stashea dirty trees antes de pullear, hace pull --ff-only, actualiza submodulos del repo principal, pulla ~/.password-store, regenera registry.db con fn index y ejecuta fn sync."
description: "Pull automatico de fn_registry + todos los sub-repos locales + submodules + fn sync. Descubre repos locales, stashea dirty trees antes de pullear, hace pull --ff-only, actualiza submodulos del repo principal, pulla ~/.password-store, regenera registry.db con fn index, ejecuta fn sync y reclona los sub-repos hijos faltantes de cada project (apps/analysis) via clone_project_subrepos."
tags: [git, pull, sync, registry, pipeline, pendiente-usar]
uses_functions:
- discover_git_repos_bash_infra
- git_pull_with_stash_bash_infra
- clone_project_subrepos_bash_pipelines
- pass_get_bash_infra
uses_types: []
returns: []
@@ -51,4 +52,10 @@ bash bash/functions/pipelines/full_git_pull.sh
## Notas
Solo hace pull fast-forward — nunca rebase ni merge automatico. Los repos con divergencia o conflicto de stash se listan al final del resumen para intervencion manual, pero el pipeline no aborta por ellos. No clona repos faltantes: cada PC tiene el subset que le interesa (clonar manualmente si se necesita uno nuevo). Modo completamente no-interactivo.
Solo hace pull fast-forward — nunca rebase ni merge automatico. Los repos con divergencia o conflicto de stash se listan al final del resumen para intervencion manual, pero el pipeline no aborta por ellos. Modo completamente no-interactivo.
Desde v1.1.0 SI reclona los sub-repos hijos faltantes de cada project: tras `fn sync` (que trae a `registry.db` las filas de apps/analysis de todos los PCs), itera los projects y llama `clone_project_subrepos` para traer al disco los hijos que falten, re-indexando si clono alguno. `registry.db` actua como manifest de sub-repos, asi que clonar el project paraguas + `/full-git-pull` reconstruye su arbol entero sin adivinar nombres. Los repos sueltos (sin project) siguen sin auto-clonarse: cada PC tiene el subset que le interesa.
## Capability growth log
- v1.1.0 (2026-06-10) — anade el paso 6: reclonado de sub-repos hijos de cada project via `clone_project_subrepos` tras `fn sync`, con re-index si clona alguno. Permite reconstruir el arbol completo de un project en un PC nuevo (issue 0171).
+39
View File
@@ -149,6 +149,42 @@ full_git_pull() {
fi
fi
# --- Paso 6: Reclonar sub-repos hijos de cada project (issue 0171) ---
# Tras fn sync, registry.db contiene las filas apps/analysis de TODOS los PCs.
# clone_project_subrepos clona en este disco los hijos que falten (skip si ya
# existen). Asi, clonar el project paraguas y correr /full-git-pull reconstruye
# su arbol entero sin adivinar nombres de sub-repos: registry.db ES el manifest.
echo "" >&2
echo "[6/6] Reclonando sub-repos de projects..." >&2
local reclone_summary=" [skip] sin projects o registry.db"
if [[ -f "$registry_root/registry.db" ]] && command -v sqlite3 >/dev/null 2>&1; then
export FN_REGISTRY_ROOT="$registry_root"
export GITEA_URL="${GITEA_URL:-$(pass_get agentes/gitea-url | head -n1 2>/dev/null || true)}"
local clone_script="$SCRIPT_DIR/clone_project_subrepos.sh"
local any_cloned=0
if [[ -f "$clone_script" ]]; then
while IFS= read -r proj_id; do
[[ -z "$proj_id" ]] && continue
local clone_out
clone_out=$(bash "$clone_script" "$proj_id" 2>&1 || true)
if echo "$clone_out" | grep -q '\[cloned\]'; then
any_cloned=1
echo " $proj_id: nuevos sub-repos clonados" >&2
fi
done < <(sqlite3 "$registry_root/registry.db" "SELECT id FROM projects;" 2>/dev/null)
if [[ "$any_cloned" -eq 1 ]]; then
echo " re-index tras clonado..." >&2
[[ -x "$fn_bin" ]] && CGO_ENABLED=1 "$fn_bin" index >/dev/null 2>&1 || true
reclone_summary=" OK: nuevos sub-repos clonados + re-index"
else
reclone_summary=" OK: nada que clonar (todo presente)"
fi
else
reclone_summary=" [skip] clone_project_subrepos.sh no encontrado"
fi
fi
echo " $reclone_summary" >&2
# --- Resumen ---
echo ""
echo "===== RESUMEN full_git_pull ====="
@@ -171,6 +207,9 @@ full_git_pull() {
echo ""
echo "fn sync:"
echo "$sync_summary"
echo ""
echo "Reclonado sub-repos de projects:"
echo "$reclone_summary"
if [[ ${#diverged[@]} -gt 0 || ${#conflicts[@]} -gt 0 ]]; then
echo ""
+7 -2
View File
@@ -3,10 +3,10 @@ name: full_git_push
kind: pipeline
lang: bash
domain: pipelines
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "full_git_push(commit_message?: string) -> stdout: tabla resumen"
description: "Push automatico de fn_registry + todos los sub-repos + fn sync. Descubre repos, escanea secrets (aborta si detecta), auto-inicializa apps/analyses sin .git via ensure_repo_synced, auto-commitea dirty trees, pushea solo repos adelantados, pushea ~/.password-store sin commitear, y ejecuta fn sync."
description: "Push automatico de fn_registry + todos los sub-repos + fn sync. Descubre repos, escanea secrets (aborta si detecta), auto-inicializa apps/analyses Y projects paraguas sin .git via ensure_repo_synced (asegurando el .gitignore canonico del project antes), auto-commitea dirty trees, pushea solo repos adelantados, pushea ~/.password-store sin commitear, y ejecuta fn sync."
tags: [git, push, sync, registry, pipeline, pendiente-usar]
uses_functions:
- discover_git_repos_bash_infra
@@ -14,6 +14,7 @@ uses_functions:
- git_auto_commit_dirty_bash_infra
- git_push_if_ahead_bash_infra
- ensure_repo_synced_bash_infra
- ensure_project_gitignore_bash_infra
- pass_get_bash_infra
uses_types: []
returns: []
@@ -62,3 +63,7 @@ bash bash/functions/pipelines/full_git_push.sh "feat: nueva funcion"
## Notas
El unico motivo para abortar antes de commitear es la deteccion de secrets. Cualquier otro error (push rechazado por non-fast-forward, fn sync no disponible) se reporta en el resumen y el pipeline continua con el resto de repos. Modo completamente no-interactivo.
## Capability growth log
- v1.1.0 (2026-06-10) — auto-inicializa tambien los projects paraguas (`projects/<p>/`) sin repo Gitea, no solo apps/analyses. Antes de pushear cada project asegura su `.gitignore` canonico via `ensure_project_gitignore` para no trackear el contenido de los sub-repos hijos. Cierra el agujero por el que projects como aurgi/obsidian/osint vivian solo en disco y se perdian al borrar el PC (issue 0171).
+32 -20
View File
@@ -13,6 +13,7 @@ source "$INFRA_DIR/git_auto_commit_dirty.sh"
source "$INFRA_DIR/git_push_if_ahead.sh"
source "$INFRA_DIR/pass_get.sh"
source "$INFRA_DIR/ensure_repo_synced.sh"
source "$INFRA_DIR/ensure_project_gitignore.sh"
source "$CYBERSEC_DIR/scan_secrets_in_dirty.sh"
full_git_push() {
@@ -65,6 +66,32 @@ full_git_push() {
ensure_repo_synced "$d" dataforge "$(basename "$d")" master "chore: initial sync" || \
echo " [warn] fallo inicializando $d" >&2
done < <(sqlite3 "$registry_root/registry.db" "SELECT dir_path FROM apps WHERE dir_path != '' UNION SELECT dir_path FROM analysis WHERE dir_path != '';" 2>/dev/null)
# Paso 1c: Auto-inicializar los PROJECTS paraguas sin .git (issue 0171).
# El directorio projects/<p>/ versiona SOLO las docs de nivel-project
# (project.md, vault.yaml, CONVENTIONS.md, tools/...). Sus hijos apps/* y
# analysis/* son sub-repos Gitea independientes, excluidos por el .gitignore
# canonico que ensure_project_gitignore garantiza ANTES del push para no
# trackear su contenido (doble-tracking). Sin esto, un project sin repo
# (aurgi, obsidian, osint) vivia solo en disco y se perdia al borrar el PC.
if [[ -f "$registry_root/registry.db" ]] && command -v sqlite3 >/dev/null 2>&1; then
while IFS= read -r proj_dir; do
[[ -z "$proj_dir" ]] && continue
local pd="$registry_root/$proj_dir"
[[ -d "$pd" ]] || continue
# Garantizar el .gitignore canonico ANTES de cualquier git add -A.
ensure_project_gitignore "$pd" || \
echo " [warn] no se pudo asegurar .gitignore de $pd" >&2
if [[ -d "$pd/.git" ]]; then
git -C "$pd" remote get-url origin >/dev/null 2>&1 && continue
echo " fix-remote: $pd (.git sin origin)" >&2
else
echo " auto-init project: $pd" >&2
fi
ensure_repo_synced "$pd" dataforge "$(basename "$pd")" master "chore: initial sync project" || \
echo " [warn] fallo inicializando project $pd" >&2
done < <(sqlite3 "$registry_root/registry.db" "SELECT CASE WHEN dir_path != '' THEN dir_path ELSE 'projects/'||id END FROM projects;" 2>/dev/null)
fi
else
echo " [warn] registry.db o sqlite3 no disponibles — omitiendo auto-init BD-driven" >&2
fi
@@ -72,28 +99,13 @@ full_git_push() {
echo " [skip] GITEA_URL/GITEA_TOKEN no disponibles — omitiendo auto-init" >&2
fi
# Redescubrir repos tras posibles inicializaciones
# Redescubrir repos tras posibles inicializaciones.
# El repo de config de Claude (dataforge/repo_Claude, al que apuntan los
# symlinks de ~/.claude/) vive en fn_registry/external/repo_Claude, asi que
# discover_git_repos ya lo encuentra y pasa por scan-secrets/commit/push
# como un repo mas. No necesita tratamiento especial.
repos=$(discover_git_repos "$registry_root")
# --- Paso 1c: Incluir el repo de configuracion de Claude ---
# Los archivos de ~/.claude/ (settings.json, commands, skills, CLAUDE.md...)
# son symlinks a un repo git externo (dataforge/repo_Claude). Lo resolvemos
# de forma portable siguiendo el symlink de settings.json — sin hardcodear
# el path, que difiere entre PCs. Si resuelve a un repo git, lo anadimos a
# la lista para que pase por scan-secrets + auto-commit + push como los demas.
local claude_repo=""
if [[ -L "$HOME/.claude/settings.json" ]]; then
local _claude_settings_real
_claude_settings_real=$(readlink -f "$HOME/.claude/settings.json" 2>/dev/null || true)
if [[ -n "$_claude_settings_real" ]]; then
claude_repo=$(git -C "$(dirname "$_claude_settings_real")" rev-parse --show-toplevel 2>/dev/null || true)
fi
fi
if [[ -n "$claude_repo" && -d "$claude_repo/.git" ]]; then
echo "[1c] Incluyendo repo de config Claude: $claude_repo" >&2
repos="$repos"$'\n'"$claude_repo"
fi
# --- Paso 2: Escanear secrets ---
echo "" >&2
echo "[2/6] Escaneando secrets en dirty trees..." >&2
+26
View File
@@ -70,6 +70,8 @@ func cmdDoctor(args []string) {
doctorDod(r, jsonOut)
case "e2e-coverage":
doctorE2ECoverage(r, jsonOut)
case "projects":
doctorProjects(r, jsonOut)
default:
fmt.Fprintf(os.Stderr, "unknown doctor subcommand: %s\n", sub)
doctorUsage()
@@ -100,6 +102,7 @@ Subcommands:
modules Drift entre uses_modules (app.md) y fn_module_<x> link calls (CMakeLists.txt) - issue 0097
dod Audita bloque dod_evidence_schema en dev/issues/ y dev/flows/ (issue 0114)
e2e-coverage Porcentaje de apps con e2e_checks declarado en su app.md (issue 0121b)
projects Cobertura de projects vs sub-repos Gitea (repo propio + hijos clonables) (issue 0171)
Flags:
--json Salida JSON (para scripting/agentes)
@@ -505,6 +508,29 @@ func doctorML(root string, jsonOut bool) {
fmt.Printf("\nOverall ML environment: %s\n", overall)
}
func doctorProjects(root string, jsonOut bool) {
rows, err := infra.AuditProjectsCoverage(root)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
orphans, oerr := infra.FindOrphanProjectRefs(root)
if oerr != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", oerr)
os.Exit(1)
}
if jsonOut {
emit(map[string]any{
"coverage": rows,
"orphan_project_ids": orphans,
})
return
}
fmt.Print(infra.FormatProjectsCoverage(rows))
fmt.Println("\n--- Check inverso: project_id huérfanos (apps/analysis sin project declarado) ---")
fmt.Print(infra.FormatOrphanProjectRefs(orphans))
}
func emit(v any) {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
@@ -0,0 +1,199 @@
---
id: "0171"
title: "Manifest de sub-repos por project + re-clonado y auditoría de cobertura en Gitea"
status: pendiente
type: enhancement
domain:
- registry-quality
- infra
scope: registry-only
priority: alta
depends: []
blocks: []
related: ["0166"]
created: 2026-06-10
updated: 2026-06-10
tags: [projects, subrepo, gitea, clone, backup, manifest, fn-doctor]
---
> **Actualización 10/06/2026 — implementado el núcleo (enfoque KISS).** El manifest
> `subrepos.yaml` propuesto abajo se **descartó**: `registry.db` (tablas `apps`/`analysis`
> con `project_id`, propagadas entre PCs por `fn sync`) **ya es** el manifest de sub-repos, y
> `clone_project_subrepos_bash_pipelines` ya lo consume. No hace falta un archivo nuevo. Lo que
> faltaba era integración + auditoría. Ver `## Estado de implementación` al final.
# 0171 — Manifest de sub-repos por project + re-clonado y auditoría de cobertura en Gitea
## APP Metadata
| Campo | Valor |
|-------|-------|
| **ID** | 0171 |
| **Estado** | pendiente |
| **Prioridad** | alta (riesgo de pérdida de datos) |
| **Tipo** | enhancement — metadata de projects + `/full-git-pull` + `fn doctor` |
## Contexto
El 10/06/2026, al preparar un dashboard sobre el project `aurgi`, se descubrió que el project
paraguas **no existía en Gitea** (`dataforge/aurgi` → 404). Sus 3 analyses sí estaban a salvo como
sub-repos independientes (`dataforge/venta_web`, `dataforge/sale_prices_comprobation`,
`dataforge/presupuestos_callcenter`), pero **el `project.md`, `vault.yaml` y `CONVENTIONS.md` de
nivel-project no estaban versionados en ningún sitio**. Reconstruir el project obligó a *adivinar*
los nombres de los sub-repos hijos uno a uno desde la lista completa de repos de Gitea.
Una auditoría de cobertura `projects ↔ Gitea` confirmó el agujero:
| Project | Repo Gitea | Riesgo |
|---|---|---|
| fleet_monitoring, fn_monitoring, message_bus, web_scraping | ✅ | ninguno |
| **obsidian**, **osint** | ❌ (solo en disco local) | alto — resuelto en esta sesión (subidos a `dataforge/obsidian`, `dataforge/osint`) |
| **aurgi** | ❌ (404, paraguas inexistente) | pendiente — analyses salvados, docs nivel-project no |
Dos problemas estructurales quedan abiertos:
1. **Projects sin repo Gitea**: su contenido de nivel-project vive solo en disco. Si se borra el
disco (o el project no se sincroniza a otro PC), se pierde. La regla `projects.md` dice que cada
project debe ser su propio repo Gitea, pero no hay nada que lo **verifique ni lo fuerce**.
2. **Sub-repos hijos no referenciados**: el `.gitignore` de cada project excluye `apps/*/` y
`analysis/*/` (son sub-repos independientes). Por tanto, **un clon fresco del project NO trae sus
hijos**, y no existe ningún manifest que diga *qué hijos clonar*. Hoy `/full-git-pull` solo
descubre repos vía `discover_git_repos_bash_infra` (busca `.git` ya presentes en disco): si el
hijo nunca se clonó, es invisible. Resultado: para reconstruir un project en una máquina nueva hay
que adivinar sus sub-repos (exactamente lo que pasó con aurgi).
## Objetivo
Que **todo project** (a) tenga su repo Gitea garantizado y (b) **referencie declarativamente sus
sub-repos hijos** (apps + analyses), de modo que clonar el project en cualquier PC permita
re-clonar automáticamente todo su árbol sin adivinar nada.
## Propuesta
### 1. Manifest de sub-repos por project
Añadir a cada project un manifest declarativo de sus hijos. Dos opciones de formato (decidir una):
- **Opción A (KISS, preferida): `subrepos.yaml`** en la raíz del project, análogo a `vault.yaml`:
```yaml
# projects/<p>/subrepos.yaml — sub-repos hijos de este project (apps + analyses)
subrepos:
- kind: analysis # app | analysis
name: venta_web
path: analysis/venta_web
repo: dataforge/venta_web
url: https://gitea-.../dataforge/venta_web
- kind: analysis
name: sale_prices_comprobation
path: analysis/sale_prices_comprobation
repo: dataforge/sale_prices_comprobation
url: https://gitea-.../dataforge/sale_prices_comprobation
```
- **Opción B: sección `## Sub-repos`** en `project.md` con una tabla `kind | name | path | url`.
`subrepos.yaml` (Opción A) es más fácil de parsear por las funciones de git y se versiona con el
project (no está en el `.gitignore`). El manifest se **autogenera/actualiza** escaneando los `.git`
hijos presentes en disco + su `remote get-url origin` (reusar `discover_git_repos_bash_infra`).
### 2. Generación y mantenimiento del manifest
Función/pipeline nueva (delegar a `fn-constructor`, grupo `infra`/git) que, dado un project:
- Escanea `apps/*/.git` y `analysis/*/.git`, lee su remote origin.
- Escribe/actualiza `subrepos.yaml`.
- Idempotente. Se invoca dentro de `/full-git-push` (o `fn index`) para mantener el manifest al día.
### 3. Re-clonado desde el manifest en `/full-git-pull`
Extender `/full-git-pull` para que, tras actualizar cada project, lea su `subrepos.yaml` y **clone
los hijos que falten** (`url` → `path`). Así, en un PC nuevo: clonar `dataforge/<project>` →
`/full-git-pull` → reconstruye apps + analyses automáticamente. Requiere una función
`clone_missing_subrepos_bash_infra(project_dir)` (delegar a `fn-constructor`).
### 4. Garantizar repo Gitea de cada project + auditoría en `fn doctor`
- Subcomando nuevo `fn doctor projects` (función `audit_projects_coverage_go_infra`): por cada
project en disco reporta `repo_gitea` (existe en Gitea sí/no), `repo_url` (declarado en project.md
sí/no), y `subrepos_manifest` (presente + cuántos hijos en disco sin entrada / en manifest sin
clonar). Salida `--json`. Cero hallazgos = sano.
- Acción derivada documentada: `repo_gitea=no` → `ensure_repo_synced_bash_infra projects/<p>
dataforge <p> master "init: project <p>"`.
### 5. Backfill inicial
- `aurgi`: traer su `project.md` / `vault.yaml` / `CONVENTIONS.md` de `aurgi-pc` (o `home-wsl`) y
crear `dataforge/aurgi` + `subrepos.yaml` con los 3 analyses ya conocidos. **No** reconstruir a
mano un `project.md` mínimo (divergiría del real).
- Resto de projects con hijos (`fleet_monitoring`, `fn_monitoring`, `message_bus`, `web_scraping`):
generar su `subrepos.yaml` con la función del punto 2.
## Definition of Done
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|---|---|---|---|
| Golden: clon fresco reconstruye árbol | e2e | clonar `dataforge/<p>` en dir limpio → `/full-git-pull` | apps + analyses del project re-clonados desde `subrepos.yaml` |
| Edge: project sin hijos (obsidian) | e2e | generar manifest | `subrepos.yaml` válido y vacío (o ausente), sin error |
| Edge: hijo en disco sin `.git` | unit | auditoría | `fn doctor projects` lo reporta como "hijo sin sub-repo" |
| Error: project sin repo Gitea | e2e | `fn doctor projects --json` | lo marca `repo_gitea=false`, sugiere `ensure_repo_synced` |
| Cobertura | audit | `fn doctor projects` | 0 projects sin repo, 0 hijos sin referenciar |
## Decisiones abiertas
1. **Formato del manifest**: `subrepos.yaml` (A) vs. sección en `project.md` (B). Recomendado A.
2. **¿Auto-generar el manifest en `fn index`** o solo en `/full-git-push`? (evitar I/O de red en
`fn index`; preferible en push).
3. **aurgi**: ¿traer de `aurgi-pc` por SSH ahora, o dejarlo para cuando el project se sincronice?
## Notas
En esta sesión ya se resolvió el riesgo inmediato: `obsidian` y `osint` se subieron a Gitea
(`dataforge/obsidian`, `dataforge/osint`) con `ensure_repo_synced_bash_infra` y se les añadió
`repo_url` en su `project.md`. Este issue cubre la solución **estructural y reutilizable** para que
el caso no vuelva a ocurrir con ningún project. Relacionado con #0166 (dependencias app→app para
build reproducible): ambos persiguen que clonar el ecosistema en un PC nuevo sea determinista.
## Estado de implementación (10/06/2026)
Implementado con enfoque KISS, **sin** `subrepos.yaml` (registry.db + `fn sync` ya cumplen esa
función). Cambios:
**Funciones nuevas:**
- `ensure_project_gitignore_bash_infra` — garantiza idempotente el `.gitignore` canónico de un
project (`apps/*/`, `analysis/*/`, `vaults/*` + excepciones) antes de cualquier `git add -A`,
para no trackear el contenido de los sub-repos hijos.
- `audit_projects_coverage_go_infra` (+ `FormatProjectsCoverage`) — motor de `fn doctor projects`.
Reporta por project: `git`/`remote`/`repo_url`/`children (cloned/inDB)` + issues
(`no_gitea_repo`, `children_missing`, `dir_not_found`). Solo git local + registry.db, sin red.
**Integraciones:**
- `full_git_push` v1.1.0 — paso 1c: auto-inicializa y pushea los **projects paraguas** sin repo
(antes solo apps/analyses), asegurando el `.gitignore` canónico primero. Cierra el agujero
aurgi/obsidian/osint.
- `full_git_pull` v1.1.0 — paso 6: tras `fn sync`, reclona los sub-repos hijos faltantes de cada
project con `clone_project_subrepos` + re-index. Clonar el paraguas + `/full-git-pull` reconstruye
el árbol entero.
- `fn doctor projects` — nuevo subcomando (`cmd/fn/doctor.go`). Hoy reporta **0 projects con
problemas**.
**Hecho aparte (riesgo inmediato):** `dataforge/obsidian` + `dataforge/osint` creados, `repo_url`
en sus `project.md`.
### Pendientes (no bloquean el núcleo)
1. **Check inverso — HECHO (10/06/2026).** `FindOrphanProjectRefs` + `FormatOrphanProjectRefs` en
`audit_projects_coverage_go_infra`, enchufado en `fn doctor projects`. Detecta apps/analysis con
`project_id` sin fila en `projects`. Hoy reporta 4 paraguas huérfanos (existen en otro PC, nunca
subidos a Gitea — mismo caso que aurgi):
- `element_agents` (6 apps: agents_and_robots, agents_dashboard, device_agent, element_matrix_chat,
matrix_admin_panel, matrix_client_pc)
- `imagegen` (image_to_3d_studio)
- `osint_graph` (graph_explorer)
- `aurgi` (sus analyses sí están en Gitea; el paraguas no)
2. **Fix de datos de los 4 paraguas huérfanos — pendiente, requiere el PC origen.** No están en disco
ni en Gitea en este PC (`lucas-linux`), así que no se pueden reconstruir aquí sin inventar. El fix
correcto: correr `/full-git-push` en el PC donde cada paraguas existe en disco (`aurgi-pc` /
`home-wsl`). Con `full_git_push` v1.1.0 (paso 1c) eso ya los crea en Gitea automáticamente. Tras
eso, `/full-git-pull` aquí (paso 6) los traerá. NO reconstruir un `project.md` mínimo a mano.
3. **DoD vida útil**: validar el reclonado en un PC nuevo real (clon limpio del paraguas →
`/full-git-pull` → árbol reconstruido) antes de declarar el issue cerrado.
+184
View File
@@ -0,0 +1,184 @@
---
id: "0172"
title: "App web OSINT: grafo sigma.js + tablas por tipo + fichas con imágenes sobre el vault osint"
status: pendiente
type: app
domain:
- osint
- frontend
scope: app-scoped
priority: media
depends: []
blocks: []
related: ["0171"]
created: 2026-06-10
updated: 2026-06-10
tags: [osint, web, sigma, graph, mantine, obsidian, vault, dashboard]
---
# 0172 — App web OSINT: grafo sigma.js + tablas por tipo + fichas con imágenes
## APP Metadata
| Campo | Valor |
|-------|-------|
| **ID** | 0172 |
| **Estado** | pendiente (solo plan — se construye cuando el vault tenga más datos) |
| **Prioridad** | media |
| **Tipo** | app — nueva app web en `projects/osint/apps/osint_web` |
| **Project** | osint (`projects/osint/`) |
## Contexto
El project `osint` guarda sus investigaciones en el vault de Obsidian
`/home/enmanuel/Obsidian/osint` (sub-repo `dataforge/osint`). Hoy ese vault tiene:
- **~82 nodos** repartidos en carpetas tipadas: `personas/` (45), `organizaciones/` (25),
`lugares/` (10), `dominios/` (1), `casos/` (1).
- **Datos tabulares** en el frontmatter YAML de cada ficha: `tipo`, `nombre`, `sexo`,
`fecha_nacimiento`, `dni`, `direccion`, `pais`, `aliases`, `tags`, etc.
- **Aristas implícitas**: los wikilinks `[[...]]` en las secciones `Relaciones`, `Lugares` y
`Documentos` conectan unas fichas con otras (y con sus attachments).
- **~240 attachments**: fotos, DNIs, certificados y PDFs en `attachments/<tipo>/<slug>/`,
embebidos en las notas con `![[...]]`.
Obsidian es bueno para *escribir* la investigación, pero malo para *explorarla* de un vistazo:
no da un grafo navegable de todos los objetivos, ni una tabla filtrable, ni una ficha-resumen
con la galería de imágenes de cada persona. Metabase/Grafana no encajan: leen BD SQL (no `.md`),
y no muestran ni grafo de nodos ni imágenes inline.
Decisión del usuario (10/06/2026): construir una **app web propia** que lea el vault y ofrezca
tres vistas — **grafo explorable con sigma.js**, **tablas filtradas por tipo**, y **fichas con
imágenes**. Este issue es **solo el plan**: la recopilación de datos en Obsidian continúa primero;
la app se implementa cuando haya suficiente material que justifique la inversión.
## Objetivo
Una app web local que, leyendo directamente los `.md` del vault `osint` (sin BD intermedia
obligatoria en v1), permita:
1. **Explorar el grafo** de nodos (personas, organizaciones, lugares, dominios, casos) y sus
conexiones por wikilinks, con sigma.js: zoom, pan, click en nodo → ficha, colores por tipo,
filtro de tipos visibles, búsqueda de nodo.
2. **Ver tablas filtradas por tipo**: una tabla por categoría (personas, organizaciones, ...)
con las columnas del frontmatter, ordenable y filtrable (por dni, lugar, fecha, tag).
3. **Abrir la ficha** de cualquier nodo: frontmatter renderizado + cuerpo Markdown + galería de
sus attachments (fotos, DNIs, PDFs) servidos por el backend.
## Arquitectura propuesta
```
projects/osint/apps/osint_web/ (sub-repo Gitea dataforge/osint_web)
app.md frontmatter de registro (framework: react-vite-mantine)
server/ backend Python (lee el vault, sirve JSON + attachments)
main.py FastAPI o stdlib http
frontend/ React + Vite + Mantine + sigma.js
src/
views/GraphView.tsx sigma.js + graphology
views/TablesView.tsx Mantine DataTable filtrable por tipo
views/NodeCard.tsx ficha + galería de attachments
```
### Backend (Python — máximo reuso del grupo `obsidian`)
Python porque el grupo de capacidad `obsidian` (11 funciones, dominio `obsidian`) ya cubre casi
todo el parseo del vault. **Registry-first**: el backend orquesta estas funciones, no reimplementa
el parseo.
Funciones del registry a reutilizar:
| Función | Uso en la app |
|---|---|
| `list_obsidian_notes_py_obsidian` | enumerar nodos por carpeta/tipo |
| `read_obsidian_note_py_obsidian` | leer ficha: `{frontmatter, body, wikilinks, tags}` |
| `parse_obsidian_frontmatter_py_obsidian` | datos tabulares de cada nodo |
| `extract_obsidian_wikilinks_py_obsidian` | aristas del grafo |
| `extract_obsidian_embeds_py_obsidian` | attachments embebidos en cada nota |
| `resolve_obsidian_embed_py_obsidian` | resolver `![[foto.jpg]]` → path real en disco para servir la imagen |
| `slugify_obsidian_name_py_obsidian` | normalizar nombre de wikilink → id de nodo |
| `search_obsidian_notes_py_obsidian` | búsqueda global en el grafo |
Funciones **nuevas** a delegar a `fn-constructor` (no escribir inline en la app):
- `build_obsidian_graph_py_obsidian` (impure) — dado `vault_dir`, devuelve
`{"nodes": [{id, tipo, label, frontmatter}], "edges": [{source, target, kind}]}`.
Resuelve cada wikilink a un nodo existente (vía slug / nombre de archivo); los wikilinks que
no resuelven a un `.md` del vault se marcan como aristas "dangling" o se descartan según flag.
Tag de grupo: `obsidian`. Es la pieza que el grupo declara como frontera no cubierta
("No indexa el grafo agregado") — esta función la cierra.
Endpoints HTTP (JSON salvo el de attachments):
| Método | Ruta | Devuelve |
|---|---|---|
| GET | `/api/graph` | grafo completo `{nodes, edges}` para sigma.js |
| GET | `/api/nodes?tipo=persona` | filas de la tabla de ese tipo (frontmatter aplanado) |
| GET | `/api/node/{slug}` | ficha: frontmatter + body (HTML/markdown) + lista de attachments |
| GET | `/api/attachment?path=...` | sirve el binario del attachment (image/pdf), con allowlist al vault |
| GET | `/api/search?q=...` | nodos que matchean |
Seguridad: el backend solo sirve archivos **dentro** del vault osint (path traversal bloqueado).
El vault contiene datos personales sensibles (DNIs) → la app escucha **solo en `127.0.0.1`**, sin
exponer a red. No es un service desplegable a VPS.
### Frontend (React + Vite + Mantine + sigma.js)
- Sistema del registry: React + Vite + Mantine v9 + `@fn_library` (grupo `mantine`, 63 funciones).
Componentes propios de `@fn_library` antes que HTML nativo (regla `frontend_theming.md`).
- **Grafo**: `sigma.js` + `graphology`. Color por `tipo`, tamaño por grado, layout
force-directed (graphology-layout-forceatlas2). Click en nodo → abre `NodeCard`. Panel lateral
con toggles de tipos visibles y caja de búsqueda.
- **Tablas**: una pestaña por tipo, Mantine `Table`/DataTable con columnas del frontmatter,
orden y filtro por columna (dni, lugar, fecha_nacimiento, tags).
- **Fichas**: `NodeCard` con frontmatter en formato clave-valor (fechas en formato europeo
DD/MM/AAAA — memoria `formato-fecha-europeo`), cuerpo Markdown, y galería de attachments
(imágenes con lightbox; PDFs como enlace/embed).
`sigma.js` y `graphology` son dependencias nuevas del frontend (no en `@fn_library`). KISS:
añadir solo esas dos; el resto (tabla, layout, modales) sale de Mantine/`@fn_library`.
## Decisiones abiertas
1. **¿BD intermedia o lectura directa del vault?** v1 lee el vault en cada arranque (cachea el
grafo en memoria). Si el vault crece mucho o se quiere histórico/diff, evaluar un
`operations.db` con `entities`/`relations` (encaja con el bucle reactivo). Recomendado:
empezar sin BD (KISS), añadirla solo si el rendimiento o un caso de uso lo exige.
2. **Backend FastAPI vs stdlib http**: FastAPI da validación y OpenAPI gratis; stdlib evita una
dependencia. Como el backend es fino (orquesta funciones del registry), decidir al construir.
3. **Live-reload del vault**: ¿re-escanear bajo demanda (botón "refrescar") o watcher de
filesystem? v1: botón refrescar (simple). Watcher si molesta.
4. **Aristas dangling**: wikilinks a notas que aún no existen — ¿mostrarlos como nodos fantasma
(útil para ver "objetivos pendientes de fichar") o esconderlos? Propuesta: nodo fantasma con
estilo atenuado, toggle para ocultar.
## Definition of Done
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|---|---|---|---|
| Golden: grafo carga el vault | e2e | `GET /api/graph` con el vault osint real | `nodes` ≥ nº de `.md`, `edges` con los wikilinks resueltos; sigma.js los pinta |
| Golden: ficha con imágenes | e2e | `GET /api/node/<persona con fotos>` + abrir NodeCard | frontmatter + cuerpo + galería con las imágenes de `attachments/personas/<slug>/` |
| Edge: tabla filtrada por tipo | e2e | `GET /api/nodes?tipo=organizacion` | solo nodos de ese tipo, columnas del frontmatter |
| Edge: wikilink dangling | unit | nota con `[[Persona-Inexistente]]` | arista marcada dangling / nodo fantasma, sin crash |
| Edge: nombre con mayúsculas/acentos | unit | wikilink `[[María del Mar]]` → slug | resuelve a `maria-del-mar-...md` vía `slugify_obsidian_name` |
| Error: path traversal en attachment | e2e | `GET /api/attachment?path=../../etc/passwd` | 403/404, jamás sirve fuera del vault |
| Error: vault inexistente | e2e | arrancar con `--vault /no/existe` | error claro al arrancar, no 500 silencioso |
| Cobertura | audit | `uses_functions` del `app.md` | declara todas las funciones del grupo `obsidian` consumidas |
Vida útil (cuando se construya): usar la app de verdad sobre el vault osint durante ≥7 días en
investigaciones reales; medir que el grafo sigue cargando sin romperse al crecer el vault.
## Notas
**Estado actual: solo plan.** No construir todavía — la recopilación de datos en Obsidian
continúa; cuando el vault tenga masa crítica de objetivos/relaciones, se arranca con
`/new-cpp-app` no aplica (es web): se hace `git init` del sub-repo `dataforge/osint_web` dentro de
`projects/osint/apps/osint_web/` antes de limpiar cualquier worktree (regla `apps_subrepo.md`),
scaffolding de frontend con el stack Mantine del registry, y backend Python orquestando el grupo
`obsidian`.
Onboarding (para cuando exista): arrancar backend `python server/main.py --vault
/home/enmanuel/Obsidian/osint --port 8470` y `pnpm dev` en `frontend/`; abrir
`http://127.0.0.1:5173`. Pestañas: Grafo / Tablas / (ficha al click). Solo localhost por los
datos sensibles del vault.
Relación con #0171 (manifest de sub-repos): cuando esta app exista será un hijo del project
`osint` y debe entrar en su `subrepos.yaml` para re-clonarse en otros PCs.
+2
View File
@@ -9,6 +9,8 @@ Registry personal de código con búsqueda FTS. Diseñado para composición func
- `integrity.md` — Reglas de integridad y referencias cruzadas
- `architecture.md` — Visión general del sistema
- `sync_setup.md` — Vincular una PC al server `registry.organic-machine.com` (env vars, `fn sync`, troubleshooting)
- `adr/` — Architecture Decision Records: decisiones de diseño (qué se decidió y por qué)
- `../reports/` — Reportes de trabajo: **artefacto local** (entregable de una tarea: qué se hizo, cómo se verificó, gaps). Gitignored salvo `.gitkeep`, NO sube a Gitea ni se versiona (como los vaults). Convención en `.claude/rules/reports.md`. Decisión: [ADR 0006](adr/0006-reports-folder.md)
## Tablas
+53
View File
@@ -0,0 +1,53 @@
# ADR 0006 — `reports/` como artefacto local para reportes de trabajo
- **Fecha:** 2026-06-06
- **Estado:** accepted
## Contexto
Cuando un agente termina una tarea no trivial (una auditoría, una tanda de fixes con verificación, un refactor, una investigación), el resumen ejecutable —qué se hizo, cómo se verificó, qué quedó pendiente— vivía solo en el chat de la sesión. Eso tiene tres problemas:
1. **Se pierde**: el chat no es consultable después; el resumen no queda en disco.
2. **No es compartible rápido**: para pasar el resultado hay que copiar a mano del chat.
3. **No tiene formato estable**: cada resumen sale distinto, sin garantía de evidencia ejecutable ni de declaración honesta de gaps.
Los contenedores existentes no encajan: los ADRs (`docs/adr/`) son decisiones de diseño; las reglas (`.claude/rules/`) son normas operativas; el diario (`docs/diary/`) es bitácora cronológica libre. Faltaba un sitio para el **entregable de una tarea concreta**: el resultado y su evidencia.
Punto clave de la decisión: un report **no es documentación del registry, es un artefacto** (en el sentido de `.claude/rules/artefactos.md`) — generado, con ciclo de vida propio, no código reutilizable. Y como artefacto del tipo "datos locales", se comporta como los **vaults**: no sube a Gitea ni se versiona en el git del padre.
## Decisión
Crear la carpeta `reports/` para reportes de trabajo, tratados como **artefacto local**:
1. **No versionados, no Gitea.** `reports/*` está en el `.gitignore` del padre (solo `reports/.gitkeep` se versiona, para mantener la carpeta presente). Un report no tiene repo propio: vive local en la máquina que lo generó. Compartir = pasar la ruta o copiar el contenido, no `git push`. Mismo trato que los vaults.
2. **Conviven en raíz o en proyectos**, como cualquier artefacto: `reports/` (sueltos) o `projects/<p>/reports/` (del trabajo de un proyecto). Ambas rutas gitignored (`reports/*`, `projects/*/reports/`). Se permiten subcarpetas para agrupar.
3. **No se indexan en `registry.db`.** Sin tabla `reports` ni schema (KISS) — son texto plano efímero, como los `playgrounds`.
4. **Convención y plantilla** viven en `.claude/rules/reports.md` (versionado): nombre `NNNN-YYYY-MM-DD-slug.md`, secciones Resumen/Cambios/Verificación/Gaps, evidencia ejecutable obligatoria.
Un report NO sustituye a un ADR ni a una regla: si durante el trabajo aparece una decisión de diseño, va a `docs/adr/` y el report solo la referencia.
## Alternativas consideradas
- **Versionar los reports en el repo padre.** Era el enfoque inicial de este ADR; descartado: un report es un artefacto (resultado de tarea, efímero, posiblemente voluminoso o ligado a un PC concreto), no documentación estable del registry. Versionarlos ensucia el historial del padre con entregables operativos. La convención correcta es la de los vaults: local, no Gitea.
- **Dejar los resúmenes solo en el chat.** Status quo; se pierden y no son compartibles. Es el motivo del ADR.
- **Usar `docs/diary/`.** El diario es cronológico, libre y versionado; mezclaría notas con entregables formales y no impone evidencia ejecutable.
- **Un ADR por tarea.** Sobrecarga el registro de decisiones con resultados operativos.
- **Indexar los reports en `registry.db`.** Añade schema y mantenimiento para un artefacto efímero. KISS: no se indexa, como los playgrounds.
## Consecuencias
- `.gitignore` del padre gana `reports/*` (con `!reports/.gitkeep`) y `projects/*/reports/`.
- Nueva regla `.claude/rules/reports.md` con convención + plantilla; entrada en `.claude/rules/INDEX.md`.
- `report` se añade como tipo de artefacto en `.claude/rules/artefactos.md` (NO indexado, NO sub-repo Gitea).
- Mención en la sección "Estructura" / "Artefactos" de `.claude/CLAUDE.md` y en `docs/README.md`.
- Los agentes pueden escribir un report al cerrar una tarea no trivial y pasar la ruta para compartir, en vez de volcar el resumen al chat. El report queda local (no viaja por git/`fn sync` salvo que el usuario lo copie aparte).
- Primer report: `projects/web_scraping/reports/0001-2026-06-06-browser-domain-audit-fixes.md` (local, gitignored; vive en el proyecto porque el trabajo tocó sus apps). Cada project que use reports añade `reports/*` (salvo `!reports/.gitkeep`) a su propio `.gitignore` para no subirlos a su Gitea.
## Relación con otras reglas y ADRs
- `.claude/rules/artefactos.md` — report es un tipo de artefacto; este ADR lo añade a la taxonomía.
- `.claude/rules/reports.md` — convención operativa derivada de este ADR.
- `.claude/rules/playgrounds.md` — mismo espíritu (artefacto local, no indexado).
- `.claude/rules/dod_quality.md` — los reports heredan su exigencia de evidencia ejecutable y gaps.
- [ADR 0002](0002-apps-analyses-as-dataforge-master.md) — apps/analyses SÍ son sub-repos Gitea; los reports NO (se parecen a los vaults, no a las apps).
- [ADR 0005](0005-keep-parent-git-lean.md) — mantener el `.git` del padre ligero; no versionar reports refuerza esa línea.
+1
View File
@@ -63,3 +63,4 @@ Qué se aprendió después. Útil cuando un ADR se supersede.
| [0003](0003-orphan-tu-as-separate-function-entry.md) | TU adicional de un parent function como entrada propia | accepted |
| [0004](0004-telemetry-driven-capability-growth.md) | Telemetria de ejecuciones de Claude como motor de crecimiento del registry | accepted |
| [0005](0005-keep-parent-git-lean.md) | Mantener el `.git` del padre ligero: no trackear artefactos hijos, purgar historial, submódulos shallow | accepted |
| [0006](0006-reports-folder.md) | Carpeta `reports/` para reportes de trabajo (entregable de tarea con evidencia) | accepted |
+6
View File
@@ -24,6 +24,8 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [docker](docker.md) | 38 | Operar Docker desde Go/Bash: build/run/stop, compose, networks, volumes, logs, deploys |
| [android](android.md) | 37 | Toolbelt Android desde WSL2: adb, emuladores AVD, APK build/install, Capacitor, logcat |
| [web-proxy](web-proxy.md) | 5 | Captura de trafico HTTP/HTTPS liviana (mitmproxy): proxy con rotacion, navegador proxeado, consulta de capturas, tee del SSE de claude. Alternativa ligera a ZAP/Burp |
| [flow-replay](flow-replay.md) | 3 | Guardar un flujo web (login, reiniciar server, formulario) como funcion reproducible: destila un HAR a call specs y lo reproduce sin navegador (HTTP puro), con fallback a chromium headless/visible. Consume las capturas de web-proxy |
| [hoppscotch](hoppscotch.md) | 7 | Operar Hoppscotch SELF-HOSTED (docker en selfhost/) via API GraphQL: login (magic link headless via mailpit), CRUD de requests (create/update/delete/list), set_environment (idempotente, resuelve secretos pass:). El agente crea/edita y el humano lo ve en vivo en su GUI (subscriptions). build es helper interno de serializacion. Modo .json local ELIMINADO |
| [metabase](metabase.md) | 106 | Operar Metabase via API REST: auth, cards, dashboards, collections, snippets, permissions |
| [doctor](doctor.md) | 11 | Diagnostico read-only del registry: artefactos, servicios, drift, funciones huerfanas |
| [notebook](notebook.md) | 5 | Operar Jupyter Lab colaborativo (discover/read/exec/write/kernel) |
@@ -38,6 +40,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output |
| [validator](validator.md) | 6 | Funciones que verifican datos/config contra reglas. Pre-flight de sinks y gates en DAGs |
| [navegator](navegator.md) | 4 | Automatización de browser via CDP + AX tree + LLM: obtener, limpiar, chunkear AX tree y llamar a Claude CLI |
| [whatsapp](whatsapp.md) | 3 | Operar WhatsApp Web por CDP sobre la pestaña existente (sin ventana ni foco): buscar/abrir chat, leer conversacion, enviar texto. Compone 4 primitivas CDP-Python (cdp_eval/type_chars/press_key/click_xy). No HTTP: WhatsApp usa WebSocket + cifrado E2E |
| [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers |
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
| [backends](backends.md) | — | Stacks backend (Go net/http+SQLite default, MCP, mautrix, bubbletea, httpx, docker-compose): decision tree + esqueleto canonico + funciones del registry a componer |
@@ -47,6 +50,9 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [mesh-3d](mesh-3d.md) | 3 | Carga y upload a GPU de meshes 3D (OBJ, GLB/glTF 2.0): loaders CPU + mesh_gpu_upload OpenGL |
| [terminal-capture](terminal-capture.md) | 6 | Automatizar y capturar el texto de una CLI/TUI interactiva via PTY headless: spawn+input scripteado (one-shot y streaming), render del layout 2D (emulador VT), strip ANSI, delta por prefijo, y parseo de la TUI de claude a datos |
| [claude-direct](claude-direct.md) | 3 | Hablar directamente con la API de Anthropic Messages usando el token OAuth de Claude Code (Claude Max): leer token, stream SSE, bucle agentico de tool-use |
| [obsidian](obsidian.md) | 14 | CRUD headless de vaults y notas Obsidian como Markdown plano (frontmatter YAML + wikilinks): parse/format, read/create/update/delete/list/search notas, list/create vaults, slugify/embeds/resolve. Sin app GUI |
| [osint-passive](osint-passive.md) | 8 | Recoleccion OSINT pasiva (fuentes publicas, no intrusiva): EXIF/PDF metadata, whois RDAP, DNS, subdominios crt.sh, guess emails, username enumeration, search dorks |
| [osint-enrich](osint-enrich.md) | 3 | Orquestadores de enriquecimiento OSINT: componen osint-passive para aumentar datapoints de personas (emails/usernames/dorks), orgs (whois+dns+subdominios) y metadatos de attachments |
## Como anadir grupo
+117
View File
@@ -0,0 +1,117 @@
# Flow Replay — Guardar un flujo web como función reproducible
Tag: `flow-replay`. Grupo de funciones para convertir un flujo de navegador que se hizo
una vez a mano (login en un panel, reiniciar un servidor, rellenar un formulario) en una
**función del registry reproducible sin intervención**. Materializa la doctrina del issue
0087: el registry crece promoviendo secuencias repetidas a operaciones de un solo paso.
Filtro MCP: `mcp__registry__fn_search query="" tag="flow-replay"`.
Complementa al grupo [`web-proxy`](web-proxy.md): `web-proxy` **graba** el tráfico,
`flow-replay` lo **destila y reproduce**.
## El patrón: grabar → destilar → reproducir
Tres fases, con una jerarquía de reproducción de más barato a más caro:
```
Fase 0 — GRABAR (una vez, siempre con browser + proxy)
web_proxy ON → haces la acción a mano en el navegador → exportas el tramo a HAR
(funciones del grupo web-proxy: start_mitm_capture, launch_chromium_proxy, query_mitm_flows --har)
Fase 1 — DESTILAR (del HAR a una secuencia de requests)
har_filter_flows → descarta estáticos/analytics, deja los flujos que importan
har_extract_calls → normaliza cada flujo a una "call spec" reproducible (método, url,
headers, cookies, body), aislando los datos de auth
Fase 2 — REPRODUCIR, en orden de preferencia:
Nivel 1 HTTP puro http_replay_sequence — rápido, headless, scriptable. PREFERIDO.
Nivel 2 headless chromium (fallback) — cuando hay token dinámico firmado en cliente,
challenge JS o WAF con fingerprint de navegador. Reutiliza
cdp_extract_recipe + cdp_save_storage_state (ver Fronteras).
Nivel 3 chromium visible + acciones humanizadas — último recurso si headless es detectado
(cdp_click_xy_human, cdp_move_mouse_human del dominio browser).
```
La función-acción concreta que guardas en el registry (`reboot_<panel>_server`,
`login_<panel>`, etc.) envuelve el nivel que funcione: idealmente una llamada a
`http_replay_sequence` con su secuencia + parámetros, y los secretos resueltos desde
`pass`/vault.
## Funciones del grupo
| ID | Firma corta | Qué hace |
|---|---|---|
| [har_filter_flows_py_cybersecurity](../../python/functions/cybersecurity/har_filter_flows.md) | `har_filter_flows(har, *, hosts, methods, drop_static, drop_analytics) -> list[dict]` | Filtra un HAR: descarta recursos estáticos y hosts de telemetría, deja los flujos candidatos a "acción". Pura. |
| [har_extract_calls_py_cybersecurity](../../python/functions/cybersecurity/har_extract_calls.md) | `har_extract_calls(entries, *, drop_headers) -> list[dict]` | Convierte entries HAR en "call specs" normalizadas (método/url/headers/cookies/body/body_type), aislando cookies de auth y descartando headers hop-by-hop. Pura. |
| [http_replay_sequence_py_infra](../../python/functions/infra/http_replay_sequence.md) | `http_replay_sequence(calls, *, params, extract, timeout_s, verify_tls, allow_redirects, base_headers) -> dict` | Motor de replay HTTP: ejecuta la secuencia compartiendo cookie jar, substituye `{{param}}` y extrae valores de una respuesta para inyectarlos en pasos siguientes (flujo CSRF-like). Impura. |
## Ejemplo canónico end-to-end
Destilar un HAR capturado y reproducir el flujo sin navegador. Las tres funciones se
encadenan; la extracción del paso 1 (un token) se inyecta en el paso 2:
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from cybersecurity.har_filter_flows import har_filter_flows
from cybersecurity.har_extract_calls import har_extract_calls
from infra.http_replay_sequence import http_replay_sequence
# 1. HAR exportado por: query_mitm_flows ~/captures/traffic-*.mitm --har ~/sesion.har
import json
har = json.load(open(os.path.expanduser("~/sesion.har")))
# 2. Destilar: del ruido a la secuencia mínima
flows = har_filter_flows(har, hosts=["panel.midominio.com"]) # solo el host del panel
calls = har_extract_calls(flows) # call specs reproducibles
# 3. Reproducir (Nivel 1, HTTP puro). El token del GET inicial se inyecta en el POST.
res = http_replay_sequence(
calls,
params={"server_id": "vps-42"}, # parametrizado por el caller
extract=[{"from": 0, "type": "json", "expr": "csrf", "as": "csrf"}],
verify_tls=True,
)
print(res["status"], [s["status_code"] for s in res["steps"]])
```
Una vez validado, el flujo se promueve a una función-acción nombrada del registry
(p. ej. `reboot_vps_server_<panel>`) que internamente llama a `http_replay_sequence`
con su secuencia fija, recibe los parámetros del caller y resuelve los secretos desde
`pass`. Esa función-acción es lo que el agente invoca en un solo paso a partir de entonces.
## Fronteras
- **No graba**: la captura es del grupo [`web-proxy`](web-proxy.md). Este grupo empieza
con un HAR ya existente.
- **No auto-parametriza** (todavía). `har_extract_calls` normaliza pero NO detecta solo
qué valor es un token dinámico ni dónde se reinyecta. La parametrización (`{{param}}`)
y las reglas de `extract` las decide el humano/agente leyendo el HAR. La detección
automática de tokens/CSRF sería una función nueva del grupo, no una ampliación.
- **No incluye el runner de Nivel 2/3** (browser fallback). Está especificado en el
patrón pero no implementado: cuando un flujo real falle en HTTP puro, se construye un
"action recipe" reutilizando casi entero `cdp_extract_recipe_py_pipelines` (mismo
formato YAML, steps de acción en vez de extracción) + `cdp_save_storage_state_go_browser`
para saltarse el login. No se construye por adelantado (KISS / registry-first).
- **No gestiona secretos**: los secretos viajan como `{{param}}` desde `pass`/vault. El
grupo nunca los hardcodea ni los persiste.
## Gotchas (seguridad — leer antes de usar)
- **El HAR es sensible**: contiene cookies y tokens en crudo. Trátalo como un secreto —
gitignored, no subir a Gitea, no indexar, borrar tras destilar. El output de
`har_extract_calls` también lleva esos valores hasta que los sustituyes por `{{param}}`.
- **Secretos a `pass`/vault**, nunca en el código de la función-acción.
- **Replay con efectos = peligroso**: reproducir un POST que reinicia, borra o paga es
destructivo. La función-acción debe pedir confirmación o exponer un flag explícito
(`--yes`/`confirm=True`) antes de disparar. Nunca replay ciego de una acción irreversible.
- **HTTP puro no siempre reproduce**: token firmado en cliente, challenge JS, o WAF que
exige fingerprint de navegador → cae a Nivel 2 (headless) o 3 (visible humanizado).
- `http_replay_sequence` sigue redirects por defecto y `verify_tls=True`. La extracción
JSON es dot-path simple (`a.b.0.c`), no JSONPath completo.
## Prerequisitos
- Fase 0 (grabar): grupo `web-proxy` operativo (mitmproxy + chromium). Ver su página.
- Fase 1-2: `requests` en `python/.venv` (ya presente). Sin dependencias nuevas.
+83
View File
@@ -0,0 +1,83 @@
# Capability group: `hoppscotch`
Operar una instancia **self-hosted de Hoppscotch** (consola de APIs, alternativa open-source a
Postman) desde el registry, vía su **API GraphQL**. El agente crea/edita requests, colecciones y
environments por la API; el humano los ve **en vivo** en su GUI (subscriptions = hot-reload real).
Las requests viven en la base de datos del self-host (Postgres), compartida entre el agente y la GUI.
Este es el **flujo canónico**. El antiguo modo "archivo `.json` local" (funciones
`parse_*` / `run_*` / `add_hoppscotch_request`) **fue eliminado**: escribía un `.json` en disco que
NO subía al workspace, así que el humano no lo veía en la GUI. No lo reintroduzcas.
## Stack self-host
Vive en `projects/web_scraping/hoppscotch/selfhost/` (docker compose: AIO + Postgres + mailpit).
| Servicio | URL | Para qué |
|---|---|---|
| App (cliente) | `http://localhost:3009` | la GUI donde el humano usa las colecciones (instalable como PWA) |
| Admin dashboard | `http://localhost:3100` | gestión (usuarios, config) |
| Backend GraphQL | `http://localhost:3170/graphql` | la API que usan las funciones |
| Mailpit | `http://localhost:8025` | captura el magic link del login (SMTP de pruebas, sin correo real) |
Levantar: `cd selfhost && docker compose up -d`. Team de trabajo: **"registry"**. Cuenta: `admin@example.com`.
## Funciones
| ID | Firma corta | Qué hace |
|---|---|---|
| `hoppscotch_login_py_infra` | `(email, *, backend_url, mailpit_url) -> {access_token,...}` | login por magic link headless (lee el link de mailpit) → JWT |
| `hoppscotch_create_request_py_infra` | `(collection_id, method, url, *, title, headers, body, body_type, team_id, access_token) -> dict` | crea una request en una colección de la team |
| `hoppscotch_update_request_py_infra` | `(request_id, method, url, *, title, headers, body, body_type, access_token) -> dict` | actualiza una request |
| `hoppscotch_delete_request_py_infra` | `(request_id, *, access_token) -> dict` | borra una request |
| `hoppscotch_list_requests_py_infra` | `(collection_id, *, access_token) -> {requests:[...]}` | lista las requests de una colección |
| `hoppscotch_set_environment_py_infra` | `(team_id, name, variables, *, access_token) -> dict` | crea/actualiza (idempotente) el environment de la team; resuelve secretos `pass:` |
| `build_hoppscotch_collection_py_infra` | `(calls, *, name, request_names) -> dict` | **helper interno** de create/update: serializa call specs al formato HoppRESTRequest. NO para escribir `.json` a mano |
| `pass_get_secret_py_infra` | `(path, *, line) -> {value}` | lee un secreto de `pass` (lo consume `set_environment` para no hardcodear keys) |
`access_token` se pasa como **cookie**, no header `Authorization`. Caduca a 24h → re-login con `hoppscotch_login`.
## Ejemplo canónico (end-to-end)
```python
import sys, os
sys.path.insert(0, os.path.join(os.path.expanduser("~/fn_registry"), "python", "functions"))
from infra.hoppscotch_login import hoppscotch_login
from infra.hoppscotch_create_request import hoppscotch_create_request
from infra.hoppscotch_set_environment import hoppscotch_set_environment
TEAM = "cmq8kn0v500030xls1nvminjy" # team "registry"
COLL = "cmq8knppc00040xlskt4ist27" # colección registry_api (de hoppscotch_list/DB)
tok = hoppscotch_login("admin@example.com")["access_token"]
# 1. Variables del workspace (secreto resuelto desde pass, no hardcodeado)
hoppscotch_set_environment(TEAM, "registry", [
{"key": "baseURL", "value": "https://registry.organic-machine.com", "secret": False},
{"key": "api_key", "value": "pass:apis/registry", "secret": True}, # pass: -> pass_get_secret
], access_token=tok)
# 2. Crear una request → aparece EN VIVO en la GUI del humano (subscriptions)
hoppscotch_create_request(
COLL, "GET", "<<baseURL>>/api/status",
title="status", headers={"Accept": "application/json"},
team_id=TEAM, access_token=tok,
)
```
## Fronteras (qué NO cubre)
- **No es modo archivo**: no escribe colecciones `.json` locales como fuente. Las requests viven en el
Postgres del self-host. (Los `.json` en `collections/` son solo respaldo/semilla importable.)
- **No automatiza la GUI**: opera por la API; la GUI la mira el humano.
- **No gestiona usuarios/teams del dashboard**: eso es el admin dashboard (`:3100`).
- **No ejecuta los scripts pre/post-request JS** de Hoppscotch.
## Gotchas
- `access_token` como **cookie** (`cookies={"access_token": tok}`), no `Authorization`. 24h de vida.
- `createRequestInCollection` de esta instancia **exige `team_id`** en el input (no solo el collectionID).
- Variables `<<var>>` se resuelven con el environment de la team (subscriptions las propagan a la GUI).
- Secretos: usa `value="pass:<ruta>"` en `set_environment` → se resuelve de `pass`, nunca se hardcodea
ni se logea en crudo.
- El secreto viaja en claro al backend local por GraphQL — es local (`127.0.0.1`), aceptable.
+80
View File
@@ -0,0 +1,80 @@
# Capability: obsidian
CRUD headless de vaults y notas de Obsidian, tratadas como Markdown plano con frontmatter YAML y wikilinks `[[...]]`. NO depende de la app GUI de Obsidian ni de su URI scheme — manipula los archivos `.md` directamente en disco. Scriptable, rapido, con telemetria del registry.
Los vaults de Obsidian del usuario viven en `/home/enmanuel/Obsidian/` y estan enlazados como vaults del registry en el project `obsidian` (`projects/obsidian/vaults/`). Ver `projects/obsidian/project.md`.
## Funciones
| ID | Firma | Que hace |
|---|---|---|
| `parse_obsidian_frontmatter_py_obsidian` | `parse_obsidian_frontmatter(content: str) -> {"frontmatter": dict, "body": str}` | **Pure.** Separa el frontmatter YAML (bloque `---` inicial) del cuerpo. Si no hay frontmatter valido devuelve `{}` + el contenido completo. |
| `extract_obsidian_wikilinks_py_obsidian` | `extract_obsidian_wikilinks(body: str) -> list` | **Pure.** Extrae los targets de los wikilinks `[[...]]` y embeds `![[...]]`. Normaliza `[[nota\|alias]]`, `[[nota#heading]]`, `[[nota#^block]]` -> `nota`. Dedup preservando orden. |
| `format_obsidian_note_py_obsidian` | `format_obsidian_note(frontmatter: dict, body: str) -> str` | **Pure.** Inversa de parse: serializa frontmatter (YAML entre `---`) + body a una nota `.md` completa. |
| `read_obsidian_note_py_obsidian` | `read_obsidian_note(path: str) -> dict` | Lee una nota: `{path, frontmatter, body, wikilinks, tags}`. Compone parse + extract. |
| `create_obsidian_note_py_obsidian` | `create_obsidian_note(vault_dir, rel_path, body="", frontmatter=None, overwrite=False) -> str` | Crea nota nueva (crea dirs padre, añade `.md`). Error si existe y `overwrite=False`. |
| `update_obsidian_note_py_obsidian` | `update_obsidian_note(path, body=None, set_frontmatter=None, append=None) -> str` | Edita nota existente: merge de frontmatter, reemplazo de body, o append al final. |
| `delete_obsidian_note_py_obsidian` | `delete_obsidian_note(path: str) -> bool` | Borra una nota (solo archivo, nunca directorio). Error si no existe. |
| `list_obsidian_notes_py_obsidian` | `list_obsidian_notes(vault_dir, subfolder="", tag="") -> list` | Lista paths de notas `.md` (recursivo). Excluye `.obsidian/` y `.trash/`. Filtro opcional por tag de frontmatter. |
| `search_obsidian_notes_py_obsidian` | `search_obsidian_notes(vault_dir, query, in_body=True, in_frontmatter=True) -> list` | Busca substring (case-insensitive) en las notas. Devuelve `[{path, matches:[{line, text}]}]`. |
| `list_obsidian_vaults_py_obsidian` | `list_obsidian_vaults(base_dir: str) -> list` | Lista los vaults (subdirs con `.obsidian/`) bajo `base_dir`. `[{name, path}]`. |
| `create_obsidian_vault_py_obsidian` | `create_obsidian_vault(parent_dir, name) -> str` | Crea un vault nuevo: carpeta + `.obsidian/app.json` minimo. Error si ya existe. |
## Ejemplo canonico
Componer varias funciones del grupo se hace por heredoc importando del registry (las funciones se importan, no se reescriben):
```bash
cd /home/enmanuel/fn_registry
python/.venv/bin/python3 - <<'PYEOF'
import sys
sys.path.insert(0, "python/functions")
from obsidian import (
list_obsidian_vaults, list_obsidian_notes, search_obsidian_notes,
create_obsidian_note, read_obsidian_note, update_obsidian_note, delete_obsidian_note,
)
# 1. Descubrir vaults del usuario
vaults = list_obsidian_vaults("/home/enmanuel/Obsidian")
print("vaults:", [v["name"] for v in vaults])
# 2. Listar y buscar notas en un vault
finanzas = "/home/enmanuel/Obsidian/Finanzas"
print("notas:", len(list_obsidian_notes(finanzas)))
print("hits:", [h["path"] for h in search_obsidian_notes(finanzas, "presupuesto")][:5])
# 3. CRUD de una nota (crear -> leer -> editar -> borrar)
p = create_obsidian_note(finanzas, "inbox/idea_x", body="Primera linea",
frontmatter={"tags": ["inbox"], "created": "2026-06-09"})
note = read_obsidian_note(p)
print("creada:", note["path"], note["frontmatter"], note["wikilinks"])
update_obsidian_note(p, set_frontmatter={"status": "done"}, append="Ver [[Otra Nota]]")
delete_obsidian_note(p)
PYEOF
```
Para una sola operacion con un id conocido, `fn run` tambien sirve:
```bash
./fn run list_obsidian_vaults /home/enmanuel/Obsidian
./fn run list_obsidian_notes /home/enmanuel/Obsidian/Finanzas
```
## Cuando usar el grupo
- Crear/editar/leer notas de cualquier vault de Obsidian desde un agente o script, sin abrir la app.
- Buscar o listar notas por contenido o tag (ingesta, migracion, reporting sobre el vault).
- Crear vaults nuevos o inventariar los existentes.
## Fronteras (que NO cubre)
- **No habla con la app GUI** (no usa el URI scheme `obsidian://`, no abre notas en la interfaz, no dispara plugins). Si la app esta abierta, escribir en disco puede chocar con sus locks/cache — cerrar la app o refrescar manualmente.
- **No resuelve wikilinks a paths** automaticamente (devuelve los targets crudos). Resolver `[[nota]]` -> archivo real es responsabilidad del caller (busqueda por nombre en el vault).
- **No renderiza Markdown** ni evalua Dataview/templating. Trata las notas como texto + frontmatter.
- **No indexa el grafo** de enlaces entre notas (solo extrae links por nota). Para grafo agregado, componer sobre `list_obsidian_notes` + `extract_obsidian_wikilinks`.
## Gotchas
- Vaults grandes son caros: `NotasDeObsidian` pesa ~554M. `list_obsidian_notes` / `search_obsidian_notes` recorren todo el arbol — filtra por `subfolder` cuando puedas.
- `delete_obsidian_note` borra de verdad (no manda a `.trash/`). Para acciones destructivas masivas, listar primero y confirmar.
- El frontmatter `tags` puede venir como lista o como CSV string; `read_obsidian_note` lo normaliza a lista.
+51
View File
@@ -0,0 +1,51 @@
# Capability: osint-enrich
Orquestadores de enriquecimiento OSINT: componen las funciones atómicas de
[osint-passive](osint-passive.md) para aumentar los datapoints de una entidad (persona u
organización) del vault `osint` a partir de fuentes públicas. No tocan al objetivo de forma
intrusiva. Mismo encuadre dual-use que `osint-passive`: solo investigación autorizada.
## Funciones
| ID | Firma | Qué hace |
|---|---|---|
| `scan_ficha_attachments_metadata_py_cybersecurity` | `scan_ficha_attachments_metadata(attachments_dir) -> dict` | Escanea los attachments de una ficha (imágenes + PDFs), extrae EXIF/PDF metadata y agrega GPS y fechas. |
| `enrich_person_passive_py_cybersecurity` | `enrich_person_passive(nombre, apellidos, dominios=None, usernames=None) -> dict` | Candidatos para una persona: emails (guess), username hits, dorks. No verifica ni ejecuta. |
| `enrich_org_passive_py_cybersecurity` | `enrich_org_passive(dominio) -> dict` | Perfil pasivo de una org: whois + dns + subdominios. Resiliente a fallo parcial (campo `errors`). |
## Ejemplo canónico
```bash
cd /home/enmanuel/fn_registry
python/.venv/bin/python3 - <<'PYEOF'
import sys; sys.path.insert(0, "python/functions")
from cybersecurity import (scan_ficha_attachments_metadata,
enrich_person_passive, enrich_org_passive)
# 1. Metadatos de los documentos ya guardados de una persona (datos propios)
m = scan_ficha_attachments_metadata(
"/home/enmanuel/Obsidian/osint/attachments/personas/enmanuel-gutierrez-perez")
print(m["summary"]) # {n_files, n_images, n_pdfs, n_gps_points, n_dates, errors}
# 2. Candidatos de enriquecimiento de una persona (no toca al objetivo)
p = enrich_person_passive("Enmanuel", "Gutierrez Perez",
dominios=["gmail.com"], usernames=["enmanuelgp"])
print(p["email_candidates"][:5], len(p["dorks"]))
# 3. Perfil pasivo de una organización por su dominio
o = enrich_org_passive("organic-machine.com")
print(o["whois"].get("registrar"), o["dns"].get("A"), len(o["subdomains"]), o["errors"])
PYEOF
```
## Fronteras
- Compone solo funciones `osint-passive`. Para activa (port scan, fingerprint) haría falta
`osint-active` (no construido).
- Devuelve candidatos/datos crudos; **decidir qué escribir en la ficha** (y verificar) es del
caller. Encaja con el reporte de `projects/osint/tools/person_datapoints.py`.
## Gotchas
- `enrich_org_passive` nunca peta por una fuente lenta (crt.sh): el fallo va a `errors`.
- `enrich_person_passive` puede tardar por `enumerate_username_sites` (un request por sitio).
+64
View File
@@ -0,0 +1,64 @@
# Capability: osint-passive
Recolección OSINT **pasiva**: obtener información sin interactuar de forma intrusiva con el
objetivo, usando solo fuentes públicas (DNS público, RDAP, Certificate Transparency, metadatos
de documentos propios, servicios de perfil públicos). Pensado para investigación autorizada,
due diligence, pentest con permiso y enriquecimiento de las fichas del vault `osint`.
**Encuadre:** dual-use. Úsese solo contra objetivos propios o con autorización. Las funciones
que tocan servicios públicos (`enumerate_username_sites`, `enum_subdomains_crtsh`) dejan una
huella mínima (un request a cada servicio); respeta sus rate limits.
## Funciones
| ID | Firma | Qué hace |
|---|---|---|
| `extract_exif_metadata_py_cybersecurity` | `extract_exif_metadata(image_path) -> dict` | EXIF de una imagen (fecha, cámara, software, GPS decimal) vía Pillow. |
| `extract_pdf_metadata_py_cybersecurity` | `extract_pdf_metadata(pdf_path) -> dict` | Document Info de un PDF (autor, fechas, software, páginas) vía pypdf. |
| `guess_email_formats_py_cybersecurity` | `guess_email_formats(nombre, apellidos, dominio) -> list` | **Pure.** Candidatos de email comunes a partir de nombre + dominio. |
| `enumerate_username_sites_py_cybersecurity` | `enumerate_username_sites(username, ...) -> list` | ¿Existe un username en ~12 redes públicas? (sherlock ligero, por código HTTP). |
| `build_search_dorks_py_cybersecurity` | `build_search_dorks(target, tipo, ...) -> list` | **Pure.** Genera dorks de motor de búsqueda (persona/email/dominio/usuario). |
| `dns_records_py_cybersecurity` | `dns_records(dominio, types=None) -> dict` | Registros DNS (A/AAAA/MX/TXT/NS/CNAME) vía `dig`. |
| `whois_lookup_py_cybersecurity` | `whois_lookup(dominio, ...) -> dict` | Datos de registro vía RDAP (WHOIS moderno HTTP/JSON, sin CLI). |
| `enum_subdomains_crtsh_py_cybersecurity` | `enum_subdomains_crtsh(dominio, ...) -> list` | Subdominios desde Certificate Transparency (crt.sh). |
Orquestadores (grupo [osint-enrich](osint-enrich.md)): `scan_ficha_attachments_metadata`,
`enrich_person_passive`, `enrich_org_passive`.
## Ejemplo canónico
```bash
cd /home/enmanuel/fn_registry
python/.venv/bin/python3 - <<'PYEOF'
import sys; sys.path.insert(0, "python/functions")
from cybersecurity import (dns_records, whois_lookup, enum_subdomains_crtsh,
guess_email_formats, build_search_dorks, extract_exif_metadata)
# Dominio (org)
print(whois_lookup("organic-machine.com")["registrar"]) # OVH sas
print(dns_records("organic-machine.com")["A"]) # ['135.125.201.30']
print(enum_subdomains_crtsh("organic-machine.com")[:5])
# Persona
print(guess_email_formats("Enmanuel", "Gutierrez Perez", "gmail.com")[:5])
print(build_search_dorks("Enmanuel Gutierrez Perez", "persona")[:3])
# Metadatos de un documento propio
print(extract_exif_metadata("/home/enmanuel/Obsidian/osint/attachments/personas/enmanuel-gutierrez-perez/dni-1.jpg"))
PYEOF
```
## Fronteras (qué NO es)
- **No es recolección activa**: no hace port scan, dns brute, ni sondea la infra del objetivo.
Eso sería el grupo `osint-active` (no construido todavía).
- **No verifica** los candidatos: `guess_email_formats` propone, no confirma que el email exista.
- **No ejecuta** los dorks: `build_search_dorks` los genera; ejecutarlos es otro paso (browser).
- **No incluye breach/leak lookup** (HIBP requiere API key de pago) — pendiente.
## Gotchas
- `crt.sh` va lento / rate-limitado y a veces responde 404; los orquestadores lo capturan en
`errors` y siguen.
- `enumerate_username_sites` da falsos positivos/negativos por anti-bot de algunos sitios.
- El GPS de EXIF revela ubicación — dato sensible; trátese como PII.
+80
View File
@@ -0,0 +1,80 @@
# WhatsApp — Operar WhatsApp Web por CDP sobre la sesión existente
Tag: `whatsapp`. Grupo de funciones para automatizar WhatsApp Web (buscar/abrir un chat,
leer la conversación, enviar texto) operando por Chrome DevTools Protocol sobre la **pestaña
ya abierta y logueada** del navegador diario, **sin abrir ventana nueva ni darle foco**.
Filtro MCP: `mcp__registry__fn_search query="" tag="whatsapp"`.
## Por qué CDP y no HTTP replay
WhatsApp Web **no envía mensajes por HTTP requests REST**: usa un **WebSocket** (wss) como
transporte y **cifrado extremo a extremo (Signal/Noise)**, con claves que rotan por mensaje y
viven en el navegador. El tráfico capturable es binario cifrado e irreproducible — por eso el
patrón `flow-replay` (grabar HTTP → reproducir) **no aplica** aquí. La única vía que opera la
sesión existente sin ventana nueva es **automatizar el DOM por CDP**. (Baileys/whatsapp-web.js
quedan descartados: emparejan un dispositivo nuevo por QR, o abren su propio navegador.)
## Funciones del grupo
| ID | Firma corta | Qué hace |
|---|---|---|
| [whatsapp_open_chat_py_browser](../../python/functions/browser/whatsapp_open_chat.md) | `whatsapp_open_chat(name, *, port=9222) -> dict` | Busca y abre un chat por nombre exacto (ancla `span[title]` + click de ratón real). Verifica el destinatario. Base de read/send. |
| [whatsapp_read_chat_py_browser](../../python/functions/browser/whatsapp_read_chat.md) | `whatsapp_read_chat(name, *, n=15, open_first=True) -> dict` | Lee los últimos N mensajes renderizados del chat (`{text, outgoing}`). |
| [whatsapp_send_message_py_browser](../../python/functions/browser/whatsapp_send_message.md) | `whatsapp_send_message(name, text, *, open_first=True) -> dict` | Envía un texto. Salvaguarda: verifica destinatario + contenido exacto del composer antes de pulsar Enter. |
### Primitivas CDP que componen (grupo `navegator`)
El transport está en 4 primitivas Python reutilizables (cualquier automatización de la sesión diaria):
| ID | Qué hace |
|---|---|
| [cdp_eval_py_browser](../../python/functions/browser/cdp_eval.md) | Evalúa JS en un target por substring de URL (leer DOM, `focus()`, resolver coords). |
| [cdp_type_chars_py_browser](../../python/functions/browser/cdp_type_chars.md) | Escribe char-by-char con key events reales (único método que funciona con el editor Lexical). |
| [cdp_press_key_py_browser](../../python/functions/browser/cdp_press_key.md) | Pulsa una tecla nombrada (Enter, Escape, Backspace, Arrows...) con modificadores. |
| [cdp_click_xy_py_browser](../../python/functions/browser/cdp_click_xy.md) | Click de ratón real en coordenadas (necesario: `element.click()` JS no dispara los handlers de React). |
## Ejemplo canónico end-to-end
Requisito: WhatsApp Web abierto y logueado en un Chrome con `--remote-debugging-port=9222`
(en este equipo, el CDP global de chromium ya lo expone). No hace falta foco ni ventana visible.
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from browser.whatsapp_read_chat import whatsapp_read_chat
from browser.whatsapp_send_message import whatsapp_send_message
# Leer los últimos mensajes de un chat
r = whatsapp_read_chat("NOTAS WASAP", n=5)
for m in r["messages"]:
print(("" if m["outgoing"] else ""), m["text"])
# Enviar un mensaje (acción con efecto: envía de verdad)
res = whatsapp_send_message("NOTAS WASAP", "hola desde el registry")
print(res) # {"sent": True, "last_row": "hola desde el registry 11:48"}
```
## Fronteras y gotchas (leer antes de usar)
- **Viola los ToS de WhatsApp; riesgo de ban del número.** Probar en un chat propio reduce
molestia a terceros pero no elimina el riesgo de detección por patrón.
- **Envío irreversible**: `whatsapp_send_message` envía de verdad y WhatsApp no permite
des-enviar por esta vía. La función verifica destinatario (`name` exacto en el composer) y
contenido antes de Enter, pero el `name` lo das tú: un nombre ambiguo abre el primer match.
- **Nombre exacto requerido** (`span[title]` exacto). El buscador **no filtra de forma fiable
los contactos NO cargados** en la lista lateral; funciona para chats recientes/visibles. Un
contacto sin chat reciente puede no encontrarse (limitación conocida; mejora futura: scroll).
- **Lexical**: escribir SOLO con `cdp_type_chars` (key events reales). `execCommand`/`el.value`
meten texto fantasma y producen duplicación/intercalado.
- **Abrir chats**: requiere click de ratón real (`cdp_click_xy`); `element.click()` JS no abre.
- **`outgoing`** se infiere de `.message-out` (heurístico) y puede no marcar bien los mensajes
propios en algunos grupos; el `text` siempre es fiable.
- **Solo lee lo renderizado** en el viewport del chat; mensajes muy antiguos requieren scroll
(no implementado).
- Funciona con la ventana **minimizada y sin foco** (CDP no depende del foco del SO).
## Prerequisitos
- Chrome/Chromium con remote debugging en el puerto 9222 y WhatsApp Web logueado.
- `websocket-client` en `python/.venv` (ya presente). Sin dependencias nuevas.
+66
View File
@@ -0,0 +1,66 @@
---
id: cdp_activate_tab_go_browser
name: cdp_activate_tab
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Pone una pestaña Chrome en foreground (foco) por su ID via GET /json/activate/<id>. Sin WebSocket — solo HTTP. Útil para traer al frente una pestaña específica antes de capturar pantalla o interactuar con ella."
tags: [cdp, browser, tabs, navegator]
signature: "func CdpActivateTab(host string, port int, tabID string) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_list_tabs.go"
example: |
tabs, _ := browser.CdpListTabs("localhost", 9222)
// Activar la primera pestaña cuyo título contenga "Dashboard"
for _, t := range tabs {
if strings.Contains(t.Title, "Dashboard") {
_ = browser.CdpActivateTab("localhost", 9222, t.ID)
break
}
}
params:
- name: host
desc: "Hostname de la instancia Chrome (vacío = localhost)"
- name: port
desc: "Puerto CDP de remote debugging (habitualmente 9222)"
- name: tabID
desc: "ID de la pestaña a activar, obtenido de CdpTab.ID via CdpListTabs"
output: "nil si la pestaña pasó a foreground correctamente; error si tabID está vacío, la conexión falla o Chrome devuelve status != 200"
---
## Ejemplo
```go
// Listar tabs y traer al frente la que corresponda a una URL concreta
tabs, err := browser.CdpListTabs("localhost", 9222)
if err != nil {
log.Fatal(err)
}
for _, t := range tabs {
if t.URL == "https://metabase.local/dashboard/1" {
if err := browser.CdpActivateTab("localhost", 9222, t.ID); err != nil {
log.Printf("error activando tab %s: %v", t.ID, err)
}
break
}
}
```
## Cuando usarla
Antes de hacer un screenshot o interactuar via CDP con una pestaña concreta que podría estar en segundo plano. También útil en dashboards que muestran el inventario de pestañas y necesitan enfocar una al hacer clic.
## Gotchas
- No requiere conexión WebSocket previa; usa HTTP puro contra `/json/activate/<id>`.
- Solo cambia el foco dentro del contexto CDP; si la ventana de Chrome está minimizada a nivel de OS, `activate` la pone como pestaña activa dentro de Chrome pero no restaura la ventana.
- El ID debe obtenerse de `CdpListTabs` — no es el índice visible del tab, es el UUID interno de Chrome.
- Si el tabID no existe, Chrome devuelve un status HTTP distinto de 200 y la función retorna error.
+12
View File
@@ -0,0 +1,12 @@
package browser
// CdpClearCookies borra TODAS las cookies del browser via Network.clearBrowserCookies.
// Equivalente a "Borrar datos de navegacion > Cookies" en Chrome.
// Cierra todas las sesiones activas — usar solo en tests o resets completos.
func CdpClearCookies(c *CDPConn) error {
if _, err := c.sendCDP("Network.enable", nil); err != nil {
return err
}
_, err := c.sendCDP("Network.clearBrowserCookies", nil)
return err
}
+54
View File
@@ -0,0 +1,54 @@
---
id: cdp_clear_cookies_go_browser
name: cdp_clear_cookies
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Borra TODAS las cookies del browser via Network.clearBrowserCookies; equivalente a 'Borrar datos de navegacion > Cookies' en Chrome."
tags: [cdp, browser, cookie, network, navegator]
signature: "func CdpClearCookies(c *CDPConn) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_clear_cookies.go"
example: |
conn, _ := CdpConnect(9222)
if err := CdpClearCookies(conn); err != nil {
log.Fatal(err)
}
// browser ahora sin cookies — todas las sesiones cerradas
params:
- name: c
desc: "Conexion CDP activa al browser (obtenida con CdpConnect)"
output: "nil si se borraron todas las cookies; error si falla la comunicacion CDP."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
// Reset completo antes de un test de login
if err := CdpClearCookies(conn); err != nil {
log.Fatal(err)
}
// A partir de aqui el browser no tiene sesion en ningun dominio
```
## Cuando usarla
Usar al inicio de un test e2e que necesita partir de un browser sin sesion previa, o cuando quieres resetear completamente el estado de autenticacion del browser en un entorno de CI.
## Gotchas
- Destructivo e irreversible: cierra TODAS las sesiones activas en todos los dominios del browser.
- Llama `Network.enable` internamente antes del clear; es idempotente.
- No afecta a LocalStorage ni SessionStorage — solo cookies.
- Para borrar solo una cookie especifica usar `CdpDeleteCookies` en su lugar.
- En un browser de perfil de usuario real (no headless de test) puede cerrar sesiones de trabajo activas.
+12 -8
View File
@@ -14,11 +14,19 @@ func CdpClick(c *CDPConn, selector string) error {
return fmt.Errorf("cdp click: conexion nula")
}
// Obtener coordenadas del centro del elemento
// Obtener coordenadas del centro del elemento, tras hacer scroll para que sea
// visible. Verificamos visibilidad: un elemento existente pero oculto
// (display:none, visibility:hidden, opacity 0 o tamaño 0) daria un rect en
// (0,0) y clicariamos en la esquina sin efecto — devolvemos error en su lugar.
js := fmt.Sprintf(`(function() {
var el = document.querySelector(%q);
if (!el) return null;
el.scrollIntoView({block:'center'});
var r = el.getBoundingClientRect();
var s = window.getComputedStyle(el);
var visible = r.width > 0 && r.height > 0 &&
s.visibility !== 'hidden' && s.display !== 'none' && s.opacity !== '0';
if (!visible) return '__HIDDEN__';
return JSON.stringify({x: r.left + r.width/2, y: r.top + r.height/2});
})()`, selector)
@@ -29,6 +37,9 @@ func CdpClick(c *CDPConn, selector string) error {
if coordStr == "" || coordStr == "null" {
return fmt.Errorf("cdp click: elemento %q no encontrado en el DOM", selector)
}
if strings.Contains(coordStr, "__HIDDEN__") {
return fmt.Errorf("cdp click: elemento %q existe pero no es visible/clickable (display:none, oculto, opacity 0 o tamaño 0)", selector)
}
// Parsear "{x:...,y:...}" — CdpEvaluate ya retorna el JSON como string
coordStr = strings.Trim(coordStr, `"`)
@@ -37,13 +48,6 @@ func CdpClick(c *CDPConn, selector string) error {
return fmt.Errorf("cdp click: parsear coordenadas %q: %w", coordStr, err)
}
// Hacer scroll al elemento para que este visible
scrollJS := fmt.Sprintf(`document.querySelector(%q).scrollIntoView({block:'center'})`, selector)
if _, err := CdpEvaluate(c, scrollJS); err != nil {
// No es fatal si el scroll falla
_ = err
}
// Despachar mousedown
mouseParams := map[string]any{
"type": "mousePressed",
+4 -26
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"math/rand"
"strings"
"time"
)
// CdpClickHuman hace click en el elemento identificado por selector CSS con
@@ -53,31 +52,10 @@ func CdpClickHuman(c *CDPConn, selector string, opts MouseHumanOpts) error {
toX := bx + bw/2 + offX
toY := by + bh/2 + offY
// Mover el ratón con trayectoria humana
if err := CdpMoveMouseHuman(c, toX, toY, opts); err != nil {
return fmt.Errorf("cdp click human: mover raton: %w", err)
}
// mousePressed
clickParams := map[string]any{
"type": "mousePressed",
"x": toX,
"y": toY,
"button": "left",
"clickCount": 1,
}
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
return fmt.Errorf("cdp click human: mousePressed: %w", err)
}
// Micro-pausa humana entre press y release (3090 ms)
pauseMs := 30 + rand.Intn(61)
time.Sleep(time.Duration(pauseMs) * time.Millisecond)
// mouseReleased
clickParams["type"] = "mouseReleased"
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
return fmt.Errorf("cdp click human: mouseReleased: %w", err)
// Delegar en el primitivo compartido: mueve el ratón con trayectoria humana
// y despacha press/release con micro-pausa.
if err := CdpClickXYHuman(c, toX, toY, opts); err != nil {
return fmt.Errorf("cdp click human: %w", err)
}
return nil
+71
View File
@@ -0,0 +1,71 @@
package browser
import "fmt"
// refBoxCenter resuelve el centro (x,y) en coords de página de un nodo DOM por su
// backendDOMNodeId, vía DOM.getBoxModel. El content quad son 8 floats (4 esquinas).
func refBoxCenter(c *CDPConn, backendNodeID int) (float64, float64, error) {
res, err := c.sendCDP("DOM.getBoxModel", map[string]any{"backendNodeId": backendNodeID})
if err != nil {
return 0, 0, fmt.Errorf("getBoxModel ref %d: %w", backendNodeID, err)
}
model, ok := res["model"].(map[string]any)
if !ok {
return 0, 0, fmt.Errorf("ref %d: sin boxModel (nodo no visible o inexistente)", backendNodeID)
}
content, ok := model["content"].([]any)
if !ok || len(content) < 8 {
return 0, 0, fmt.Errorf("ref %d: content quad invalido", backendNodeID)
}
num := func(i int) float64 { f, _ := content[i].(float64); return f }
cx := (num(0) + num(2) + num(4) + num(6)) / 4
cy := (num(1) + num(3) + num(5) + num(7)) / 4
return cx, cy, nil
}
// CdpClickRef hace click sobre el elemento del #ref (un backendDOMNodeId extraído
// del AX outline por page_perceive). Por defecto usa click humanizado (Bézier +
// jitter) sobre el centro del bbox. Dos casos caen al click via element.click() JS:
// - opts.Mode == "instant": sin eventos de ratón reales (rápido, tests).
// - el nodo no tiene box model (display:contents, área 0): degradado natural en
// vez de fallar con error duro — un elemento clicable sin geometría sí se clica.
// Hace scroll al elemento si es necesario antes de calcular las coordenadas.
func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error {
if c == nil {
return fmt.Errorf("cdp click ref: conexión nil")
}
if opts.Mode == "instant" {
return clickRefViaJS(c, backendNodeID)
}
// scroll al elemento si no está visible; ignorar error (no fatal)
_, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID})
cx, cy, err := refBoxCenter(c, backendNodeID)
if err != nil {
// Sin geometría: fallback a element.click() JS en vez de error duro.
return clickRefViaJS(c, backendNodeID)
}
return CdpClickXYHuman(c, cx, cy, opts)
}
// clickRefViaJS resuelve el nodo por backendDOMNodeId y llama element.click() en
// el contexto JS de la página. No dispara eventos de ratón reales (mousemove/
// mousedown), por lo que algunos listeners de hover no se activan; a cambio
// funciona sin geometría y al instante.
func clickRefViaJS(c *CDPConn, backendNodeID int) error {
res, err := c.sendCDP("DOM.resolveNode", map[string]any{"backendNodeId": backendNodeID})
if err != nil {
return fmt.Errorf("cdp click ref (js): resolveNode ref %d: %w", backendNodeID, err)
}
obj, _ := res["object"].(map[string]any)
objID, _ := obj["objectId"].(string)
if objID == "" {
return fmt.Errorf("cdp click ref (js): sin objectId para ref %d", backendNodeID)
}
if _, err := c.sendCDP("Runtime.callFunctionOn", map[string]any{
"objectId": objID,
"functionDeclaration": "function(){ this.click(); }",
}); err != nil {
return fmt.Errorf("cdp click ref (js): click ref %d: %w", backendNodeID, err)
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
---
name: cdp_click_ref
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error"
description: "Click humanizado (Bézier + jitter) sobre el elemento identificado por su #ref del AX outline. El #ref es el backendDOMNodeId estable del nodo DOM. Hace scroll al elemento si no está en viewport antes de calcular las coordenadas vía DOM.getBoxModel."
tags: [cdp, browser, action, ref, humanized, navegator]
uses_functions: [cdp_click_xy_human_go_browser]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
params:
- name: c
desc: "Conexión CDP activa al tab objetivo."
- name: backendNodeID
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
- name: opts
desc: "Opciones de trayectoria humanizada (jitter, velocidad, curva Bézier). Zero-value da humanización por defecto."
output: "nil si el click se completó; error si la conexión es nil, el nodo no tiene boxModel visible, o el click CDP falla."
file_path: "functions/browser/cdp_click_ref.go"
---
## Ejemplo
```go
// Tras un page_perceive que devuelve outline con #ref=1234:
conn, _ := CdpConnect(9222)
err := CdpClickRef(conn, 1234, MouseHumanOpts{})
if err != nil {
log.Fatal(err)
}
```
## Cuando usarla
Tras `page_perceive` / `render_ax_outline`, cuando el agente tiene el `#ref` de un elemento del outline y quiere hacer click sobre él sin necesitar un selector CSS — cierra el bucle percibir→actuar. Preferir sobre `CdpClickHuman` cuando el nodo viene del AX outline (más estable que un selector).
## Gotchas
- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir antes de actuar.
- `DOM.getBoxModel` falla si el elemento no está en el DOM renderizado (display:none, fuera del shadow DOM accesible, o ya eliminado). El error describe la causa.
- `DOM.scrollIntoViewIfNeeded` se invoca antes del cálculo de coordenadas pero su fallo se ignora (no fatal) — si el elemento no es scrollable al viewport el click puede caer en coordenadas incorrectas.
- El click va por `CdpClickXYHuman` (Bézier): no despaches `Input.dispatchMouseEvent` crudo en código que use esta función.
+64
View File
@@ -0,0 +1,64 @@
package browser
import (
"fmt"
"math/rand"
"time"
)
// CdpClickXYHuman hace click en las coordenadas absolutas (x, y) de la página con
// comportamiento humano: mueve el ratón hasta el punto por una trayectoria de
// Bézier cúbica (CdpMoveMouseHuman) y despacha mousePressed/mouseReleased con una
// micro-pausa variable (30-90 ms) entre ambos.
//
// Es el PRIMITIVO de click compartido por las tres vías de acción del agente:
// - por selector CSS → CdpClickHuman (obtiene el bbox y llama aquí).
// - por #ref del AX tree → CdpClickRef (resuelve backendDOMNodeId → bbox → aquí).
// - por visión → click sobre el bounding box que devuelve OCR/YOLO.
// Construir un único primitivo evita tener tres caminos de click divergentes.
func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error {
if c == nil {
return fmt.Errorf("cdp click xy human: conexion nula")
}
// Mover el ratón hasta el destino con trayectoria humana.
if err := CdpMoveMouseHuman(c, x, y, opts); err != nil {
return fmt.Errorf("cdp click xy human: mover raton: %w", err)
}
clickParams := map[string]any{
"type": "mousePressed",
"x": x,
"y": y,
"button": "left",
"clickCount": 1,
}
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
return fmt.Errorf("cdp click xy human: mousePressed: %w", err)
}
// Pausa entre press y release según el modo de velocidad.
if pms := clickPauseMs(opts.Mode); pms > 0 {
time.Sleep(time.Duration(pms) * time.Millisecond)
}
clickParams["type"] = "mouseReleased"
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
return fmt.Errorf("cdp click xy human: mouseReleased: %w", err)
}
return nil
}
// clickPauseMs devuelve la pausa (ms) entre press y release según el modo de
// velocidad: human 30-90, fast 5-15, instant 0.
func clickPauseMs(mode string) int {
switch mode {
case "instant":
return 0
case "fast":
return 5 + rand.Intn(11) // 5..15
default: // "human" o ""
return 30 + rand.Intn(61) // 30..90
}
}
+62
View File
@@ -0,0 +1,62 @@
---
id: cdp_click_xy_human_go_browser
name: cdp_click_xy_human
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Click humanizado en coordenadas absolutas (x,y): mueve el ratón con trayectoria Bézier y despacha mousePressed/mouseReleased con micro-pausa variable. Primitivo de click compartido por las tres vías de acción del agente: por selector, por #ref del AX tree y por visión (bounding box de OCR/YOLO)."
tags: [cdp, browser, action, humanized, click, navegator]
signature: "func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error"
uses_functions:
- cdp_move_mouse_human_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_click_xy_human.go"
example: |
conn, _ := browser.CdpConnect(9333)
defer browser.CdpClose(conn, 0)
// Click humanizado en el centro de un elemento detectado por visión (bbox):
browser.CdpClickXYHuman(conn, 412.0, 318.0, browser.MouseHumanOpts{})
params:
- name: c
desc: "Conexión CDP activa (de CdpConnect)."
- name: x
desc: "Coordenada X absoluta en la página, en px CSS del viewport."
- name: y
desc: "Coordenada Y absoluta en la página, en px CSS del viewport."
- name: opts
desc: "Opciones de la trayectoria humana (zero-value = defaults). Origen del movimiento via FromX/FromY."
output: "error si el movimiento del ratón o el despacho de eventos falla; nil en éxito."
---
## Ejemplo
```go
conn, _ := browser.CdpConnect(9333)
defer browser.CdpClose(conn, 0)
// El centro del bounding box lo da el #ref del AX tree (DOM.getBoxModel) o la
// detección de visión (OCR/YOLO). Aquí, click humanizado sobre ese punto:
if err := browser.CdpClickXYHuman(conn, 412.0, 318.0, browser.MouseHumanOpts{}); err != nil {
log.Fatal(err)
}
```
## Cuando usarla
Cuando ya tienes las coordenadas de píxel del objetivo: el centro del bounding box de un elemento
(resuelto por `#ref` del AX outline vía `DOM.getBoxModel`, o detectado por visión OCR/YOLO). Es el
único primitivo de click del agente — no despaches `Input.dispatchMouseEvent` a mano.
## Gotchas
- Coordenadas en el sistema de la página (px CSS del viewport), no de pantalla física.
- La humanización añade latencia (movimiento Bézier + micro-pausa). Para scraping masivo de alto
volumen, el llamador debe usar un preset rápido de `MouseHumanOpts` (política de sesión `fast`),
no humanización completa por acción.
- El destino debe estar dentro del viewport visible; haz scroll al elemento antes si hace falta.
+15
View File
@@ -6,6 +6,21 @@ import (
"syscall"
)
// CdpDisconnect cierra SOLO la conexion WebSocket CDP, sin tocar el proceso
// Chrome. Es un alias legible de CdpClose(c, 0): usalo cuando quieras soltar la
// sesion pero dejar el navegador vivo (p.ej. el navegador diario en 9222 al que
// te adjuntaste, no quieres matarlo).
func CdpDisconnect(c *CDPConn) error {
return CdpClose(c, 0)
}
// CdpQuit cierra la conexion WebSocket Y mata el proceso Chrome (y su grupo de
// proceso completo en Linux nativo). Es un alias legible de CdpClose(c, pid) con
// pid > 0: usalo para apagar un Chrome que TU lanzaste con ChromeLaunch.
func CdpQuit(c *CDPConn, pid int) error {
return CdpClose(c, pid)
}
// CdpClose cierra la conexion WebSocket CDP y, si pid > 0, mata el proceso Chrome.
// En Linux nativo mata el grupo de proceso completo (chromium lanza zygote, gpu,
// renderers como hijos del mismo grupo cuando ChromeLaunch seteo Setpgid: true).
+15 -6
View File
@@ -3,11 +3,11 @@ name: cdp_close
kind: function
lang: go
domain: browser
version: "1.1.0"
version: "1.2.0"
purity: impure
signature: "func CdpClose(c *CDPConn, pid int) error"
description: "Cierra la conexion WebSocket CDP y opcionalmente mata el proceso Chrome por PID. En Linux nativo mata el grupo de proceso completo (pid == pgid cuando ChromeLaunch seteo Setpgid=true), lo que incluye zygote, gpu-process y renderers. Si c es nil, solo mata el proceso. Si pid <= 0, solo cierra la conexion. Siempre intenta ambas operaciones aunque una falle."
tags: [chrome, cdp, browser, automation, cleanup, devtools, linux]
description: "Cierra la conexion WebSocket CDP y opcionalmente mata el proceso Chrome por PID. En Linux nativo mata el grupo de proceso completo (pid == pgid cuando ChromeLaunch seteo Setpgid=true), lo que incluye zygote, gpu-process y renderers. Si c es nil, solo mata el proceso. Si pid <= 0, solo cierra la conexion. Siempre intenta ambas operaciones aunque una falle. Wrappers nombrados: CdpDisconnect(c) solo cierra el WebSocket; CdpQuit(c, pid) cierra y mata Chrome."
tags: [chrome, cdp, browser, automation, cleanup, devtools, linux, navegator]
uses_functions: []
uses_types: []
returns: []
@@ -20,9 +20,9 @@ params:
- name: pid
desc: "PID del proceso Chrome (0 para no matar; en Linux nativo este PID es tambien el PGID cuando ChromeLaunch uso Setpgid)"
output: "error si falla la desconexion o el cierre del proceso; nil si todo OK"
tested: false
tests: []
test_file_path: ""
tested: true
tests: ["TestCdpCloseWrappers"]
test_file_path: "functions/browser/cdp_close_test.go"
file_path: "functions/browser/cdp_close.go"
---
@@ -43,6 +43,14 @@ defer CdpClose(nil, pid) // solo mata Chrome (y su grupo en Linux)
Usar siempre en `defer` después de `ChromeLaunch` para garantizar cleanup del proceso Chrome y del WebSocket CDP. En Linux nativo mata el árbol completo de procesos (zygote, gpu, renderers) evitando procesos zombie.
**Elige el wrapper según la intención** (más legible que el `pid` mágico):
| Quiero... | Usa | Equivale a |
|---|---|---|
| Soltar la sesión, dejar Chrome vivo (navegador diario en 9222) | `CdpDisconnect(c)` | `CdpClose(c, 0)` |
| Apagar el Chrome que yo lancé | `CdpQuit(c, pid)` | `CdpClose(c, pid)` |
| Control fino (decidir pid en runtime) | `CdpClose(c, pid)` | — |
## Gotchas
- **Kill por grupo (Linux nativo)**: usa `syscall.Kill(-pid, SIGKILL)` que envía la señal a todos los procesos del grupo. Funciona porque `ChromeLaunch` setea `Setpgid: true` en Linux, haciendo que `pid == pgid`. En WSL+chrome.exe el Setpgid no se aplica, por lo que el fallback a `os.FindProcess(pid).Kill()` maneja ese caso.
@@ -56,4 +64,5 @@ Usar en `defer` para garantizar cleanup. Si tanto la conexion como el proceso so
## Capability growth log
- v1.2.0 (2026-06-06) — añade wrappers nombrados CdpDisconnect(c) (solo WebSocket) y CdpQuit(c, pid) (WebSocket + mata Chrome) para desambiguar el `pid` mágico; CdpClose sin cambios de comportamiento.
- v1.1.0 (2026-06-05) — Linux-native kill: usa syscall.Kill(-pid, SIGKILL) para matar grupo completo (zygote, gpu, renderers), con fallback a os.FindProcess para WSL+exe o proceso ya terminado
+63
View File
@@ -0,0 +1,63 @@
---
id: cdp_close_tab_go_browser
name: cdp_close_tab
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Cierra una pestaña Chrome por su ID via GET /json/close/<id>. Sin WebSocket — solo HTTP. Util para limpiar pestañas abiertas por automatizaciones."
tags: [cdp, browser, tabs, navegator]
signature: "func CdpCloseTab(host string, port int, tabID string) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_list_tabs.go"
example: |
tabs, _ := browser.CdpListTabs("localhost", 9222)
for _, t := range tabs {
if t.URL == "https://example.com" {
_ = browser.CdpCloseTab("localhost", 9222, t.ID)
}
}
params:
- name: host
desc: "Hostname de la instancia Chrome (vacío = localhost)"
- name: port
desc: "Puerto CDP de remote debugging (habitualmente 9222)"
- name: tabID
desc: "ID de la pestaña a cerrar, obtenido de CdpTab.ID via CdpListTabs"
output: "nil si la pestaña se cerró correctamente; error si tabID está vacío, la conexión falla o Chrome devuelve status != 200"
---
## Ejemplo
```go
// Listar tabs y cerrar la primera que coincida con una URL
tabs, err := browser.CdpListTabs("localhost", 9222)
if err != nil {
log.Fatal(err)
}
for _, t := range tabs {
if t.URL == "https://example.com/login" {
if err := browser.CdpCloseTab("localhost", 9222, t.ID); err != nil {
log.Printf("error cerrando tab %s: %v", t.ID, err)
}
}
}
```
## Cuando usarla
Después de terminar una sesión de scraping o automatización: cierra las pestañas abiertas programáticamente sin afectar el resto del perfil. También útil para liberar recursos cuando `CdpNewTab` ha creado muchas pestañas temporales.
## Gotchas
- No requiere conexión WebSocket previa; usa HTTP puro contra `/json/close/<id>`.
- Si Chrome ya cerró la pestaña (o el ID es inválido), devuelve error de status HTTP.
- El ID debe obtenerse de `CdpListTabs` — no es el índice visible del tab, es el UUID interno de Chrome.
- No espera confirmación de cierre; para saber si la pestaña desapareció, volver a llamar `CdpListTabs`.
+25
View File
@@ -0,0 +1,25 @@
package browser
import "testing"
// TestCdpCloseWrappers es un smoke nil-safe de los wrappers nombrados. Sin Chrome:
// con conexión nil y pid 0 no hay nada que cerrar ni matar, así que no debe error.
func TestCdpCloseWrappers(t *testing.T) {
t.Run("CdpDisconnect(nil) no error (nada que cerrar)", func(t *testing.T) {
if err := CdpDisconnect(nil); err != nil {
t.Errorf("CdpDisconnect(nil) = %v, esperaba nil", err)
}
})
t.Run("CdpQuit(nil, 0) no error (sin conexion ni pid)", func(t *testing.T) {
if err := CdpQuit(nil, 0); err != nil {
t.Errorf("CdpQuit(nil, 0) = %v, esperaba nil", err)
}
})
t.Run("CdpClose(nil, 0) sigue siendo no-op", func(t *testing.T) {
if err := CdpClose(nil, 0); err != nil {
t.Errorf("CdpClose(nil, 0) = %v, esperaba nil", err)
}
})
}
+6
View File
@@ -35,6 +35,12 @@ type CDPConn struct {
closed bool
handlers map[string][]EventHandler
hMu sync.Mutex
// frameCtx cachea el executionContextId del isolated world por frameID, para
// que CdpEvalInFrame no cree un mundo aislado nuevo en cada llamada.
// frameCtxMu protege solo el lazy-init del puntero (el cache tiene su mutex).
frameCtx *frameCtxCache
frameCtxMu sync.Mutex
}
type cdpRequest struct {
+17 -14
View File
@@ -67,18 +67,9 @@ func CdpConnect(port int) (*CDPConn, error) {
return CdpConnectHost("localhost", port)
}
// CdpConnectHost es como CdpConnect pero permite especificar el host.
// Util para WSL2 donde Chrome Windows escucha en una IP distinta a localhost.
func CdpConnectHost(host string, port int) (*CDPConn, error) {
if host == "" {
host = "localhost"
}
wsURL, err := cdpGetPageWSURL(host, port)
if err != nil {
return nil, fmt.Errorf("cdp connect: %w", err)
}
// Parsear la URL del WebSocket para extraer host y path
// cdpConnectWS abre la conexion CDP a partir de un webSocketDebuggerUrl ya resuelto.
// Es el helper compartido por CdpConnectHost y CdpConnectTarget para evitar duplicacion.
func cdpConnectWS(wsURL string, port int) (*CDPConn, error) {
u, err := url.Parse(wsURL)
if err != nil {
return nil, fmt.Errorf("cdp connect: parsear ws url %q: %w", wsURL, err)
@@ -96,8 +87,7 @@ func CdpConnectHost(host string, port int) (*CDPConn, error) {
}
// Realizar handshake WebSocket
path := u.RequestURI()
reader, err := wsHandshake(tcpConn, wsHost, path)
reader, err := wsHandshake(tcpConn, wsHost, u.RequestURI())
if err != nil {
tcpConn.Close()
return nil, fmt.Errorf("cdp connect: ws handshake: %w", err)
@@ -115,3 +105,16 @@ func CdpConnectHost(host string, port int) (*CDPConn, error) {
return c, nil
}
// CdpConnectHost es como CdpConnect pero permite especificar el host.
// Util para WSL2 donde Chrome Windows escucha en una IP distinta a localhost.
func CdpConnectHost(host string, port int) (*CDPConn, error) {
if host == "" {
host = "localhost"
}
wsURL, err := cdpGetPageWSURL(host, port)
if err != nil {
return nil, fmt.Errorf("cdp connect: %w", err)
}
return cdpConnectWS(wsURL, port)
}
+56
View File
@@ -0,0 +1,56 @@
package browser
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
// CdpConnectTarget se conecta a un target CDP DETERMINISTA identificado por match.
//
// Si host es "" se usa "localhost".
// match puede ser:
// - "" → primer target con Type "page" y WebSocketDebuggerURL no vacío (misma
// semántica que CdpConnectHost, útil como fallback compatible).
// - ID exacto del target (campo "id" en /json).
// - Substring case-insensitive de la URL del target.
//
// Retorna error si ningún target type=page satisface el match.
func CdpConnectTarget(host string, port int, match string) (*CDPConn, error) {
if host == "" {
host = "localhost"
}
resp, err := http.Get(fmt.Sprintf("http://%s:%d/json", host, port))
if err != nil {
return nil, fmt.Errorf("cdp connect target: listar targets: %w", err)
}
defer resp.Body.Close()
var targets []cdpTarget
if err := json.NewDecoder(resp.Body).Decode(&targets); err != nil {
return nil, fmt.Errorf("cdp connect target: decode targets: %w", err)
}
matchLower := strings.ToLower(match)
for _, t := range targets {
if t.Type != "page" || t.WebSocketDebuggerURL == "" {
continue
}
if match == "" {
// Sin filtro: primera tab page disponible.
return cdpConnectWS(t.WebSocketDebuggerURL, port)
}
// Coincidencia por ID exacto o substring de URL (case-insensitive).
if t.ID == match || strings.Contains(strings.ToLower(t.URL), matchLower) {
return cdpConnectWS(t.WebSocketDebuggerURL, port)
}
}
if match == "" {
return nil, fmt.Errorf("cdp connect target: no hay ninguna tab 'page' disponible en %s:%d", host, port)
}
return nil, fmt.Errorf("cdp connect target: no hay tab 'page' que matchee %q en %s:%d", match, host, port)
}
+58
View File
@@ -0,0 +1,58 @@
---
name: cdp_connect_target
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpConnectTarget(host string, port int, match string) (*CDPConn, error)"
description: "Conecta por CDP a un target DETERMINISTA elegido por ID exacto o substring de URL, evitando engancharse a una pestaña al azar con el CDP global en 9222."
tags: [cdp, browser, connection, security, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: host
desc: "Host donde escucha el CDP. Vacío usa 'localhost'. Útil en WSL2 para apuntar a la IP de Windows."
- name: port
desc: "Puerto CDP del navegador (habitualmente 9222)."
- name: match
desc: "Filtro de target: vacío = primera tab page (compat con CdpConnectHost); ID exacto del target; o substring case-insensitive de la URL de la pestaña."
output: "*CDPConn listo para enviar comandos CDP al target elegido. Error si ninguna tab 'page' satisface el match."
tested: false
tests: []
test_file_path: ""
file_path: "functions/browser/cdp_connect_target.go"
---
## Ejemplo
```go
// Fijar la pestaña de GitHub para que el agente no toque otras abiertas
conn, err := browser.CdpConnectTarget("", 9222, "github.com")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// Por ID exacto de target (obtenido de GET http://localhost:9222/json)
conn2, err := browser.CdpConnectTarget("", 9222, "ABCD1234-target-id")
// Compatibilidad: sin filtro = primera tab page (igual que CdpConnect)
conn3, err := browser.CdpConnectTarget("", 9222, "")
```
## Cuando usarla
Cuando un agente debe atarse a UNA pestaña concreta (por URL) y NO a la primera al azar — crítico con CDP global en 9222 para no operar sobre pestañas ajenas (banca, correo, sesiones activas). Usar en lugar de `CdpConnect`/`CdpConnectHost` siempre que el contexto del agente sea "esta URL concreta" y no "cualquier tab disponible".
## Gotchas
- Si hay varias tabs cuya URL contiene el substring dado, se elige la **primera** que aparezca en `/json` (orden interno del navegador). Para mayor precisión, usar el ID exacto del target.
- El match de URL es substring **case-insensitive**; `"github"` matchea `"https://github.com/usuario/repo"`.
- Con CDP global en 9222 y muchas pestañas abiertas, un `match=""` sigue siendo tan arriesgado como `CdpConnect`. Especificar siempre el match en producción.
- La forma más segura para agentes automatizados es lanzar un perfil Chromium dedicado con `--user-data-dir` aislado y `--remote-debugging-port` propio, de modo que `/json` solo exponga las pestañas del agente.
- `WebSocketDebuggerURL` puede cambiar entre reinicios del navegador; recalcular en cada sesión, no cachear entre ejecuciones.
+15
View File
@@ -0,0 +1,15 @@
package browser
// CdpDeleteCookies borra las cookies que coincidan con name (y opcionalmente domain)
// via Network.deleteCookies. Si domain es "" se borran todas las cookies con ese
// nombre en cualquier dominio.
func CdpDeleteCookies(c *CDPConn, name, domain string) error {
params := map[string]any{
"name": name,
}
if domain != "" {
params["domain"] = domain
}
_, err := c.sendCDP("Network.deleteCookies", params)
return err
}
+61
View File
@@ -0,0 +1,61 @@
---
id: cdp_delete_cookies_go_browser
name: cdp_delete_cookies
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Borra las cookies que coincidan con name (+ domain opcional) via Network.deleteCookies; si domain es vacío elimina en todos los dominios."
tags: [cdp, browser, cookie, network, navegator]
signature: "func CdpDeleteCookies(c *CDPConn, name, domain string) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_delete_cookies.go"
example: |
conn, _ := CdpConnect(9222)
// Borrar cookie de sesion solo en el dominio concreto
err := CdpDeleteCookies(conn, "session_id", "app.example.com")
// Borrar en todos los dominios (sin filtro de dominio)
err = CdpDeleteCookies(conn, "tracking_cookie", "")
params:
- name: c
desc: "Conexion CDP activa al browser (obtenida con CdpConnect)"
- name: name
desc: "Nombre exacto de la cookie a borrar; obligatorio para Network.deleteCookies"
- name: domain
desc: "Dominio donde borrar la cookie; cadena vacía borra en todos los dominios que tengan esa cookie"
output: "nil si la cookie fue borrada (o no existia); error si falla la comunicacion CDP."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
// Borrar cookie de sesion solo en dominio especifico
if err := CdpDeleteCookies(conn, "session_id", "app.example.com"); err != nil {
log.Fatal(err)
}
// Borrar cookie en todos los dominios
if err := CdpDeleteCookies(conn, "analytics_token", ""); err != nil {
log.Fatal(err)
}
```
## Cuando usarla
Usar cuando necesitas forzar un logout de sesion especifica, limpiar una cookie de tracking antes de un test, o resetear el estado de autenticacion de un dominio concreto sin tocar el resto de cookies.
## Gotchas
- `name` es obligatorio en `Network.deleteCookies`; CDP devuelve error si se omite.
- Sin `domain`, CDP borra la cookie en TODOS los dominios que tengan esa cookie — puede cerrar sesiones inesperadas en otros dominios abiertos.
- No devuelve error si la cookie no existia; la operacion es idempotente.
- Para borrar todas las cookies de golpe usar `CdpClearCookies` en su lugar.
+176
View File
@@ -0,0 +1,176 @@
package browser
import (
"encoding/json"
"fmt"
"strings"
"sync"
)
// frameCtxCache mapea frameID -> executionContextId del isolated world creado
// para ese frame. Evita pagar Page.createIsolatedWorld en cada CdpEvalInFrame.
// Es puro y testeable de forma aislada (su propio mutex, sin tocar CDP).
type frameCtxCache struct {
mu sync.Mutex
m map[string]int
}
func newFrameCtxCache() *frameCtxCache {
return &frameCtxCache{m: map[string]int{}}
}
func (f *frameCtxCache) get(frameID string) (int, bool) {
f.mu.Lock()
defer f.mu.Unlock()
id, ok := f.m[frameID]
return id, ok
}
func (f *frameCtxCache) set(frameID string, ctxID int) {
f.mu.Lock()
f.m[frameID] = ctxID
f.mu.Unlock()
}
func (f *frameCtxCache) invalidate(frameID string) {
f.mu.Lock()
delete(f.m, frameID)
f.mu.Unlock()
}
// isStaleContextError reconoce el error de CDP cuando un executionContextId
// cacheado ya no existe (el frame recargó/navegó y su isolated world murió). Es
// puro: decide a partir del texto del error. Permite reintentar recreando el
// mundo en vez de fallar.
func isStaleContextError(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "Cannot find context") ||
strings.Contains(s, "context with specified id") ||
strings.Contains(s, "Execution context was destroyed") ||
strings.Contains(s, "uniqueContextId")
}
// frameCtxCacheLazy devuelve el cache de contextos del frame de esta conexion,
// inicializandolo en el primer uso. El mutex de CDPConn solo protege este
// lazy-init del puntero.
func (c *CDPConn) frameCtxCacheLazy() *frameCtxCache {
c.frameCtxMu.Lock()
defer c.frameCtxMu.Unlock()
if c.frameCtx == nil {
c.frameCtx = newFrameCtxCache()
}
return c.frameCtx
}
// createIsolatedWorld crea un mundo aislado en el frame y devuelve su
// executionContextId.
func createIsolatedWorld(c *CDPConn, frameID string) (int, error) {
ctxRes, err := c.sendCDP("Page.createIsolatedWorld", map[string]any{
"frameId": frameID,
"worldName": "fn_registry_isolated",
"grantUniversalAccess": false,
})
if err != nil {
return 0, fmt.Errorf("createIsolatedWorld: %w", err)
}
ctxIDRaw, ok := ctxRes["executionContextId"]
if !ok {
return 0, fmt.Errorf("createIsolatedWorld: executionContextId no encontrado en respuesta")
}
ctxID, ok := ctxIDRaw.(float64)
if !ok {
return 0, fmt.Errorf("createIsolatedWorld: executionContextId tipo inesperado: %T", ctxIDRaw)
}
return int(ctxID), nil
}
// evalInFrameContext ejecuta la expresion en el executionContextId dado y
// serializa el resultado como string (mismo patron que CdpEvaluate).
func evalInFrameContext(c *CDPConn, ctxID int, frameID, expression string) (string, error) {
evRes, err := c.sendCDP("Runtime.evaluate", map[string]any{
"expression": expression,
"contextId": ctxID,
"returnByValue": true,
"awaitPromise": true,
})
if err != nil {
return "", fmt.Errorf("Runtime.evaluate: %w", err)
}
if exc, ok := evRes["exceptionDetails"]; ok && exc != nil {
excMap, _ := exc.(map[string]any)
text, _ := excMap["text"].(string)
return "", fmt.Errorf("excepcion JS en frame %q: %s", frameID, text)
}
resVal, ok := evRes["result"].(map[string]any)
if !ok {
return "", fmt.Errorf("resultado inesperado: %v", evRes)
}
value, ok := resVal["value"]
if !ok {
typ, _ := resVal["type"].(string)
return typ, nil
}
if s, ok := value.(string); ok {
return s, nil
}
b, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("%v", value), nil
}
return string(b), nil
}
// CdpEvalInFrame ejecuta una expresion JavaScript en el contexto aislado de un iframe
// especifico usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame.
// Retorna el resultado serializado como string.
//
// Cachea el executionContextId por frameID en la conexion: la primera llamada
// crea el mundo aislado, las siguientes lo reutilizan. Si el contexto cacheado
// caducó (el frame navegó/recargó), recrea el mundo una vez y reintenta.
func CdpEvalInFrame(c *CDPConn, frameID, expression string) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp eval in frame: conexion nula")
}
if frameID == "" {
return "", fmt.Errorf("cdp eval in frame: frameID vacio")
}
// Page.enable es idempotente; necesario antes de crear mundos aislados.
if _, err := c.sendCDP("Page.enable", nil); err != nil {
return "", fmt.Errorf("cdp eval in frame: Page.enable: %w", err)
}
cache := c.frameCtxCacheLazy()
ctxID, cached := cache.get(frameID)
if !cached {
newID, err := createIsolatedWorld(c, frameID)
if err != nil {
return "", fmt.Errorf("cdp eval in frame: %w", err)
}
ctxID = newID
cache.set(frameID, ctxID)
}
out, evErr := evalInFrameContext(c, ctxID, frameID, expression)
if evErr != nil && cached && isStaleContextError(evErr) {
// El contexto cacheado murió (frame recargó). Recrear una vez.
cache.invalidate(frameID)
newID, err := createIsolatedWorld(c, frameID)
if err != nil {
return "", fmt.Errorf("cdp eval in frame: %w", err)
}
cache.set(frameID, newID)
out, evErr = evalInFrameContext(c, newID, frameID, expression)
}
if evErr != nil {
return "", fmt.Errorf("cdp eval in frame: %w", evErr)
}
return out, nil
}
+80
View File
@@ -0,0 +1,80 @@
---
id: cdp_eval_in_frame_go_browser
name: cdp_eval_in_frame
kind: function
lang: go
domain: browser
purity: impure
version: 1.1.0
tested: true
tests: ["TestCdpEvalInFrame_guards", "TestFrameCtxCache", "TestIsStaleContextError"]
test_file_path: "functions/browser/cdp_eval_in_frame_test.go"
description: "Ejecuta una expresión JavaScript en el contexto aislado de un iframe concreto usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame. Cachea el executionContextId por frameID en la conexión para no recrear el isolated world en cada llamada; si el contexto caduca (frame recargó) lo recrea una vez y reintenta."
tags: [cdp, browser, iframe, javascript, eval, navegator]
signature: "func CdpEvalInFrame(c *CDPConn, frameID string, expression string) (string, error)"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_eval_in_frame.go"
example: |
conn, _ := CdpConnect("localhost", 9222, "")
frames, _ := CdpListFrames(conn)
// Tomar el primer iframe (índice 1, el 0 es el frame raíz)
result, err := CdpEvalInFrame(conn, frames[1].ID, "document.title")
fmt.Println(result) // "Título del iframe"
params:
- name: c
desc: "Conexión CDP activa obtenida con CdpConnect."
- name: frameID
desc: "ID del frame donde ejecutar el JS; obtenido de CdpListFrames (campo CdpFrame.ID)."
- name: expression
desc: "Expresión JavaScript a evaluar en el contexto del frame; puede ser una expresión simple o una Promise."
output: "Resultado de la expresión serializado como string (fmt.Sprintf del valor CDP); error si la conexión es nula, el frameID está vacío, la comunicación CDP falla o la expresión lanza una excepción JS."
---
## Ejemplo
```go
conn, err := CdpConnect("localhost", 9222, "")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
frames, err := CdpListFrames(conn)
if err != nil {
log.Fatal(err)
}
// frames[0] es el frame raíz; frames[1] sería el primer iframe
iframeID := frames[1].ID
title, err := CdpEvalInFrame(conn, iframeID, "document.title")
if err != nil {
log.Fatal(err)
}
fmt.Println("Título del iframe:", title)
// Leer un elemento del DOM del iframe
text, _ := CdpEvalInFrame(conn, iframeID, "document.querySelector('h1').innerText")
fmt.Println("H1 del iframe:", text)
```
## Cuando usarla
Cuando necesites leer o manipular el DOM de un iframe específico sin afectar el contexto JS de la página principal. Útil para extraer datos de iframes de terceros, formularios embebidos o widgets. Obtén el `frameID` con `CdpListFrames` antes de llamar a esta función.
## Gotchas
- El mundo aislado (`fn_registry_isolated`) puede leer el DOM del iframe pero NO accede a variables JS definidas en el page-world del iframe (ej. `window.miVariable`). Para acceder a variables JS del frame, evalúa sin `createIsolatedWorld` usando el `contextId` principal del frame (no expuesto por esta función).
- Requiere `Page.enable` (se llama internamente, idempotente).
- Si el iframe tiene `sandbox` attribute sin `allow-scripts`, el CDP puede crear el mundo aislado pero las evaluaciones fallarán con excepción de seguridad.
- Cross-origin iframes en Chrome permiten evaluación CDP siempre que la conexión tenga acceso al target; no aplican las restricciones CORS de JS normal.
- El `frameID` debe obtenerse con `CdpListFrames`; si se pasa un ID obsoleto (frame recargado o destruido), `createIsolatedWorld` retorna error.
- **Cache de contexto por frameID**: la primera llamada crea el isolated world; las siguientes reutilizan su `executionContextId` (más rápido). Si el frame navega/recarga, el contexto cacheado caduca; la función detecta el error ("Cannot find context", "Execution context was destroyed") y recrea el mundo una vez automáticamente. El cache vive en la conexión: persiste entre llamadas mientras la conexión esté viva.
## Capability growth log
- v1.1.0 (2026-06-06) — corrige typo `grantUniveralAccess``grantUniversalAccess` (la opción nunca se aplicaba); cachea executionContextId por frameID en CDPConn (vía `frameCtxCache`) para no crear un isolated world por llamada; recrea+reintenta una vez si el contexto cacheado caducó.
@@ -0,0 +1,79 @@
package browser
import (
"errors"
"testing"
)
// TestCdpEvalInFrame_guards cubre precondiciones sin Chrome.
func TestCdpEvalInFrame_guards(t *testing.T) {
t.Run("conexion nula", func(t *testing.T) {
if _, err := CdpEvalInFrame(nil, "f1", "1"); err == nil {
t.Fatal("esperaba error con conexion nula")
}
})
t.Run("frameID vacio", func(t *testing.T) {
if _, err := CdpEvalInFrame(&CDPConn{}, "", "1"); err == nil {
t.Fatal("esperaba error con frameID vacio")
}
})
}
// TestFrameCtxCache cubre el núcleo puro del cache de contextos por frame.
func TestFrameCtxCache(t *testing.T) {
t.Run("golden: set/get devuelve el ctxId cacheado", func(t *testing.T) {
c := newFrameCtxCache()
if _, ok := c.get("frameA"); ok {
t.Fatal("cache recién creado no debería tener frameA")
}
c.set("frameA", 42)
id, ok := c.get("frameA")
if !ok || id != 42 {
t.Fatalf("get(frameA) = (%d,%v), esperaba (42,true)", id, ok)
}
})
t.Run("edge: frames distintos no se pisan", func(t *testing.T) {
c := newFrameCtxCache()
c.set("frameA", 1)
c.set("frameB", 2)
if id, _ := c.get("frameA"); id != 1 {
t.Errorf("frameA = %d, esperaba 1", id)
}
if id, _ := c.get("frameB"); id != 2 {
t.Errorf("frameB = %d, esperaba 2", id)
}
})
t.Run("invalidate: tras invalidar, get falla (fuerza recrear mundo)", func(t *testing.T) {
c := newFrameCtxCache()
c.set("frameA", 7)
c.invalidate("frameA")
if _, ok := c.get("frameA"); ok {
t.Error("tras invalidate, get(frameA) debería fallar")
}
})
}
// TestIsStaleContextError cubre el discriminador puro que decide si reintentar
// recreando el isolated world.
func TestIsStaleContextError(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{"nil no es stale", nil, false},
{"error generico no es stale", errors.New("boom"), false},
{"Cannot find context es stale", errors.New("cdp error: Cannot find context with specified id"), true},
{"Execution context was destroyed es stale", errors.New("Execution context was destroyed"), true},
{"uniqueContextId es stale", errors.New("invalid uniqueContextId"), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isStaleContextError(tc.err); got != tc.want {
t.Errorf("isStaleContextError(%v) = %v, esperaba %v", tc.err, got, tc.want)
}
})
}
}
+13 -1
View File
@@ -1,6 +1,7 @@
package browser
import (
"encoding/json"
"fmt"
)
@@ -44,5 +45,16 @@ func CdpEvaluate(c *CDPConn, expression string) (string, error) {
return typ, nil
}
return fmt.Sprintf("%v", value), nil
// Strings se devuelven tal cual (sin comillas). Objetos y arrays JS, que Chrome
// deserializa a map/slice cuando returnByValue=true, se serializan a JSON real
// en vez de la repr de Go de fmt.Sprintf("%v") (que produciria "map[a:1]" en lugar
// de {"a":1}). Asi el caller puede parsear datos estructurados.
if s, ok := value.(string); ok {
return s, nil
}
b, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("%v", value), nil
}
return string(b), nil
}
+5 -3
View File
@@ -25,8 +25,10 @@ type FindByTextOpts struct {
// - "#<id>" si el elemento tiene id.
// - path "tag:nth-of-type(n) > tag:nth-of-type(n) > ..." si no.
//
// Retorna ("", nil) si no encuentra nada (no es error). Error solo si la
// evaluacion JS rompe (conexion CDP caida).
// Retorna error si no encuentra ningun elemento con ese texto. Antes devolvia
// ("", nil) en silencio, lo que hacia que el caller creyera que habia encontrado
// algo y operara sobre un selector vacio. Tambien error si la evaluacion JS rompe
// (conexion CDP caida).
func CdpFindByText(c *CDPConn, text string, opts FindByTextOpts) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp find by text: conexion nula")
@@ -96,7 +98,7 @@ func CdpFindByText(c *CDPConn, text string, opts FindByTextOpts) (string, error)
// CdpEvaluate retorna el valor stringificado. Para "" devuelve cadena vacia.
res = strings.TrimSpace(res)
if res == "" || res == "<nil>" {
return "", nil
return "", fmt.Errorf("cdp find by text: no se encontro elemento con texto %q", text)
}
return res, nil
}
+141
View File
@@ -0,0 +1,141 @@
package browser
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
// findByTextCoreJS es el preludio JS compartido por las dos evaluaciones de
// CdpFindRefByText: define norm/matches/leafmost y la lista de nodos candidatos.
// Mismo algoritmo "leafmost" que CdpFindByText: prefiere el elemento más interno
// que matchea (donde suele vivir el handler), no el contenedor que lo envuelve.
const findByTextCoreJS = `
var P = %s;
var target = P.cs ? P.text : P.text.toLowerCase();
var nodes = document.querySelectorAll(P.tag || '*');
function norm(v) {
v = (v || '').replace(/\s+/g, ' ').trim();
return P.cs ? v : v.toLowerCase();
}
function matches(el) {
var v = norm(el.innerText || el.textContent || '');
return P.exact ? v === target : v.indexOf(target) >= 0;
}
function leafmost(el) {
for (var i = 0; i < el.children.length; i++) {
if (matches(el.children[i])) return false;
}
return true;
}`
// parseBackendNodeID extrae node.backendNodeId de la respuesta de DOM.describeNode.
// Es puro: recibe el mapa ya deserializado por CDP y devuelve el id entero, o un
// error claro si la estructura no es la esperada (nodo destruido, respuesta vacía).
func parseBackendNodeID(resp map[string]any) (int, error) {
node, ok := resp["node"].(map[string]any)
if !ok {
return 0, fmt.Errorf("describeNode: respuesta sin campo node")
}
raw, ok := node["backendNodeId"]
if !ok {
return 0, fmt.Errorf("describeNode: node sin backendNodeId")
}
f, ok := raw.(float64)
if !ok {
return 0, fmt.Errorf("describeNode: backendNodeId tipo inesperado %T", raw)
}
return int(f), nil
}
// CdpFindRefByText busca el primer elemento cuyo innerText matchea `text` y
// devuelve su backendDOMNodeId — el mismo identificador estable (#ref) que
// produce el outline de page_perceive y que consume CdpClickRef. Así se puede
// hacer click-by-text sin pasar por un selector CSS frágil (nth-of-type).
//
// Retorna (backendNodeID, count, error):
// - backendNodeID: ref del primer match, listo para CdpClickRef/CdpHoverRef.
// - count: número total de elementos que matchean (tras el filtro leafmost).
// count > 1 indica ambigüedad: el caller decide si refinar la búsqueda.
// - error: si la conexión es nula, el texto vacío, el eval JS falla o no hay
// ningún match (count == 0).
//
// Identidad unificada con el puente backendDOMNodeId: resuelve el nodo JS a un
// RemoteObject (Runtime.evaluate returnByValue=false) y de ahí al nodo DOM
// (DOM.describeNode), evitando el round-trip por selector CSS.
func CdpFindRefByText(c *CDPConn, text string, opts FindByTextOpts) (int, int, error) {
if c == nil {
return 0, 0, fmt.Errorf("cdp find ref by text: conexion nula")
}
if text == "" {
return 0, 0, fmt.Errorf("cdp find ref by text: texto vacio")
}
payload, _ := json.Marshal(map[string]any{
"text": text,
"tag": opts.Tag,
"exact": opts.Exact,
"cs": opts.CaseSensitive,
})
core := fmt.Sprintf(findByTextCoreJS, string(payload))
// 1. Contar matches (returnByValue=true vía CdpEvaluate).
countJS := "(function(){" + core + `
var n = 0;
for (var i = 0; i < nodes.length; i++) {
if (matches(nodes[i]) && leafmost(nodes[i])) n++;
}
return n;
})()`
countStr, err := CdpEvaluate(c, countJS)
if err != nil {
return 0, 0, fmt.Errorf("cdp find ref by text: contar matches: %w", err)
}
count, _ := strconv.Atoi(strings.TrimSpace(countStr))
if count == 0 {
return 0, 0, fmt.Errorf("cdp find ref by text: no se encontro elemento con texto %q", text)
}
// 2. Resolver el primer match a un RemoteObject (returnByValue=false para
// obtener un objectId que apunta al nodo DOM vivo).
elJS := "(function(){" + core + `
for (var i = 0; i < nodes.length; i++) {
if (matches(nodes[i]) && leafmost(nodes[i])) return nodes[i];
}
return null;
})()`
evRes, err := c.sendCDP("Runtime.evaluate", map[string]any{
"expression": elJS,
"returnByValue": false,
})
if err != nil {
return 0, count, fmt.Errorf("cdp find ref by text: evaluate elemento: %w", err)
}
if exc, ok := evRes["exceptionDetails"]; ok && exc != nil {
excMap, _ := exc.(map[string]any)
txt, _ := excMap["text"].(string)
return 0, count, fmt.Errorf("cdp find ref by text: excepcion JS: %s", txt)
}
remote, ok := evRes["result"].(map[string]any)
if !ok {
return 0, count, fmt.Errorf("cdp find ref by text: respuesta evaluate sin result")
}
objID, _ := remote["objectId"].(string)
if objID == "" {
// El conteo dio >0 pero el elemento desapareció entre ambos evals (DOM
// mutó): tratamos como no encontrado para no devolver un ref inválido.
return 0, count, fmt.Errorf("cdp find ref by text: elemento volátil, sin objectId (el DOM cambió entre conteo y resolución)")
}
// 3. Del RemoteObject al nodo DOM: backendNodeId.
dn, err := c.sendCDP("DOM.describeNode", map[string]any{"objectId": objID})
if err != nil {
return 0, count, fmt.Errorf("cdp find ref by text: describeNode: %w", err)
}
backendNodeID, err := parseBackendNodeID(dn)
if err != nil {
return 0, count, fmt.Errorf("cdp find ref by text: %w", err)
}
return backendNodeID, count, nil
}
+58
View File
@@ -0,0 +1,58 @@
---
name: cdp_find_ref_by_text
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpFindRefByText(c *CDPConn, text string, opts FindByTextOpts) (int, int, error)"
description: "Busca el primer elemento cuyo innerText matchea el texto dado y devuelve su backendDOMNodeId (#ref estable) en vez de un selector CSS. Resuelve el nodo JS a RemoteObject (Runtime.evaluate returnByValue=false) y de ahi al nodo DOM (DOM.describeNode), unificando la identidad con page_perceive y CdpClickRef. Devuelve tambien el numero de matches para detectar ambiguedad. Prefiere elementos hoja (leafmost)."
tags: [browser, cdp, find, locator, ref, accessibility, navegator]
uses_functions:
- cdp_evaluate_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: [encoding/json, fmt, strconv, strings]
params:
- name: c
desc: "Conexion CDP activa obtenida con CdpConnect."
- name: text
desc: "Texto visible a buscar. Comparacion contra innerText/textContent normalizado (whitespace colapsado)."
- name: opts
desc: "FindByTextOpts: Tag (filtro por tag, vacio = cualquiera), Exact (default false), CaseSensitive (default false)."
output: "(backendNodeID, count, error): backendNodeID es el #ref del primer match listo para CdpClickRef; count es el numero total de matches (>1 = ambiguo); error si conexion nula, texto vacio, eval JS falla o no hay match (count==0)."
tested: true
tests: ["TestCdpFindRefByText_guards", "TestParseBackendNodeID"]
test_file_path: "functions/browser/cdp_find_ref_by_text_test.go"
file_path: "functions/browser/cdp_find_ref_by_text.go"
---
## Ejemplo
```go
c, _ := browser.CdpConnect(9222)
defer browser.CdpClose(c, 0)
// Encontrar el botón "Login" por su texto y clicar por #ref (sin selector CSS).
ref, count, err := browser.CdpFindRefByText(c, "Login", browser.FindByTextOpts{Tag: "button"})
if err != nil {
log.Fatal(err)
}
if count > 1 {
log.Printf("aviso: %d elementos matchean 'Login', usando el primero", count)
}
_ = browser.CdpClickRef(c, ref, browser.MouseProfileForMode("human"))
```
## Cuando usarla
Cuando quieras clicar/hacer hover sobre un elemento identificándolo por su texto visible y operar después por `#ref` (backendDOMNodeId) en vez de por un selector CSS frágil. Es el puente entre "lo veo por su texto" y el bucle percibir→actuar de `page_perceive` + `CdpClickRef`. Preferible a `cdp_find_by_text` (que devuelve selector `nth-of-type`) cuando el frontend cambia sus clases/estructura con cada build pero el texto es estable.
## Gotchas
- **count > 1 = ambigüedad**: la función devuelve el primer match pero te avisa con `count` cuántos hay. Refina con `opts.Tag` o `opts.Exact` si el texto aparece en varios sitios.
- **Elemento volátil**: si el DOM muta entre el conteo y la resolución del nodo (SPA re-renderizando), el `objectId` puede venir vacío y la función devuelve error "elemento volátil" en vez de un `#ref` inválido. Reintenta tras `CdpWaitIdle`.
- **El #ref es efímero por documento**: el `backendDOMNodeId` es estable mientras el nodo viva, pero se invalida tras navegar o recargar. No lo persistas entre páginas.
- **Tests sin Chrome**: el núcleo puro (`parseBackendNodeID`) y los guards se testean sin navegador. El flujo completo (eval + describeNode contra DOM real) requiere Chrome y se valida por e2e.
@@ -0,0 +1,70 @@
package browser
import (
"strings"
"testing"
)
// TestCdpFindRefByText_guards cubre las precondiciones (sin Chrome).
func TestCdpFindRefByText_guards(t *testing.T) {
t.Run("conexion nula", func(t *testing.T) {
if _, _, err := CdpFindRefByText(nil, "x", FindByTextOpts{}); err == nil {
t.Fatal("esperaba error con conexion nula")
}
})
t.Run("texto vacio", func(t *testing.T) {
c := &CDPConn{}
_, _, err := CdpFindRefByText(c, "", FindByTextOpts{})
if err == nil {
t.Fatal("esperaba error con texto vacio")
}
if !strings.Contains(err.Error(), "vacio") {
t.Fatalf("mensaje %q no menciona vacio", err.Error())
}
})
}
// TestParseBackendNodeID cubre el nucleo puro que convierte la respuesta de
// DOM.describeNode en el backendNodeId entero. No requiere Chrome.
func TestParseBackendNodeID(t *testing.T) {
t.Run("golden: node con backendNodeId", func(t *testing.T) {
resp := map[string]any{
"node": map[string]any{"backendNodeId": 123.0, "nodeName": "BUTTON"},
}
id, err := parseBackendNodeID(resp)
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if id != 123 {
t.Fatalf("id = %d, esperaba 123", id)
}
})
t.Run("edge: backendNodeId grande se trunca a int correctamente", func(t *testing.T) {
resp := map[string]any{"node": map[string]any{"backendNodeId": 90001.0}}
id, err := parseBackendNodeID(resp)
if err != nil || id != 90001 {
t.Fatalf("id=%d err=%v, esperaba 90001 sin error", id, err)
}
})
t.Run("error: respuesta sin node", func(t *testing.T) {
if _, err := parseBackendNodeID(map[string]any{}); err == nil {
t.Error("esperaba error cuando falta node")
}
})
t.Run("error: node sin backendNodeId", func(t *testing.T) {
resp := map[string]any{"node": map[string]any{"nodeName": "DIV"}}
if _, err := parseBackendNodeID(resp); err == nil {
t.Error("esperaba error cuando falta backendNodeId")
}
})
t.Run("error: backendNodeId tipo no numerico", func(t *testing.T) {
resp := map[string]any{"node": map[string]any{"backendNodeId": "abc"}}
if _, err := parseBackendNodeID(resp); err == nil {
t.Error("esperaba error cuando backendNodeId no es numero")
}
})
}
+409
View File
@@ -0,0 +1,409 @@
package browser
import (
"fmt"
"strings"
)
// axoActionableRoles son los roles que el LLM puede referir con #ref. Misma
// lista que _ACTIONABLE_ROLES de render_ax_outline.py.
var axoActionableRoles = map[string]struct{}{
"button": {},
"link": {},
"textbox": {},
"searchbox": {},
"checkbox": {},
"radio": {},
"combobox": {},
"listbox": {},
"menuitem": {},
"menuitemcheckbox": {},
"menuitemradio": {},
"tab": {},
"option": {},
"switch": {},
"slider": {},
"spinbutton": {},
"treeitem": {},
"gridcell": {},
}
// axoSkipRoles son roles sin valor semantico: se omiten y sus hijos se elevan al
// nivel actual. Misma lista que _SKIP_ROLES de render_ax_outline.py.
var axoSkipRoles = map[string]struct{}{
"none": {},
"presentation": {},
"ignored": {},
}
// axoMaxDepth limita la profundidad de render (guard anti-RecursionError de
// arboles AX patologicos). Igual que _MAX_DEPTH del .py.
const axoMaxDepth = 60
// axNode es la representacion interna de un AXNode CDP, ya extraida del
// map[string]any de la respuesta. Los helpers de poda y render operan sobre
// estos structs, lo que los hace puros y testeables sin Chrome.
type axNode struct {
nodeID string
backendDOMNodeID string
ignored bool
role string
name string
value string
childIDs []string
parentID string
}
// CdpGetAXOutline percibe la pagina (o un iframe concreto via frameID) como un
// outline accesible indentado y accionable, reusando la conexion CDP viva del
// pool — sin abrir un WebSocket nuevo ni levantar el venv de Python.
//
// Envia Accessibility.enable (idempotente) y Accessibility.getFullAXTree. Si
// frameID != "", pasa {"frameId": frameID} para obtener el arbol DENTRO de ese
// iframe; con frameID == "" obtiene el arbol completo de la pagina (depth -1).
//
// El resultado se poda (trim) y luego se renderiza replicando exactamente el
// formato del pipeline Python cdp_get_ax_tree -> trim_ax_tree -> render_ax_outline:
// indentacion de 2 espacios por nivel, `role "name"`, ` = 'value'` para inputs,
// y marcador ` #ref=<backendDOMNodeId>` en roles accionables. maxChars > 0
// trunca y añade "\n…[outline truncado]"; maxChars <= 0 = sin limite.
func CdpGetAXOutline(c *CDPConn, frameID string, maxChars int) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp get ax outline: conexion nula")
}
// Accessibility.enable es idempotente; necesario antes de getFullAXTree.
if _, err := c.sendCDP("Accessibility.enable", nil); err != nil {
return "", fmt.Errorf("cdp get ax outline: Accessibility.enable: %w", err)
}
var params map[string]any
if frameID != "" {
params = map[string]any{"frameId": frameID}
}
res, err := c.sendCDP("Accessibility.getFullAXTree", params)
if err != nil {
return "", fmt.Errorf("cdp get ax outline: Accessibility.getFullAXTree: %w", err)
}
nodes := axoParseNodes(res)
trimmed := trimAXTree(nodes)
return renderAXOutline(trimmed, maxChars), nil
}
// axoParseNodes extrae la lista de axNode del result de getFullAXTree. Tras el
// JSON unmarshal a map[string]any, los nodos vienen como []any de
// map[string]any y los enteros (backendDOMNodeId, nodeId) como float64; nodeId y
// childIds suelen llegar como strings. Normalizamos todo a string.
func axoParseNodes(result map[string]any) []axNode {
raw, ok := result["nodes"].([]any)
if !ok {
return nil
}
out := make([]axNode, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
n := axNode{
nodeID: axoStr(m["nodeId"]),
backendDOMNodeID: axoStr(m["backendDOMNodeId"]),
ignored: axoBool(m["ignored"]),
role: axoNested(m["role"]),
name: axoNested(m["name"]),
value: axoNested(m["value"]),
childIDs: axoStrSlice(m["childIds"]),
parentID: axoStr(m["parentId"]),
}
out = append(out, n)
}
return out
}
// axoNested extrae el campo "value" de un objeto CDP del tipo {value: ...} (role,
// name, value vienen asi). Devuelve "" si esta ausente o vacio.
func axoNested(v any) string {
m, ok := v.(map[string]any)
if !ok {
if v == nil {
return ""
}
return axoStr(v)
}
return axoStr(m["value"])
}
// axoStr normaliza cualquier escalar JSON a string. Los enteros CDP llegan como
// float64 tras el unmarshal; los renderizamos sin decimales.
func axoStr(v any) string {
switch t := v.(type) {
case nil:
return ""
case string:
return t
case float64:
// IDs CDP son enteros: evitar notacion 1.234e+06 / sufijo .0.
return fmt.Sprintf("%d", int64(t))
case bool:
if t {
return "true"
}
return "false"
default:
return fmt.Sprintf("%v", t)
}
}
func axoBool(v any) bool {
b, _ := v.(bool)
return b
}
func axoStrSlice(v any) []string {
raw, ok := v.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(raw))
for _, item := range raw {
out = append(out, axoStr(item))
}
return out
}
// trimAXTree compacta la lista de axNode descartando nodos irrelevantes y
// colapsando cadenas padre->hijo del mismo role. Puro: porta trim_ax_tree.py.
//
// Descarta: ignored=true; role 'generic'/'none' sin name ni childIds;
// role 'StaticText' con name vacio. Colapsa: nodo con exactamente 1 hijo del
// mismo role hereda los childIds del hijo (el hijo se descarta). Itera hasta
// convergencia. Preserva el orden original de aparicion.
func trimAXTree(nodes []axNode) []axNode {
if len(nodes) == 0 {
return nil
}
shouldDiscard := func(n axNode) bool {
if n.ignored {
return true
}
if (n.role == "generic" || n.role == "none") && n.name == "" && len(n.childIDs) == 0 {
return true
}
if n.role == "StaticText" && n.name == "" {
return true
}
return false
}
byID := map[string]axNode{}
for _, n := range nodes {
if shouldDiscard(n) {
continue
}
byID[n.nodeID] = n
}
// Colapso iterativo hasta convergencia.
for {
changed := false
removed := map[string]struct{}{}
for _, node := range byID {
if _, gone := removed[node.nodeID]; gone {
continue
}
if len(node.childIDs) != 1 {
continue
}
childID := node.childIDs[0]
child, ok := byID[childID]
if !ok || child.role != node.role {
continue
}
// Fusionar: el padre hereda los childIds del hijo.
merged := node
merged.childIDs = child.childIDs
byID[node.nodeID] = merged
removed[childID] = struct{}{}
changed = true
}
if !changed {
break
}
for id := range removed {
delete(byID, id)
}
}
// Preservar orden original.
result := make([]axNode, 0, len(byID))
seen := map[string]struct{}{}
for _, n := range nodes {
node, ok := byID[n.nodeID]
if !ok {
continue
}
if _, dup := seen[n.nodeID]; dup {
continue
}
result = append(result, node)
seen[n.nodeID] = struct{}{}
}
return result
}
// renderAXOutline convierte axNode en un outline indentado, legible y
// accionable. Puro: porta render_ax_outline.py al caracter. La jerarquia se
// reconstruye con childIDs; las raices son nodeIds que no aparecen como hijo de
// nadie (fallback al primer nodo). maxChars > 0 trunca con sufijo.
func renderAXOutline(nodes []axNode, maxChars int) string {
if len(nodes) == 0 {
return ""
}
byID := map[string]axNode{}
for _, n := range nodes {
if n.nodeID != "" {
byID[n.nodeID] = n
}
}
allChildIDs := map[string]struct{}{}
for _, n := range nodes {
for _, cid := range n.childIDs {
allChildIDs[cid] = struct{}{}
}
}
var roots []axNode
for _, n := range nodes {
if _, isChild := allChildIDs[n.nodeID]; !isChild {
roots = append(roots, n)
}
}
if len(roots) == 0 {
roots = []axNode{nodes[0]}
}
var lines []string
visited := map[string]struct{}{} // guard de ciclo: un nodeId no se renderiza dos veces
var renderNode func(node axNode, depth int)
renderNode = func(node axNode, depth int) {
nid := node.nodeID
if depth > axoMaxDepth {
return
}
if nid != "" {
if _, dup := visited[nid]; dup {
return
}
visited[nid] = struct{}{}
}
if node.ignored {
return
}
role := node.role
if _, skip := axoSkipRoles[role]; role == "" || skip {
// Nodos sin role util: elevar los hijos al nivel actual.
for _, cid := range node.childIDs {
if child, ok := byID[cid]; ok {
renderNode(child, depth)
}
}
return
}
indent := strings.Repeat(" ", depth)
var base string
if node.name != "" {
base = fmt.Sprintf("%s%s %q", indent, role, node.name)
} else {
base = indent + role
}
// Estado actual del campo (texto escrito, valor de slider/combobox).
if node.value != "" {
base += " = " + axoPyRepr(node.value)
}
// Ref accionable, sin padding.
if _, ok := axoActionableRoles[role]; ok {
ref := axoRefID(node)
if ref != "" {
base += " #ref=" + ref
}
}
lines = append(lines, base)
for _, cid := range node.childIDs {
if child, ok := byID[cid]; ok {
renderNode(child, depth+1)
}
}
}
for _, root := range roots {
renderNode(root, 0)
}
result := strings.Join(lines, "\n")
if maxChars > 0 && len(result) > maxChars {
result = strings.TrimRight(result[:maxChars], " \t\n\r\v\f")
result += "\n…[outline truncado]"
}
return result
}
// axoRefID devuelve el ref estable del nodo: backendDOMNodeId (apunta al nodo DOM
// real, estable mientras el nodo viva) con fallback al nodeId. Igual que
// _ref_id() del .py.
func axoRefID(n axNode) string {
if n.backendDOMNodeID != "" {
return n.backendDOMNodeID
}
return n.nodeID
}
// axoPyRepr replica Python repr() para strings: comillas simples por defecto;
// comillas dobles si la cadena contiene comilla simple pero no doble; escape de
// backslash y de la comilla delimitadora. Reproduce el efecto de `{value!r}`
// del render_ax_outline.py para que la salida coincida al caracter.
func axoPyRepr(s string) string {
hasSingle := strings.Contains(s, "'")
hasDouble := strings.Contains(s, "\"")
quote := byte('\'')
if hasSingle && !hasDouble {
quote = '"'
}
var b strings.Builder
b.WriteByte(quote)
for i := 0; i < len(s); i++ {
ch := s[i]
switch ch {
case '\\':
b.WriteString("\\\\")
case '\n':
b.WriteString("\\n")
case '\r':
b.WriteString("\\r")
case '\t':
b.WriteString("\\t")
case quote:
b.WriteByte('\\')
b.WriteByte(quote)
default:
b.WriteByte(ch)
}
}
b.WriteByte(quote)
return b.String()
}
+64
View File
@@ -0,0 +1,64 @@
---
id: cdp_get_ax_outline_go_browser
name: cdp_get_ax_outline
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpGetAXOutline(c *CDPConn, frameID string, maxChars int) (string, error)"
description: "Percibe la pagina (o un iframe via frameID) como outline accesible indentado y accionable reusando la conexion CDP viva del pool. Envia Accessibility.enable + getFullAXTree, poda el arbol y lo renderiza con #ref=backendDOMNodeId en roles accionables. Replica al caracter el pipeline Python cdp_get_ax_tree -> trim_ax_tree -> render_ax_outline pero nativo en Go, sin subprocess ni venv."
tags: [browser, cdp, ax, accessibility, perceive, iframe, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["TestRenderAXOutline_ActionableRoleCarriesRef", "TestRenderAXOutline_InputShowsValue", "TestRenderAXOutline_SkipRoleElevatesChildren", "TestRenderAXOutline_IndentationPerLevel", "TestRenderAXOutline_TruncationAddsSuffix", "TestTrimAXTree_DiscardsIgnored", "TestTrimAXTree_CollapsesSameRoleSingleChild", "TestAxoPyRepr", "TestAxoParseNodes"]
test_file_path: "functions/browser/cdp_get_ax_outline_test.go"
file_path: "functions/browser/cdp_get_ax_outline.go"
params:
- name: c
desc: "Conexion CDP viva (*CDPConn) del pool, ya conectada al tab/target objetivo. No abre WebSocket nuevo: reusa la del pool. Nil devuelve error."
- name: frameID
desc: "frameId CDP del iframe a percibir. Cadena vacia ('') percibe el arbol completo de la pagina (depth -1). Con valor, obtiene el AX tree DENTRO de ese iframe."
- name: maxChars
desc: "Limite de caracteres del outline. >0 trunca y añade '\\n…[outline truncado]'. <=0 = sin limite."
output: "Outline accesible multi-linea: 2 espacios de indentacion por nivel, 'role \"name\"' por nodo, ' = '\\''value'\\''' en inputs, y marcador ' #ref=<backendDOMNodeId>' en roles accionables. Cadena vacia si no hay nodos utiles."
---
## Ejemplo
```go
// c es una *CDPConn viva del pool (la misma que usa el browser_mcp).
// Percibir la pagina entera, truncando a 8000 chars:
outline, err := CdpGetAXOutline(c, "", 8000)
if err != nil {
log.Fatal(err)
}
fmt.Println(outline)
// WebArea "Example Domain"
// heading "Example Domain"
// link "More information..." #ref=128
// Percibir DENTRO de un iframe concreto (frameId del frame tree):
inner, err := CdpGetAXOutline(c, "F1A2B3C4D5E6", 0) // 0 = sin limite
```
## Cuando usarla
- Cuando necesites **percibir la pagina (o un iframe) como outline accionable** para que un LLM decida sobre `#ref` sin reventar el contexto.
- **Reemplaza el subprocess Python** `fn run cdp_perceive_outline`: es nativo Go, reusa la conexion CDP viva del pool y no arranca el venv en cada percepcion (mas rapido y sin dependencia de runtime `fn`/venv).
- Pasa `frameID` cuando el contenido objetivo vive dentro de un iframe; deja `frameID=""` para la pagina top-level.
- El `#ref` que devuelve (backendDOMNodeId) se pasa luego a `cdp_click_ref` / `cdp_type_ref` / `cdp_hover_ref`.
## Gotchas
- **Impura**: requiere un Chrome vivo con CDP accesible y el dominio `Accessibility` disponible. `Accessibility.enable` se envia siempre (idempotente).
- **Conexion nula** devuelve error inmediato; no intenta reconectar.
- **OOPIF cross-origin**: un iframe de distinto origen corre en un target (proceso) separado. Si `Accessibility.getFullAXTree` con ese `frameId` no devuelve nodos, probablemente necesites una `*CDPConn` adjunta al target del frame, no el `frameId` desde el target padre.
- **`#ref` = backendDOMNodeId**: estable mientras el nodo DOM viva, pero si la pagina re-renderiza ese subarbol el ref puede invalidarse. Percibe de nuevo tras una mutacion grande antes de actuar.
- El outline omite roles `none`/`presentation`/`ignored` y nodos `ignored=true`, y eleva sus hijos al nivel actual; un arbol con todo ignorado devuelve cadena vacia.
- Guard de profundidad 60 y guard de ciclo: arboles patologicos no cuelgan, pero pueden quedar recortados a partir de la profundidad 60.
@@ -0,0 +1,279 @@
package browser
import (
"strings"
"testing"
)
// --- renderAXOutline: casos clave portados de render_ax_outline.py ---
func TestRenderAXOutline_ActionableRoleCarriesRef(t *testing.T) {
nodes := []axNode{
{nodeID: "1", role: "WebArea", name: "Page", childIDs: []string{"2"}},
{nodeID: "2", backendDOMNodeID: "555", role: "button", name: "Submit"},
}
got := renderAXOutline(nodes, 0)
want := "WebArea \"Page\"\n button \"Submit\" #ref=555"
if got != want {
t.Errorf("got:\n%q\nwant:\n%q", got, want)
}
}
func TestRenderAXOutline_NonActionableHasNoRef(t *testing.T) {
nodes := []axNode{
{nodeID: "1", backendDOMNodeID: "9", role: "heading", name: "Title"},
}
got := renderAXOutline(nodes, 0)
if strings.Contains(got, "#ref") {
t.Errorf("rol no accionable no debe llevar #ref: %q", got)
}
if got != "heading \"Title\"" {
t.Errorf("got %q", got)
}
}
func TestRenderAXOutline_InputShowsValue(t *testing.T) {
nodes := []axNode{
{nodeID: "1", role: "form", childIDs: []string{"2"}},
{nodeID: "2", backendDOMNodeID: "42", role: "textbox", name: "Email", value: "a@b.com"},
}
got := renderAXOutline(nodes, 0)
want := "form\n textbox \"Email\" = 'a@b.com' #ref=42"
if got != want {
t.Errorf("got:\n%q\nwant:\n%q", got, want)
}
}
func TestRenderAXOutline_ValueWithSingleQuoteUsesDoubleQuote(t *testing.T) {
// Python repr: "it's" -> "it's" (comilla doble como delimitador).
nodes := []axNode{
{nodeID: "1", backendDOMNodeID: "7", role: "textbox", value: "it's"},
}
got := renderAXOutline(nodes, 0)
want := "textbox = \"it's\" #ref=7"
if got != want {
t.Errorf("got %q want %q", got, want)
}
}
func TestRenderAXOutline_SkipRoleElevatesChildren(t *testing.T) {
// El nodo 'none' se omite; su hijo button sube al nivel del padre (depth 1,
// no depth 2), porque el render del skip-node reusa el mismo depth.
nodes := []axNode{
{nodeID: "1", role: "WebArea", name: "Root", childIDs: []string{"2"}},
{nodeID: "2", role: "none", childIDs: []string{"3"}},
{nodeID: "3", backendDOMNodeID: "30", role: "button", name: "Go"},
}
got := renderAXOutline(nodes, 0)
want := "WebArea \"Root\"\n button \"Go\" #ref=30"
if got != want {
t.Errorf("got:\n%q\nwant:\n%q", got, want)
}
}
func TestRenderAXOutline_EmptyRoleElevatesChildren(t *testing.T) {
nodes := []axNode{
{nodeID: "1", role: "", childIDs: []string{"2"}}, // sin role: se omite
{nodeID: "2", backendDOMNodeID: "20", role: "link", name: "Home"},
}
got := renderAXOutline(nodes, 0)
// El nodo raiz sin role eleva su hijo a depth 0.
want := "link \"Home\" #ref=20"
if got != want {
t.Errorf("got %q want %q", got, want)
}
}
func TestRenderAXOutline_IndentationPerLevel(t *testing.T) {
nodes := []axNode{
{nodeID: "1", role: "WebArea", name: "A", childIDs: []string{"2"}},
{nodeID: "2", role: "group", name: "B", childIDs: []string{"3"}},
{nodeID: "3", role: "group", name: "C"},
}
got := renderAXOutline(nodes, 0)
want := "WebArea \"A\"\n group \"B\"\n group \"C\""
if got != want {
t.Errorf("got:\n%q\nwant:\n%q", got, want)
}
}
func TestRenderAXOutline_TruncationAddsSuffix(t *testing.T) {
nodes := []axNode{
{nodeID: "1", role: "WebArea", name: "AAAAAAAAAAAAAAAAAAAA"},
}
got := renderAXOutline(nodes, 10)
if !strings.HasSuffix(got, "\n…[outline truncado]") {
t.Errorf("falta sufijo de truncado: %q", got)
}
// El cuerpo truncado (sin sufijo) no debe exceder los 10 chars.
body := strings.TrimSuffix(got, "\n…[outline truncado]")
if len([]byte(body)) > 10 {
t.Errorf("cuerpo truncado mas largo que maxChars: %q (%d bytes)", body, len(body))
}
}
func TestRenderAXOutline_NoTruncationWhenUnderLimit(t *testing.T) {
nodes := []axNode{{nodeID: "1", role: "button", name: "X", backendDOMNodeID: "1"}}
got := renderAXOutline(nodes, 1000)
if strings.Contains(got, "truncado") {
t.Errorf("no debe truncar bajo el limite: %q", got)
}
}
func TestRenderAXOutline_Empty(t *testing.T) {
if got := renderAXOutline(nil, 0); got != "" {
t.Errorf("nil -> %q, want vacio", got)
}
}
func TestRenderAXOutline_RefFallsBackToNodeID(t *testing.T) {
// Sin backendDOMNodeId, el #ref usa el nodeId.
nodes := []axNode{
{nodeID: "77", role: "button", name: "Fallback"},
}
got := renderAXOutline(nodes, 0)
want := "button \"Fallback\" #ref=77"
if got != want {
t.Errorf("got %q want %q", got, want)
}
}
func TestRenderAXOutline_CycleGuard(t *testing.T) {
// Ciclo 1 -> 2 -> 1: no debe colgar ni duplicar nodos.
nodes := []axNode{
{nodeID: "1", role: "group", name: "A", childIDs: []string{"2"}},
{nodeID: "2", role: "group", name: "B", childIDs: []string{"1"}},
}
got := renderAXOutline(nodes, 0)
if strings.Count(got, "group \"A\"") != 1 {
t.Errorf("nodo A renderizado mas de una vez: %q", got)
}
}
// --- trimAXTree: casos clave portados de trim_ax_tree.py ---
func TestTrimAXTree_DiscardsIgnored(t *testing.T) {
nodes := []axNode{
{nodeID: "1", role: "button", name: "Keep"},
{nodeID: "2", role: "button", name: "Drop", ignored: true},
}
got := trimAXTree(nodes)
if len(got) != 1 || got[0].nodeID != "1" {
t.Errorf("trim debe descartar ignored: %+v", got)
}
}
func TestTrimAXTree_DiscardsEmptyGeneric(t *testing.T) {
nodes := []axNode{
{nodeID: "1", role: "generic"}, // sin name ni childIds -> descartado
{nodeID: "2", role: "none"}, // idem
{nodeID: "3", role: "StaticText", name: ""}, // staticText vacio -> descartado
{nodeID: "4", role: "StaticText", name: "Hola"},
}
got := trimAXTree(nodes)
if len(got) != 1 || got[0].nodeID != "4" {
t.Errorf("trim debe descartar generic/none/staticText vacios: %+v", got)
}
}
func TestTrimAXTree_KeepsGenericWithChildren(t *testing.T) {
nodes := []axNode{
{nodeID: "1", role: "generic", childIDs: []string{"2"}}, // tiene hijos -> se queda
{nodeID: "2", role: "button", name: "X"},
}
got := trimAXTree(nodes)
if len(got) != 2 {
t.Errorf("generic con hijos debe conservarse: %+v", got)
}
}
func TestTrimAXTree_CollapsesSameRoleSingleChild(t *testing.T) {
// list -> list (1 hijo, mismo role): se fusiona, el padre hereda los childIds.
nodes := []axNode{
{nodeID: "1", role: "list", childIDs: []string{"2"}},
{nodeID: "2", role: "list", childIDs: []string{"3"}},
{nodeID: "3", role: "listitem", name: "item"},
}
got := trimAXTree(nodes)
// Nodo 2 desaparece; nodo 1 debe apuntar ahora a 3.
var saw1, saw2 bool
var node1 axNode
for _, n := range got {
if n.nodeID == "1" {
saw1 = true
node1 = n
}
if n.nodeID == "2" {
saw2 = true
}
}
if !saw1 || saw2 {
t.Fatalf("colapso fallido: saw1=%v saw2=%v got=%+v", saw1, saw2, got)
}
if len(node1.childIDs) != 1 || node1.childIDs[0] != "3" {
t.Errorf("padre fusionado debe heredar childIds del hijo: %+v", node1.childIDs)
}
}
func TestTrimAXTree_PreservesOrder(t *testing.T) {
nodes := []axNode{
{nodeID: "3", role: "button", name: "C"},
{nodeID: "1", role: "button", name: "A"},
{nodeID: "2", role: "button", name: "B"},
}
got := trimAXTree(nodes)
if len(got) != 3 || got[0].nodeID != "3" || got[1].nodeID != "1" || got[2].nodeID != "2" {
t.Errorf("orden original no preservado: %+v", got)
}
}
func TestTrimAXTree_Empty(t *testing.T) {
if got := trimAXTree(nil); got != nil {
t.Errorf("nil -> %+v, want nil", got)
}
}
// --- axoPyRepr: paridad con Python repr() ---
func TestAxoPyRepr(t *testing.T) {
cases := []struct{ in, want string }{
{"hola", "'hola'"},
{"it's", "\"it's\""}, // tiene ', no " -> delimitador "
{"say \"hi\"", "'say \"hi\"'"}, // tiene " -> delimitador '
{"both ' and \"", "'both \\' and \"'"}, // ambos -> ' con escape del '
{"a\nb", "'a\\nb'"},
{"back\\slash", "'back\\\\slash'"},
}
for _, c := range cases {
if got := axoPyRepr(c.in); got != c.want {
t.Errorf("axoPyRepr(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// --- axoParseNodes: extraccion del map CDP (numeros como float64) ---
func TestAxoParseNodes(t *testing.T) {
result := map[string]any{
"nodes": []any{
map[string]any{
"nodeId": "1",
"backendDOMNodeId": float64(555), // CDP int llega como float64
"ignored": false,
"role": map[string]any{"value": "button"},
"name": map[string]any{"value": "Go"},
"value": map[string]any{"value": "x"},
"childIds": []any{"2", "3"},
},
},
}
got := axoParseNodes(result)
if len(got) != 1 {
t.Fatalf("got %d nodos, want 1", len(got))
}
n := got[0]
if n.nodeID != "1" || n.backendDOMNodeID != "555" || n.role != "button" ||
n.name != "Go" || n.value != "x" || len(n.childIDs) != 2 {
t.Errorf("parse incorrecto: %+v", n)
}
}
+63
View File
@@ -0,0 +1,63 @@
package browser
// CdpCookie representa una cookie del browser tal como la devuelve CDP.
type CdpCookie struct {
Name string `json:"name"`
Value string `json:"value"`
Domain string `json:"domain"`
Path string `json:"path"`
Expires float64 `json:"expires"`
HTTPOnly bool `json:"httpOnly"`
Secure bool `json:"secure"`
SameSite string `json:"sameSite"`
}
// cookieFromMap convierte un map[string]any CDP a CdpCookie con casts defensivos.
func cookieFromMap(m map[string]any) CdpCookie {
c := CdpCookie{}
if v, ok := m["name"].(string); ok {
c.Name = v
}
if v, ok := m["value"].(string); ok {
c.Value = v
}
if v, ok := m["domain"].(string); ok {
c.Domain = v
}
if v, ok := m["path"].(string); ok {
c.Path = v
}
if v, ok := m["expires"].(float64); ok {
c.Expires = v
}
if v, ok := m["httpOnly"].(bool); ok {
c.HTTPOnly = v
}
if v, ok := m["secure"].(bool); ok {
c.Secure = v
}
if v, ok := m["sameSite"].(string); ok {
c.SameSite = v
}
return c
}
// CdpGetCookies devuelve todas las cookies del browser via Network.getAllCookies.
// El caller puede filtrar por dominio, nombre, etc. sobre el slice retornado.
func CdpGetCookies(c *CDPConn) ([]CdpCookie, error) {
if _, err := c.sendCDP("Network.enable", nil); err != nil {
return nil, err
}
result, err := c.sendCDP("Network.getAllCookies", nil)
if err != nil {
return nil, err
}
raw, _ := result["cookies"].([]any)
cookies := make([]CdpCookie, 0, len(raw))
for _, item := range raw {
if m, ok := item.(map[string]any); ok {
cookies = append(cookies, cookieFromMap(m))
}
}
return cookies, nil
}
+59
View File
@@ -0,0 +1,59 @@
---
id: cdp_get_cookies_go_browser
name: cdp_get_cookies
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Devuelve todas las cookies del browser via Network.getAllCookies; el caller filtra por dominio o nombre sobre el slice []CdpCookie."
tags: [cdp, browser, cookie, network, navegator]
signature: "func CdpGetCookies(c *CDPConn) ([]CdpCookie, error)"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_get_cookies.go"
example: |
conn, _ := CdpConnect(9222)
cookies, err := CdpGetCookies(conn)
if err != nil { log.Fatal(err) }
for _, ck := range cookies {
if ck.Domain == "app.example.com" {
fmt.Printf("name=%s value=%s httpOnly=%v\n", ck.Name, ck.Value, ck.HTTPOnly)
}
}
params:
- name: c
desc: "Conexion CDP activa al browser (obtenida con CdpConnect)"
output: "Slice de CdpCookie con todas las cookies del browser; error si falla la comunicacion CDP."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
cookies, err := CdpGetCookies(conn)
if err != nil {
log.Fatal(err)
}
for _, ck := range cookies {
if ck.Domain == "app.example.com" {
fmt.Printf("name=%s value=%s httpOnly=%v\n", ck.Name, ck.Value, ck.HTTPOnly)
}
}
```
## Cuando usarla
Usar cuando necesitas inspeccionar el estado de cookies del browser tras un login CDP, antes de propagarlas a otro contexto, o para auditar sesiones activas en tests e2e.
## Gotchas
- Llama `Network.enable` internamente antes de `getAllCookies`; es idempotente pero suma latencia en la primera llamada.
- `Network.getAllCookies` devuelve cookies de TODOS los dominios del browser, no solo la tab activa. Filtrar por `Domain` en el caller.
- Las cookies HttpOnly son visibles via CDP aunque no lo sean desde JavaScript del browser.
- `Expires == -1` indica cookie de sesion (sin fecha de expiración).
+23
View File
@@ -0,0 +1,23 @@
package browser
import (
"fmt"
)
// CdpGetFrameHTML retorna el HTML completo (outerHTML del documentElement) de un iframe
// especifico usando CdpEvalInFrame con la expresion "document.documentElement.outerHTML".
func CdpGetFrameHTML(c *CDPConn, frameID string) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp get frame html: conexion nula")
}
if frameID == "" {
return "", fmt.Errorf("cdp get frame html: frameID vacio")
}
html, err := CdpEvalInFrame(c, frameID, "document.documentElement.outerHTML")
if err != nil {
return "", fmt.Errorf("cdp get frame html: %w", err)
}
return html, nil
}
+70
View File
@@ -0,0 +1,70 @@
---
id: cdp_get_frame_html_go_browser
name: cdp_get_frame_html
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Devuelve el HTML completo (document.documentElement.outerHTML) de un iframe concreto componiendo sobre CdpEvalInFrame con un mundo aislado CDP."
tags: [cdp, browser, iframe, html, scraping, navegator]
signature: "func CdpGetFrameHTML(c *CDPConn, frameID string) (string, error)"
uses_functions: [cdp_eval_in_frame_go_browser]
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_get_frame_html.go"
example: |
conn, _ := CdpConnect("localhost", 9222, "")
frames, _ := CdpListFrames(conn)
html, err := CdpGetFrameHTML(conn, frames[1].ID)
fmt.Println(html[:200]) // primeros 200 chars del HTML del iframe
params:
- name: c
desc: "Conexión CDP activa obtenida con CdpConnect."
- name: frameID
desc: "ID del frame cuyo HTML se quiere obtener; obtenido de CdpListFrames (campo CdpFrame.ID)."
output: "String con el HTML completo del iframe (outerHTML del documentElement); error si la conexión es nula, el frameID está vacío o la evaluación CDP falla."
---
## Ejemplo
```go
conn, err := CdpConnect("localhost", 9222, "")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// 1. Listar frames para obtener el ID del iframe deseado
frames, err := CdpListFrames(conn)
if err != nil {
log.Fatal(err)
}
// frames[0] = frame raíz, frames[1] = primer iframe
for _, f := range frames {
if f.ParentID != "" { // es un iframe, no el raíz
html, err := CdpGetFrameHTML(conn, f.ID)
if err != nil {
log.Printf("error en frame %s: %v", f.ID, err)
continue
}
fmt.Printf("=== iframe %s (%s) ===\n%s\n", f.ID, f.URL, html[:min(500, len(html))])
}
}
```
## Cuando usarla
Cuando necesites el HTML completo de un iframe para parsearlo, scrapearlo o inspeccionarlo. Flujo típico: `CdpListFrames` → elegir frame por URL → `CdpGetFrameHTML` → parsear con `golang.org/x/net/html` o regexp.
## Gotchas
- El mundo aislado ve el DOM pero NO las variables JS del page-world del iframe; suficiente para leer `outerHTML` y hacer scraping estructural.
- `frameID` debe obtenerse de `CdpListFrames`; un ID obsoleto (frame recargado) provoca error en `CdpEvalInFrame`.
- Para iframes con contenido dinámico (renderizado por JS), espera a que el iframe termine de cargar antes de llamar a esta función; de lo contrario el HTML puede estar incompleto.
- En páginas con muchos iframes pesados, el outerHTML puede ser muy grande (MBs); considera evaluar selectores más específicos con `CdpEvalInFrame` si solo necesitas parte del DOM.
+11 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "func CdpGetHTML(c *CDPConn) (string, error)"
description: "Retorna el HTML completo de la pagina actual (document.documentElement.outerHTML) via Runtime.evaluate. Captura el DOM vivo post-JavaScript, no el HTML fuente original."
tags: [chrome, cdp, browser, automation, html, dom, scraping, devtools]
tags: [chrome, cdp, browser, automation, html, dom, scraping, devtools, navegator]
uses_functions: [cdp_connect_go_browser, cdp_evaluate_go_browser]
uses_types: []
returns: []
@@ -35,6 +35,16 @@ html, err := CdpGetHTML(conn)
// html contiene el DOM completo con todos los cambios JS aplicados
```
## Cuando usarla
Cuando necesites el HTML completo del DOM vivo (post-JavaScript) para parsear/extraer con un selector externo, guardar un snapshot fiel, o alimentar un parser HTML. Ideal para scraping de SPAs (React, Vue, Angular) donde el HTML fuente original está vacío.
## Gotchas
- **Devuelve el HTML COMPLETO sin límite, a propósito**: no trunca ni resume. En páginas complejas pueden ser cientos de KB. Esto es deliberado: su trabajo es dar el DOM íntegro para parsing fiel, no un resumen.
- **NO usar para alimentar un LLM directamente**: el HTML crudo quema tokens y trae ruido (scripts, estilos inline, atributos). Para contexto de modelo usa `cdp_get_text` (innerText, con `maxBytes` opcional) o `cdp_perceive_outline` (outline accesible con #refs accionables). Reserva `cdp_get_html` para parsing programático.
- **Es el DOM actual, no el HTML fuente**: incluye los cambios que el JavaScript haya aplicado hasta el instante de la llamada. Si la página sigue hidratando, espera con `cdp_wait_idle` antes.
## Notas
A diferencia de `Page.getResourceContent`, esta funcion captura el estado actual del DOM incluyendo modificaciones hechas por JavaScript. Ideal para scraping de SPAs (React, Vue, Angular). El HTML retornado puede ser muy largo para paginas complejas.
+54
View File
@@ -0,0 +1,54 @@
package browser
import (
"encoding/json"
"fmt"
"unicode/utf8"
)
// CdpGetText retorna el texto visible (innerText) de la pagina o de un elemento.
// Si selector es "" lee document.body.innerText completo.
// Si selector no matchea ningun elemento retorna error.
// Si maxBytes > 0 trunca al limite dado (corte rune-safe) y añade sufijo con total original.
// Si maxBytes <= 0 no hay limite.
func CdpGetText(c *CDPConn, selector string, maxBytes int) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp get text: conexion nula")
}
var expr string
if selector == "" {
expr = `document.body ? document.body.innerText : ""`
} else {
// Escapa el selector como string JSON para evitar inyeccion via comillas/backslash.
selectorJSON, err := json.Marshal(selector)
if err != nil {
return "", fmt.Errorf("cdp get text: escapar selector: %w", err)
}
expr = fmt.Sprintf(
`(function(){var e=document.querySelector(%s); return e ? e.innerText : "__FN_GET_TEXT_NOTFOUND__";})()`,
string(selectorJSON),
)
}
text, err := CdpEvaluate(c, expr)
if err != nil {
return "", fmt.Errorf("cdp get text: %w", err)
}
if selector != "" && text == "__FN_GET_TEXT_NOTFOUND__" {
return "", fmt.Errorf("cdp get text: elemento no encontrado: %s", selector)
}
if maxBytes > 0 && len(text) > maxBytes {
total := len(text)
// Corte rune-safe: retrocede hasta encontrar un rune valido completo.
cut := maxBytes
for cut > 0 && !utf8.RuneStart(text[cut]) {
cut--
}
text = text[:cut] + fmt.Sprintf("\n…[truncado, total %d bytes]", total)
}
return text, nil
}
+59
View File
@@ -0,0 +1,59 @@
---
name: cdp_get_text
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpGetText(c *CDPConn, selector string, maxBytes int) (string, error)"
description: "Retorna el texto visible (innerText) de la pagina o de un elemento CSS, con truncado opcional. Alternativa compacta a cdp_get_html cuando solo se necesita el texto legible."
tags: [cdp, browser, read, perception, navegator]
uses_functions: [cdp_evaluate_go_browser]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [encoding/json, fmt, unicode/utf8]
params:
- name: c
desc: "Conexion CDP activa a una tab de Chrome. Debe estar conectada a una tab tipo 'page'."
- name: selector
desc: "Selector CSS del elemento del que leer el innerText. Si es cadena vacia, lee document.body.innerText (toda la pagina)."
- name: maxBytes
desc: "Limite maximo de bytes del texto retornado. Si es <= 0 no hay limite. Si el texto supera el limite, se trunca con corte rune-safe y se añade un sufijo con el total original."
output: "Texto visible del elemento o de toda la pagina. Si maxBytes > 0 y el texto supera el limite, retorna el texto truncado con sufijo '…[truncado, total N bytes]'. Error si el selector no matchea ningun elemento o si la conexion falla."
tested: false
tests: []
test_file_path: ""
file_path: "functions/browser/cdp_get_text.go"
---
## Ejemplo
```go
// Leer todo el body con limite de 20000 bytes (apto para LLM)
text, err := CdpGetText(conn, "", 20000)
if err != nil {
log.Fatal(err)
}
fmt.Println(text)
// Leer un elemento concreto sin limite
price, err := CdpGetText(conn, ".product-price", 0)
if err != nil {
// err contiene "elemento no encontrado: .product-price" si no existe en el DOM
log.Fatal(err)
}
fmt.Println(price)
```
## Cuando usarla
Para que un LLM lea el contenido de una pagina sin reventar su ventana de contexto. Preferir sobre `cdp_get_html` cuando solo necesitas el texto — innerText es 5-50x mas compacto que el HTML crudo. Usar `selector` para acotar a la seccion relevante (articulo, tabla, formulario) y `maxBytes` para garantizar el presupuesto de tokens.
## Gotchas
- `innerText` solo devuelve el texto de nodos visibles: elementos con `display:none` o `visibility:hidden` quedan excluidos. Si necesitas leer contenido oculto usa `cdp_get_html` y parsea.
- El truncado corta en boundary de rune pero puede partir a media frase o a medio parrafo. Si necesitas preservar estructura semantica, ajusta `maxBytes` con margen o usa el selector para acotar la region.
- Requiere conexion activa a una tab de tipo `page` (no `background_page`, no `service_worker`). Tabs en estado de carga pueden devolver texto parcial; esperar con `cdp_wait_load` si el contenido es dinamico.
- El selector se escapa via `json.Marshal` — caracteres especiales como comillas simples, backslash o comillas dobles en el selector CSS son seguros.
@@ -0,0 +1,44 @@
package browser
import (
"fmt"
"unicode/utf8"
)
// CdpGetTextInFrame retorna el texto visible (innerText) del documento de un
// iframe especifico, componiendo sobre CdpEvalInFrame con un mundo aislado CDP.
//
// Lee document.body.innerText (cae a document.documentElement.innerText si no
// hay body), evitando parsear HTML crudo. Replica la politica de truncado de
// CdpGetText: si maxBytes > 0 trunca al limite dado con corte rune-safe y añade
// un sufijo con el total original en bytes; si maxBytes <= 0 no hay limite.
//
// Propaga los errores de CdpEvalInFrame (frame inexistente, contexto caducado)
// envueltos con %w.
func CdpGetTextInFrame(c *CDPConn, frameID string, maxBytes int) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp get text in frame: conexion nula")
}
if frameID == "" {
return "", fmt.Errorf("cdp get text in frame: frameID vacio")
}
const expr = `(document.body ? document.body.innerText : document.documentElement.innerText) || ""`
text, err := CdpEvalInFrame(c, frameID, expr)
if err != nil {
return "", fmt.Errorf("cdp get text in frame: %w", err)
}
if maxBytes > 0 && len(text) > maxBytes {
total := len(text)
// Corte rune-safe: retrocede hasta encontrar un rune valido completo.
cut := maxBytes
for cut > 0 && !utf8.RuneStart(text[cut]) {
cut--
}
text = text[:cut] + fmt.Sprintf("\n…[truncado, total %d bytes]", total)
}
return text, nil
}
@@ -0,0 +1,73 @@
---
id: cdp_get_text_in_frame_go_browser
name: cdp_get_text_in_frame
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Devuelve el texto visible (innerText) del documento de un iframe concreto componiendo sobre CdpEvalInFrame en un mundo aislado CDP, sin parsear HTML crudo. Trunca a maxBytes con corte rune-safe igual que CdpGetText."
tags: [browser, cdp, iframe, frame, text, navegator]
signature: "func CdpGetTextInFrame(c *CDPConn, frameID string, maxBytes int) (string, error)"
uses_functions: [cdp_eval_in_frame_go_browser]
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_get_text_in_frame.go"
example: |
conn, _ := CdpConnect("localhost", 9222, "")
frames, _ := CdpListFrames(conn)
text, err := CdpGetTextInFrame(conn, frames[1].ID, 4096)
fmt.Println(text) // texto visible del primer iframe, truncado a 4096 bytes
params:
- name: c
desc: "Conexión CDP activa obtenida con CdpConnect."
- name: frameID
desc: "ID del frame cuyo texto visible se quiere leer; obtenido de CdpListFrames (campo CdpFrame.ID)."
- name: maxBytes
desc: "Límite de bytes del texto devuelto. Si maxBytes > 0 trunca con corte rune-safe y añade un sufijo con el total original; si maxBytes <= 0 no hay límite."
output: "String con el innerText visible del documento del iframe (document.body.innerText, o document.documentElement.innerText si no hay body), opcionalmente truncado a maxBytes; error si la conexión es nula, el frameID está vacío o la evaluación CDP del frame falla."
---
## Ejemplo
```go
conn, err := CdpConnect("localhost", 9222, "")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// 1. Listar frames para localizar el iframe deseado
frames, err := CdpListFrames(conn)
if err != nil {
log.Fatal(err)
}
// 2. Leer el texto visible de cada iframe (saltando el frame raíz)
for _, f := range frames {
if f.ParentID == "" { // frame raíz, no es un iframe
continue
}
text, err := CdpGetTextInFrame(conn, f.ID, 4096)
if err != nil {
log.Printf("error en frame %s: %v", f.ID, err)
continue
}
fmt.Printf("=== iframe %s (%s) ===\n%s\n", f.ID, f.URL, text)
}
```
## Cuando usarla
Cuando necesites leer los datos visibles dentro de un iframe sin parsear HTML crudo: extraer el contenido textual de un widget embebido, un panel de pago, un captcha de texto o cualquier documento dentro de un `<iframe>`. Flujo típico: `CdpListFrames` → elegir frame por URL → `CdpGetTextInFrame`. Para HTML estructural completo usa `CdpGetFrameHTML`; para texto visible usa esta.
## Gotchas
- Impura: el frame debe existir y haber terminado de cargar. Un `frameID` obsoleto (frame recargado/navegado) o un frame aún sin cargar propaga el error de `CdpEvalInFrame`.
- Cross-origin OOPIF (out-of-process iframe): el mundo aislado puede vivir en un contexto distinto; si el frame es de otro origen y aislado del proceso, la lectura puede fallar o requerir el `frameID` exacto del OOPIF.
- `innerText` omite el texto oculto por CSS (`display:none`, `visibility:hidden`) y colapsa espacios; refleja lo *visible*, no el contenido literal del DOM. Si necesitas todo el texto del DOM usa `textContent` vía `CdpEvalInFrame`, o el HTML completo vía `CdpGetFrameHTML`.
- El corte por `maxBytes` es rune-safe pero ciego al contenido: puede cortar a mitad de una palabra o de una línea.
@@ -0,0 +1,21 @@
package browser
import (
"testing"
)
// TestCdpGetTextInFrame_guards cubre las precondiciones sin necesitar Chrome vivo.
// La lectura real del innerText de un iframe requiere una conexion CDP activa y
// un frame cargado, igual que los demas tests del paquete que la dejan gated.
func TestCdpGetTextInFrame_guards(t *testing.T) {
t.Run("conexion nula", func(t *testing.T) {
if _, err := CdpGetTextInFrame(nil, "f1", 0); err == nil {
t.Fatal("esperaba error con conexion nula")
}
})
t.Run("frameID vacio", func(t *testing.T) {
if _, err := CdpGetTextInFrame(&CDPConn{}, "", 0); err == nil {
t.Fatal("esperaba error con frameID vacio")
}
})
}
+106
View File
@@ -0,0 +1,106 @@
package browser
import (
"fmt"
"sync"
)
// DialogLog acumula lo que CdpHandleDialog auto-respondió. El worker lo rellena en
// cada diálogo; el caller lo lee con Snapshot() de forma segura (mutex interno).
// Los campos son públicos para inspección directa en tests controlados, pero en
// concurrencia usa siempre Snapshot() para evitar data races.
type DialogLog struct {
mu sync.Mutex
Count int // número de diálogos auto-respondidos
LastType string // tipo del último diálogo: alert|confirm|prompt|beforeunload
LastMessage string // mensaje del último diálogo
}
// record registra un diálogo auto-respondido. Es el núcleo puro (no toca CDP).
func (l *DialogLog) record(dialogType, message string) {
l.mu.Lock()
l.Count++
l.LastType = dialogType
l.LastMessage = message
l.mu.Unlock()
}
// Snapshot devuelve una copia consistente del estado actual del log.
func (l *DialogLog) Snapshot() (count int, lastType, lastMessage string) {
l.mu.Lock()
defer l.mu.Unlock()
return l.Count, l.LastType, l.LastMessage
}
// dialogJobBuffer es el tamaño del canal que desacopla el readLoop del worker
// que responde diálogos. Amplio para absorber ráfagas sin bloquear la lectura
// del WebSocket.
const dialogJobBuffer = 64
// CdpHandleDialog instala un auto-handler que responde automaticamente a todos
// los dialogos JS (alert, confirm, prompt, beforeunload) hasta que se llame la
// funcion cancel devuelta. Usa el evento Page.javascriptDialogOpening y
// Page.handleJavaScriptDialog del protocolo CDP.
//
// Devuelve, además del cancel, un *DialogLog que el handler rellena en cada
// diálogo: así el caller sabe cuántos diálogos se auto-respondieron y cuál fue
// el último (tipo + mensaje).
//
// Concurrencia: el handler de evento corre en la goroutine de lectura del
// WebSocket y NO puede llamar sendCDP de forma síncrona (deadlock). En vez de
// lanzar una goroutine nueva por diálogo (spawn ilimitado), encola el evento en
// un canal con buffer que consume UN único worker; el worker serializa las
// respuestas. cancel() detiene el worker y des-registra el handler; es
// idempotente (seguro llamarlo varias veces).
func CdpHandleDialog(c *CDPConn, accept bool, promptText string) (func(), *DialogLog, error) {
if c == nil {
return nil, nil, fmt.Errorf("cdp handle dialog: conexion nula")
}
if _, err := c.sendCDP("Page.enable", nil); err != nil {
return nil, nil, fmt.Errorf("cdp handle dialog: %w", err)
}
dlog := &DialogLog{}
jobs := make(chan map[string]any, dialogJobBuffer)
done := make(chan struct{})
// Worker único: serializa las respuestas a diálogos. Una sola goroutine para
// toda la vida del handler, no una por diálogo.
go func() {
for {
select {
case params := <-jobs:
dtype, _ := params["type"].(string)
msg, _ := params["message"].(string)
dlog.record(dtype, msg)
p := map[string]any{"accept": accept}
if promptText != "" {
p["promptText"] = promptText
}
_, _ = c.sendCDP("Page.handleJavaScriptDialog", p)
case <-done:
return
}
}
}()
cancelEvent := c.OnEvent("Page.javascriptDialogOpening", func(_ string, params map[string]any) {
// Encolar sin bloquear el readLoop. Si el buffer está lleno (tormenta de
// diálogos), descartamos ese evento para no colgar la conexión entera.
select {
case jobs <- params:
default:
}
})
var once sync.Once
cancel := func() {
once.Do(func() {
cancelEvent()
close(done)
})
}
return cancel, dlog, nil
}
+77
View File
@@ -0,0 +1,77 @@
---
id: cdp_handle_dialog_go_browser
name: cdp_handle_dialog
kind: function
lang: go
domain: browser
purity: impure
version: 1.1.0
tested: true
tests: ["TestCdpHandleDialog_nilConn", "TestDialogLog"]
test_file_path: "functions/browser/cdp_handle_dialog_test.go"
description: "Instala un auto-handler que responde automaticamente a dialogos JS (alert/confirm/prompt/beforeunload) via Page.javascriptDialogOpening CDP hasta que se llame el cancel devuelto. Devuelve un *DialogLog con Count/LastType/LastMessage de lo auto-respondido. Un unico worker serializa las respuestas (no spawnea una goroutine por dialogo)."
tags: [cdp, browser, dialog, input, navegator]
signature: "func CdpHandleDialog(c *CDPConn, accept bool, promptText string) (func(), *DialogLog, error)"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_handle_dialog.go"
example: |
// Aceptar automaticamente confirm() antes de navegar
cancel, _ := CdpHandleDialog(c, true, "")
defer cancel()
_ = CdpClick(c, "#delete-account-btn")
_ = CdpWaitIdle(c, 2000)
params:
- name: c
desc: "Conexion CDP activa obtenida con CdpConnect."
- name: accept
desc: "true para aceptar/OK el dialogo; false para rechazar/Cancel. Para alert() el valor no importa (siempre se cierra), para confirm() determina el valor de retorno, para prompt() determina si se devuelve el texto o null."
- name: promptText
desc: "Texto a inyectar en dialogos prompt(). Vacio string para no inyectar texto. Ignorado en alert() y confirm()."
output: "(cancel func(), *DialogLog, error): cancel des-registra el handler y detiene el worker (idempotente, seguro llamarlo varias veces); DialogLog acumula Count/LastType/LastMessage de lo auto-respondido (leer con Snapshot()); error si la conexion es nula o Page.enable falla."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
_ = CdpNavigate(conn, "https://example.com/admin")
_ = CdpWaitLoad(conn, 3*time.Second)
// Instalar handler antes de la accion que dispara el dialogo
cancel, dlog, err := CdpHandleDialog(conn, true, "")
if err != nil {
log.Fatal(err)
}
defer cancel()
// Este boton dispara confirm("¿Seguro que quieres borrar?")
// El handler lo acepta automaticamente sin bloquear
_ = CdpClick(conn, "#btn-delete-all")
_ = CdpWaitIdle(conn, CdpWaitIdleOpts{})
// Saber qué se auto-respondió
count, lastType, lastMsg := dlog.Snapshot()
fmt.Printf("auto-respondidos: %d (último %s: %q)\n", count, lastType, lastMsg)
```
## Cuando usarla
Instalar antes de cualquier accion que pueda disparar `alert()`, `confirm()`, `prompt()` o `beforeunload` en la pagina. Sin este handler, el dialogo bloquea el tab del navegador indefinidamente y todas las llamadas CDP siguientes se quedan colgadas esperando. Imprescindible en scraping de paneles de administracion, flujos de borrado con confirmacion, y paginas con `beforeunload` que pregunta si quieres salir.
## Gotchas
- DEADLOCK GARANTIZADO si se llama `sendCDP` de forma sincrona dentro del handler de evento. El handler corre en la goroutine de lectura del WebSocket; `sendCDP` espera una respuesta que esa misma goroutine deberia leer. La implementacion encola el evento en un canal y lo responde desde UN worker aparte — no modificar este patron.
- **Un único worker, no goroutine por diálogo**: el handler antiguo hacía `go c.sendCDP(...)` por cada diálogo (spawn ilimitado). Ahora encola en un canal con buffer (64) que consume un worker. Si la página dispara una tormenta de diálogos que llena el buffer, los excedentes se descartan (no se responden) para no colgar la conexión — caso patológico, raro en la práctica.
- **Leer el log con `Snapshot()`**: `DialogLog` tiene mutex interno. En concurrencia, usa `dlog.Snapshot()` en vez de leer los campos públicos directamente (evita data race con el worker).
- El handler responde todos los diálogos con los mismos `accept` y `promptText` hasta que se llame `cancel()`.
- `cancel()` es idempotente (seguro llamarlo varias veces) y detiene el worker. No cierra diálogos ya abiertos; solo evita responder los futuros.
- Para `beforeunload`, `accept: true` permite la navegacion y `accept: false` la bloquea.
## Capability growth log
- v1.1.0 (2026-06-06) — devuelve `*DialogLog` (Count/LastType/LastMessage) para que el caller sepa qué se auto-respondió; reemplaza el spawn de una goroutine por diálogo por un worker único alimentado por canal con buffer; `cancel()` ahora idempotente vía sync.Once.
@@ -0,0 +1,55 @@
package browser
import (
"sync"
"testing"
)
// TestCdpHandleDialog_nilConn cubre la precondición sin Chrome.
func TestCdpHandleDialog_nilConn(t *testing.T) {
_, _, err := CdpHandleDialog(nil, true, "")
if err == nil {
t.Fatal("esperaba error con conexion nula")
}
}
// TestDialogLog cubre el núcleo puro del registro de diálogos: contar, recordar
// el último, y la seguridad concurrente del mutex. No requiere Chrome.
func TestDialogLog(t *testing.T) {
t.Run("golden: cuenta y recuerda el ultimo", func(t *testing.T) {
l := &DialogLog{}
l.record("alert", "hola")
l.record("confirm", "¿seguro?")
count, lastType, lastMsg := l.Snapshot()
if count != 2 {
t.Errorf("count = %d, esperaba 2", count)
}
if lastType != "confirm" || lastMsg != "¿seguro?" {
t.Errorf("last = (%q,%q), esperaba (confirm, ¿seguro?)", lastType, lastMsg)
}
})
t.Run("edge: log vacio", func(t *testing.T) {
l := &DialogLog{}
count, lastType, lastMsg := l.Snapshot()
if count != 0 || lastType != "" || lastMsg != "" {
t.Errorf("log vacio = (%d,%q,%q), esperaba (0,\"\",\"\")", count, lastType, lastMsg)
}
})
t.Run("concurrencia: 100 records desde N goroutines no pierde cuentas", func(t *testing.T) {
l := &DialogLog{}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
l.record("alert", "x")
}()
}
wg.Wait()
if count, _, _ := l.Snapshot(); count != 100 {
t.Errorf("count = %d, esperaba 100 (sin perder por race)", count)
}
})
}
+19
View File
@@ -0,0 +1,19 @@
package browser
import "fmt"
// CdpHoverRef mueve el ratón con trayectoria humanizada (Bézier) sobre el
// elemento del #ref. Útil para activar menús y tooltips que reaccionan a hover.
// El #ref es un backendDOMNodeId extraído del AX outline por page_perceive.
func CdpHoverRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error {
if c == nil {
return fmt.Errorf("cdp hover ref: conexión nil")
}
// scroll al elemento si no está visible; ignorar error (no fatal)
_, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID})
cx, cy, err := refBoxCenter(c, backendNodeID)
if err != nil {
return fmt.Errorf("cdp hover ref: %w", err)
}
return CdpMoveMouseHuman(c, cx, cy, opts)
}
+53
View File
@@ -0,0 +1,53 @@
---
name: cdp_hover_ref
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpHoverRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error"
description: "Mueve el ratón con trayectoria humanizada (Bézier) sobre el elemento identificado por su #ref del AX outline. Útil para activar menús desplegables, tooltips y cualquier interacción que dependa de hover. El #ref es el backendDOMNodeId estable del nodo DOM."
tags: [cdp, browser, action, ref, humanized, navegator]
uses_functions: [cdp_move_mouse_human_go_browser]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
params:
- name: c
desc: "Conexión CDP activa al tab objetivo."
- name: backendNodeID
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
- name: opts
desc: "Opciones de trayectoria humanizada (jitter, velocidad, curva Bézier). Zero-value da humanización por defecto."
output: "nil si el movimiento de ratón se completó; error si la conexión es nil, el nodo no tiene boxModel visible, o el movimiento CDP falla."
file_path: "functions/browser/cdp_hover_ref.go"
---
## Ejemplo
```go
// Activar un menú desplegable cuyo trigger tiene #ref=9999:
conn, _ := CdpConnect(9222)
err := CdpHoverRef(conn, 9999, MouseHumanOpts{})
if err != nil {
log.Fatal(err)
}
// esperar a que el menú aparezca y re-percibir el outline
```
## Cuando usarla
Tras `page_perceive` / `render_ax_outline`, cuando el agente necesita hacer hover sobre un elemento del `#ref` para revelar contenido oculto (menús, submenús, tooltips, dropdowns) — cierra el bucle percibir→actuar para interacciones hover. Seguir con otro `page_perceive` tras el hover para capturar el nuevo estado del DOM.
## Gotchas
- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir antes de actuar.
- `DOM.getBoxModel` falla si el elemento no está en el DOM renderizado. El error describe la causa.
- `DOM.scrollIntoViewIfNeeded` se invoca antes del cálculo de coordenadas pero su fallo se ignora (no fatal).
- Solo mueve el ratón — no hace click. Para activar elementos que requieren click usar `CdpClickRef`.
- Algunos menús hover requieren un pequeño `time.Sleep` o `CdpWaitIdle` tras el hover para que el DOM se actualice antes del siguiente `page_perceive`.
+73
View File
@@ -0,0 +1,73 @@
package browser
import (
"fmt"
)
// CdpFrame representa un frame/iframe del arbol de navegacion.
type CdpFrame struct {
ID string `json:"id"`
ParentID string `json:"parentId"`
URL string `json:"url"`
Name string `json:"name"`
}
// CdpListFrames lista todos los frames de la pagina actual (frame raiz + iframes anidados)
// usando Page.getFrameTree. Retorna el arbol aplanado con cada frame y su parentId.
func CdpListFrames(c *CDPConn) ([]CdpFrame, error) {
if c == nil {
return nil, fmt.Errorf("cdp list frames: conexion nula")
}
// Page.enable es idempotente; necesario para que Page.getFrameTree funcione
if _, err := c.sendCDP("Page.enable", nil); err != nil {
return nil, fmt.Errorf("cdp list frames: Page.enable: %w", err)
}
result, err := c.sendCDP("Page.getFrameTree", nil)
if err != nil {
return nil, fmt.Errorf("cdp list frames: Page.getFrameTree: %w", err)
}
frameTree, ok := result["frameTree"].(map[string]any)
if !ok {
return nil, fmt.Errorf("cdp list frames: frameTree no encontrado en respuesta")
}
var frames []CdpFrame
frameFlatten(frameTree, "", &frames)
return frames, nil
}
// frameFlatten recorre recursivamente el arbol de frames CDP y acumula CdpFrame.
// parentID es el ID del nodo padre; el frame raiz lo recibe vacio.
func frameFlatten(node map[string]any, parentID string, acc *[]CdpFrame) {
frameData, ok := node["frame"].(map[string]any)
if !ok {
return
}
f := CdpFrame{
ID: stringField(frameData, "id"),
ParentID: parentID,
URL: stringField(frameData, "url"),
Name: stringField(frameData, "name"),
}
*acc = append(*acc, f)
// Recorrer hijos
children, _ := node["childFrames"].([]any)
for _, child := range children {
childNode, ok := child.(map[string]any)
if !ok {
continue
}
frameFlatten(childNode, f.ID, acc)
}
}
// stringField extrae un campo string de un map[string]any de forma segura.
func stringField(m map[string]any, key string) string {
v, _ := m[key].(string)
return v
}
+62
View File
@@ -0,0 +1,62 @@
---
id: cdp_list_frames_go_browser
name: cdp_list_frames
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Lista todos los frames/iframes de la pestaña activa usando Page.getFrameTree y devuelve el árbol aplanado con ID, parentID, URL y nombre de cada frame."
tags: [cdp, browser, iframe, frames, page, navegator]
signature: "func CdpListFrames(c *CDPConn) ([]CdpFrame, error)"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_list_frames.go"
example: |
conn, _ := CdpConnect("localhost", 9222, "")
frames, err := CdpListFrames(conn)
for _, f := range frames {
fmt.Printf("frame %s parent=%s url=%s\n", f.ID, f.ParentID, f.URL)
}
params:
- name: c
desc: "Conexión CDP activa obtenida con CdpConnect; apunta a la pestaña cuyo árbol de frames se quiere inspeccionar."
output: "Slice de CdpFrame con ID, ParentID, URL y Name de cada frame aplanado; error si la conexión es nula, Page.enable falla o la respuesta CDP es inesperada."
---
## Ejemplo
```go
conn, err := CdpConnect("localhost", 9222, "")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
frames, err := CdpListFrames(conn)
if err != nil {
log.Fatal(err)
}
for _, f := range frames {
fmt.Printf("id=%-40s parent=%-40s url=%s\n", f.ID, f.ParentID, f.URL)
}
// Salida ejemplo:
// id=ABCD1234 parent= url=https://example.com
// id=EFGH5678 parent=ABCD1234 url=https://ads.example.com/iframe
```
## Cuando usarla
Antes de evaluar JS en un iframe con `CdpEvalInFrame`: necesitas el `frameID` exacto que usa CDP, no el `src` del iframe. También útil para auditar la estructura de frames de una página o detectar iframes de terceros.
## Gotchas
- Requiere que la pestaña ya esté cargada; si se llama justo tras `CdpNavigate` en páginas con lazy-load de iframes, puede devolver un listado incompleto — espera a `Page.loadEventFired` o usa un breve delay.
- `Page.enable` se llama internamente (idempotente); no hace falta llamarlo manualmente antes.
- El frame raíz tiene `ParentID` vacío. Los iframes anidados tienen como `ParentID` el `ID` del frame contenedor.
- `Name` puede ser vacío si el `<iframe>` no tiene atributo `name`.
@@ -0,0 +1,98 @@
package browser
import (
"encoding/json"
"fmt"
"os"
"strings"
)
// jsQuote serializa s como literal string JavaScript con comillas dobles y
// caracteres escapados correctamente. Usa json.Marshal internamente para
// reutilizar el mismo escapado que JSON (compatible con JS).
func jsQuote(s string) string {
b, err := json.Marshal(s)
if err != nil {
// Fallback seguro: comillas dobles escapando backslash y comilla doble
return fmt.Sprintf("%q", s)
}
return string(b)
}
// CdpLoadStorageState lee el JSON generado por CdpSaveStorageState y restaura
// cookies y localStorage en la pestaña activa. Permite retomar una sesion
// autenticada sin repetir el login.
//
// CRITICO: el localStorage es por-origen. Antes de llamar a esta funcion hay
// que haber navegado al origen correcto (CdpNavigate al dominio). Orden
// correcto: navegar -> CdpLoadStorageState -> recargar pagina.
func CdpLoadStorageState(c *CDPConn, inPath string) error {
if c == nil {
return fmt.Errorf("cdp load storage state: conexion nula")
}
if inPath == "" {
return fmt.Errorf("cdp load storage state: inPath vacio")
}
data, err := os.ReadFile(inPath)
if err != nil {
return fmt.Errorf("cdp load storage state: leer archivo: %w", err)
}
var state CdpStorageState
if err := json.Unmarshal(data, &state); err != nil {
return fmt.Errorf("cdp load storage state: unmarshal: %w", err)
}
// Habilitar dominio Network para manipular cookies
if _, err := c.sendCDP("Network.enable", nil); err != nil {
return fmt.Errorf("cdp load storage state: Network.enable: %w", err)
}
// Restaurar cookies. Network.setCookies aplica de forma fiable las cookies
// (sobre todo httpOnly y de sesión) cuando cada una lleva el campo `url`: de
// ahí deriva scheme y scope. getAllCookies no lo incluye, así que lo
// sintetizamos a partir de domain/secure/path cuando falta.
if len(state.Cookies) > 0 {
for _, ck := range state.Cookies {
if _, has := ck["url"]; has {
continue
}
dom, _ := ck["domain"].(string)
dom = strings.TrimPrefix(dom, ".")
if dom == "" {
continue
}
scheme := "http"
if sec, _ := ck["secure"].(bool); sec {
scheme = "https"
}
path, _ := ck["path"].(string)
if path == "" {
path = "/"
}
ck["url"] = scheme + "://" + dom + path
}
if _, err := c.sendCDP("Network.setCookies", map[string]any{
"cookies": state.Cookies,
}); err != nil {
return fmt.Errorf("cdp load storage state: setCookies: %w", err)
}
}
// Restaurar localStorage y sessionStorage — setItem por cada par clave/valor
for k, v := range state.LocalStorage {
expr := fmt.Sprintf("window.localStorage.setItem(%s, %s)", jsQuote(k), jsQuote(v))
if _, err := CdpEvaluate(c, expr); err != nil {
return fmt.Errorf("cdp load storage state: localStorage setItem %q: %w", k, err)
}
}
for k, v := range state.SessionStorage {
expr := fmt.Sprintf("window.sessionStorage.setItem(%s, %s)", jsQuote(k), jsQuote(v))
if _, err := CdpEvaluate(c, expr); err != nil {
return fmt.Errorf("cdp load storage state: sessionStorage setItem %q: %w", k, err)
}
}
return nil
}
@@ -0,0 +1,67 @@
---
id: cdp_load_storage_state_go_browser
name: cdp_load_storage_state
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Restaura cookies y localStorage desde un archivo JSON (generado por CdpSaveStorageState) en la pestaña activa, reanudando una sesión autenticada sin repetir el login."
tags: [cdp, browser, storage, session, cookies, localStorage, auth, navegator]
signature: "func CdpLoadStorageState(c *CDPConn, inPath string) error"
uses_functions:
- cdp_evaluate_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_load_storage_state.go"
example: |
conn, _ := CdpConnect(9222)
defer CdpClose(conn)
CdpNavigate(conn, "https://app.example.com")
CdpLoadStorageState(conn, "/tmp/session.json")
CdpNavigate(conn, "https://app.example.com") // reload para que la app lea el localStorage restaurado
params:
- name: c
desc: "Conexión CDP activa apuntando a la pestaña donde se restaurará el estado."
- name: inPath
desc: "Ruta del archivo JSON producido previamente por CdpSaveStorageState."
output: "nil si cookies y localStorage se restauraron correctamente; error con contexto si el archivo no existe, el JSON es inválido o falla algún comando CDP."
---
## Ejemplo
```go
conn, err := CdpConnect(9222)
if err != nil {
log.Fatal(err)
}
defer CdpClose(conn)
// 1. Navegar al origen correcto ANTES de restaurar
CdpNavigate(conn, "https://app.example.com")
// 2. Restaurar cookies + localStorage
if err := CdpLoadStorageState(conn, "/tmp/session.json"); err != nil {
log.Fatal(err)
}
// 3. Recargar para que la app lea el localStorage restaurado
CdpNavigate(conn, "https://app.example.com")
// A partir de aquí la sesión está activa — no se necesitó login
```
## Cuando usarla
Al inicio de un script de scraping autenticado, después de `CdpNavigate` al dominio objetivo y antes de cualquier interacción. Sustituye el flujo de login cuando ya existe un archivo de estado guardado con `CdpSaveStorageState`.
## Gotchas
- **Orden obligatorio: navegar → load → reload**. El localStorage es por-origen: si llamas a esta función antes de navegar al dominio correcto, los `setItem` escriben en el origen equivocado (p.ej. `about:blank`) y la app no los ve. Secuencia correcta: `CdpNavigate(dominio)``CdpLoadStorageState(...)``CdpNavigate(dominio)` de nuevo.
- **Cookies globales del perfil**: `Network.setCookies` restaura todas las cookies del archivo, que pueden ser de múltiples dominios. Esto es el comportamiento esperado y compatible con cómo las guardó `CdpSaveStorageState`.
- **Archivo inexistente o corrupto**: la función devuelve error explícito; comprueba que el archivo existe antes de llamarla (por ejemplo con `os.Stat`) si quieres un fallback a login completo.
- **Sesión expirada**: restaurar el estado no renueva tokens del servidor. Si la sesión expiró (cookies caducadas, JWT vencido), la app redirigirá a login igualmente. En ese caso re-autentícate y vuelve a guardar el estado.
+60 -12
View File
@@ -9,12 +9,21 @@ import (
// MouseHumanOpts configura el movimiento humano del ratón.
type MouseHumanOpts struct {
// Steps es el número de puntos intermedios de la curva (default 25).
// Mode es la política de velocidad: "human" (default, ""), "fast" o "instant".
// Controla los defaults de Steps/DurationMs/JitterPx y la pausa press/release:
// - human: Bézier ~25 pts, 350-800ms, jitter 2px (sigilo anti-bot alto).
// - fast: recta ~5 pts, 40-80ms, jitter mínimo (eventos de ratón reales,
// para scraping masivo propio).
// - instant: sin movimiento de ratón (CdpMoveMouseHuman es no-op); el click
// por #ref usa element.click() JS. Para tests y fallback sin bbox.
// Los valores explícitos (Steps/DurationMs/JitterPx != 0) ganan al preset del modo.
Mode string
// Steps es el número de puntos intermedios de la curva (default según Mode).
Steps int
// DurationMs es la duración total aproximada del movimiento en milisegundos.
// Si es 0, se elige aleatoriamente entre 350 y 800 ms.
// Si es 0, se elige según Mode.
DurationMs int
// JitterPx es la desviación perpendicular máxima por punto en píxeles (default 2.0).
// JitterPx es la desviación perpendicular máxima por punto en píxeles (default según Mode).
JitterPx float64
// FromX es la coordenada X de origen. Si < 0, se usa (0, 0) como origen.
FromX float64
@@ -22,16 +31,49 @@ type MouseHumanOpts struct {
FromY float64
}
// mouseHumanDefaults aplica valores por defecto a opts.
// MouseProfileForMode construye las opciones de ratón para un modo de velocidad.
// Es la fuente única que MCP, runner YAML y CLI usan para mapear un modo a opts,
// sin duplicar números. El mapeo modo→valores concretos vive en mouseHumanDefaults.
// Un modo desconocido se trata como "human" (el más seguro).
func MouseProfileForMode(mode string) MouseHumanOpts {
switch mode {
case "fast", "instant", "human", "":
return MouseHumanOpts{Mode: mode, FromX: -1, FromY: -1}
default:
return MouseHumanOpts{Mode: "human", FromX: -1, FromY: -1}
}
}
// mouseHumanDefaults aplica valores por defecto a opts según opts.Mode.
func mouseHumanDefaults(opts MouseHumanOpts) MouseHumanOpts {
if opts.Steps <= 0 {
opts.Steps = 25
}
if opts.DurationMs <= 0 {
opts.DurationMs = 350 + rand.Intn(451) // 350..800
}
if opts.JitterPx <= 0 {
opts.JitterPx = 2.0
switch opts.Mode {
case "instant":
// El movimiento se omite en CdpMoveMouseHuman; valores mínimos por si acaso.
if opts.Steps <= 0 {
opts.Steps = 1
}
if opts.DurationMs <= 0 {
opts.DurationMs = 1
}
// JitterPx se queda en 0.
case "fast":
if opts.Steps <= 0 {
opts.Steps = 5
}
if opts.DurationMs <= 0 {
opts.DurationMs = 40 + rand.Intn(41) // 40..80
}
// JitterPx se queda en lo recibido (0 por defecto, sin jitter en fast).
default: // "human" o ""
if opts.Steps <= 0 {
opts.Steps = 25
}
if opts.DurationMs <= 0 {
opts.DurationMs = 350 + rand.Intn(451) // 350..800
}
if opts.JitterPx <= 0 {
opts.JitterPx = 2.0
}
}
if opts.FromX < 0 {
opts.FromX = 0
@@ -119,6 +161,12 @@ func CdpMoveMouseHuman(c *CDPConn, toX, toY float64, opts MouseHumanOpts) error
}
opts = mouseHumanDefaults(opts)
// Modo instant: sin movimiento de ratón (el click lo resuelve quien llama,
// por coords directas o por element.click() JS).
if opts.Mode == "instant" {
return nil
}
p0 := [2]float64{opts.FromX, opts.FromY}
p3 := [2]float64{toX, toY}
ctrl1, ctrl2 := randomControlPoints(p0, p3)
+67
View File
@@ -0,0 +1,67 @@
package browser
import (
"fmt"
)
// CdpNavBack retrocede una entrada en el historial de navegacion de la pestana activa.
// Obtiene el historial via Page.getNavigationHistory, calcula el indice anterior y
// navega a esa entrada via Page.navigateToHistoryEntry.
// Retorna error si ya estamos al inicio del historial.
func CdpNavBack(c *CDPConn) error {
if c == nil {
return fmt.Errorf("cdp nav back: conexion nula")
}
result, err := c.sendCDP("Page.getNavigationHistory", nil)
if err != nil {
return fmt.Errorf("cdp nav back: obtener historial: %w", err)
}
currentIndexRaw, ok := result["currentIndex"]
if !ok {
return fmt.Errorf("cdp nav back: respuesta sin currentIndex")
}
currentIndex, ok := currentIndexRaw.(float64)
if !ok {
return fmt.Errorf("cdp nav back: currentIndex tipo inesperado: %T", currentIndexRaw)
}
entriesRaw, ok := result["entries"]
if !ok {
return fmt.Errorf("cdp nav back: respuesta sin entries")
}
entries, ok := entriesRaw.([]any)
if !ok {
return fmt.Errorf("cdp nav back: entries tipo inesperado: %T", entriesRaw)
}
idx := int(currentIndex) - 1
if idx < 0 {
return fmt.Errorf("cdp nav back: ya en el inicio del historial")
}
if idx >= len(entries) {
return fmt.Errorf("cdp nav back: indice %d fuera de rango (len=%d)", idx, len(entries))
}
entry, ok := entries[idx].(map[string]any)
if !ok {
return fmt.Errorf("cdp nav back: entrada[%d] tipo inesperado: %T", idx, entries[idx])
}
entryIDRaw, ok := entry["id"]
if !ok {
return fmt.Errorf("cdp nav back: entrada sin campo id")
}
entryIDFloat, ok := entryIDRaw.(float64)
if !ok {
return fmt.Errorf("cdp nav back: entry id tipo inesperado: %T", entryIDRaw)
}
entryID := int(entryIDFloat)
_, err = c.sendCDP("Page.navigateToHistoryEntry", map[string]any{"entryId": entryID})
if err != nil {
return fmt.Errorf("cdp nav back: navegar a entrada %d: %w", entryID, err)
}
return nil
}
+62
View File
@@ -0,0 +1,62 @@
---
id: cdp_nav_back_go_browser
name: cdp_nav_back
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Retrocede una entrada en el historial de navegación de la pestaña activa via Page.getNavigationHistory + Page.navigateToHistoryEntry. Equivalente a pulsar el botón Atrás del navegador."
tags: [cdp, browser, navigation, navegator]
signature: "func CdpNavBack(c *CDPConn) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_nav_back.go"
example: |
conn, _ := browser.CdpConnect(9222)
defer browser.CdpClose(conn, 0)
_ = browser.CdpNavigate(conn, "https://example.com/paso1")
_ = browser.CdpNavigate(conn, "https://example.com/paso2")
// Volver a /paso1
if err := browser.CdpNavBack(conn); err != nil {
log.Println(err)
}
params:
- name: c
desc: "Conexión CDP activa obtenida de CdpConnect, apuntando a la pestaña que se quiere retroceder"
output: "nil si navegó correctamente a la entrada anterior; error si ya estamos al inicio del historial, la conexión es nula o el cast de tipos falla (respuesta CDP malformada)"
---
## Ejemplo
```go
conn, err := browser.CdpConnect(9222)
if err != nil {
log.Fatal(err)
}
defer browser.CdpClose(conn, 0)
_ = browser.CdpNavigate(conn, "https://metabase.local/dashboard/1")
_ = browser.CdpNavigate(conn, "https://metabase.local/question/42")
// Volver al dashboard
if err := browser.CdpNavBack(conn); err != nil {
log.Printf("no se pudo retroceder: %v", err)
}
```
## Cuando usarla
Cuando un flujo de automatización navega por varias páginas y necesita volver atrás sin conocer la URL anterior. Útil en scraping de paginaciones o en flujos de formularios multipaso donde la URL destino no es predecible.
## Gotchas
- Navega dentro del historial de ESA pestaña CDP (sesión WS), no del perfil en disco ni del historial global de Chrome.
- Si `currentIndex == 0` (primer elemento del historial), retorna error "ya en el inicio del historial" — no es un fallo de red, es estado válido.
- Requiere que `Page` esté habilitado en la sesión; Chrome lo activa automáticamente con la mayoría de conexiones CDP, pero si usas una sesión muy restrictiva puede fallar.
- No espera a que la página de destino cargue; encadenar con `CdpWaitLoad` o `CdpWaitIdle` si necesitas esperar la carga completa.
+64
View File
@@ -0,0 +1,64 @@
package browser
import (
"fmt"
)
// CdpNavForward avanza una entrada en el historial de navegacion de la pestana activa.
// Obtiene el historial via Page.getNavigationHistory, calcula el indice siguiente y
// navega a esa entrada via Page.navigateToHistoryEntry.
// Retorna error si ya estamos al final del historial (no hay entradas adelante).
func CdpNavForward(c *CDPConn) error {
if c == nil {
return fmt.Errorf("cdp nav forward: conexion nula")
}
result, err := c.sendCDP("Page.getNavigationHistory", nil)
if err != nil {
return fmt.Errorf("cdp nav forward: obtener historial: %w", err)
}
currentIndexRaw, ok := result["currentIndex"]
if !ok {
return fmt.Errorf("cdp nav forward: respuesta sin currentIndex")
}
currentIndex, ok := currentIndexRaw.(float64)
if !ok {
return fmt.Errorf("cdp nav forward: currentIndex tipo inesperado: %T", currentIndexRaw)
}
entriesRaw, ok := result["entries"]
if !ok {
return fmt.Errorf("cdp nav forward: respuesta sin entries")
}
entries, ok := entriesRaw.([]any)
if !ok {
return fmt.Errorf("cdp nav forward: entries tipo inesperado: %T", entriesRaw)
}
idx := int(currentIndex) + 1
if idx >= len(entries) {
return fmt.Errorf("cdp nav forward: ya en el final del historial")
}
entry, ok := entries[idx].(map[string]any)
if !ok {
return fmt.Errorf("cdp nav forward: entrada[%d] tipo inesperado: %T", idx, entries[idx])
}
entryIDRaw, ok := entry["id"]
if !ok {
return fmt.Errorf("cdp nav forward: entrada sin campo id")
}
entryIDFloat, ok := entryIDRaw.(float64)
if !ok {
return fmt.Errorf("cdp nav forward: entry id tipo inesperado: %T", entryIDRaw)
}
entryID := int(entryIDFloat)
_, err = c.sendCDP("Page.navigateToHistoryEntry", map[string]any{"entryId": entryID})
if err != nil {
return fmt.Errorf("cdp nav forward: navegar a entrada %d: %w", entryID, err)
}
return nil
}
+64
View File
@@ -0,0 +1,64 @@
---
id: cdp_nav_forward_go_browser
name: cdp_nav_forward
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Avanza una entrada en el historial de navegación de la pestaña activa via Page.getNavigationHistory + Page.navigateToHistoryEntry. Equivalente a pulsar el botón Adelante del navegador."
tags: [cdp, browser, navigation, navegator]
signature: "func CdpNavForward(c *CDPConn) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_nav_forward.go"
example: |
conn, _ := browser.CdpConnect(9222)
defer browser.CdpClose(conn, 0)
_ = browser.CdpNavigate(conn, "https://example.com/paso1")
_ = browser.CdpNavigate(conn, "https://example.com/paso2")
_ = browser.CdpNavBack(conn) // volver a /paso1
// Avanzar de nuevo a /paso2
if err := browser.CdpNavForward(conn); err != nil {
log.Println(err)
}
params:
- name: c
desc: "Conexión CDP activa obtenida de CdpConnect, apuntando a la pestaña que se quiere avanzar"
output: "nil si navegó correctamente a la entrada siguiente; error si ya estamos al final del historial, la conexión es nula o el cast de tipos falla (respuesta CDP malformada)"
---
## Ejemplo
```go
conn, err := browser.CdpConnect(9222)
if err != nil {
log.Fatal(err)
}
defer browser.CdpClose(conn, 0)
_ = browser.CdpNavigate(conn, "https://metabase.local/dashboard/1")
_ = browser.CdpNavigate(conn, "https://metabase.local/question/42")
_ = browser.CdpNavBack(conn) // vuelve a /dashboard/1
// Avanzar de nuevo a /question/42
if err := browser.CdpNavForward(conn); err != nil {
log.Printf("no se pudo avanzar: %v", err)
}
```
## Cuando usarla
Cuando un flujo de automatización ha retrocedido con `CdpNavBack` y necesita volver a avanzar sin conocer la URL destino. Útil para recorrer un historial de páginas hacia adelante y hacia atrás de forma programática, por ejemplo en herramientas de replay de sesiones.
## Gotchas
- Navega dentro del historial de ESA pestaña CDP (sesión WS), no del perfil en disco ni del historial global de Chrome.
- Si `currentIndex` es el último elemento del historial (`currentIndex == len(entries) - 1`), retorna error "ya en el final del historial" — no es un fallo de red, es estado válido.
- El historial se trunca cuando se navega a una URL nueva estando en una entrada intermedia: las entradas "adelante" desaparecen, igual que en un navegador real.
- No espera a que la página de destino cargue; encadenar con `CdpWaitLoad` o `CdpWaitIdle` si necesitas esperar la carga completa.
+3 -2
View File
@@ -5,8 +5,9 @@ import (
)
// CdpNavigate navega a la URL indicada usando Page.navigate.
// Espera a que la carga este confirmada via Page.loadEventFired antes de retornar.
// El timeout de la navegacion es gestionado por Chrome internamente.
// NO espera a que la pagina cargue: retorna en cuanto Chrome acepta la navegacion
// (solo verifica que no haya errorText). Para esperar la carga real encadena
// despues CdpWaitLoad (document.readyState) o CdpWaitIdle (red en reposo).
func CdpNavigate(c *CDPConn, targetURL string) error {
if c == nil {
return fmt.Errorf("cdp navigate: conexion nula")
+69
View File
@@ -0,0 +1,69 @@
package browser
import "fmt"
// pressKeyEntry define los atributos CDP de una tecla especial.
type pressKeyEntry struct {
vk int
key string
code string
text string
}
// pressKeyTable mapea nombres de tecla a sus atributos CDP.
var pressKeyTable = map[string]pressKeyEntry{
"Enter": {vk: 13, key: "Enter", code: "Enter", text: "\r"},
"Tab": {vk: 9, key: "Tab", code: "Tab"},
"Escape": {vk: 27, key: "Escape", code: "Escape"},
"Backspace": {vk: 8, key: "Backspace", code: "Backspace"},
"Delete": {vk: 46, key: "Delete", code: "Delete"},
"ArrowUp": {vk: 38, key: "ArrowUp", code: "ArrowUp"},
"ArrowDown": {vk: 40, key: "ArrowDown", code: "ArrowDown"},
"ArrowLeft": {vk: 37, key: "ArrowLeft", code: "ArrowLeft"},
"ArrowRight": {vk: 39, key: "ArrowRight", code: "ArrowRight"},
"Home": {vk: 36, key: "Home", code: "Home"},
"End": {vk: 35, key: "End", code: "End"},
"PageUp": {vk: 33, key: "PageUp", code: "PageUp"},
"PageDown": {vk: 34, key: "PageDown", code: "PageDown"},
"Space": {vk: 32, key: " ", code: "Space", text: " "},
}
// CdpPressKey pulsa una tecla especial por nombre usando Input.dispatchKeyEvent.
// Soporta: Enter, Tab, Escape, Backspace, Delete, ArrowUp, ArrowDown, ArrowLeft,
// ArrowRight, Home, End, PageUp, PageDown, Space.
// Actua sobre el elemento con foco activo en la pagina.
func CdpPressKey(c *CDPConn, keyName string) error {
if c == nil {
return fmt.Errorf("cdp press key: conexion nula")
}
entry, ok := pressKeyTable[keyName]
if !ok {
return fmt.Errorf("cdp press key: tecla no soportada: %s", keyName)
}
down := map[string]any{
"type": "keyDown",
"windowsVirtualKeyCode": entry.vk,
"key": entry.key,
"code": entry.code,
}
if entry.text != "" {
down["text"] = entry.text
}
if _, err := c.sendCDP("Input.dispatchKeyEvent", down); err != nil {
return fmt.Errorf("cdp press key: keyDown %q: %w", keyName, err)
}
up := map[string]any{
"type": "keyUp",
"windowsVirtualKeyCode": entry.vk,
"key": entry.key,
"code": entry.code,
}
if _, err := c.sendCDP("Input.dispatchKeyEvent", up); err != nil {
return fmt.Errorf("cdp press key: keyUp %q: %w", keyName, err)
}
return nil
}
+67
View File
@@ -0,0 +1,67 @@
---
id: cdp_press_key_go_browser
name: cdp_press_key
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
tests: []
test_file_path: ""
description: "Pulsa una tecla especial por nombre via Input.dispatchKeyEvent CDP (Enter, Tab, Escape, flechas, etc.) sobre el elemento con foco activo."
tags: [cdp, browser, input, keyboard, navegator]
signature: "func CdpPressKey(c *CDPConn, keyName string) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_press_key.go"
example: |
// Enfocar un input y pulsar Enter para enviar el formulario
_ = CdpClick(c, "input[name='q']")
_ = CdpTypeText(c, "golang")
_ = CdpPressKey(c, "Enter")
params:
- name: c
desc: "Conexion CDP activa obtenida con CdpConnect."
- name: keyName
desc: "Nombre de la tecla a pulsar. Valores soportados: Enter, Tab, Escape, Backspace, Delete, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Home, End, PageUp, PageDown, Space."
output: "nil si la tecla se despacho correctamente. Error si la conexion es nula, la tecla no esta en la tabla soportada, o CDP rechaza el evento."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
// Enfocar campo de busqueda, escribir y enviar con Enter
_ = CdpClick(conn, "input[name='q']")
_ = CdpTypeText(conn, "golang generics")
if err := CdpPressKey(conn, "Enter"); err != nil {
log.Fatal(err)
}
// Navegar en un desplegable con flechas
_ = CdpClick(conn, "#dropdown")
_ = CdpPressKey(conn, "ArrowDown")
_ = CdpPressKey(conn, "ArrowDown")
_ = CdpPressKey(conn, "Enter")
// Cerrar un modal con Escape
_ = CdpPressKey(conn, "Escape")
```
## Cuando usarla
Usar cuando necesites simular pulsaciones de teclas especiales sobre el elemento con foco: enviar formularios con Enter, navegar opciones con flechas, limpiar campos con Backspace/Delete, cerrar modales con Escape, o desplazarse con PageUp/PageDown. Para escribir texto normal usa CdpTypeText.
## Gotchas
- La tecla actua sobre el elemento con foco activo. Llama a CdpClick primero para enfocar el elemento objetivo.
- Teclas sin caracter imprimible (Tab, Escape, flechas, Home, End, PageUp, PageDown) no envian el campo "text" — Chrome lo requiere asi para distinguir navegacion de insercion.
- Enter envia `text: "\r"` que es lo que Chrome espera para confirmar formularios y autocompletados.
- Space envia `key: " "` y `text: " "` — funciona como barra espaciadora y como insercion de espacio en inputs.
- Si la tecla que necesitas no esta en la tabla, la funcion retorna error explicito en vez de silencio.

Some files were not shown because too many files have changed in this diff Show More