@jossuealcala/madre 0.3.2 → 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,8 +4,24 @@ 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
+
7
19
  ## 0.3.2 · 2026-09-19
8
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
+
9
25
  ### Modos y permisos, una sola lógica
10
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.
11
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.
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 |
@@ -86,11 +86,12 @@ Cada mensaje sale con un modo. Tu modo es el techo de cualquier plan que ese men
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
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 git antes, lista de cambios y `UNDO` después. |
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
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
 
@@ -173,7 +174,7 @@ El botón de la barra abre la pantalla de diagnóstico. Escribe un síntoma, un
173
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 |
174
175
  | `◉ NOSTROMO` | El mapa de la memoria |
175
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 |
176
- | `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 |
177
178
  | `✎ FEEDBACK` | Un issue en blanco con tu entorno ya escrito |
178
179
 
179
180
  ---
@@ -184,14 +185,15 @@ El botón de la barra abre la pantalla de diagnóstico. Escribe un síntoma, un
184
185
 
185
186
  | Módulo | Qué añade |
186
187
  |---|---|
187
- | **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 |
188
189
  | **Image Studio** | `generate_image` para Claude, Gemini y OpenCode, con tu key de Gemini, dentro de CREATE |
189
190
  | **RIPLEY** | El visor renderiza HTML, SVG y Markdown en un marco sellado, con recarga automática |
190
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` |
191
193
  | **AshCode** (beta) | `$ ash_code`: abrevia mensajes localmente antes de enviarlos y pide respuestas concisas. Puede cambiar el significado; el original siempre queda |
192
194
  | **AHP+** | Integración externa opcional: estado verificado del proyecto, checkpoints y handoffs en `.ahp/`; `/ahp status`, `/ahp check`, `/ahp context` |
193
195
 
194
- 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).
195
197
 
196
198
  ---
197
199
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jossuealcala/madre",
3
- "version": "0.3.2",
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
@@ -44,6 +44,7 @@ const PLACEHOLDERS = {
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
  };
@@ -57,6 +58,7 @@ const MODES = {
57
58
  1: { key: 'exchange', label: 'EXCHANGE', hint: 'Read the project and talk to the room. Writes nothing.' },
58
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
 
@@ -2235,7 +2266,7 @@ function renderMenu() {
2235
2266
  if (found.kind === '!') return fileMenu(found);
2236
2267
  if (found.kind === '#') {
2237
2268
  const cap = ceilingFor(els.target.value);
2238
- 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 }));
2239
2270
  if (!items.length) return closeMenu();
2240
2271
  return showMenu(items, { ...found, hint: 'MODE · ↑↓ · TAB OR ENTER' });
2241
2272
  }
