@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 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 menú |
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** | Crear archivos e imágenes, solo dentro de `<proyecto>/.pulse/out/<fecha>-<id>/`. |
89
- | `#3` | **CONTROL** | Editar el proyecto real sin aprobación por acción. Un titular por sala, checkpoint git antes, lista de cambios y `UNDO` después. |
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.** MADRE crea la carpeta del turno y pasa cada CLI a escritura acotada a ella: Codex con `--sandbox workspace-write` sobre la carpeta, Claude con `Write`/`Edit` permitidos solo ahí, Gemini con reglas de política, OpenCode con permisos `edit` por patrón. Al terminar compara la carpeta y registra lo aparecido como `artifacts.created`; los archivos se muestran bajo la respuesta.
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
- **Techo por agente.** En `⚙ CONNECTIONS` cada agente tiene un `MAX MODE` y sus alcances: crear archivos, generar imágenes, web. `ALWAYS · STANDING LEASE` da a un agente una carpeta de creación en cada turno sin pedirla.
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 alcances; timeouts, presupuesto, delegación, modelo de OpenCode; MEMORY y PRIVACY |
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 exacto para cómo corre tu copia. MADRE nunca se actualiza sola |
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`. Cómo escribir uno, en [CONTRIBUTING.md](https://github.com/jossuealcacao-exe/madre/blob/main/CONTRIBUTING.md#writing-a-module).
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 dentro de `.pulse/out/`. En `#3` todo el proyecto salvo las zonas prohibidas, con checkpoint y `UNDO`.
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.1",
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 the creation lease for this message: the agent may create files inside .pulse/out/.', available: true, client: true },
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: 'Creation lease on: what to create and how it should look. It lands in .pulse/out/',
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: 'Write new files, only inside this turn\'s .pulse/out/.' },
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 === 3 && canRaise;
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 ? 'RAISE TO #3 ›' : locked ? 'LOCKED' : n === 3 ? 'OVERRIDE' : n === defaultModeFor(id) ? 'DEFAULT' : '';
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 #3 in CONNECTIONS and opens the override.`
861
- : n === 3 ? `@${id}'s CLI cannot write files, so CONTROL is not possible for it.`
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 === 3) { openOverride(id, { raise }); return; }
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 !== 3) state.modeArmedFor = null;
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 === 3);
889
- for (const n of [0, 1, 2, 3]) els.composer.classList.toggle(`m${n}`, mode === n);
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 === 3 ? 'control' : mode === 0 ? 'ghost' : 'ash');
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 #3: arming also raises MAX MODE in CONNECTIONS
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.brief.textContent = `PRIORITY ONE. CONTROL GIVES @${id.toUpperCase()} THE PROJECT ITSELF: READ, CREATE, MODIFY, NO APPROVAL PER ACTION.${raise ? ` THIS ALSO RAISES @${id.toUpperCase()} MAX MODE TO #3 IN CONNECTIONS.` : ''} TYPE THE PROJECT DESIGNATION TO ARM.`;
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 expected = projectDesignation();
929
- if (!typed || typed.toLowerCase() !== expected.toLowerCase()) {
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
- return;
935
- }
936
- override.reply.textContent = `SPECIAL ORDER 937 ACKNOWLEDGED. CONTROL ARMED FOR @${override.agent.toUpperCase()}. CREW IN COMMAND.`;
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: 3, write: true } } });
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}. CONTROL stays off.`);
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(3, { wink: true });
954
- toast(`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.`);
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} creates files for this step only, inside its own .pulse/out/ directory.`;
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
- node.append(el('b', null, standing ? 'standing lease · ' : escalated ? `lease granted on request${escalated === 'plan' ? ' · whole plan' : ''} · ` : 'creation lease · '));
1598
- node.append(`@${agent} may ${scopes.map((scope) => CAP_LABELS[scope] ?? scope).join(', ') || 'create files'}${unavailable.length ? ` (cannot ${unavailable.join(', ')})` : ''} in `);
1599
- const link = el('a', 'file-link', outDir);
1600
- link.href = '#';
1601
- link.addEventListener('click', (ev) => { ev.preventDefault(); });
1602
- node.append(link);
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
- ? `Standing lease: every turn of @${id} may create files inside .pulse/out/ (set in CONNECTIONS). Arming CREATE is not needed.`
2355
- : 'Creation lease: let the agent create files for this request, only inside .pulse/out/';
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 !== 3 ? 'INTRUDER ›' : state.mode === 3 ? `MU/TH/UR · CONTROL @${(state.modeArmedFor ?? els.target.value ?? '').toUpperCase()} ›`
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 === 3 && [403, 409, 412].includes(response.status)) setMode(1);
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
- for (const [key, labelText] of [['write', 'CREATE FILES'], ['imageGen', 'GENERATE IMAGES'], ['web', 'WEB ACCESS']]) {
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, ...(n >= 2 ? { write: true } : {}) } } }, `@${agent.id} is now capped at #${n} ${MODES[n].label}.${n === 3 ? ' CONTROL still needs the override per message.' : ''}`);
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
- 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'}; STOP THE ROOM, RUN THIS IN YOUR TERMINAL, START AGAIN. MADRE NEVER UPDATES ITSELF WHILE YOU WORK.`));
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">CONTROL IS NEVER DELEGATED · EVERY TURN IS CHECKPOINTED · STOPALL REVOKES IT</footer>
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">