@jossuealcala/madre 0.4.1 → 0.5.0

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/public/es.js CHANGED
@@ -64,6 +64,8 @@ export const ES = {
64
64
  'NO ADAPTER': 'SIN ADAPTADOR',
65
65
  'READY': 'LISTO',
66
66
  'SIGNED OUT': 'SIN SESIÓN',
67
+ 'ROUTE BLOCKED': 'RUTA BLOQUEADA',
68
+ 'route: {destination}': 'ruta: {destination}',
67
69
  'Not on this computer.': 'No lo tienes instalado.',
68
70
  'Not on this computer · {command}': 'No lo tienes instalado · {command}',
69
71
  'version unknown': 'versión desconocida',
@@ -729,6 +731,8 @@ export const ES = {
729
731
  'Load your module files again without restarting the room': 'Vuelve a cargar tus archivos de módulo sin reiniciar la sala',
730
732
  'YOURS · ': 'TUYOS · ',
731
733
  'REMOVE': 'QUITAR',
734
+ 'UNINSTALL': 'DESINSTALAR',
735
+ 'Delete module file {file}': 'Borrar el archivo del módulo {file}',
732
736
  'DID NOT LOAD · ': 'NO CARGÓ · ',
733
737
  'connections › ': 'conexiones › ',
734
738
  'HELP': 'AYUDA',
@@ -902,7 +906,7 @@ export const ES = {
902
906
  'loading…': 'cargando…',
903
907
  'select & copy': 'selecciona y copia',
904
908
  'to ': 'para ',
905
- 'memory · answers & asks the crew · never writes': 'memoria · contesta y le pregunta a la tripulación · nunca escribe',
909
+ 'memory · never writes': 'memoria · nunca escribe',
906
910
  'Choose the model for this agent': 'Elige el modelo de este agente',
907
911
  'loading models…': 'cargando modelos…',
908
912
  'other model name…': 'otro nombre de modelo…',
@@ -1620,12 +1624,13 @@ export const ES = {
1620
1624
  // ── EL COMPOSITOR, LOS MODOS Y LA ANULACIÓN ──────────────────────────────
1621
1625
  'Arm CREATE for this message: the agent may add new files to the project where they belong.':
1622
1626
  'Arma CREATE para este mensaje: el agente puede agregar archivos nuevos donde les toque en el proyecto.',
1627
+ 'Build one MADRE module with the guided SDK flow: the agent writes it, then you review and install it.':
1628
+ 'Construye un módulo de MADRE con el flujo guiado del SDK: el agente lo escribe y después tú lo revisas e instalas.',
1623
1629
  'Image': 'Imagen',
1624
1630
  'Ask for an image: arms CREATE with the image scope and routes to an agent that can generate images.':
1625
1631
  'Pide una imagen: arma CREATE con el alcance de imagen y la manda a un agente que sepa generarlas.',
1626
1632
  'Master brake: halt every plan and turn in flight. Never reaches an agent.':
1627
1633
  'Freno maestro: detiene todos los planes y turnos en vuelo. Nunca llega a un agente.',
1628
- 'REVIEW {what} WITH': 'REVISAR {what} CON',
1629
1634
  'RIPLEY can render this file. ': 'RIPLEY puede dibujar este archivo. ',
1630
1635
  'RIPLEY preview of {path}': 'Vista previa de RIPLEY de {path}',
1631
1636
  'Show the file as text': 'Ver el archivo como texto',
@@ -1634,7 +1639,7 @@ export const ES = {
1634
1639
  'Permission mode for this message · {hint}': 'Modo de permiso de este mensaje · {hint}',
1635
1640
  'default model ▾': 'modelo por defecto ▾',
1636
1641
  ' · model for this request': ' · modelo para esta petición',
1637
- ' · mode for this message · ceiling #{cap}': ' · modo de este mensaje · techo #{cap}',
1642
+ ' · ceiling #{cap}': ' · techo #{cap}',
1638
1643
  'NOW': 'AHORA',
1639
1644
  'RAISE TO #{n} ›': 'SUBIR A #{n} ›',
1640
1645
  'LOCKED': 'BLOQUEADO',
@@ -1781,6 +1786,11 @@ export const ES = {
1781
1786
  'Claude Code encontró en el entorno una llave de API además del inicio de sesión de claude.ai, y gana la llave. MADRE le entrega al CLI el entorno tal como lo tiene esta computadora, así que lo que esté exportado en el perfil de tu shell le llega. El turno se le cobra a la cuenta de API, no al plan, y los conectores de tu organización no cargan.',
1782
1787
  'Decide which one you mean. To work on the plan, unset the key where your shell exports it and open the room again; to work on the API, this message is only a notice and can be ignored.':
1783
1788
  'Decide cuál quieres. Para trabajar con el plan, quita la llave de donde tu shell la exporta y vuelve a abrir la sala; para trabajar con la API, este mensaje es solo un aviso y se puede ignorar.',
1789
+ 'The agent is pointed at a custom endpoint': 'El agente apunta a un endpoint personalizado',
1790
+ 'The installed CLI inherited a base-URL override from this computer, so its request was going to another gateway instead of the official agent service. A demand to join a Telegram channel comes from that gateway, not from MADRE, Anthropic or OpenAI. MADRE now names that route and stops before sending the briefing.':
1791
+ 'El CLI instalado heredó de esta computadora una dirección base distinta, así que su solicitud iba a otro gateway y no al servicio oficial del agente. La exigencia de unirse a un canal de Telegram viene de ese gateway, no de MADRE, Anthropic ni OpenAI. MADRE ahora nombra esa ruta y se detiene antes de enviar el briefing.',
1792
+ 'Do not subscribe or enter credentials. Remove the base-URL override and reopen MADRE to use the official service. If the gateway is intentional and you have verified who operates it, explicitly allow only that agent.':
1793
+ 'No te suscribas ni ingreses credenciales. Quita la dirección base modificada y vuelve a abrir MADRE para usar el servicio oficial. Si el gateway es intencional y verificaste quién lo opera, autoriza explícitamente solo a ese agente.',
1784
1794
  // La condición que declara el módulo PLAYWRIGHT. Las de los módulos viven en su archivo, no
1785
1795
  // en troubleshooting.js, y por eso se quedaron fuera del catálogo hasta ahora.
1786
1796
  'PLAYWRIGHT: the browser server is not installed': 'PLAYWRIGHT: el servidor de navegador no está instalado',
@@ -1792,6 +1802,16 @@ export const ES = {
1792
1802
  'SHOW THE {n} DISMISSED': 'VER LOS {n} DESCARTADOS',
1793
1803
  'DISMISS ALL {n}': 'DESCARTAR LOS {n}',
1794
1804
  'DOWNLOAD THE LOG': 'DESCARGAR EL REGISTRO',
1805
+ '/module writes a file, so it needs #2 CREATE. Your request is still in the composer.':
1806
+ '/module escribe un archivo, así que necesita #2 CREATE. Tu petición sigue en el compositor.',
1807
+ 'ARM #2 CREATE': 'ARMAR #2 CREATE',
1808
+ 'review it with': 'revisarlo con',
1809
+ 'no session': 'sin sesión',
1810
+ 'CLICK A LINE NUMBER TO SELECT · SHIFT FOR A RANGE':
1811
+ 'CLIC EN UN NÚMERO DE LÍNEA PARA SELECCIONAR · SHIFT PARA UN RANGO',
1812
+ '{agent} wrote this file in this room': '{agent} escribió este archivo en esta sala',
1813
+ 'MU/TH/UR › {name} declares {fields}, which this MADRE does not know. A typo, or a field from a newer version.':
1814
+ 'MU/TH/UR › {name} declara {fields}, que esta MADRE no conoce. O es un dedazo, o es un campo de una versión más nueva.',
1795
1815
  'OR ON MADRE.RUN ↗': 'O EN MADRE.RUN ↗',
1796
1816
  '✎ REPORT ON MADRE.RUN ↗': '✎ REPORTAR EN MADRE.RUN ↗',
1797
1817
  'OPEN ⚙ CONNECTIONS · @{agent}': 'ABRIR ⚙ CONEXIONES · @{agent}',
@@ -1803,6 +1823,12 @@ export const ES = {
1803
1823
  '# borra o comenta la línea que encuentre, y luego:',
1804
1824
  '# and start MADRE from a new terminal':
1805
1825
  '# y arranca MADRE desde una terminal nueva',
1826
+ '# remove that export from ~/.zshrc, ~/.zprofile or ~/.zshenv; then:':
1827
+ '# quita esa exportación de ~/.zshrc, ~/.zprofile o ~/.zshenv; después:',
1828
+ '# remove that export from ~/.bashrc, ~/.profile or ~/.bash_profile; then:':
1829
+ '# quita esa exportación de ~/.bashrc, ~/.profile o ~/.bash_profile; después:',
1830
+ '# open a new room; only for a gateway you verified:':
1831
+ '# abre una sala nueva; solo para un gateway que sí verificaste:',
1806
1832
  '# inside gemini: /auth → "Use Gemini API key" and paste the key from https://aistudio.google.com/app/apikey':
1807
1833
  '# dentro de gemini: /auth → "Use Gemini API key" y pega la llave de https://aistudio.google.com/app/apikey',
1808
1834
  '# or, without the prompt:':
@@ -1995,6 +2021,13 @@ export const ES = {
1995
2021
  '{title} is not available here · see MODULES': '{title} no está disponible aquí · mira MÓDULOS',
1996
2022
  'MU/TH/UR › /{name} needs a request after it, e.g. "/{name} a poster for the launch".':
1997
2023
  'MU/TH/UR › /{name} necesita una petición después, por ejemplo «/{name} un póster para el lanzamiento».',
2024
+ 'MU/TH/UR › /module needs a job, e.g. "/module summarize today’s commits".':
2025
+ 'MU/TH/UR › /module necesita una tarea, por ejemplo «/module resume los commits de hoy».',
2026
+ 'MU/TH/UR › /module needs #2 CREATE. Choose #2 in the mode chip, then send again.':
2027
+ 'MU/TH/UR › /module necesita #2 CREATE. Elige #2 en el chip de modo y vuelve a enviar.',
2028
+ 'MU/TH/UR · MODULE · NEEDS #2 ›': 'MU/TH/UR · MODULE · REQUIERE #2 ›',
2029
+ 'Create a MADRE module for this request: {request} Write exactly one <id>.module.mjs file for review. Do not install it; MADRE will show the human an install card.':
2030
+ 'Crea un módulo de MADRE para esta petición: {request} Escribe exactamente un archivo <id>.module.mjs para revisión. No lo instales; MADRE le mostrará al humano una tarjeta de instalación.',
1998
2031
  'MU/TH/UR › unknown command /{name}. Type "/" to see what this room offers.':
1999
2032
  'MU/TH/UR › comando desconocido /{name}. Escribe «/» para ver lo que ofrece esta sala.',
2000
2033
  '{name} · uploading…': '{name} · subiendo…',
package/public/index.html CHANGED
@@ -305,6 +305,8 @@
305
305
  <button id="viewer-reload" class="nav" type="button" title="Reload the page" aria-label="Reload">↻</button>
306
306
  </span>
307
307
  <span id="viewer-path" class="viewer-path"></span>
308
+ <span id="viewer-lang" class="viewer-lang" hidden></span>
309
+ <span id="viewer-hint-select" class="viewer-lang" hidden>CLICK A LINE NUMBER TO SELECT · SHIFT FOR A RANGE</span>
308
310
  <span id="viewer-selection" class="viewer-selection" hidden></span>
309
311
  <button id="viewer-mode" class="viewer-review viewer-mode" type="button" hidden>SOURCE</button>
310
312
  <button id="viewer-review" class="viewer-review" type="button" hidden>REVIEW WITH ▾</button>
package/public/styles.css CHANGED
@@ -40,10 +40,26 @@
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
42
  --mode-4: #8fd3ff; --mode-4-ink: #04131c; /* AIRLOCK · the cold outside */
43
+ --module-electric: #b44cff; --module-electric-ink: #12001d; /* MODULE · MADRE electric purple */
43
44
  }
45
+ /* Reading code, in whichever ground the room is standing on. These were picked for phosphor on
46
+ black; on white the same green and yellow disappear, so each one has a light value of its own. */
47
+ :root {
48
+ --syn-keyword: #9bb8ff;
49
+ --syn-string: #9bff66;
50
+ --syn-comment: color-mix(in srgb, var(--text-3) 88%, transparent);
51
+ --syn-number: #ffd166;
52
+ --syn-literal: #ff9ecd;
53
+ }
54
+
44
55
  @media (prefers-color-scheme: light) {
45
56
  :root:not([data-theme="dark"]) {
46
57
  color-scheme: light;
58
+ --syn-keyword: #0b57b8;
59
+ --syn-string: #0a6e35;
60
+ --syn-comment: #6a7280;
61
+ --syn-number: #9a5000;
62
+ --syn-literal: #9c1f6b;
47
63
  --bg: #f4f5f2;
48
64
  --bg-2: #ffffff;
49
65
  --panel: #ffffff;
@@ -71,6 +87,11 @@
71
87
  }
72
88
  :root[data-theme="light"] {
73
89
  color-scheme: light;
90
+ --syn-keyword: #0b57b8;
91
+ --syn-string: #0a6e35;
92
+ --syn-comment: #6a7280;
93
+ --syn-number: #9a5000;
94
+ --syn-literal: #9c1f6b;
74
95
  --bg: #f4f5f2;
75
96
  --bg-2: #ffffff;
76
97
  --panel: #ffffff;
@@ -145,7 +166,11 @@ body {
145
166
  .brand-mark .beat-dot { animation: none; opacity: 1; }
146
167
  .brand-word { animation: none; opacity: 1; transform: none; }
147
168
  }
148
- .project { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-3); }
169
+ /* Two names sit here and only one of them tells you where you are. The project never gives way:
170
+ a room whose project reads `pul…` has stopped saying which project it is. The conversation is
171
+ the one that shrinks — it has a panel of its own where its whole name is written. */
172
+ .project { flex: 0 0 auto; max-width: 24ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-3); }
173
+ .project.chat-here { flex: 0 1 auto; min-width: 3ch; max-width: none; }
149
174
  .update-command { display: flex; align-items: center; gap: 10px; margin: 8px 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: color-mix(in srgb, var(--text) 4%, transparent); }
150
175
  .update-command code { flex: 1; min-width: 0; overflow-x: auto; white-space: nowrap; font-family: var(--mono); font-size: 11.5px; color: var(--text); }
151
176
  .update-command button { font: inherit; font-family: var(--mono); font-size: 9.5px; letter-spacing: .14em; text-transform: uppercase; border: 1px solid var(--line); background: transparent; color: var(--text-2); border-radius: 8px; padding: 4px 10px; cursor: pointer; }
@@ -162,7 +187,7 @@ body {
162
187
  .train-step-label { font-family: var(--mono); font-size: 9px; letter-spacing: .16em; text-transform: uppercase; color: var(--text-3); }
163
188
  .project::before { content: "/ "; }
164
189
  .agents { display: flex; gap: 8px; margin-left: 0; flex: none; }
165
- .connection { display: inline-flex; align-items: center; gap: 6px; color: var(--text-3); text-transform: uppercase; font-size: 10px; }
190
+ .connection { display: inline-flex; flex: 0 0 auto; white-space: nowrap; align-items: center; gap: 6px; color: var(--text-3); text-transform: uppercase; font-size: 10px; }
166
191
  .connection-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-3); }
167
192
  .connection[data-state="live"] .connection-dot { background: var(--phosphor); box-shadow: 0 0 10px var(--phosphor); }
168
193
  .connection[data-state="reconnecting"] .connection-dot { background: var(--warn); animation: blink 1s infinite; }
@@ -570,11 +595,14 @@ pre.core-egress-log { margin: 0; max-height: 34vh; overflow: auto; padding: 10px
570
595
  /* ================= composer ================= */
571
596
  .composer { display: grid; gap: 12px; padding: 14px 0 0; }
572
597
  .composer[aria-disabled="true"] { opacity: .45; pointer-events: none; }
573
- .picker { display: flex; gap: 8px; align-items: center; padding: 0 4px; }
598
+ /* One line, always. The spheres and the chips keep their size; the note is the only piece that
599
+ gives way, because its whole sentence is in its tooltip anyway. */
600
+ .picker { display: flex; flex-wrap: nowrap; gap: 8px; align-items: center; padding: 0 4px; }
574
601
  .picker .pick { --size: 30px; cursor: pointer; border: 1px solid transparent; background: color-mix(in srgb, var(--agent) 10%, transparent); transition: transform .12s ease, box-shadow .12s ease; }
575
602
  .picker .pick:hover { transform: translateY(-1px); }
576
603
  .picker .pick[aria-checked="true"] { box-shadow: 0 0 0 2px var(--bg), 0 0 0 3.5px var(--agent); background: color-mix(in srgb, var(--agent) 22%, transparent); }
577
- .picker .pick-label { font-family: var(--mono); font-size: 10.5px; letter-spacing: .1em; text-transform: uppercase; color: var(--text-2); margin-left: 2px; }
604
+ .picker .pick-label { display: flex; align-items: center; gap: 8px; min-width: 0; font-family: var(--mono); font-size: 10.5px; letter-spacing: .1em; text-transform: uppercase; color: var(--text-2); margin-left: 2px; }
605
+ .picker .pick-label > .mode-chip, .picker .pick-label > .model-chip { flex: 0 0 auto; }
578
606
  .picker .pick-label b { color: var(--agent, var(--text)); font-weight: 600; }
579
607
  .field { position: relative; display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: end; padding: 6px 6px 6px 18px; border: 1px solid var(--line-2); background: var(--panel); border-radius: 24px; }
580
608
  .crew-label { align-self: center; font-family: var(--mono); font-size: 10px; letter-spacing: .18em; color: var(--text-3); white-space: nowrap; transition: color .4s ease; }
@@ -593,7 +621,7 @@ textarea::placeholder { color: var(--text-3); }
593
621
  .field button[type="submit"] svg { width: 18px; height: 18px; }
594
622
  .field button[type="submit"]:hover { filter: brightness(1.06); }
595
623
  .field button[type="submit"]:disabled { opacity: .4; cursor: default; box-shadow: none; }
596
- .pick-label .pick-note { font-family: var(--mono); font-size: 9px; letter-spacing: .14em; text-transform: uppercase; color: var(--text-3); margin-left: 4px; white-space: nowrap; }
624
+ .pick-label .pick-note { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; font-family: var(--mono); font-size: 9px; letter-spacing: .14em; text-transform: uppercase; color: var(--text-3); white-space: nowrap; }
597
625
  .safety { margin: 14px auto 18px; color: var(--text-3); font-family: var(--mono); font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase; text-align: center; }
598
626
 
599
627
  /* toast */
@@ -801,6 +829,8 @@ dialog.mother::backdrop { background: rgba(0, 0, 0, .82); backdrop-filter: blur(
801
829
  .module-card .actions { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
802
830
  .module-card .actions button { border: 1px solid var(--ph); background: transparent; color: var(--ph); font: inherit; font-size: 10.5px; letter-spacing: .18em; padding: 8px 14px; cursor: pointer; }
803
831
  .module-card .actions button.primary { background: var(--ph); color: #041004; text-shadow: none; }
832
+ .module-card .actions button.remove-module { border-color: color-mix(in srgb, var(--danger) 65%, transparent); color: var(--danger); }
833
+ .module-card .actions button.remove-module:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); }
804
834
  .module-card .actions button:disabled { opacity: .4; cursor: default; }
805
835
  .module-card .actions .note { font-size: 10px; letter-spacing: .12em; color: var(--ph-dim); }
806
836
  .module-card .confirm { border: 1px dashed var(--ph); padding: 12px 14px; display: grid; gap: 10px; }
@@ -915,6 +945,9 @@ dialog.mother::backdrop { background: rgba(0, 0, 0, .82); backdrop-filter: blur(
915
945
  .picker .mode-chip.m3 { --mode: var(--mode-3); }
916
946
  .picker .mode-chip.m4 { --mode: var(--mode-4); }
917
947
  .mode-menu { width: min(380px, calc(100vw - 24px)); padding: 12px; }
948
+ /* One line. The header carries two facts — whose mode, and how far it may go — and both have to
949
+ be readable at once; the sentence about this message living only in this message is on the chip
950
+ that opens this menu. Short enough that the ellipsis below should never have anything to do. */
918
951
  .mode-menu .model-menu-title { margin: 4px 10px 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
919
952
  .mode-menu .mode-ladder { display: grid; gap: 2px; }
920
953
  .mode-menu .mode-option { --mode: var(--text-2); display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; gap: 14px; align-items: baseline; width: 100%; text-align: left; border: 0; background: transparent; color: var(--text); font: inherit; font-family: var(--mono); padding: 12px 10px; border-radius: 8px; cursor: pointer; transition: background .15s, opacity .15s; }
@@ -1051,7 +1084,11 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
1051
1084
  .viewer-review { border: 1px solid var(--phosphor); background: color-mix(in srgb, var(--phosphor) 10%, transparent); color: var(--phosphor); font: inherit; font-family: var(--mono); font-size: 10px; letter-spacing: .14em; padding: 5px 10px; border-radius: 6px; cursor: pointer; white-space: nowrap; }
1052
1085
  .viewer-review:hover { background: color-mix(in srgb, var(--phosphor) 20%, transparent); }
1053
1086
  .viewer-menu { position: fixed; z-index: 50; min-width: 220px; border: 1px solid var(--line-2); background: var(--panel); border-radius: 12px; box-shadow: 0 14px 40px rgba(0, 0, 0, .35); padding: 6px; display: grid; gap: 2px; }
1054
- .viewer-menu .hint { padding: 4px 10px 6px; font-family: var(--mono); font-size: 10px; letter-spacing: .12em; color: var(--text-3); }
1087
+ /* Two lines: the range being sent, then the question. The reference is a path and can be long,
1088
+ so it wraps instead of pushing the menu wider or being clipped mid-word. */
1089
+ .viewer-menu .hint { display: grid; gap: 2px; padding: 6px 10px 8px; font-family: var(--mono); font-size: 10px; letter-spacing: .12em; color: var(--text-3); }
1090
+ .viewer-menu .hint b { color: var(--text-2); font-weight: 500; overflow-wrap: anywhere; }
1091
+ .viewer-menu .item span { color: var(--text-3); font-size: 11px; }
1055
1092
  .viewer-menu .item { display: flex; gap: 10px; align-items: center; padding: 7px 10px; border-radius: 8px; border: 0; background: transparent; color: var(--text); font: inherit; font-size: 13px; cursor: pointer; text-align: left; }
1056
1093
  .viewer-menu .item:hover { background: color-mix(in srgb, var(--agent, var(--phosphor)) 14%, transparent); }
1057
1094
  .viewer-menu .item b { color: var(--agent, var(--phosphor)); font-family: var(--mono); font-size: 11.5px; letter-spacing: .06em; }
@@ -1204,6 +1241,36 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
1204
1241
  .system.escalation.denied { color: var(--text-3); border-color: var(--line-2); background: transparent; }
1205
1242
  .composer.creating .field { border-color: var(--mode-2); box-shadow: 0 0 0 3px color-mix(in srgb, var(--mode-2) 18%, transparent); }
1206
1243
  .composer.creating .crew-label { color: var(--mode-2); }
1244
+ /* /module temporarily gives CREATE its own electric MADRE signature. The pixel layer crosses
1245
+ the typebox once, then leaves a clean purple field behind while the request is composed. */
1246
+ .composer.module-command { --mode: var(--module-electric); --mode-ink: var(--module-electric-ink); }
1247
+ .composer.creating.module-command .field, .composer.module-command .field {
1248
+ border-color: var(--module-electric);
1249
+ background: linear-gradient(100deg, color-mix(in srgb, var(--module-electric) 8%, var(--panel)), var(--panel) 62%);
1250
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--module-electric) 18%, transparent), 0 0 28px color-mix(in srgb, var(--module-electric) 18%, transparent);
1251
+ transition: border-color .18s ease, background .22s ease, box-shadow .22s ease;
1252
+ }
1253
+ .composer.module-command .crew-label { color: var(--module-electric); text-shadow: 0 0 9px color-mix(in srgb, var(--module-electric) 58%, transparent); }
1254
+ .composer.module-command .editor textarea { caret-color: var(--module-electric); }
1255
+ .composer.module-command .editor .highlight .chip.cmd { --agent: var(--module-electric); }
1256
+ .composer .field::after {
1257
+ content: ''; position: absolute; inset: 0; z-index: 2; border-radius: inherit; pointer-events: none; opacity: 0;
1258
+ background:
1259
+ linear-gradient(90deg, transparent 0 10%, color-mix(in srgb, var(--module-electric) 72%, transparent) 46% 54%, transparent 90% 100%),
1260
+ repeating-linear-gradient(90deg, color-mix(in srgb, var(--module-electric) 78%, transparent) 0 7px, transparent 7px 12px),
1261
+ repeating-linear-gradient(0deg, color-mix(in srgb, var(--module-electric) 62%, transparent) 0 7px, transparent 7px 12px);
1262
+ background-size: 42% 100%, 100% 12px, 12px 100%;
1263
+ background-position: -55% 0, 0 0, 0 0;
1264
+ mix-blend-mode: normal;
1265
+ }
1266
+ .composer.module-command .field::after { animation: module-pixel-shift .58s steps(9, end) 1; }
1267
+ @keyframes module-pixel-shift {
1268
+ 0% { opacity: 0; clip-path: inset(0 100% 0 0); background-position: -55% 0, 0 0, 0 0; }
1269
+ 14% { opacity: .82; }
1270
+ 58% { opacity: .48; clip-path: inset(0 0 0 0); background-position: 86% 0, 9px 0, 0 7px; }
1271
+ 100% { opacity: 0; clip-path: inset(0 0 0 100%); background-position: 155% 0, 18px 0, 0 14px; }
1272
+ }
1273
+ @media (prefers-reduced-motion: reduce) { .composer.module-command .field::after { animation: none; } }
1207
1274
  .who .badge.lease { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 50%, transparent); }
1208
1275
  .user .stamp .lease { color: var(--warn); }
1209
1276
  .system.lease { color: var(--warn); }
@@ -1244,17 +1311,28 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
1244
1311
  .composer.creating { width: min(1180px, 100% - 40px); }
1245
1312
  .editor textarea::placeholder { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1246
1313
  .editor { position: relative; display: grid; min-width: 0; }
1247
- .editor textarea, .editor .highlight { grid-area: 1 / 1; margin: 0; padding: 7px 0; line-height: 21px; font-size: 15px; font-family: inherit; letter-spacing: normal; white-space: pre-wrap; overflow-wrap: anywhere; word-break: normal; box-sizing: content-box; min-height: 0; max-height: none; overflow-y: auto; }
1314
+ .editor textarea, .editor .highlight { grid-area: 1 / 1; margin: 0 -5px; padding: 7px 5px; line-height: 21px; font-size: 15px; font-family: inherit; letter-spacing: normal; white-space: pre-wrap; overflow-wrap: anywhere; word-break: normal; box-sizing: content-box; min-height: 0; max-height: none; overflow-y: auto; }
1248
1315
  .editor textarea { position: relative; z-index: 1; color: transparent; caret-color: var(--text); }
1249
1316
  .editor textarea::selection { background: color-mix(in srgb, var(--phosphor) 30%, transparent); }
1250
1317
  .composer.stopall .editor textarea { color: var(--terror); }
1251
1318
  .composer.stopall .editor .highlight { visibility: hidden; }
1252
1319
  .editor .highlight { color: var(--text); pointer-events: none; scrollbar-width: none; }
1253
1320
  .editor .highlight::-webkit-scrollbar { display: none; }
1254
- .editor .highlight .chip { --chip-bg: color-mix(in srgb, var(--agent, var(--phosphor)) 16%, var(--panel)); border-radius: 5px; padding: 0; margin: 0; background: var(--chip-bg); color: var(--agent, var(--phosphor)); box-shadow: 0 0 0 3px var(--chip-bg), 0 0 0 4px color-mix(in srgb, var(--agent, var(--phosphor)) 40%, transparent); font-weight: 600; }
1255
- .editor .highlight .chip.cmd { --agent: var(--phosphor); font-family: var(--mono); font-size: 13px; letter-spacing: .04em; }
1256
- .editor .highlight .chip.file { --agent: var(--text-2); font-family: var(--mono); font-size: 13px; letter-spacing: 0; font-weight: 500; }
1257
- .editor .highlight .chip.unknown { --agent: var(--text-3); text-decoration: underline dotted; font-weight: 500; }
1321
+ /* A chip may take any colour it likes and no metric at all. Font family, size, weight and
1322
+ letter-spacing all change how wide a string draws, and the caret under this layer is placed by
1323
+ the textarea's metrics, not by these: change one and the caret drifts into the pill. Colour,
1324
+ background, radius and box-shadow are free — they paint without moving anything. */
1325
+ /* A pill may grow upward and it may not grow sideways. Vertical padding costs nothing: an inline
1326
+ box overflows its line without moving a character. Horizontal padding does move them — and the
1327
+ negative margin that pays for it only gives the room back to the TEXT, while the background
1328
+ still paints those pixels, so the pill ends up covering the space after it and the next word
1329
+ arrives glued to its edge. Its ring is drawn INSET for the same reason: a spread shadow paints
1330
+ outward on every side, so it would swallow that space too, only more quietly. It hugs its
1331
+ characters; the gap after it is the space the human typed, and it stays visible. */
1332
+ .editor .highlight .chip { --chip-bg: color-mix(in srgb, var(--agent, var(--phosphor)) 20%, var(--panel)); border-radius: 5px; padding: 3px 0; margin: 0; background: var(--chip-bg); color: var(--agent, var(--phosphor)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--agent, var(--phosphor)) 50%, transparent); }
1333
+ .editor .highlight .chip.cmd { --agent: var(--phosphor); }
1334
+ .editor .highlight .chip.file { --agent: var(--text-2); }
1335
+ .editor .highlight .chip.unknown { --agent: var(--text-3); text-decoration: underline dotted; }
1258
1336
  /* A mention is a word in the sentence you are writing, so it is only ever coloured. A pill drawn
1259
1337
  round it turns the line into a form to fill in and breaks the rhythm of reading it back. */
1260
1338
  .editor .highlight .chip.agent { background: transparent; box-shadow: none; }
@@ -1394,6 +1472,7 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
1394
1472
  :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); }
1395
1473
  :root[data-scheme="light"] .composer.ash-on .field { box-shadow: 0 0 0 2px color-mix(in srgb, var(--brew) 22%, transparent); }
1396
1474
  :root[data-scheme="light"] .composer.dropping .field { box-shadow: 0 0 0 2px color-mix(in srgb, var(--phosphor) 35%, transparent); }
1475
+ :root[data-scheme="light"] .composer.module-command .field { box-shadow: 0 0 0 2px color-mix(in srgb, var(--module-electric) 34%, transparent); }
1397
1476
  :root[data-scheme="light"] .memory-hint .pill { animation: none; box-shadow: none; }
1398
1477
  :root[data-scheme="light"] .mode-menu .mode-option .tag .dot { box-shadow: none; }
1399
1478
  :root[data-scheme="light"] .picker .mode-chip, :root[data-scheme="light"] .who .badge { text-shadow: none; box-shadow: none; }
@@ -1643,3 +1722,15 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
1643
1722
  /* A remedy this room can carry out itself: the button goes above the commands it replaces. */
1644
1723
  .mother-goto { display: block; margin: 6px 0 0; border: 1px solid var(--ph-dim); border-radius: 4px; background: transparent; color: var(--ph); cursor: pointer; font: inherit; font-size: 10px; letter-spacing: .14em; padding: 6px 12px; text-transform: uppercase; }
1645
1724
  .mother-goto:hover { background: color-mix(in srgb, var(--ph) 12%, transparent); }
1725
+
1726
+ /* A file an agent wrote in this room, in that agent's own colour. */
1727
+ .tree .node .touched { width: 6px; height: 6px; border-radius: 50%; background: var(--agent, var(--ph)); box-shadow: 0 0 6px var(--agent, var(--ph)); flex: 0 0 auto; margin-left: 2px; }
1728
+
1729
+ /* Read by what the file is. Six roles, the room's own phosphor: enough to find a string or a
1730
+ comment at a glance, and never so many colours that the code stops being the thing you see. */
1731
+ .viewer .code .t-keyword { color: var(--syn-keyword); }
1732
+ .viewer .code .t-string { color: var(--syn-string); }
1733
+ .viewer .code .t-comment { color: var(--syn-comment); font-style: italic; }
1734
+ .viewer .code .t-number { color: var(--syn-number); }
1735
+ .viewer .code .t-literal { color: var(--syn-literal); }
1736
+ .viewer-lang { color: var(--ph-dim); font-size: 9.5px; letter-spacing: .16em; text-transform: uppercase; }
@@ -0,0 +1,121 @@
1
+ // The names below are the names of formats — JavaScript, JSON, CSS, Markdown — and a format is
2
+ // called the same in every language, so they never pass through the catalogue.
3
+ //
4
+ // Reading a file in the room, not just seeing it. MADRE has no dependencies and is not about to
5
+ // take one for this: a highlighter good enough for a viewer is a few hundred lines, and a
6
+ // highlighter that has to be perfect is somebody else's product.
7
+ //
8
+ // The rule it follows is the one that keeps it honest: it never rewrites the file. It hands back
9
+ // spans over the same characters, in the same order, so what the screen shows and what is on disk
10
+ // are the same bytes. Anything it cannot classify stays plain text rather than being guessed at.
11
+
12
+ const FAMILIES = {
13
+ js: { ext: /\.(m|c)?jsx?$|\.tsx?$/i, label: 'JavaScript',
14
+ keywords: /\b(await|async|break|case|catch|class|const|continue|default|delete|do|else|export|extends|finally|for|from|function|get|if|import|in|instanceof|let|new|of|return|set|static|super|switch|this|throw|try|typeof|var|void|while|yield|as|interface|type|enum|implements|readonly|declare|namespace|satisfies)\b/,
15
+ literals: /\b(true|false|null|undefined|NaN|Infinity)\b/,
16
+ line: /\/\/.*/, block: [/\/\*/, /\*\//], strings: ['"', "'", '`'] },
17
+ json: { ext: /\.json(c|5)?$|^\.?[a-z-]*rc$/i, label: 'JSON',
18
+ keywords: /(?!)/, literals: /\b(true|false|null)\b/, line: /(?!)/, block: null, strings: ['"'] },
19
+ css: { ext: /\.(css|scss|less|sass)$/i, label: 'CSS',
20
+ keywords: /@[a-z-]+|\b(important)\b/, literals: /#[0-9a-f]{3,8}\b|\b\d+(\.\d+)?(px|rem|em|%|vh|vw|s|ms|fr|deg)\b/i,
21
+ line: /\/\/.*/, block: [/\/\*/, /\*\//], strings: ['"', "'"] },
22
+ html: { ext: /\.(html?|xhtml|xml|svg|vue|svelte)$/i, label: 'HTML',
23
+ keywords: /<\/?[a-zA-Z][\w:-]*|\/?>/, literals: /(?!)/, line: /(?!)/, block: [/<!--/, /-->/], strings: ['"', "'"] },
24
+ md: { ext: /\.(md|markdown|mdx)$/i, label: 'Markdown',
25
+ keywords: /^#{1,6}\s.*$|^\s*[-*+]\s|^\s*\d+\.\s|^>\s?.*$/, literals: /\*\*[^*]+\*\*|__[^_]+__|\[[^\]]*\]\([^)]*\)/,
26
+ line: /(?!)/, block: null, strings: ['`'] },
27
+ py: { ext: /\.pyi?$/i, label: 'Python',
28
+ keywords: /\b(and|as|assert|async|await|break|class|continue|def|del|elif|else|except|finally|for|from|global|if|import|in|is|lambda|nonlocal|not|or|pass|raise|return|try|while|with|yield|match|case)\b/,
29
+ literals: /\b(True|False|None|self|cls)\b/, line: /#.*/, block: null, strings: ['"', "'"] },
30
+ sh: { ext: /\.(sh|bash|zsh|fish)$|^(Makefile|Dockerfile|\.env.*)$/i, label: 'Shell',
31
+ keywords: /\b(if|then|else|elif|fi|for|while|do|done|case|esac|function|return|export|local|source|set|trap|in)\b/,
32
+ literals: /\$\{?[A-Za-z_][\w]*\}?|\$\(.*?\)/, line: /#.*/, block: null, strings: ['"', "'"] },
33
+ yaml: { ext: /\.(ya?ml|toml|ini|cfg|conf)$/i, label: 'Config',
34
+ keywords: /^\s*[\w.-]+(?=\s*[:=])/, literals: /\b(true|false|null|yes|no|on|off)\b/, line: /#.*/, block: null, strings: ['"', "'"] },
35
+ sql: { ext: /\.sql$/i, label: 'SQL',
36
+ keywords: /\b(select|from|where|insert|into|values|update|set|delete|create|table|index|drop|alter|join|left|right|inner|outer|on|group|order|by|having|limit|offset|and|or|not|null|primary|key|foreign|references|as|distinct|union|with)\b/i,
37
+ literals: /\b\d+\b/, line: /--.*/, block: [/\/\*/, /\*\//], strings: ["'", '"'] },
38
+ };
39
+
40
+ // What this file is, by its name alone. A family MADRE does not know is not an error: the file is
41
+ // shown as plain text, which is what it would have been anyway.
42
+ export function familyOf(name) {
43
+ const base = String(name ?? '').split('/').pop() ?? '';
44
+ for (const [id, family] of Object.entries(FAMILIES)) if (family.ext.test(base)) return { id, ...family };
45
+ return null;
46
+ }
47
+
48
+ export function languageLabel(name) {
49
+ return familyOf(name)?.label ?? null;
50
+ }
51
+
52
+ // One line, cut into pieces. Returns [{ text, kind }] covering exactly the input: joining every
53
+ // `text` back together must give the line unchanged, which is what the test holds it to.
54
+ export function tokenize(line, family, state = { inBlock: false }) {
55
+ if (!family) return [{ text: line, kind: null }];
56
+ const out = [];
57
+ let index = 0;
58
+ const push = (text, kind) => { if (text) out.push({ text, kind }); };
59
+
60
+ while (index < line.length) {
61
+ const rest = line.slice(index);
62
+
63
+ if (state.inBlock) {
64
+ const close = family.block ? rest.match(family.block[1]) : null;
65
+ if (!close) { push(rest, 'comment'); index = line.length; break; }
66
+ push(rest.slice(0, close.index + close[0].length), 'comment');
67
+ index += close.index + close[0].length;
68
+ state.inBlock = false;
69
+ continue;
70
+ }
71
+
72
+ const blockOpen = family.block ? rest.match(family.block[0]) : null;
73
+ const lineOpen = rest.match(family.line);
74
+ const quote = family.strings.map((q) => rest.indexOf(q)).filter((at) => at >= 0).sort((a, b) => a - b)[0];
75
+
76
+ // Whichever comes first on this line decides what happens next.
77
+ const marks = [
78
+ blockOpen && blockOpen.index === 0 ? { at: 0, what: 'block' } : blockOpen ? { at: blockOpen.index, what: 'block' } : null,
79
+ lineOpen && lineOpen.index !== undefined ? { at: lineOpen.index, what: 'line' } : null,
80
+ quote !== undefined ? { at: quote, what: 'string' } : null,
81
+ ].filter(Boolean).sort((a, b) => a.at - b.at);
82
+ const next = marks[0];
83
+
84
+ if (!next) { pushCode(rest, family, push); index = line.length; break; }
85
+ if (next.at > 0) { pushCode(rest.slice(0, next.at), family, push); index += next.at; continue; }
86
+
87
+ if (next.what === 'line') { push(rest, 'comment'); index = line.length; break; }
88
+ if (next.what === 'block') {
89
+ const opened = rest.match(family.block[0])[0];
90
+ const after = rest.slice(opened.length);
91
+ const close = after.match(family.block[1]);
92
+ if (close) { push(rest.slice(0, opened.length + close.index + close[0].length), 'comment'); index += opened.length + close.index + close[0].length; continue; }
93
+ push(rest, 'comment'); index = line.length; state.inBlock = true; break;
94
+ }
95
+
96
+ // A string: to its closing quote, respecting a backslash escape.
97
+ const q = rest[0];
98
+ let at = 1;
99
+ while (at < rest.length) {
100
+ if (rest[at] === '\\') { at += 2; continue; }
101
+ if (rest[at] === q) { at += 1; break; }
102
+ at += 1;
103
+ }
104
+ push(rest.slice(0, at), 'string');
105
+ index += at;
106
+ }
107
+ return out;
108
+ }
109
+
110
+ // Code between the comments and the strings: keywords, literals, numbers, and the rest plain.
111
+ function pushCode(chunk, family, push) {
112
+ const pattern = new RegExp(`${family.keywords.source}|${family.literals.source}|\\b\\d+(?:\\.\\d+)?\\b`, family.keywords.flags.includes('i') ? 'gi' : 'g');
113
+ let last = 0;
114
+ for (const match of chunk.matchAll(pattern)) {
115
+ if (match.index > last) push(chunk.slice(last, match.index), null);
116
+ const text = match[0];
117
+ push(text, family.keywords.test(text) ? 'keyword' : /^\d/.test(text) ? 'number' : 'literal');
118
+ last = match.index + text.length;
119
+ }
120
+ if (last < chunk.length) push(chunk.slice(last), null);
121
+ }
@@ -60,6 +60,19 @@ export const CONDITIONS = [
60
60
  linux: ['grep -rn ANTHROPIC_API_KEY ~/.bashrc ~/.profile ~/.bash_profile 2>/dev/null', 'unset ANTHROPIC_API_KEY', '# and start MADRE from a new terminal'],
61
61
  },
62
62
  },
63
+ {
64
+ id: 'custom-agent-endpoint',
65
+ code: 'MU-055',
66
+ severity: 'blocking',
67
+ title: 'The agent is pointed at a custom endpoint',
68
+ match: /MADRE blocked @(?:codex|claude) before sending the briefing|Subscribe to (?:the )?Telegram|@conduitapi/i,
69
+ diagnosis: 'The installed CLI inherited a base-URL override from this computer, so its request was going to another gateway instead of the official agent service. A demand to join a Telegram channel comes from that gateway, not from MADRE, Anthropic or OpenAI. MADRE now names that route and stops before sending the briefing.',
70
+ remedy: 'Do not subscribe or enter credentials. Remove the base-URL override and reopen MADRE to use the official service. If the gateway is intentional and you have verified who operates it, explicitly allow only that agent.',
71
+ fixes: {
72
+ darwin: ['env | grep -E \'^(ANTHROPIC|OPENAI)_BASE_URL=\'', '# remove that export from ~/.zshrc, ~/.zprofile or ~/.zshenv; then:', 'unset ANTHROPIC_BASE_URL OPENAI_BASE_URL', '# open a new room; only for a gateway you verified:', 'PULSE_ALLOW_CUSTOM_AGENT_ENDPOINTS=claude madre start'],
73
+ linux: ['env | grep -E \'^(ANTHROPIC|OPENAI)_BASE_URL=\'', '# remove that export from ~/.bashrc, ~/.profile or ~/.bash_profile; then:', 'unset ANTHROPIC_BASE_URL OPENAI_BASE_URL', '# open a new room; only for a gateway you verified:', 'PULSE_ALLOW_CUSTOM_AGENT_ENDPOINTS=claude madre start'],
74
+ },
75
+ },
63
76
  {
64
77
  id: 'codex-reading-stdin',
65
78
  code: 'MU-002',
@@ -50,6 +50,7 @@ export function buildCodexArgs({ projectRoot, prompt, model = null, attachments
50
50
  export function parseCodexOutput(output) {
51
51
  let text = '';
52
52
  let usage = null;
53
+ let error = null;
53
54
  for (const line of output.split('\n').filter(Boolean)) {
54
55
  try {
55
56
  const event = JSON.parse(line);
@@ -66,11 +67,13 @@ export function parseCodexOutput(output) {
66
67
  source: 'codex-json',
67
68
  };
68
69
  }
70
+ if (event.type === 'error' && typeof event.message === 'string') error = event.message;
71
+ if (event.type === 'turn.failed' && typeof event.error?.message === 'string') error = event.error.message;
69
72
  } catch {
70
73
  // Ignore CLI diagnostics that are not JSON events.
71
74
  }
72
75
  }
73
- return { text: text.trim(), usage };
76
+ return { text: text.trim(), usage, ...(error ? { error } : {}) };
74
77
  }
75
78
 
76
79
  export function invokeCodex({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, memoryServer = null, mcpServers = [], onProgress = null }) {
@@ -79,8 +79,20 @@ export async function verifyModuleText({ text, name = null, replace = true }) {
79
79
  try {
80
80
  const probe = join(scratch, name && /\.m?js$/.test(name) ? basename(name) : 'candidate.mjs');
81
81
  await writeFile(probe, text);
82
- const module = checkExternal(await importModuleFile(probe), { replace });
83
- return { id: module.id, name: module.name, vendor: module.vendor, version: module.version ?? null, summary: module.summary ?? '', updates: module.updates ?? null };
82
+ let module;
83
+ try {
84
+ module = checkExternal(await importModuleFile(probe), { replace });
85
+ } catch (error) {
86
+ // The one mistake that looks right and never works: MADRE's own modules import the SDK by
87
+ // relative path because they live inside the package, and anyone — an agent most of all —
88
+ // reading one of them copies that line. Here the file is alone in a scratch folder, so the
89
+ // import cannot resolve and node says only that it cannot find a file. Say what to do.
90
+ if (error?.code === 'ERR_MODULE_NOT_FOUND' || /Cannot find (module|package)/.test(error?.message ?? '')) {
91
+ throw new Error(`${error.message.split('\n')[0]} — a module imports nothing: MADRE loads it from a folder of its own to check it, where no package resolves. Export a plain object, or a function ({ defineModule }) => defineModule({ … }) and MADRE hands you the SDK.`);
92
+ }
93
+ throw error;
94
+ }
95
+ return { id: module.id, name: module.name, vendor: module.vendor, version: module.version ?? null, summary: module.summary ?? '', updates: module.updates ?? null, unknown: module.unknown ?? [] };
84
96
  } finally { await rm(scratch, { recursive: true, force: true, maxRetries: 6, retryDelay: 60 }); }
85
97
  }
86
98
 
@@ -95,7 +107,7 @@ export async function installModuleText({ text, name = null, scope = 'user', sta
95
107
  // Where it came from, so the same place can be asked for a newer one later.
96
108
  if (source) await writeFile(join(dir, `${module.id}.source.json`), JSON.stringify({ ...source, at: new Date().toISOString(), version: module.version ?? null }, null, 2)).catch(() => {});
97
109
  await loadExternalModules({ stateRoot, projectRoot });
98
- return { id: module.id, name: module.name, version: module.version ?? null, file: target, origin: scope === 'project' ? 'project' : 'user', replaced };
110
+ return { id: module.id, name: module.name, version: module.version ?? null, file: target, origin: scope === 'project' ? 'project' : 'user', replaced, unknown: module.unknown ?? [] };
99
111
  }
100
112
 
101
113
  // The same thing from a file already on this computer. `source` must be inside the project or the
@@ -122,9 +134,15 @@ export async function removeExternalModule({ id, stateRoot, projectRoot }) {
122
134
  const module = MODULES.find((known) => known.id === id);
123
135
  if (!module) throw new Error(`No module "${id}".`);
124
136
  if (!module.external) throw new Error(`${module.name} ships with MADRE and cannot be removed; switch it off instead.`);
125
- await unlink(module.file).catch(() => {});
137
+ // The origin record goes first: if it cannot be removed, the executable module file remains
138
+ // installed rather than leaving a live registry entry whose file has already disappeared.
139
+ const files = [...new Set([module.file.replace(/\.(?:mjs|js)$/, '.source.json'), module.file])];
140
+ const removedFiles = [];
141
+ for (const file of files) {
142
+ try { await unlink(file); removedFiles.push(file); } catch (error) { if (error.code !== 'ENOENT') throw error; }
143
+ }
126
144
  await loadExternalModules({ stateRoot, projectRoot });
127
- return { id, name: module.name, file: module.file };
145
+ return { id, name: module.name, file: module.file, removedFiles };
128
146
  }
129
147
 
130
148
  export const moduleById = (id) => MODULES.find((module) => module.id === id) ?? null;
@@ -51,6 +51,19 @@ export function dependencies(runs) {
51
51
  return list.filter((dep) => dep && dep.name).map((dep) => ({ name: String(dep.name), version: dep.version ?? null, target: dep.target ?? null }));
52
52
  }
53
53
 
54
+ // Every field this contract answers to. Thirty-four of them and all but two optional, which is
55
+ // generous until you misspell one: `sumary` used to install without a word and leave a card with
56
+ // an empty summary. A key MADRE does not know is not fatal — a module written for a newer MADRE
57
+ // may carry fields this one has not learned yet — so it is said out loud instead of refused.
58
+ export const MODULE_FIELDS = new Set([
59
+ 'id', 'kind', 'name', 'vendor', 'package', 'version', 'tracks', 'updates', 'summary', 'creates',
60
+ 'requires', 'models', 'commands', 'card', 'configKey', 'settings', 'controls', 'routes',
61
+ 'conditions', 'slash', 'status', 'preflight', 'detect', 'confirm', 'installCommand',
62
+ 'updatePlan', 'toolsForTurn', 'toggle', 'onToggle', 'onSettings', 'onEvent', 'toggledEvent',
63
+ 'toggledBody', 'describe',
64
+ ]);
65
+ export const unknownFields = (spec) => Object.keys(spec ?? {}).filter((key) => !MODULE_FIELDS.has(key));
66
+
54
67
  export function defineModule(spec) {
55
68
  if (!spec?.id || !/^[a-z][a-z0-9-]*$/.test(spec.id)) throw new Error(`Module id must be kebab-case: ${spec?.id}`);
56
69
  if (!spec.name) throw new Error(`Module ${spec.id} needs a name.`);
@@ -59,6 +72,8 @@ export function defineModule(spec) {
59
72
  const defaults = { ...(kind === 'builtin' ? { enabled: false } : {}), ...(spec.settings ?? {}) };
60
73
  const base = {
61
74
  id: spec.id, kind, name: spec.name, vendor: spec.vendor ?? 'MADRE', package: spec.package ?? null, version: spec.version ?? null,
75
+ // Keys this contract does not answer to. Carried so whoever installs the module is told.
76
+ unknown: unknownFields(spec),
62
77
  // What this module's version follows, when it is not its own: { name, npm } or { name, github }.
63
78
  // MADRE reads the version from there and looks for a newer one on its own, once a day.
64
79
  tracks: spec.tracks ? { name: spec.tracks.name ?? spec.tracks.npm ?? spec.tracks.github ?? null, npm: spec.tracks.npm ?? null, github: spec.tracks.github ?? null } : null,
package/src/outbound.mjs CHANGED
@@ -269,7 +269,7 @@ export class OutboundLog {
269
269
  export function outboundView({ log = null, state = {}, agents = [] } = {}) {
270
270
  const counts = log?.counts() ?? {};
271
271
  const vendors = { codex: 'OpenAI', claude: 'Anthropic', gemini: 'Google', opencode: 'the provider OpenCode is signed in to' };
272
- const crew = agents.filter((agent) => agent.detected && !agent.local).map((agent) => `@${agent.id} → ${vendors[agent.id] ?? 'its own provider'}`);
272
+ const crew = agents.filter((agent) => agent.detected && !agent.local).map((agent) => `@${agent.id} → ${agent.route?.destination ?? vendors[agent.id] ?? 'its own provider'}`);
273
273
  const destinations = DESTINATIONS.map((one) => ({
274
274
  id: one.id,
275
275
  to: one.id === 'crew' && crew.length ? crew.join(' · ') : one.to,
package/src/room.mjs CHANGED
@@ -945,6 +945,18 @@ export class Room {
945
945
  await this.#emit('message.failed', { messageId, target: targetId, planId, error: `${agent.label} was detected, but its MADRE adapter is not enabled yet.` });
946
946
  return null;
947
947
  }
948
+ if (agent.route?.custom && !agent.route.allowed) {
949
+ const destination = agent.route.host ?? 'an unrecognised host';
950
+ const source = agent.route.source ?? 'a custom endpoint setting';
951
+ const official = agent.id === 'codex' ? 'OpenAI' : agent.id === 'claude' ? 'Anthropic' : 'the agent\'s official service';
952
+ await this.#emit('message.failed', {
953
+ messageId,
954
+ target: targetId,
955
+ planId,
956
+ error: `MADRE blocked @${agent.id} before sending the briefing: ${source} points to ${destination}, not ${official}. Remove that override to use the official service, or set PULSE_ALLOW_CUSTOM_AGENT_ENDPOINTS=${agent.id} only if you trust that endpoint.`,
957
+ });
958
+ return null;
959
+ }
948
960
 
949
961
  const priorEvents = await this.#store.readAll();
950
962
  // @madre never reads its own canned replies back: a small model would echo them.