@@ -2400,7 +2431,7 @@ fetch('/api/mother').then((response) => response.json()).then((status) => { if (
2400
2431
 
2401
2432
  function updateCrewLabel() {
2402
2433
  const order = state.ashCode && state.ashCodeInstalled;
2403
- 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()} ›`
2404
2435
  : state.mode === 0 ? 'HUMAN · GHOST ›'
2405
2436
  : order ? (state.create ? 'MU/TH/UR · ASH · CREATE ›' : 'MU/TH/UR · ASH CODE ›')
2406
2437
  : state.create ? 'HUMAN · CREATE ›'
@@ -2408,7 +2439,7 @@ function updateCrewLabel() {
2408
2439
  }
2409
2440
  function updatePlaceholder() {
2410
2441
  const local = Boolean(state.agents.get(els.target.value)?.local);
2411
- 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;
2412
2443
  }
2413
2444
  function setOrder937(on, { wink = false } = {}) {
2414
2445
  state.ashCode = on;
@@ -2541,7 +2572,7 @@ els.composer.addEventListener('submit', async (event) => {
2541
2572
  if (!response.ok) {
2542
2573
  const result = await response.json().catch(() => ({ error: `Request failed (${response.status}).` }));
2543
2574
  toast(`MU/TH/UR › ${result.error ?? 'The room rejected the message.'}`);
2544
- 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);
2545
2576
  } else {
2546
2577
  els.input.value = '';
2547
2578
  state.pending = [];
@@ -3359,7 +3390,7 @@ function connectionCard(agent) {
3359
3390
  ceiling.append(el('span', 'k', 'MAX MODE'));
3360
3391
  const seg = el('div', 'seg');
3361
3392
  const currentCap = agentScopes.maxMode ?? 1;
3362
- for (const n of [0, 1, 2, 3]) {
3393
+ for (const n of [0, 1, 2, 3, 4]) {
3363
3394
  const button = el('button', `seg-option o${n}${currentCap === n ? ' current' : ''}`, `#${n}`);
3364
3395
  button.type = 'button';
3365
3396
  button.title = `${MODES[n].label} · ${MODES[n].hint}`;
@@ -3369,7 +3400,7 @@ function connectionCard(agent) {
3369
3400
  if (n === currentCap) return;
3370
3401
  for (const other of seg.children) other.disabled = true;
3371
3402
  try {
3372
- 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.' : ''}`);
3373
3404
  await loadSettings();
3374
3405
  } catch (error) { toast(`Max mode was not saved: ${error.message}`); for (const other of seg.children) other.disabled = false; }
3375
3406
  });
@@ -4510,11 +4541,13 @@ document.querySelector('#nostromo-forget')?.addEventListener('click', async (eve
4510
4541
 
4511
4542
  /* ---------- Release channel: is there a newer MADRE? A pill in the bar, the command in MU/TH/UR. ---------- */
4512
4543
 
4513
- 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 };
4514
4545
  async function loadVersion({ force = false } = {}) {
4515
4546
  try {
4516
4547
  updateUI.info = await fetch(`/api/version${force ? '?force=1' : ''}`).then((response) => response.json());
4517
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.`); }
4518
4551
  renderUpdate();
4519
4552
  clearTimeout(updateUI.timer);
4520
4553
  updateUI.timer = setTimeout(() => void loadVersion(), 60 * 60 * 1000);
@@ -4534,7 +4567,31 @@ function renderUpdate() {
4534
4567
  const when = info.checkedAt ? new Date(info.checkedAt).toLocaleString() : null;
4535
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'}`));
4536
4569
  if (info.available) {
4537
- 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
+ }
4538
4595
  const row = el('div', 'update-command');
4539
4596
  const code = el('code', null, info.command);
4540
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">
package/public/styles.css CHANGED
@@ -39,6 +39,7 @@
39
39
  --mode-1: var(--phosphor); --mode-1-ink: var(--phosphor-ink); /* EXCHANGE · phosphor */
40
40
  --mode-2: #ffb000; --mode-2-ink: #1a1000; /* CREATE · amber phosphor */
41
41
  --mode-3: var(--terror); --mode-3-ink: #fff3f1; /* CONTROL · the red screen */
42
+ --mode-4: #8fd3ff; --mode-4-ink: #04131c; /* AIRLOCK · the cold outside */
42
43
  }
43
44
  @media (prefers-color-scheme: light) {
44
45
  :root:not([data-theme="dark"]) {
@@ -65,6 +66,7 @@
65
66
  --mode-0: #6f786e; --mode-0-ink: #ffffff;
66
67
  --mode-2: #a06e00; --mode-2-ink: #ffffff;
67
68
  --mode-3: #d81b12; --mode-3-ink: #ffffff;
69
+ --mode-4: #0b6fa8; --mode-4-ink: #ffffff;
68
70
  }
69
71
  }
70
72
  :root[data-theme="light"] {
@@ -395,6 +397,7 @@ textarea::placeholder { color: var(--text-3); }
395
397
  .composer.m0 { --mode: var(--mode-0); --mode-ink: var(--mode-0-ink); }
396
398
  .composer.m2 { --mode: var(--mode-2); --mode-ink: var(--mode-2-ink); }
397
399
  .composer.m3 { --mode: var(--mode-3); --mode-ink: var(--mode-3-ink); }
400
+ .composer.m4 { --mode: var(--mode-4); --mode-ink: var(--mode-4-ink); }
398
401
  .field button[type="submit"] { width: 34px; height: 34px; padding: 0; border: 0; border-radius: 50%; background: var(--mode, var(--phosphor)); color: var(--mode-ink, var(--phosphor-ink)); cursor: pointer; display: grid; place-items: center; box-shadow: 0 0 18px color-mix(in srgb, var(--mode, var(--phosphor)) 45%, transparent); transition: background .25s, box-shadow .25s; }
399
402
  .field button[type="submit"] svg { width: 18px; height: 18px; }
400
403
  .field button[type="submit"]:hover { filter: brightness(1.06); }
@@ -566,11 +569,15 @@ dialog.mother::backdrop { background: rgba(0, 0, 0, .82); backdrop-filter: blur(
566
569
  .stop-all.armed { opacity: 1; cursor: pointer; border-color: var(--terror); background: color-mix(in srgb, var(--terror) 12%, transparent); color: var(--terror); text-shadow: 0 0 8px color-mix(in srgb, var(--terror) 60%, transparent); animation: blink 1.4s infinite; }
567
570
  .stop-all.armed:hover { background: var(--terror); color: #fff; text-shadow: none; animation: none; }
568
571
  .stop-all-glyph { width: 8px; height: 8px; background: currentColor; }
569
- .update-pill { opacity: 1; cursor: pointer; border-color: var(--warn); background: color-mix(in srgb, var(--warn) 12%, transparent); color: var(--warn); text-shadow: 0 0 8px color-mix(in srgb, var(--warn) 60%, transparent); animation: update-breathe 2.8s ease-in-out infinite; }
570
- .update-pill:hover { background: var(--warn); color: #050605; text-shadow: none; animation: none; }
571
- .update-pill .update-glyph { border-radius: 50%; }
572
- @keyframes update-breathe { 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 0%, transparent); } 50% { box-shadow: 0 0 14px 0 color-mix(in srgb, var(--warn) 45%, transparent); } }
573
- @media (prefers-reduced-motion: reduce) { .update-pill { animation: none; } }
572
+ /* The version alert: an amber hairline, a soft fill, no glow. It should read as information, not alarm. */
573
+ .update-pill { opacity: 1; cursor: pointer; border-color: color-mix(in srgb, var(--warn) 45%, transparent); background: color-mix(in srgb, var(--warn) 9%, transparent); color: var(--warn); text-shadow: none; transition: background-color .22s ease, border-color .22s ease, color .22s ease, transform .16s ease; }
574
+ .update-pill:hover { background: color-mix(in srgb, var(--warn) 16%, transparent); border-color: color-mix(in srgb, var(--warn) 70%, transparent); }
575
+ .update-pill:active { transform: scale(.98); }
576
+ .update-pill:focus-visible { outline: 2px solid color-mix(in srgb, var(--warn) 60%, transparent); outline-offset: 2px; }
577
+ .update-pill .update-glyph { border-radius: 50%; opacity: .9; }
578
+ .update-restart { display: inline-flex; align-items: center; height: 30px; padding: 0 14px; margin: 4px 0 10px; border: 1px solid var(--ph); background: color-mix(in srgb, var(--ph) 12%, transparent); color: var(--ph); font: inherit; font-size: 10px; letter-spacing: .18em; cursor: pointer; transition: background-color .22s ease, color .22s ease, opacity .22s ease; }
579
+ .update-restart:hover { background: color-mix(in srgb, var(--ph) 22%, transparent); }
580
+ .update-restart:disabled { opacity: .55; cursor: default; }
574
581
  .system.alert { color: var(--terror); text-shadow: 0 0 10px color-mix(in srgb, var(--terror) 70%, transparent); letter-spacing: .14em; animation: terror-in 1.4s steps(3, end) 1; }
575
582
  .system.alert b { color: var(--terror); }
576
583
  .system.alert .cmd { display: inline-block; margin-left: 8px; padding: 1px 8px; border: 1px solid var(--terror); letter-spacing: .2em; }
@@ -643,6 +650,7 @@ dialog.mother::backdrop { background: rgba(0, 0, 0, .82); backdrop-filter: blur(
643
650
  .picker .mode-chip.m1 { --mode: var(--mode-1); }
644
651
  .picker .mode-chip.m2 { --mode: var(--mode-2); }
645
652
  .picker .mode-chip.m3 { --mode: var(--mode-3); }
653
+ .picker .mode-chip.m4 { --mode: var(--mode-4); }
646
654
  .mode-menu { width: min(380px, calc(100vw - 24px)); padding: 12px; }
647
655
  .mode-menu .model-menu-title { margin: 4px 10px 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
648
656
  .mode-menu .mode-ladder { display: grid; gap: 2px; }
@@ -653,6 +661,7 @@ dialog.mother::backdrop { background: rgba(0, 0, 0, .82); backdrop-filter: blur(
653
661
  .mode-menu .mode-option.o1.current { --mode: var(--mode-1); }
654
662
  .mode-menu .mode-option.o2.current { --mode: var(--mode-2); }
655
663
  .mode-menu .mode-option.o3 { --mode: var(--mode-3); }
664
+ .mode-menu .mode-option.o4 { --mode: var(--mode-4); }
656
665
  .mode-menu .mode-option.locked { opacity: .45; }
657
666
  .mode-menu .mode-option.locked:hover { opacity: 1; }
658
667
  .mode-menu .mode-option:disabled { opacity: .3; cursor: default; }
@@ -661,11 +670,11 @@ dialog.mother::backdrop { background: rgba(0, 0, 0, .82); backdrop-filter: blur(
661
670
  .mode-menu .mode-option .body { display: grid; gap: 3px; min-width: 0; }
662
671
  .mode-menu .mode-option .name { font-size: 11px; font-weight: 700; letter-spacing: .14em; color: var(--mode); }
663
672
  .mode-menu .mode-option.current .name, .mode-menu .mode-option:hover .name { color: var(--mode); }
664
- .mode-menu .mode-option:not(.current):not(.o3) .name { color: var(--text); }
673
+ .mode-menu .mode-option:not(.current):not(.o3):not(.o4) .name { color: var(--text); }
665
674
  .mode-menu .mode-option .body { gap: 5px; }
666
675
  .mode-menu .mode-option .hint { display: block; font-size: 10.5px; line-height: 1.5; letter-spacing: 0; color: var(--text-2); text-transform: none; font-family: var(--sans, inherit); }
667
676
  .mode-menu .mode-option .tag { display: inline-flex; align-items: center; gap: 7px; font-size: 8.5px; font-weight: 700; letter-spacing: .16em; color: var(--mode); white-space: nowrap; align-self: center; margin-left: 4px; }
668
- .mode-menu .mode-option .tag.raise { border: 1px solid var(--mode-3); border-radius: 999px; padding: 3px 9px; }
677
+ .mode-menu .mode-option .tag.raise { border: 1px solid var(--mode); border-radius: 999px; padding: 3px 9px; }
669
678
  .mode-menu .mode-option .tag .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--mode); box-shadow: 0 0 8px var(--mode); animation: mode-dot 1.4s ease-in-out infinite; }
670
679
  @keyframes mode-dot { 0%, 100% { opacity: 1; } 50% { opacity: .15; } }
671
680
  @media (prefers-reduced-motion: reduce) { .mode-menu .mode-option .tag .dot { animation: none; } }
@@ -677,10 +686,13 @@ dialog.mother::backdrop { background: rgba(0, 0, 0, .82); backdrop-filter: blur(
677
686
  .composer.control { width: min(1180px, 100% - 40px); }
678
687
  .composer.control .field { border-color: var(--terror); box-shadow: 0 0 0 3px color-mix(in srgb, var(--terror) 18%, transparent), 0 0 30px color-mix(in srgb, var(--terror) 20%, transparent); }
679
688
  .composer.control .crew-label { color: var(--terror); text-shadow: 0 0 8px color-mix(in srgb, var(--terror) 60%, transparent); }
689
+ /* AIRLOCK: the same width as CONTROL, a cold ring instead of the red one. */
690
+ .composer.airlock .field { border-color: var(--mode-4); box-shadow: 0 0 0 3px color-mix(in srgb, var(--mode-4) 18%, transparent), 0 0 30px color-mix(in srgb, var(--mode-4) 16%, transparent); }
691
+ .composer.airlock .crew-label { color: var(--mode-4); text-shadow: 0 0 8px color-mix(in srgb, var(--mode-4) 55%, transparent); }
680
692
  .composer.intruder .crew-label { color: var(--terror); text-shadow: 0 0 8px color-mix(in srgb, var(--terror) 60%, transparent); animation: blink 1.6s steps(1) infinite; }
681
693
  /* Mode badges on bubbles and plan steps */
682
694
  .who .badge.mode { font-weight: 700; letter-spacing: .1em; }
683
- .who .badge.mode.m0 { color: var(--mode-0); border-color: color-mix(in srgb, var(--mode-0) 50%, transparent); } .who .badge.mode.m1 { color: var(--mode-1); border-color: color-mix(in srgb, var(--mode-1) 45%, transparent); } .who .badge.mode.m2 { color: var(--mode-2); border-color: color-mix(in srgb, var(--mode-2) 50%, transparent); } .who .badge.mode.m3 { color: var(--mode-3); border-color: var(--mode-3); }
695
+ .who .badge.mode.m0 { color: var(--mode-0); border-color: color-mix(in srgb, var(--mode-0) 50%, transparent); } .who .badge.mode.m1 { color: var(--mode-1); border-color: color-mix(in srgb, var(--mode-1) 45%, transparent); } .who .badge.mode.m2 { color: var(--mode-2); border-color: color-mix(in srgb, var(--mode-2) 50%, transparent); } .who .badge.mode.m3 { color: var(--mode-3); border-color: var(--mode-3); } .who .badge.mode.m4 { color: var(--mode-4); border-color: var(--mode-4); }
684
696
  .row.ghost .bubble { border: 1px dashed var(--line-2); background: transparent; opacity: .85; }
685
697
  .row.ghost.user .bubble { background: transparent; color: var(--text); border-color: var(--text-3); }
686
698
  .row.ghost .stamp::after { content: ' · not recorded'; color: var(--text-3); }
@@ -1040,6 +1052,7 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
1040
1052
  :root[data-scheme="light"] .field button[type="submit"] { box-shadow: 0 2px 8px rgba(0, 0, 0, .14); }
1041
1053
  :root[data-scheme="light"] .user .bubble { box-shadow: 0 3px 12px rgba(0, 0, 0, .08); }
1042
1054
  :root[data-scheme="light"] .composer.control .field { box-shadow: 0 0 0 2px color-mix(in srgb, var(--terror) 30%, transparent); }
1055
+ :root[data-scheme="light"] .composer.airlock .field { box-shadow: 0 0 0 2px color-mix(in srgb, var(--mode-4) 35%, transparent); }
1043
1056
  :root[data-scheme="light"] .composer.creating .field, :root[data-scheme="light"] .composer.creating.ordering .field { box-shadow: 0 0 0 2px color-mix(in srgb, var(--mode-2) 30%, transparent); }
1044
1057
  :root[data-scheme="light"] .composer.ordering .field { box-shadow: 0 0 0 2px color-mix(in srgb, var(--brew) 22%, transparent); }
1045
1058
  :root[data-scheme="light"] .composer.dropping .field { box-shadow: 0 0 0 2px color-mix(in srgb, var(--phosphor) 35%, transparent); }
@@ -1068,3 +1081,8 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
1068
1081
  .module-card .actions .toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 9.5px; letter-spacing: .14em; color: var(--ph-dim); cursor: pointer; }
1069
1082
  .module-card .actions .toggle input { accent-color: #9bff66; }
1070
1083
  .mother-section.sentinel .note, .mother-section.update .note { margin: 0 0 10px; font-size: 10px; letter-spacing: .12em; line-height: 1.7; color: var(--ph-dim); }
1084
+
1085
+ /* Motion of controls: every colour change eases over 220 ms. Nothing snaps. */
1086
+ .mother-button, .tree-button, .stop-all, .mother-close, .mother-section button, .seg-option, .mode-chip, .model-chip, .bubble-actions .act, .update-command button, .codeblock .copy { transition: background-color .22s ease, color .22s ease, border-color .22s ease, box-shadow .22s ease, opacity .22s ease, transform .16s ease; }
1087
+ .mother-button:active, .tree-button:active, .mother-close:active, .mother-section button:active, .update-command button:active { transform: scale(.98); }
1088
+ @media (prefers-reduced-motion: reduce) { .mother-button, .tree-button, .stop-all, .mother-close, .mother-section button, .seg-option, .mode-chip, .model-chip, .bubble-actions .act, .update-command button, .update-pill, .update-restart { transition: none; } }
@@ -299,11 +299,11 @@ export const CONDITIONS = [
299
299
  {
300
300
  id: 'modes',
301
301
  severity: 'informational',
302
- title: 'Permission modes: #0 GHOST · #1 EXCHANGE · #2 CREATE · #3 CONTROL',
303
- match: /mode|ghost|exchange|control|override|designation|max mode|#[0-3]\b/i,
304
- diagnosis: 'Every message goes out at a mode, chosen in the chip after TO @agent (or typed as #2 in the text). #0 GHOST is off the record: nothing is saved, no other agent remembers it, gone on reload, no delegation. #1 EXCHANGE is the default: read and coordinate. #2 CREATE lets the agent add new files and folders anywhere in the project, where they belong; whatever existed before is put back after the turn and the room says so. #3 CONTROL puts one agent in command of the project itself: a git checkpoint is taken first, every change is listed afterwards, writes into .git, .pulse or .env files are reverted on the spot, and UNDO restores the checkpoint. One holder at a time; it needs MAX MODE 3 and a git repository. Your mode is the ceiling of any plan the message starts, and #3 is never delegated. Each agent has a MAX MODE in CONNECTIONS; above it, #2 is answered read-only and #3 is refused. When a #1 plan reaches a step that wants to create something, the room pauses and asks you: GRANT ONCE, GRANT FOR PLAN or DENY, with a 3-minute clock; silence denies.',
305
- remedy: 'Pick the mode in the chip, or type #0..#3 in the message. Raise an agent\'s MAX MODE in ⚙ CONNECTIONS. CONTROL asks for the project designation (the folder name) in the override before arming.',
306
- fixes: same(['# chip: TO @codex #1 EXCHANGE ▾ → choose', '@codex #2 create the poster', '# ⚙ CONNECTIONS → agent card → MAX MODE']),
302
+ title: 'Permission modes: #0 GHOST · #1 EXCHANGE · #2 CREATE · #3 CONTROL · #4 AIRLOCK',
303
+ match: /mode|ghost|exchange|control|airlock|deploy|despleg|producci|override|designation|max mode|#[0-4]\b/i,
304
+ diagnosis: 'Every message goes out at a mode, chosen in the chip after TO @agent (or typed as #2 in the text). #0 GHOST is off the record: nothing is saved, no other agent remembers it, gone on reload, no delegation. #1 EXCHANGE is the default: read and coordinate. #2 CREATE lets the agent add new files and folders anywhere in the project, where they belong; whatever existed before is put back after the turn and the room says so. #3 CONTROL puts one agent in command of the project itself: a git checkpoint is taken first, every change is listed afterwards, writes into .git, .pulse or .env files are reverted on the spot, and UNDO restores the checkpoint. One holder at a time; it needs MAX MODE 3 and a git repository. Your mode is the ceiling of any plan the message starts, and #3 is never delegated. Each agent has a MAX MODE in CONNECTIONS; above it, #2 is answered read-only and #3 is refused. When a #1 plan reaches a step that wants to create something, the room pauses and asks you: GRANT ONCE, GRANT FOR PLAN or DENY, with a 3-minute clock; silence denies. #4 AIRLOCK is CONTROL plus commands: tests, builds, git push, deploys with the CLIs and sessions already on this machine. Files still come back with UNDO; what leaves the machine does not, so the override asks twice: the designation, then the word AIRLOCK. An agent that says it needs a "production mode" or "permission to run commands" is asking for #4.',
305
+ remedy: 'Pick the mode in the chip, or type #0..#4 in the message. Raise an agent\'s MAX MODE in ⚙ CONNECTIONS. CONTROL asks for the project designation (the folder name) in the override; AIRLOCK asks for the designation and then the word AIRLOCK.',
306
+ fixes: same(['# chip: TO @codex #1 EXCHANGE ▾ → choose', '@codex #2 create the poster', '@opencode #4 run the tests and deploy to preview', '# ⚙ CONNECTIONS → agent card → MAX MODE']),
307
307
  },
308
308
  {
309
309
  id: 'lease-missing',
@@ -6,22 +6,27 @@ import { runReadonlyProcess } from './process.mjs';
6
6
  export function claudeTools({ lease = null, scopes = null } = {}) {
7
7
  const tools = ['Read', 'Glob', 'Grep'];
8
8
  if (lease) tools.push('Write', 'Edit');
9
+ if (lease?.airlock) tools.push('Bash'); // AIRLOCK (#4): commands, git, deploy CLIs
9
10
  if (scopes?.web) tools.push('WebFetch', 'WebSearch');
10
11
  return tools;
11
12
  }
12
13
 
13
- export function mcpServersFor({ imageStudio = null, memoryServer = null } = {}) {
14
+ export function mcpServersFor({ imageStudio = null, memoryServer = null, mcpServers = [] } = {}) {
14
15
  const servers = {};
15
16
  if (memoryServer) servers[memoryServer.name] = { command: memoryServer.command, args: memoryServer.args, env: memoryServer.env };
16
17
  if (imageStudio) servers[imageStudio.name] = { command: imageStudio.command, args: imageStudio.args, env: imageStudio.env };
18
+ for (const server of mcpServers) servers[server.name] = { command: server.command, args: server.args ?? [], env: server.env ?? {} };
17
19
  return servers;
18
20
  }
21
+ // Claude's permission names for a module's server: each tool, or the whole server when the list is open.
22
+ export const claudeMcpTools = (server) => (server.tools?.length ? server.tools.map((tool) => `mcp__${server.name}__${tool}`) : [`mcp__${server.name}`]);
19
23
 
20
- export function buildClaudeArgs({ prompt, model = null, attachmentsDir = null, lease = null, scopes = null, imageStudio = null, memoryServer = null }) {
24
+ export function buildClaudeArgs({ prompt, model = null, attachmentsDir = null, lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] }) {
21
25
  const tools = claudeTools({ lease, scopes });
22
26
  const mcpTools = [
23
27
  ...(memoryServer ? memoryServer.tools.map((tool) => `mcp__${memoryServer.name}__${tool}`) : []),
24
28
  ...(imageStudio ? [`mcp__${imageStudio.name}__${imageStudio.tool}`] : []),
29
+ ...mcpServers.flatMap(claudeMcpTools),
25
30
  ];
26
31
  const allowed = [
27
32
  'Read', 'Glob', 'Grep',
@@ -30,13 +35,14 @@ export function buildClaudeArgs({ prompt, model = null, attachmentsDir = null, l
30
35
  // CLI: a lone Write rule is denied under dontAsk), so both come together; in CREATE the room
31
36
  // restores existing files after the turn.
32
37
  ...(lease ? [`Write(//${lease.outDir}/**)`, `Edit(//${lease.outDir}/**)`] : []),
38
+ ...(lease?.airlock ? ['Bash'] : []),
33
39
  ...(scopes?.web ? ['WebFetch', 'WebSearch'] : []),
34
40
  ...mcpTools,
35
41
  ];
36
42
  // Only MADRE's own MCP servers ever reach Claude here; --strict-mcp-config
37
43
  // keeps the user's servers out of the isolated run.
38
- const anyMcp = Boolean(imageStudio || memoryServer);
39
- const mcpConfig = JSON.stringify({ mcpServers: mcpServersFor({ imageStudio, memoryServer }) });
44
+ const anyMcp = Boolean(imageStudio || memoryServer || mcpServers.length);
45
+ const mcpConfig = JSON.stringify({ mcpServers: mcpServersFor({ imageStudio, memoryServer, mcpServers }) });
40
46
  return [
41
47
  '-p',
42
48
  ...(model ? ['--model', model] : []),
@@ -95,10 +101,10 @@ export function parseClaudeOutput(output) {
95
101
  }
96
102
  }
97
103
 
98
- export function invokeClaude({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null }) {
104
+ export function invokeClaude({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] }) {
99
105
  return runReadonlyProcess({
100
106
  executable,
101
- args: buildClaudeArgs({ prompt, model, attachmentsDir: attachments[0]?.dir ?? null, lease, scopes, imageStudio, memoryServer }),
107
+ args: buildClaudeArgs({ prompt, model, attachmentsDir: attachments[0]?.dir ?? null, lease, scopes, imageStudio, memoryServer, mcpServers }),
102
108
  cwd: projectRoot,
103
109
  env: process.env,
104
110
  timeoutMs,