@jossuealcala/madre 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/CONTRIBUTING.md +3 -0
- package/README.md +15 -11
- package/docs/INTERNALS.md +7 -1
- package/package.json +1 -1
- package/public/app.js +146 -69
- package/public/index.html +1 -1
- package/public/styles.css +26 -8
- package/public/troubleshooting.js +8 -8
- package/src/adapters/claude.mjs +16 -7
- package/src/adapters/codex.mjs +16 -13
- package/src/adapters/gemini.mjs +27 -14
- package/src/adapters/opencode.mjs +36 -16
- package/src/capabilities.mjs +12 -9
- package/src/checkpoint.mjs +118 -21
- package/src/commands.mjs +25 -3
- package/src/directives.mjs +7 -4
- package/src/lease.mjs +15 -8
- package/src/modules/git-pulse.mjs +3 -3
- package/src/modules/index.mjs +7 -1
- package/src/modules/playwright.mjs +73 -0
- package/src/modules/sdk.mjs +11 -0
- package/src/room/control.mjs +24 -12
- package/src/room/prompt.mjs +6 -3
- package/src/room.mjs +101 -37
- package/src/server.mjs +31 -3
- package/src/updates.mjs +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,34 @@ Todas las versiones publicadas de `@jossuealcala/madre`. Fechas en ISO.
|
|
|
4
4
|
|
|
5
5
|
Una versión se cierra cuando está en npm: hasta entonces su sección se llama **Sin publicar** y puede crecer. Cada versión publicada tiene exactamente una etiqueta `vX.Y.Z`, una release en GitHub y una sección aquí; el parche puede llegar a dos dígitos (`0.2.10`) antes de subir el menor. Ver `docs/ROADMAP.md` para el criterio de qué sube cada número.
|
|
6
6
|
|
|
7
|
+
## 0.3.3 · Sin publicar
|
|
8
|
+
|
|
9
|
+
### El SDK entrega herramientas a los turnos, y PLAYWRIGHT es el primero en usarlo
|
|
10
|
+
- `defineModule` acepta `toolsForTurn(ctx, turn)`: un módulo encendido devuelve servidores MCP (`name, command, args, env, tools, brief`) y MADRE los adjunta a la CLI de ese turno, en su corrida aislada, en Codex, Claude Code, Gemini CLI y OpenCode, y se los describe al agente en el briefing. Un módulo que falla no entrega nada y nunca rompe el turno. Image Studio y la memoria conservan su cableado propio; los módulos nuevos nacen sobre el gancho.
|
|
11
|
+
- Módulo **PLAYWRIGHT**: un navegador headless por turno, `@playwright/mcp` aislado, con orígenes permitidos solo en la dirección de esta MADRE. Los agentes abren la vista previa de RIPLEY, hacen clic, leen consola y red y guardan capturas en la carpeta de borrador del turno. Requiere `npm install -g @playwright/mcp` y un navegador de Playwright; el módulo lo detecta y lo dice. Apagado en GHOST.
|
|
12
|
+
- Git Pulse gana la mano del humano: `/git commit "mensaje"` confirma todo el árbol en local, y `/git push` muestra qué saldría y solo envía con `/git push confirm`. Ningún agente puede escribir esos comandos por ti; los comandos corren en el servidor con tu propia sesión de git.
|
|
13
|
+
|
|
14
|
+
### #4 AIRLOCK: la compuerta
|
|
15
|
+
- Un cuarto modo, pedido desde una sala real por un agente que no podía desplegar en `#2`. AIRLOCK es CONTROL más comandos: pruebas, builds, `git commit` y `git push`, deploys con las CLIs y las sesiones que ya viven en la máquina. Los archivos siguen bajo checkpoint y `UNDO`; lo que sale de la nave no vuelve, y por eso la anulación pide dos llaves: la designación del proyecto y la palabra `AIRLOCK`. Un titular a la vez, como CONTROL.
|
|
16
|
+
- Cada CLI recibe su herramienta de comandos solo en `#4`: Codex `--sandbox danger-full-access`, Claude Code `Bash`, Gemini `run_shell_command`, OpenCode `bash`. Las zonas prohibidas siguen bloqueadas. El briefing exige decir en una línea qué va a salir y adónde antes de que salga, y cerrar con los comandos corridos y lo que dejó la máquina.
|
|
17
|
+
- `MAX MODE` llega a `#4` en CONNECTIONS; un orquestador en `#4` puede dar `#4` a un paso (`@opencode #4: despliega a preview`). Color propio, hielo, en chip, campo, menú y badges. MU/TH/UR reconoce "modo producción" y "permiso para ejecutar comandos" como peticiones de `#4`.
|
|
18
|
+
|
|
19
|
+
## 0.3.2 · 2026-09-19
|
|
20
|
+
|
|
21
|
+
### Canal de liberación, un clic
|
|
22
|
+
- `RESTART WITH x.y.z` en MU/TH/UR: la sala registra `room.updating`, cierra su puerto, instala la versión según cómo corre esta copia (npx, dependencia del proyecto o global) y vuelve a abrir en la misma dirección; la página espera y se recarga sola. Con agentes trabajando se niega hasta que terminen. Desde el código fuente sigue siendo `git pull`.
|
|
23
|
+
- La primera vez que aparece una versión nueva en la sesión, un aviso de MU/TH/UR lo dice; la alerta de la barra deja de brillar y respirar: línea ámbar fina, relleno suave, transiciones de 220 ms. Todos los controles de MADRE cambian de color con la misma curva; nada salta.
|
|
24
|
+
|
|
25
|
+
### Modos y permisos, una sola lógica
|
|
26
|
+
- `#2 CREATE` ya no encierra al agente en `.pulse/out/`: crea archivos y carpetas nuevos donde corresponda en el proyecto, según sus convenciones, con `.pulse/out/<turno>/` como borrador. MADRE fotografía el proyecto antes del turno; lo que apareció se conserva y se muestra como artefacto, y todo archivo previo modificado, renombrado o borrado se restaura y se avisa (`create.reverted`). Las CLIs reciben sus herramientas de escritura sobre el proyecto (Claude Code y OpenCode solo escriben si también pueden editar, verificado con los CLIs reales); la garantía de "solo añadir" la da la restauración de MADRE al terminar.
|
|
27
|
+
- Un orquestador puede pedir el modo de cada paso de su plan: `@codex #2: …`, `@claude #3: …`. MADRE lo acota al modo del mensaje del humano y al `MAX MODE` del agente. Bajo `#3` la palabra del orquestador basta: un paso `#2` recibe su lease de proyecto y un paso `#3` toma CONTROL para su turno, con checkpoint propio. Bajo `#1` un paso `#2` sigue pasando por la escalación.
|
|
28
|
+
- CONNECTIONS se reduce a dos controles por agente, `MAX MODE` y `DEFAULT MODE` (`#1` o `#2`), más dos habilidades, imágenes y web. Los interruptores `CREATE FILES` y `ALWAYS · STANDING LEASE` desaparecen; las configuraciones viejas (`write: false`, `alwaysCreate`) se siguen leyendo como `MAX MODE #1` y `DEFAULT MODE #2`.
|
|
29
|
+
- Los checkpoints funcionan en proyectos sin git: MADRE usa un repositorio sombra propio fuera del proyecto, así `#2` y `#3` tienen la misma reversibilidad en cualquier carpeta.
|
|
30
|
+
|
|
31
|
+
### Permisos que sí se cumplen
|
|
32
|
+
- OpenCode nunca había podido escribir en CREATE ni en CONTROL: sus reglas de permiso se comparan con rutas relativas al proyecto, no absolutas, y crear un archivo es la herramienta `write`, distinta de `edit`. Verificado contra opencode 1.18.4 con proyectos reales, con y sin espacios en la ruta. Ahora CREATE permite `write` y `edit` solo dentro de la carpeta del turno, y CONTROL permite todo el proyecto menos `.git/`, `.pulse/`, `.madre/`, los `.env` y `.claude/settings.local.json`.
|
|
33
|
+
- El checkpoint de CONTROL fotografía también los repositorios git anidados dentro del proyecto (un monorepo de sitios, cada uno con su `.git`): antes un cambio dentro de uno de ellos era invisible, la sala decía "no cambió nada" y UNDO no lo deshacía. Ahora la lista de cambios, las zonas prohibidas y UNDO cubren cada repositorio, con rutas relativas al proyecto. La fotografía se toma con `ls-files` y no con `add`, así un repo anidado sin commits ya no la rompe.
|
|
34
|
+
|
|
7
35
|
## 0.3.1 · 2026-09-19
|
|
8
36
|
|
|
9
37
|
### Canal de liberación: la sala avisa cuando hay versión nueva
|
package/CONTRIBUTING.md
CHANGED
|
@@ -38,6 +38,9 @@ export default defineModule({
|
|
|
38
38
|
|
|
39
39
|
`ctx` carries `projectRoot`, `config`, `settings`, `agents`, `room`, `readConfig()`, `updateConfig(patch)`, `record(type, payload)` and `services` (what the server offers: `imageKey`, `setImageModule`, `ollama`). A builtin gets a default switch that flips `enabled`, persists and records `extension.toggled`. Add a card branch in `public/app.js` only if the generic switch is not enough, and a condition in `public/troubleshooting.js` so MU/TH/UR knows it.
|
|
40
40
|
|
|
41
|
+
|
|
42
|
+
A module may hand tools to every turn: declare `toolsForTurn(ctx, turn)` and return MCP server specs `{ name, command, args, env, tools, brief }`. MADRE attaches them to the CLI of that turn only, in its isolated run, for all four agents, and puts `brief` in the agent's briefing. `turn` carries `agent`, `mode`, `lease`, the absolute `scratchDir` and the room's `port`. Return `[]` when there is nothing to hand; throw and MADRE hands nothing for you. `src/modules/playwright.mjs` is the reference.
|
|
43
|
+
|
|
41
44
|
## Look and voice
|
|
42
45
|
|
|
43
46
|
Three layers that never mix: the dialogue reads like a chat (system font, bubbles), the metadata reads like a terminal (mono, 10px, uppercase, grey), and MOTHER's screens (diagnosis, memory, control) are phosphor on black. One colour per meaning: phosphor is MADRE, amber is CREATE, red is CONTROL, each provider keeps its own colour. Toasts speak as `MU/TH/UR › …`. A new component that needs a new colour is a sign the component is wrong, not the palette.
|
package/README.md
CHANGED
|
@@ -64,7 +64,7 @@ npx @jossuealcala/madre start --no-open --project RUTA
|
|
|
64
64
|
| `@codex`, `@claude`, `@gemini`, `@opencode`, `@madre` | Menciona a un agente; `@` abre la lista |
|
|
65
65
|
| `!src/room.mjs:12-20` | Adjunta un archivo del proyecto, con líneas si las das; `!` busca entre los archivos |
|
|
66
66
|
| `#0` a `#3` | Fija el modo de permiso de ese mensaje |
|
|
67
|
-
| `/create …`, `/image …`, `/stopall`, `/git …`, `/ahp …` | Comandos; `/` abre el
|
|
67
|
+
| `/create …`, `/image …`, `/stopall`, `/git …`, `/ahp …` | Comandos; `/` abre el menú. `/git push` muestra qué saldría; solo `/git push confirm` lo envía |
|
|
68
68
|
| `STOPALL` | Freno maestro |
|
|
69
69
|
| Clip, arrastrar o pegar una imagen | Adjunto para la sala, nunca en el proyecto |
|
|
70
70
|
| `@madre, pregúntale al crew …` | Mesa redonda: un paso por agente en línea, cierre citado |
|
|
@@ -85,16 +85,19 @@ Cada mensaje sale con un modo. Tu modo es el techo de cualquier plan que ese men
|
|
|
85
85
|
|---|---|---|
|
|
86
86
|
| `#0` | **GHOST** | Fuera del registro. No se escribe en el ledger, nadie lo recuerda, desaparece al recargar. Los tokens sí cuentan. |
|
|
87
87
|
| `#1` | **EXCHANGE** | Leer el proyecto y coordinar. El predeterminado. |
|
|
88
|
-
| `#2` | **CREATE** |
|
|
89
|
-
| `#3` | **CONTROL** | Editar el proyecto real sin aprobación por acción. Un titular por sala, checkpoint
|
|
88
|
+
| `#2` | **CREATE** | Añadir archivos y carpetas nuevos donde corresponda en el proyecto. Lo que ya existía no cambia: si un agente lo toca, MADRE lo restaura al terminar y lo dice. |
|
|
89
|
+
| `#3` | **CONTROL** | Editar el proyecto real sin aprobación por acción. Un titular por sala, checkpoint antes, lista de cambios y `UNDO` después. |
|
|
90
|
+
| `#4` | **AIRLOCK** | Todo lo de CONTROL más ejecutar comandos: pruebas, builds, `git push`, deploys con las CLIs y sesiones que ya hay en la máquina. Los archivos vuelven con `UNDO`; lo que sale de la nave, no. La anulación pide dos llaves: la designación y la palabra `AIRLOCK`. |
|
|
90
91
|
|
|
91
|
-
**CREATE por dentro.**
|
|
92
|
+
**CREATE por dentro.** El agente decide dónde va lo nuevo según las convenciones del proyecto, y crea carpetas si hace falta; `.pulse/out/<turno>/` queda como borrador para lo que no tiene sitio. MADRE toma un checkpoint antes del turno y, al terminar, conserva lo que apareció, lo muestra bajo la respuesta como artefactos, y restaura cualquier archivo previo que se haya modificado, renombrado o borrado, avisando en la sala. Las CLIs reciben sus herramientas de escritura sobre el proyecto y la instrucción de no tocar lo existente; la garantía la da la restauración de MADRE al terminar, no la regla previa.
|
|
92
93
|
|
|
93
|
-
**CONTROL por dentro.** Antes del turno, un commit real bajo `refs/madre/checkpoints/` que no toca tu rama, tu índice ni tu stash. Durante el turno, `.git/`, `.pulse/`, `.madre/`, los `.env` y `.claude/settings.local.json` quedan en solo lectura a nivel de sistema de archivos y recuperan sus permisos al terminar. Después, la lista de archivos añadidos, modificados y borrados, y `UNDO` restaura el checkpoint. Armar `#3` pide la designación del proyecto.
|
|
94
|
+
**CONTROL por dentro.** Antes del turno, un commit real bajo `refs/madre/checkpoints/` que no toca tu rama, tu índice ni tu stash. Durante el turno, `.git/`, `.pulse/`, `.madre/`, los `.env` y `.claude/settings.local.json` quedan en solo lectura a nivel de sistema de archivos y recuperan sus permisos al terminar. Después, la lista de archivos añadidos, modificados y borrados, y `UNDO` restaura el checkpoint. Armar `#3` pide la designación del proyecto; armar `#4` pide además la palabra `AIRLOCK`. En `#4` cada CLI recibe su herramienta de comandos (Codex sin sandbox de red, Claude `Bash`, Gemini `run_shell_command`, OpenCode `bash`) y la instrucción de decir en una línea qué va a salir y adónde antes de que salga.
|
|
94
95
|
|
|
95
96
|
**Escalación.** Si un plan en `#1` llega a un paso que pide crear algo, la sala se detiene y pregunta: `GRANT ONCE · GRANT FOR PLAN · DENY`, con cronómetro de tres minutos. Solo el humano concede; un permiso escrito por un agente dentro de la conversación no cuenta.
|
|
96
97
|
|
|
97
|
-
**
|
|
98
|
+
**Modo por paso.** Un orquestador puede pedir el modo de cada paso: `@codex #2: crea la página`, `@claude #3: arregla el router`. MADRE lo acota al modo de tu mensaje y al `MAX MODE` de ese agente. Con tu mensaje en `#3`, la palabra del orquestador basta; con tu mensaje en `#1`, un paso `#2` pasa por la escalación.
|
|
99
|
+
|
|
100
|
+
**Dos controles por agente.** En `⚙ CONNECTIONS` cada agente tiene `MAX MODE`, hasta dónde puede llegar un mensaje dirigido a él, y `DEFAULT MODE`, dónde empieza: `#1` solo lectura hasta que armes CREATE, o `#2` para que cada turno pueda añadir archivos sin pedirlo. Aparte, dos habilidades: generar imágenes y web.
|
|
98
101
|
|
|
99
102
|
---
|
|
100
103
|
|
|
@@ -168,10 +171,10 @@ El botón de la barra abre la pantalla de diagnóstico. Escribe un síntoma, un
|
|
|
168
171
|
|
|
169
172
|
| Sección | Qué hace |
|
|
170
173
|
|---|---|
|
|
171
|
-
| `⚙ CONNECTIONS` | Sesión, versión y ruta de cada CLI; `SIGN IN` y `RECHECK`; `MAX MODE` y
|
|
174
|
+
| `⚙ CONNECTIONS` | Sesión, versión y ruta de cada CLI; `SIGN IN` y `RECHECK`; `MAX MODE`, `DEFAULT MODE` y habilidades; timeouts, presupuesto, delegación, modelo de OpenCode; MEMORY y PRIVACY |
|
|
172
175
|
| `◉ NOSTROMO` | El mapa de la memoria |
|
|
173
176
|
| `SENTINEL` | Fallos que ninguna condición explica y caídas del proceso, con rutas, usuarios, correos y claves eliminados. Cada reporte tiene `REPORT ON GITHUB ↗` para leerlo antes de publicarlo; `AUTO-REPORT`, apagado por defecto, envía los nuevos al colector del proyecto |
|
|
174
|
-
| `RELEASE CHANNEL` | Una consulta a npm al día. Si hay versión nueva, una alerta en la barra y aquí el comando
|
|
177
|
+
| `RELEASE CHANNEL` | Una consulta a npm al día. Si hay versión nueva, una alerta en la barra y aquí `RESTART WITH x.y.z`: la sala cierra, instala y vuelve en la misma dirección; o el comando para hacerlo tú. Nunca mientras los agentes trabajan |
|
|
175
178
|
| `✎ FEEDBACK` | Un issue en blanco con tu entorno ya escrito |
|
|
176
179
|
|
|
177
180
|
---
|
|
@@ -182,14 +185,15 @@ El botón de la barra abre la pantalla de diagnóstico. Escribe un síntoma, un
|
|
|
182
185
|
|
|
183
186
|
| Módulo | Qué añade |
|
|
184
187
|
|---|---|
|
|
185
|
-
| **Git Pulse** | `/git status`, `/git log [n]`, `/git diff`, `/git branches`: hechos del repositorio como tarjeta en el hilo, que los agentes también leen |
|
|
188
|
+
| **Git Pulse** | `/git status`, `/git log [n]`, `/git diff`, `/git branches`: hechos del repositorio como tarjeta en el hilo, que los agentes también leen. `/git commit "mensaje"` y `/git push confirm`: tu mano sobre el repositorio, con vista previa de lo que saldría |
|
|
186
189
|
| **Image Studio** | `generate_image` para Claude, Gemini y OpenCode, con tu key de Gemini, dentro de CREATE |
|
|
187
190
|
| **RIPLEY** | El visor renderiza HTML, SVG y Markdown en un marco sellado, con recarga automática |
|
|
188
191
|
| **OLLAMA** | Embeddings, archivista y `@madre` en local |
|
|
192
|
+
| **PLAYWRIGHT** | Un navegador headless por turno que solo alcanza esta MADRE: abrir la vista previa de RIPLEY, hacer clic, leer consola, capturas al borrador del turno. Requiere `@playwright/mcp` |
|
|
189
193
|
| **AshCode** (beta) | `$ ash_code`: abrevia mensajes localmente antes de enviarlos y pide respuestas concisas. Puede cambiar el significado; el original siempre queda |
|
|
190
194
|
| **AHP+** | Integración externa opcional: estado verificado del proyecto, checkpoints y handoffs en `.ahp/`; `/ahp status`, `/ahp check`, `/ahp context` |
|
|
191
195
|
|
|
192
|
-
Cada módulo es un archivo en `src/modules/` declarado con `defineModule
|
|
196
|
+
Cada módulo es un archivo en `src/modules/` declarado con `defineModule`; un módulo puede entregar herramientas MCP a cada turno con `toolsForTurn`, como hace PLAYWRIGHT. Cómo escribir uno, en [CONTRIBUTING.md](https://github.com/jossuealcacao-exe/madre/blob/main/CONTRIBUTING.md#writing-a-module).
|
|
193
197
|
|
|
194
198
|
---
|
|
195
199
|
|
|
@@ -198,7 +202,7 @@ Cada módulo es un archivo en `src/modules/` declarado con `defineModule`. Cómo
|
|
|
198
202
|
- **Nada por sí solo.** MADRE no tiene nube, cuenta ni backend. No guarda credenciales.
|
|
199
203
|
- **Lo que un agente lee, viaja a su proveedor.** Codex a OpenAI, Claude Code a Anthropic, Gemini CLI a Google, OpenCode a quien tenga configurado. Aplican su cuenta, sus límites y sus términos. `@madre` y el archivista con Ollama no salen de la máquina.
|
|
200
204
|
- **Dos envíos propios, ambos bajo tu interruptor.** El sentinel, apagado por defecto, envía reportes redactados al colector del proyecto. El canal de liberación, encendido por defecto, pregunta a npm por la última versión: viaja el nombre del paquete, nada más, la misma petición que hace `npx`. `PULSE_UPDATE_CHECK=0` lo apaga.
|
|
201
|
-
- **Escritura.** En `#1` nadie escribe. En `#2` solo
|
|
205
|
+
- **Escritura.** En `#1` nadie escribe. En `#2` solo se añade: lo que existía se restaura al terminar el turno. En `#3` todo el proyecto salvo las zonas prohibidas, con checkpoint y `UNDO`. En un proyecto sin git, MADRE guarda sus fotografías en un repositorio sombra fuera del proyecto.
|
|
202
206
|
- **Memoria.** Todo lo dicho fuera de GHOST queda en `~/.pulse/rooms/<sala>/` y vuelve a los prompts de todos los agentes de esa sala. GHOST es la salida para lo que no debe recordarse; PRIVACY, para los nombres que nunca deben aparecer.
|
|
203
207
|
|
|
204
208
|
---
|
package/docs/INTERNALS.md
CHANGED
|
@@ -62,9 +62,15 @@ Cada agente corre como proceso en su propio grupo, con un home aislado que solo
|
|
|
62
62
|
|
|
63
63
|
Los alcances de imagen de Claude, Gemini y OpenCode se cumplen con **Image Studio**: el servidor MCP `src/mcp/image-server.mjs`, adjunto solo dentro de un lease con el alcance encendido, que genera con la API de imágenes de Gemini y guarda el archivo en la carpeta del turno.
|
|
64
64
|
|
|
65
|
+
## CREATE por dentro
|
|
66
|
+
|
|
67
|
+
Un turno en `#2` toma la misma fotografía que CONTROL, sin asiento: varios turnos `#2` pueden correr a la vez. Al terminar, `diff` contra la fotografía: los archivos añadidos se conservan y se registran como `artifacts.created` con su ruta en el proyecto; los modificados, renombrados o borrados se restauran desde la fotografía y se anuncian en `create.reverted`. Las zonas prohibidas se restauran igual que en CONTROL. Cada CLI recibe sus herramientas de escritura sobre el proyecto: Claude Code niega `Write` si `Edit` no está permitido en la misma ruta y OpenCode deja sin herramienta de escritura si `edit` está denegado (ambos verificados con el CLI real), así que en `#2` van juntas y la garantía de "solo añadir" es la restauración posterior, no la regla previa. Gemini recibe solo `write_file`. El `scratchDir` bajo `.pulse/out/` sigue existiendo para lo que no tiene sitio y para Image Studio.
|
|
68
|
+
|
|
69
|
+
En un plan, el modo de cada paso es el mínimo entre lo que pidió el orquestador (`@agente #n:`), el techo del plan (el modo del mensaje humano) y el `MAX MODE` del agente. Bajo techo `#3`, un paso `#2` recibe un lease de proyecto emitido a nombre del orquestador y un paso `#3` toma CONTROL con su propio checkpoint, uno a la vez. Bajo techo `#1`, un paso `#2` pasa por la escalación al humano.
|
|
70
|
+
|
|
65
71
|
## CONTROL por dentro
|
|
66
72
|
|
|
67
|
-
Antes del turno, MADRE fotografía el proyecto como un commit real bajo `refs/madre/checkpoints/` con un índice temporal: incluye archivos sin seguimiento, respeta `.gitignore`, no toca tu rama, tu índice ni tu stash, y funciona en repositorios sin commits. Mientras dura el turno, `.env*`, `.pulse/`, `.madre/` y `.claude/settings.local.json` quedan en solo lectura a nivel de sistema de archivos y recuperan sus permisos al terminar. Después, `git diff` contra el checkpoint da la lista de cambios; cualquier escritura en una zona prohibida que hubiera pasado se revierte desde el checkpoint y se nombra. `UNDO` restaura el checkpoint completo.
|
|
73
|
+
Antes del turno, MADRE fotografía el proyecto como un commit real bajo `refs/madre/checkpoints/` con un índice temporal (en un proyecto sin git, en un repositorio sombra bajo el directorio temporal del sistema, con el proyecto como árbol de trabajo): incluye archivos sin seguimiento, respeta `.gitignore`, no toca tu rama, tu índice ni tu stash, y funciona en repositorios sin commits. Mientras dura el turno, `.env*`, `.pulse/`, `.madre/` y `.claude/settings.local.json` quedan en solo lectura a nivel de sistema de archivos y recuperan sus permisos al terminar. Después, `git diff` contra el checkpoint da la lista de cambios; cualquier escritura en una zona prohibida que hubiera pasado se revierte desde el checkpoint y se nombra. `UNDO` restaura el checkpoint completo.
|
|
68
74
|
|
|
69
75
|
## Recuperación operativa
|
|
70
76
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jossuealcala/madre",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "MADRE · one local room where the AI coding agents already on your machine (Codex, Claude Code, Gemini CLI, OpenCode) work on a project together, with a shared memory every one of them recalls and a local @madre that speaks for it. Read-only by default, per-message permission modes up to a checkpointed CONTROL. Nothing leaves your machine on its own.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
package/public/app.js
CHANGED
|
@@ -33,17 +33,18 @@ const els = {
|
|
|
33
33
|
// Built-in commands live in the composer; module commands come from the
|
|
34
34
|
// server (/api/commands) and run there, read-only, as fact cards.
|
|
35
35
|
const CLIENT_COMMANDS = [
|
|
36
|
-
{ name: 'create', title: 'CREATE', usage: '/create <what to make>', summary: 'Arm
|
|
36
|
+
{ name: 'create', title: 'CREATE', usage: '/create <what to make>', summary: 'Arm CREATE for this message: the agent may add new files to the project where they belong.', available: true, client: true },
|
|
37
37
|
{ name: 'image', title: 'Image', usage: '/image <what to draw>', summary: 'Ask for an image: arms CREATE with the image scope and routes to an agent that can generate images.', available: true, client: true },
|
|
38
38
|
{ name: 'stopall', title: 'STOP ALL', usage: '/stopall', summary: 'Master brake: halt every plan and turn in flight. Never reaches an agent.', available: true, client: true },
|
|
39
39
|
];
|
|
40
40
|
|
|
41
41
|
const PLACEHOLDERS = {
|
|
42
42
|
plain: 'Type here, human. Ask the room…',
|
|
43
|
-
create: '
|
|
43
|
+
create: 'CREATE on: what to make. New files land where they belong in the project; nothing existing changes.',
|
|
44
44
|
order: 'Priority one. Terse transmissions; the original stays in the record.',
|
|
45
45
|
ghost: 'Off the record. Ask anything; nothing is saved, nobody else will remember it.',
|
|
46
46
|
control: 'Control armed. Say what to change in the project; every action runs without asking.',
|
|
47
|
+
airlock: 'Airlock open. Commands run; pushes and deploys leave the ship. Say exactly what should go out.',
|
|
47
48
|
expendable: 'Type here, human. MOTHER is listening.',
|
|
48
49
|
memory: 'Ask what the room remembers. @madre answers from memory with citations; it does not act.',
|
|
49
50
|
};
|
|
@@ -55,8 +56,9 @@ const MAX_ROWS = 3;
|
|
|
55
56
|
const MODES = {
|
|
56
57
|
0: { key: 'ghost', label: 'GHOST', hint: 'Off the record. Nothing is saved; gone on reload.' },
|
|
57
58
|
1: { key: 'exchange', label: 'EXCHANGE', hint: 'Read the project and talk to the room. Writes nothing.' },
|
|
58
|
-
2: { key: 'create', label: 'CREATE', hint: '
|
|
59
|
+
2: { key: 'create', label: 'CREATE', hint: 'Add new files where they belong in the project. Existing files stay untouched.' },
|
|
59
60
|
3: { key: 'control', label: 'CONTROL', hint: 'Edit the project itself, no approval per action. Override required.' },
|
|
61
|
+
4: { key: 'airlock', label: 'AIRLOCK', hint: 'Run commands, push, deploy. What leaves the ship does not come back. Override, twice.' },
|
|
60
62
|
};
|
|
61
63
|
|
|
62
64
|
const state = {
|
|
@@ -841,28 +843,28 @@ function toggleModeMenu(id, anchor) {
|
|
|
841
843
|
title.append(el('b', null, `@${id}`), ` · mode for this message · ceiling #${cap}`);
|
|
842
844
|
modeMenu.append(title);
|
|
843
845
|
const ladder = el('div', 'mode-ladder');
|
|
844
|
-
for (const n of [0, 1, 2, 3]) {
|
|
846
|
+
for (const n of [0, 1, 2, 3, 4]) {
|
|
845
847
|
const locked = n > cap;
|
|
846
|
-
const raise = locked && n
|
|
848
|
+
const raise = locked && n >= 3 && canRaise;
|
|
847
849
|
const option = el('button', `mode-option o${n}${state.mode === n ? ' current' : ''}${locked ? ' locked' : ''}`);
|
|
848
850
|
option.type = 'button';
|
|
849
851
|
option.disabled = locked && !raise;
|
|
850
852
|
const body = el('span', 'body');
|
|
851
853
|
body.append(el('span', 'name', MODES[n].label), el('span', 'hint', MODES[n].hint));
|
|
852
854
|
option.append(el('span', 'n', `#${n}`), body);
|
|
853
|
-
const tag = state.mode === n ? 'NOW' : raise ?
|
|
855
|
+
const tag = state.mode === n ? 'NOW' : raise ? `RAISE TO #${n} ›` : locked ? 'LOCKED' : n === 4 ? 'OVERRIDE ×2' : n === 3 ? 'OVERRIDE' : n === defaultModeFor(id) ? 'DEFAULT' : '';
|
|
854
856
|
if (tag) {
|
|
855
857
|
const label = el('span', `tag${raise ? ' raise' : ''}`, tag);
|
|
856
858
|
if (state.mode === n) label.append(el('span', 'dot'));
|
|
857
859
|
option.append(label);
|
|
858
860
|
}
|
|
859
861
|
option.title = !locked ? MODES[n].hint
|
|
860
|
-
: raise ? `@${id} is capped at #${cap}. This raises MAX MODE to
|
|
861
|
-
: n
|
|
862
|
+
: raise ? `@${id} is capped at #${cap}. This raises MAX MODE to #${n} in CONNECTIONS and opens the override.`
|
|
863
|
+
: n >= 3 ? `@${id}'s CLI cannot write files, so ${MODES[n].label} is not possible for it.`
|
|
862
864
|
: `Above @${id}'s MAX MODE (#${cap}). Raise it in CONNECTIONS.`;
|
|
863
865
|
option.addEventListener('click', () => {
|
|
864
866
|
closeModeMenu();
|
|
865
|
-
if (n
|
|
867
|
+
if (n >= 3) { openOverride(id, { raise, mode: n }); return; }
|
|
866
868
|
setMode(n, { wink: n === 0 });
|
|
867
869
|
});
|
|
868
870
|
ladder.append(option);
|
|
@@ -878,20 +880,21 @@ function toggleModeMenu(id, anchor) {
|
|
|
878
880
|
|
|
879
881
|
// One place changes the mode: chip, classes, label, placeholder and the old CREATE state.
|
|
880
882
|
function setMode(n, { wink = false } = {}) {
|
|
881
|
-
const mode = [0, 1, 2, 3].includes(n) ? n : 1;
|
|
883
|
+
const mode = [0, 1, 2, 3, 4].includes(n) ? n : 1;
|
|
882
884
|
state.mode = mode;
|
|
883
885
|
state.create = mode === 2;
|
|
884
|
-
if (mode
|
|
886
|
+
if (mode < 3) state.modeArmedFor = null;
|
|
885
887
|
els.createToggle.setAttribute('aria-pressed', String(state.create));
|
|
886
888
|
els.composer.classList.toggle('creating', mode === 2);
|
|
887
889
|
els.composer.classList.toggle('ghost', mode === 0);
|
|
888
|
-
els.composer.classList.toggle('control', mode
|
|
889
|
-
|
|
890
|
+
els.composer.classList.toggle('control', mode >= 3);
|
|
891
|
+
els.composer.classList.toggle('airlock', mode === 4);
|
|
892
|
+
for (const n of [0, 1, 2, 3, 4]) els.composer.classList.toggle(`m${n}`, mode === n);
|
|
890
893
|
if (typeof renderCreateScopes === 'function') renderCreateScopes();
|
|
891
894
|
if (typeof updateCrewLabel === 'function') { updateCrewLabel(); updatePlaceholder(); }
|
|
892
895
|
if (typeof renderPicker === 'function') renderPicker();
|
|
893
896
|
if (typeof autosize === 'function') autosize();
|
|
894
|
-
if (wink && typeof winkField === 'function') winkField(mode
|
|
897
|
+
if (wink && typeof winkField === 'function') winkField(mode >= 3 ? 'control' : mode === 0 ? 'ghost' : 'ash');
|
|
895
898
|
}
|
|
896
899
|
// After a message goes out the mode falls back to the target's default: #2 for a standing lease, else #1.
|
|
897
900
|
function resetModeAfterSend() { setMode(defaultModeFor(els.target.value)); }
|
|
@@ -907,14 +910,25 @@ const override = {
|
|
|
907
910
|
cancel: document.querySelector('#override-cancel'),
|
|
908
911
|
frame: document.querySelector('#override .override-frame'),
|
|
909
912
|
agent: null,
|
|
910
|
-
raise: false, // the agent is capped below
|
|
913
|
+
raise: false, // the agent is capped below the mode: arming also raises MAX MODE in CONNECTIONS
|
|
914
|
+
mode: 3, // #3 CONTROL or #4 AIRLOCK
|
|
915
|
+
stage: 'designation', // AIRLOCK asks twice: the designation, then the word AIRLOCK
|
|
916
|
+
};
|
|
917
|
+
const OVERRIDE_BRIEF = {
|
|
918
|
+
3: (id, raise) => `PRIORITY ONE. CONTROL GIVES @${id} THE PROJECT ITSELF: READ, CREATE, MODIFY, NO APPROVAL PER ACTION.${raise ? ` THIS ALSO RAISES @${id} MAX MODE TO #3 IN CONNECTIONS.` : ''} TYPE THE PROJECT DESIGNATION TO ARM.`,
|
|
919
|
+
4: (id, raise) => `PRIORITY ONE. AIRLOCK OPENS THE SHIP FOR @${id}: EVERYTHING CONTROL ALLOWS, PLUS COMMANDS, GIT PUSH AND DEPLOYS WITH THE SESSIONS ON THIS MACHINE. FILES COME BACK WITH UNDO; WHAT LEAVES THE SHIP DOES NOT.${raise ? ` THIS ALSO RAISES @${id} MAX MODE TO #4 IN CONNECTIONS.` : ''} TYPE THE PROJECT DESIGNATION, THEN THE WORD AIRLOCK.`,
|
|
911
920
|
};
|
|
912
921
|
function projectDesignation() { return (state.projectRoot ?? '').split('/').filter(Boolean).pop() ?? ''; }
|
|
913
|
-
function openOverride(id, { raise = false } = {}) {
|
|
922
|
+
function openOverride(id, { raise = false, mode = 3 } = {}) {
|
|
914
923
|
if (!override.dialog) return;
|
|
915
924
|
override.agent = id;
|
|
916
925
|
override.raise = raise;
|
|
917
|
-
override.
|
|
926
|
+
override.mode = mode;
|
|
927
|
+
override.stage = 'designation';
|
|
928
|
+
override.form.querySelector('.k').textContent = 'DESIGNATION ›';
|
|
929
|
+
override.input.placeholder = "type the current project's folder name to arm";
|
|
930
|
+
override.dialog.querySelector('.mother-sub').textContent = mode === 4 ? 'AIRLOCK OVERRIDE 100375 · SECOND KEY REQUIRED' : 'EMERGENCY COMMAND OVERRIDE 100375';
|
|
931
|
+
override.brief.textContent = OVERRIDE_BRIEF[mode](id.toUpperCase(), raise);
|
|
918
932
|
override.reply.textContent = '';
|
|
919
933
|
override.reply.className = 'mother-answer override-reply';
|
|
920
934
|
override.input.value = '';
|
|
@@ -925,33 +939,50 @@ override.cancel?.addEventListener('click', () => override.dialog.close());
|
|
|
925
939
|
override.form?.addEventListener('submit', (event) => {
|
|
926
940
|
event.preventDefault();
|
|
927
941
|
const typed = override.input.value.trim();
|
|
928
|
-
const
|
|
929
|
-
|
|
930
|
-
override.reply.textContent = 'UNABLE TO COMPUTE. UNABLE TO CLARIFY.';
|
|
942
|
+
const deny = (text = 'UNABLE TO COMPUTE. UNABLE TO CLARIFY.') => {
|
|
943
|
+
override.reply.textContent = text;
|
|
931
944
|
override.reply.className = 'mother-answer override-reply denied';
|
|
932
945
|
override.frame.classList.remove('shake'); void override.frame.offsetWidth; override.frame.classList.add('shake');
|
|
933
946
|
override.input.select();
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
947
|
+
};
|
|
948
|
+
if (override.stage === 'designation') {
|
|
949
|
+
const expected = projectDesignation();
|
|
950
|
+
if (!typed || typed.toLowerCase() !== expected.toLowerCase()) { deny(); return; }
|
|
951
|
+
if (override.mode === 4) {
|
|
952
|
+
// The second key: the word itself, so an airlock is never opened by a folder name alone.
|
|
953
|
+
override.stage = 'confirm';
|
|
954
|
+
override.reply.textContent = 'DESIGNATION ACCEPTED. SECOND KEY: TYPE AIRLOCK TO OPEN THE SHIP.';
|
|
955
|
+
override.reply.className = 'mother-answer override-reply';
|
|
956
|
+
override.form.querySelector('.k').textContent = 'SECOND KEY ›';
|
|
957
|
+
override.input.value = ''; override.input.placeholder = 'AIRLOCK';
|
|
958
|
+
override.input.focus();
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
} else if (typed.toUpperCase() !== 'AIRLOCK') { deny('SECOND KEY REJECTED. TYPE AIRLOCK, OR CANCEL.'); return; }
|
|
962
|
+
override.reply.textContent = override.mode === 4
|
|
963
|
+
? `SPECIAL ORDER 937 ACKNOWLEDGED. AIRLOCK OPEN FOR @${override.agent.toUpperCase()}. WHAT LEAVES DOES NOT COME BACK.`
|
|
964
|
+
: `SPECIAL ORDER 937 ACKNOWLEDGED. CONTROL ARMED FOR @${override.agent.toUpperCase()}. CREW IN COMMAND.`;
|
|
937
965
|
override.reply.className = 'mother-answer override-reply granted';
|
|
938
966
|
const agent = override.agent;
|
|
939
967
|
const raise = override.raise;
|
|
968
|
+
const mode = override.mode;
|
|
940
969
|
setTimeout(async () => {
|
|
941
970
|
override.dialog.close();
|
|
942
971
|
if (raise) {
|
|
943
972
|
try {
|
|
944
|
-
await saveSettingNow({ scopes: { [agent]: { maxMode:
|
|
973
|
+
await saveSettingNow({ scopes: { [agent]: { maxMode: mode } } });
|
|
945
974
|
if (settingsUI.open) renderSettings();
|
|
946
975
|
} catch (error) {
|
|
947
|
-
toast(`MU/TH/UR › MAX MODE was not raised for @${agent}: ${error.message}.
|
|
976
|
+
toast(`MU/TH/UR › MAX MODE was not raised for @${agent}: ${error.message}. ${MODES[mode].label} stays off.`);
|
|
948
977
|
return;
|
|
949
978
|
}
|
|
950
979
|
}
|
|
951
980
|
state.modeArmedFor = agent;
|
|
952
981
|
els.target.value = agent;
|
|
953
|
-
setMode(
|
|
954
|
-
toast(
|
|
982
|
+
setMode(mode, { wink: true });
|
|
983
|
+
toast(mode === 4
|
|
984
|
+
? `MU/TH/UR › AIRLOCK open for @${agent} for this message.${raise ? ' MAX MODE is now #4 in CONNECTIONS.' : ''} Files are checkpointed; what leaves the machine is not undone.`
|
|
985
|
+
: `MU/TH/UR › CONTROL armed for @${agent} for this message.${raise ? ' MAX MODE is now #3 in CONNECTIONS.' : ''} A checkpoint is taken before it runs; every change is listed and UNDO is one click.`);
|
|
955
986
|
}, 900);
|
|
956
987
|
});
|
|
957
988
|
|
|
@@ -1426,6 +1457,18 @@ function renderLeaseRefused(event) {
|
|
|
1426
1457
|
|
|
1427
1458
|
/* ---------- CONTROL: the project itself, between two checkpoints ---------- */
|
|
1428
1459
|
|
|
1460
|
+
// create · @codex · 2 existing files put back: CREATE only adds (README.md, src/index.astro)
|
|
1461
|
+
function renderCreateReverted(event) {
|
|
1462
|
+
const { agent, existing = [], forbidden = [] } = event.payload;
|
|
1463
|
+
const node = el('div', 'system memory warn');
|
|
1464
|
+
node.style.setProperty('--agent', agentColor(agent));
|
|
1465
|
+
node.append('create · ', el('b', 'who', `@${agent}`));
|
|
1466
|
+
if (existing.length) node.append(` · ${existing.length} existing file${existing.length === 1 ? '' : 's'} put back, CREATE only adds: ${existing.slice(0, 4).join(', ')}${existing.length > 4 ? '…' : ''}`);
|
|
1467
|
+
if (forbidden.length) node.append(` · ${forbidden.length} write${forbidden.length === 1 ? '' : 's'} into forbidden zones reverted`);
|
|
1468
|
+
node.append(' · need to change existing files? ask again in #3 CONTROL');
|
|
1469
|
+
return node;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1429
1472
|
function renderControlStarted(event) {
|
|
1430
1473
|
const { agent, commit, message } = event.payload;
|
|
1431
1474
|
const node = el('div', 'system control');
|
|
@@ -1552,7 +1595,7 @@ function renderModeRequest(event) {
|
|
|
1552
1595
|
for (const other of actions.querySelectorAll('button')) other.disabled = false;
|
|
1553
1596
|
}
|
|
1554
1597
|
};
|
|
1555
|
-
const once = el('button', 'grant', 'GRANT ONCE'); once.type = 'button'; once.title = `@${agent}
|
|
1598
|
+
const once = el('button', 'grant', 'GRANT ONCE'); once.type = 'button'; once.title = `@${agent} may add files to the project for this step only.`;
|
|
1556
1599
|
const plan = el('button', 'grant plan', 'GRANT FOR PLAN'); plan.type = 'button'; plan.title = 'Every remaining writable step of this plan shares one lease directory.';
|
|
1557
1600
|
const deny = el('button', 'deny', 'DENY'); deny.type = 'button'; deny.title = `@${agent} answers read-only and says what it would have created.`;
|
|
1558
1601
|
once.addEventListener('click', () => decide('once', once));
|
|
@@ -1594,12 +1637,19 @@ function renderLease(event) {
|
|
|
1594
1637
|
const { agent, outDir, scopes = [], unavailable = [], standing = false, escalated = null } = event.payload;
|
|
1595
1638
|
const node = el('div', 'system lease');
|
|
1596
1639
|
node.style.setProperty('--agent', agentColor(agent));
|
|
1597
|
-
|
|
1598
|
-
node.append(
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1640
|
+
const { delegated = false, scratchDir = null, grantedBy = null } = event.payload;
|
|
1641
|
+
node.append(el('b', null, standing ? 'default #2 · ' : escalated ? `#2 granted on request${escalated === 'plan' ? ' · whole plan' : ''} · ` : delegated ? `#2 by @${grantedBy} · ` : 'create · '));
|
|
1642
|
+
node.append(`@${agent} may ${scopes.map((scope) => CAP_LABELS[scope] ?? scope).join(', ') || 'create files'}${unavailable.length ? ` (cannot ${unavailable.join(', ')})` : ''} `);
|
|
1643
|
+
if (outDir === '.' || !outDir) {
|
|
1644
|
+
node.append('anywhere in the project · existing files stay untouched');
|
|
1645
|
+
if (scratchDir) { node.append(' · scratch '); const link = el('a', 'file-link', scratchDir); link.href = '#'; link.addEventListener('click', (ev) => { ev.preventDefault(); }); node.append(link); }
|
|
1646
|
+
} else {
|
|
1647
|
+
node.append('in ');
|
|
1648
|
+
const link = el('a', 'file-link', outDir);
|
|
1649
|
+
link.href = '#';
|
|
1650
|
+
link.addEventListener('click', (ev) => { ev.preventDefault(); });
|
|
1651
|
+
node.append(link);
|
|
1652
|
+
}
|
|
1603
1653
|
state.lastSender = null;
|
|
1604
1654
|
return node;
|
|
1605
1655
|
}
|
|
@@ -1861,6 +1911,7 @@ function renderEventNode(event) {
|
|
|
1861
1911
|
case 'lease.missing': node = renderLeaseMissing(event); break;
|
|
1862
1912
|
case 'mode.requested': node = renderModeRequest(event); break;
|
|
1863
1913
|
case 'control.started': node = renderControlStarted(event); break;
|
|
1914
|
+
case 'create.reverted': node = renderCreateReverted(event); break;
|
|
1864
1915
|
case 'control.changed': node = renderControlChanged(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); break;
|
|
1865
1916
|
case 'control.reverted': node = renderControlReverted(event); break;
|
|
1866
1917
|
case 'mode.granted':
|
|
@@ -2215,7 +2266,7 @@ function renderMenu() {
|
|
|
2215
2266
|
if (found.kind === '!') return fileMenu(found);
|
|
2216
2267
|
if (found.kind === '#') {
|
|
2217
2268
|
const cap = ceilingFor(els.target.value);
|
|
2218
|
-
const items = [0, 1, 2, 3].filter((n) => String(n).startsWith(found.query)).map((n) => ({ key: `#${n} ${MODES[n].label}`, insert: `#${n} `, what: n > cap ? `${MODES[n].hint} · above @${els.target.value}'s max mode` : MODES[n].hint, off: n > cap }));
|
|
2269
|
+
const items = [0, 1, 2, 3, 4].filter((n) => String(n).startsWith(found.query)).map((n) => ({ key: `#${n} ${MODES[n].label}`, insert: `#${n} `, what: n > cap ? `${MODES[n].hint} · above @${els.target.value}'s max mode` : MODES[n].hint, off: n > cap }));
|
|
2219
2270
|
if (!items.length) return closeMenu();
|
|
2220
2271
|
return showMenu(items, { ...found, hint: 'MODE · ↑↓ · TAB OR ENTER' });
|
|
2221
2272
|
}
|
|
@@ -2351,8 +2402,8 @@ function renderCreateScopes() {
|
|
|
2351
2402
|
const standing = Boolean(scopes?.write?.always);
|
|
2352
2403
|
els.createToggle.classList.toggle('standing', standing);
|
|
2353
2404
|
els.createToggle.title = standing
|
|
2354
|
-
?
|
|
2355
|
-
: '
|
|
2405
|
+
? `@${id} starts in #2 (DEFAULT MODE in CONNECTIONS): every turn may add files to the project. Arming CREATE is not needed.`
|
|
2406
|
+
: 'CREATE: let the agent add new files to the project for this request; existing files stay untouched';
|
|
2356
2407
|
box.hidden = !state.create || !scopes;
|
|
2357
2408
|
if (box.hidden) return;
|
|
2358
2409
|
box.replaceChildren();
|
|
@@ -2380,7 +2431,7 @@ fetch('/api/mother').then((response) => response.json()).then((status) => { if (
|
|
|
2380
2431
|
|
|
2381
2432
|
function updateCrewLabel() {
|
|
2382
2433
|
const order = state.ashCode && state.ashCodeInstalled;
|
|
2383
|
-
els.crewLabel.textContent = state.intruder && state.mode
|
|
2434
|
+
els.crewLabel.textContent = state.intruder && state.mode < 3 ? 'INTRUDER ›' : state.mode >= 3 ? `MU/TH/UR · ${MODES[state.mode].label} @${(state.modeArmedFor ?? els.target.value ?? '').toUpperCase()} ›`
|
|
2384
2435
|
: state.mode === 0 ? 'HUMAN · GHOST ›'
|
|
2385
2436
|
: order ? (state.create ? 'MU/TH/UR · ASH · CREATE ›' : 'MU/TH/UR · ASH CODE ›')
|
|
2386
2437
|
: state.create ? 'HUMAN · CREATE ›'
|
|
@@ -2388,7 +2439,7 @@ function updateCrewLabel() {
|
|
|
2388
2439
|
}
|
|
2389
2440
|
function updatePlaceholder() {
|
|
2390
2441
|
const local = Boolean(state.agents.get(els.target.value)?.local);
|
|
2391
|
-
els.input.placeholder = state.mode === 3 ? PLACEHOLDERS.control : state.mode === 0 ? PLACEHOLDERS.ghost : local ? PLACEHOLDERS.memory : state.create ? PLACEHOLDERS.create : (state.ashCode && state.ashCodeInstalled) ? PLACEHOLDERS.order : state.expendable ? PLACEHOLDERS.expendable : PLACEHOLDERS.plain;
|
|
2442
|
+
els.input.placeholder = state.mode === 4 ? PLACEHOLDERS.airlock : state.mode === 3 ? PLACEHOLDERS.control : state.mode === 0 ? PLACEHOLDERS.ghost : local ? PLACEHOLDERS.memory : state.create ? PLACEHOLDERS.create : (state.ashCode && state.ashCodeInstalled) ? PLACEHOLDERS.order : state.expendable ? PLACEHOLDERS.expendable : PLACEHOLDERS.plain;
|
|
2392
2443
|
}
|
|
2393
2444
|
function setOrder937(on, { wink = false } = {}) {
|
|
2394
2445
|
state.ashCode = on;
|
|
@@ -2521,7 +2572,7 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
2521
2572
|
if (!response.ok) {
|
|
2522
2573
|
const result = await response.json().catch(() => ({ error: `Request failed (${response.status}).` }));
|
|
2523
2574
|
toast(`MU/TH/UR › ${result.error ?? 'The room rejected the message.'}`);
|
|
2524
|
-
if (state.mode
|
|
2575
|
+
if (state.mode >= 3 && [403, 409, 412].includes(response.status)) setMode(1);
|
|
2525
2576
|
} else {
|
|
2526
2577
|
els.input.value = '';
|
|
2527
2578
|
state.pending = [];
|
|
@@ -3298,7 +3349,8 @@ function connectionCard(agent) {
|
|
|
3298
3349
|
card.append(meta);
|
|
3299
3350
|
const scopes = el('div', 'scopes');
|
|
3300
3351
|
const agentScopes = settingsUI.data.settings.capabilities?.[agent.id]?.scopes ?? {};
|
|
3301
|
-
|
|
3352
|
+
// Two abilities per agent: images and web. Writing is not an ability, it is the ceiling below.
|
|
3353
|
+
for (const [key, labelText] of [['imageGen', 'GENERATE IMAGES'], ['web', 'WEB ACCESS']]) {
|
|
3302
3354
|
const scope = agentScopes[key] ?? { capable: false, enabled: false, wired: false };
|
|
3303
3355
|
const line = el('label', `scope${!scope.capable || !scope.wired ? ' unavailable' : ''}`);
|
|
3304
3356
|
const box = el('input'); box.type = 'checkbox'; box.checked = Boolean(scope.enabled); box.disabled = !scope.capable || !scope.wired;
|
|
@@ -3332,37 +3384,13 @@ function connectionCard(agent) {
|
|
|
3332
3384
|
line.append(assist);
|
|
3333
3385
|
}
|
|
3334
3386
|
scopes.append(line);
|
|
3335
|
-
if (key === 'write' && scope.capable) {
|
|
3336
|
-
const always = el('label', `scope sub${scope.enabled ? '' : ' unavailable'}`);
|
|
3337
|
-
const alwaysBox = el('input'); alwaysBox.type = 'checkbox'; alwaysBox.checked = Boolean(scope.always); alwaysBox.disabled = !scope.enabled;
|
|
3338
|
-
alwaysBox.dataset.agent = agent.id; alwaysBox.dataset.scope = 'alwaysCreate'; alwaysBox.className = 'scope-input';
|
|
3339
|
-
alwaysBox.addEventListener('change', async () => {
|
|
3340
|
-
alwaysBox.disabled = true;
|
|
3341
|
-
try {
|
|
3342
|
-
const response = await fetch('/api/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ scopes: { [agent.id]: { alwaysCreate: alwaysBox.checked } } }) });
|
|
3343
|
-
const result = await response.json().catch(() => ({}));
|
|
3344
|
-
if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
|
|
3345
|
-
if (result.settings?.capabilities) { state.capabilities = result.settings.capabilities; settingsUI.data.settings.capabilities = result.settings.capabilities; renderCreateScopes(); }
|
|
3346
|
-
toast(`MU/TH/UR › @${agent.id} ${alwaysBox.checked ? 'now holds a standing lease: every turn may create files in .pulse/out/, including plan steps.' : 'creates files only when you arm CREATE.'}`);
|
|
3347
|
-
} catch (error) {
|
|
3348
|
-
alwaysBox.checked = !alwaysBox.checked;
|
|
3349
|
-
toast(`Scope was not saved: ${error.message}`);
|
|
3350
|
-
} finally {
|
|
3351
|
-
alwaysBox.disabled = !scope.enabled;
|
|
3352
|
-
}
|
|
3353
|
-
});
|
|
3354
|
-
always.append(alwaysBox, 'ALWAYS · STANDING LEASE');
|
|
3355
|
-
always.append(el('span', 'why', scope.always ? 'every turn and plan step may create files' : 'only when CREATE is armed'));
|
|
3356
|
-
always.title = 'Skip arming CREATE: this agent gets a fresh .pulse/out/ directory on every turn, also when another agent delegates to it.';
|
|
3357
|
-
scopes.append(always);
|
|
3358
|
-
}
|
|
3359
3387
|
}
|
|
3360
3388
|
// The ceiling: how far the composer may take this agent.
|
|
3361
3389
|
const ceiling = el('div', 'ceiling');
|
|
3362
3390
|
ceiling.append(el('span', 'k', 'MAX MODE'));
|
|
3363
3391
|
const seg = el('div', 'seg');
|
|
3364
3392
|
const currentCap = agentScopes.maxMode ?? 1;
|
|
3365
|
-
for (const n of [0, 1, 2, 3]) {
|
|
3393
|
+
for (const n of [0, 1, 2, 3, 4]) {
|
|
3366
3394
|
const button = el('button', `seg-option o${n}${currentCap === n ? ' current' : ''}`, `#${n}`);
|
|
3367
3395
|
button.type = 'button';
|
|
3368
3396
|
button.title = `${MODES[n].label} · ${MODES[n].hint}`;
|
|
@@ -3372,7 +3400,7 @@ function connectionCard(agent) {
|
|
|
3372
3400
|
if (n === currentCap) return;
|
|
3373
3401
|
for (const other of seg.children) other.disabled = true;
|
|
3374
3402
|
try {
|
|
3375
|
-
await saveSettingNow({ scopes: { [agent.id]: { maxMode: n
|
|
3403
|
+
await saveSettingNow({ scopes: { [agent.id]: { maxMode: n } } }, `@${agent.id} is now capped at #${n} ${MODES[n].label}.${n === 3 ? ' CONTROL still needs the override per message.' : ''}`);
|
|
3376
3404
|
await loadSettings();
|
|
3377
3405
|
} catch (error) { toast(`Max mode was not saved: ${error.message}`); for (const other of seg.children) other.disabled = false; }
|
|
3378
3406
|
});
|
|
@@ -3381,6 +3409,29 @@ function connectionCard(agent) {
|
|
|
3381
3409
|
ceiling.append(seg);
|
|
3382
3410
|
ceiling.append(el('span', 'why', `${MODES[currentCap].label} · ${MODES[currentCap].hint}`));
|
|
3383
3411
|
scopes.append(ceiling);
|
|
3412
|
+
// The start: where a message to this agent begins. #2 means every turn may create files without arming CREATE.
|
|
3413
|
+
const start = el('div', 'ceiling');
|
|
3414
|
+
start.append(el('span', 'k', 'DEFAULT MODE'));
|
|
3415
|
+
const startSeg = el('div', 'seg');
|
|
3416
|
+
const currentStart = agentScopes.defaultMode ?? 1;
|
|
3417
|
+
for (const n of [1, 2]) {
|
|
3418
|
+
const button = el('button', `seg-option o${n}${currentStart === n ? ' current' : ''}`, `#${n}`);
|
|
3419
|
+
button.type = 'button';
|
|
3420
|
+
button.title = n === 2 ? 'Every message to this agent starts in CREATE: new files where they belong, existing files untouched. Plan steps to it too.' : 'Messages start read-only; arm CREATE when you want files.';
|
|
3421
|
+
button.disabled = n > currentCap;
|
|
3422
|
+
button.addEventListener('click', async () => {
|
|
3423
|
+
if (n === currentStart) return;
|
|
3424
|
+
for (const other of startSeg.children) other.disabled = true;
|
|
3425
|
+
try {
|
|
3426
|
+
await saveSettingNow({ scopes: { [agent.id]: { defaultMode: n } } }, n === 2 ? `@${agent.id} starts in #2 CREATE: every turn may add files to the project.` : `@${agent.id} starts read-only; arm CREATE when you want files.`);
|
|
3427
|
+
await loadSettings();
|
|
3428
|
+
} catch (error) { toast(`Default mode was not saved: ${error.message}`); for (const other of startSeg.children) other.disabled = false; }
|
|
3429
|
+
});
|
|
3430
|
+
startSeg.append(button);
|
|
3431
|
+
}
|
|
3432
|
+
start.append(startSeg);
|
|
3433
|
+
start.append(el('span', 'why', currentStart === 2 ? 'every turn may create files, plan steps too' : 'read-only until you arm CREATE'));
|
|
3434
|
+
scopes.append(start);
|
|
3384
3435
|
card.append(scopes);
|
|
3385
3436
|
|
|
3386
3437
|
const row = el('div', 'row');
|
|
@@ -4490,11 +4541,13 @@ document.querySelector('#nostromo-forget')?.addEventListener('click', async (eve
|
|
|
4490
4541
|
|
|
4491
4542
|
/* ---------- Release channel: is there a newer MADRE? A pill in the bar, the command in MU/TH/UR. ---------- */
|
|
4492
4543
|
|
|
4493
|
-
const updateUI = { section: document.querySelector('#mother-update'), pill: document.querySelector('#update-pill'), info: null, timer: null };
|
|
4544
|
+
const updateUI = { section: document.querySelector('#mother-update'), pill: document.querySelector('#update-pill'), info: null, timer: null, announced: null, restarting: false };
|
|
4494
4545
|
async function loadVersion({ force = false } = {}) {
|
|
4495
4546
|
try {
|
|
4496
4547
|
updateUI.info = await fetch(`/api/version${force ? '?force=1' : ''}`).then((response) => response.json());
|
|
4497
4548
|
} catch { updateUI.info = null; }
|
|
4549
|
+
// The first time a newer version shows up in this session, say so once; the pill stays.
|
|
4550
|
+
if (updateUI.info?.available && updateUI.announced !== updateUI.info.latest && !replaying) { updateUI.announced = updateUI.info.latest; toast(`MU/TH/UR › MADRE ${updateUI.info.latest} is on npm. Open MU/TH/UR to restart with it.`); }
|
|
4498
4551
|
renderUpdate();
|
|
4499
4552
|
clearTimeout(updateUI.timer);
|
|
4500
4553
|
updateUI.timer = setTimeout(() => void loadVersion(), 60 * 60 * 1000);
|
|
@@ -4514,7 +4567,31 @@ function renderUpdate() {
|
|
|
4514
4567
|
const when = info.checkedAt ? new Date(info.checkedAt).toLocaleString() : null;
|
|
4515
4568
|
section.append(el('h3', null, info.available ? `RELEASE CHANNEL · ${info.latest} AVAILABLE · YOU RUN ${info.current}` : `RELEASE CHANNEL · MADRE ${info.current}${info.latest ? ' · UP TO DATE' : info.enabled ? ' · NPM NOT REACHED YET' : ' · CHECK OFF'}`));
|
|
4516
4569
|
if (info.available) {
|
|
4517
|
-
|
|
4570
|
+
const canRestart = info.install !== 'source';
|
|
4571
|
+
section.append(el('p', 'note', `A NEWER MADRE IS ON NPM. THIS COPY RUNS ${info.install === 'npx' ? 'FROM THE NPX CACHE' : info.install === 'project' ? 'FROM THIS PROJECT\'S NODE_MODULES' : info.install === 'global' ? 'AS A GLOBAL INSTALL' : 'FROM SOURCE'}. ${canRestart ? 'RESTART WITH IT HERE: THE ROOM CLOSES, INSTALLS, AND COMES BACK ON THIS SAME ADDRESS IN A FEW SECONDS. NOTHING IN THE LEDGER IS LOST. OR RUN THE COMMAND YOURSELF.' : 'PULL THE REPOSITORY AND START IT AGAIN.'}`));
|
|
4572
|
+
if (canRestart) {
|
|
4573
|
+
const restart = el('button', 'update-restart', updateUI.restarting ? 'RESTARTING…' : `RESTART WITH ${info.latest}`);
|
|
4574
|
+
restart.type = 'button';
|
|
4575
|
+
restart.disabled = updateUI.restarting;
|
|
4576
|
+
restart.addEventListener('click', async () => {
|
|
4577
|
+
restart.disabled = true; restart.textContent = 'RESTARTING…'; updateUI.restarting = true;
|
|
4578
|
+
try {
|
|
4579
|
+
const payload = await fetch('/api/updates/apply', { method: 'POST' }).then((response) => response.json());
|
|
4580
|
+
if (payload.error) throw new Error(payload.error);
|
|
4581
|
+
toast(`MU/TH/UR › closing to install ${payload.to}. Back in a moment.`);
|
|
4582
|
+
const from = info.current;
|
|
4583
|
+
const wait = async () => {
|
|
4584
|
+
for (let attempt = 0; attempt < 90; attempt += 1) {
|
|
4585
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
4586
|
+
try { const next = await fetch('/api/version', { cache: 'no-store' }).then((response) => response.json()); if (next?.current && next.current !== from) { location.reload(); return; } } catch { /* still restarting */ }
|
|
4587
|
+
}
|
|
4588
|
+
updateUI.restarting = false; renderUpdate(); toast('MU/TH/UR › the room did not come back on its own. Start it from your terminal.');
|
|
4589
|
+
};
|
|
4590
|
+
void wait();
|
|
4591
|
+
} catch (error) { updateUI.restarting = false; renderUpdate(); toast(`Update did not start: ${error.message}`); }
|
|
4592
|
+
});
|
|
4593
|
+
section.append(restart);
|
|
4594
|
+
}
|
|
4518
4595
|
const row = el('div', 'update-command');
|
|
4519
4596
|
const code = el('code', null, info.command);
|
|
4520
4597
|
const copy = el('button', null, 'COPY');
|
package/public/index.html
CHANGED
|
@@ -156,7 +156,7 @@
|
|
|
156
156
|
<button type="submit" class="mother-close">ARM</button>
|
|
157
157
|
</form>
|
|
158
158
|
<p class="mother-answer override-reply" id="override-reply" aria-live="polite"></p>
|
|
159
|
-
<footer class="mother-foot">
|
|
159
|
+
<footer class="mother-foot">ONE HOLDER AT A TIME · EVERY TURN IS CHECKPOINTED · WHAT LEAVES THE SHIP DOES NOT COME BACK · STOPALL REVOKES IT</footer>
|
|
160
160
|
</div>
|
|
161
161
|
</dialog>
|
|
162
162
|
<dialog id="nostromo-gate" class="mother override" aria-label="NOSTROMO access">
|