@jossuealcala/madre 0.2.1 → 0.2.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
@@ -2,6 +2,23 @@
2
2
 
3
3
  Todas las versiones publicadas de `@jossuealcala/madre`. Fechas en ISO.
4
4
 
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
+
7
+ ## 0.2.3 · 2026-09-18
8
+
9
+ ### RIPLEY navega
10
+ - El visor renderiza HTML como un navegador del proyecto: la página se sirve en `/preview/project/<ruta>`, sus scripts corren y sus rutas relativas a CSS, JS, imágenes y fuentes funcionan. El marco sigue sellado: sin origen propio, sin red, sin formularios, sin acceso a MADRE, y solo carga recursos del proyecto a través de MADRE. Antes los scripts estaban bloqueados y una página construida con JavaScript se veía vacía.
11
+ - Barra mínima en el visor: atrás y recargar, con la ruta y el título de la página en pantalla. Sin URL editable: RIPLEY es un visor del proyecto, no un navegador general.
12
+ - Recarga sola cuando un agente cambia la página abierta o algo de su carpeta, en CONTROL o en un lease.
13
+ - Los errores de la página se ven: un puente de una línea dentro del marco reenvía `window.onerror`, promesas rechazadas y recursos que no cargan; el visor los muestra en una franja con `ASK THE ROOM`, que deja el error y el archivo en el compositor.
14
+
15
+ ## 0.2.2 · 2026-09-18
16
+
17
+ ### Corrección crítica
18
+ - Claude Code negaba toda escritura en `#2 CREATE` y `#3 CONTROL`: las reglas `Write(/ruta/**)` se leían como relativas al proyecto. Ahora van como `//ruta/**`, la forma absoluta de Claude Code; las zonas prohibidas siguen bloqueadas antes del turno.
19
+
20
+ Reproducido con el CLI real en un repositorio temporal: con una barra el archivo quedaba bloqueado sin pregunta; con dos se escribe y `.env` sigue bloqueado. Sin otros cambios respecto a 0.2.1.
21
+
5
22
  ## 0.2.1 · 2026-09-18 · beta pública
6
23
 
7
24
  Primera versión pensada para manos ajenas. Requiere Node 22.5 o superior.
package/CONTRIBUTING.md CHANGED
@@ -35,4 +35,11 @@ public/ the room UI; troubleshooting.js is MU/TH/UR's knowledge bas
35
35
  docs/report-collector/ the Worker that turns sentinel reports into issues
36
36
  ```
37
37
 
38
+ ## Versions
39
+
40
+ - A version is **closed only when it is on npm**. Until then its changelog section reads *Sin publicar* and keeps growing; no new number is opened while the previous one is unpublished.
41
+ - One published version = one `vX.Y.Z` tag = one GitHub release = one changelog section. Tags and releases are created at publish time, never before.
42
+ - Patch (`0.2.x`) for fixes and additions inside existing modules; the patch number may reach two digits. Minor (`0.x`) for a new mode, a new module, a new agent, or a change to what leaves the machine. Major when the room's ledger or memory format stops being readable by the previous version.
43
+ - `scripts/release.mjs` does the closing in one go: checks the tree is clean and CI-green, runs the suite and pack:check, turns *Sin publicar* into the dated section, tags, pushes, creates the release and prints the publish command.
44
+
38
45
  Open questions go to issues with the `question` label. Ideas go through `✎ FEEDBACK` in MU/TH/UR or a plain issue. Be kind to the crew.
package/README.md CHANGED
@@ -212,7 +212,7 @@ La sala ofrece módulos integrados de MADRE y una integración externa opcional.
212
212
 
213
213
  - **Git Pulse** (integrado, sin instalación): `/git status`, `/git log [n]`, `/git diff` y `/git branches` traen a la sala la rama, los cambios sin confirmar, los últimos commits o el resumen del diff, en solo lectura y sin gastar un turno de agente. La tarjeta queda en el registro como `command.output` y entra en el contexto que reciben los agentes, así todos razonan sobre los mismos hechos del repositorio. Requiere que el proyecto sea un repositorio git.
214
214
  - **Image Studio** (integrado): ver la sección anterior.
215
- - **RIPLEY** (integrado): actívalo en `MODULES` y el visor de archivos renderiza HTML, SVG y Markdown en lugar de mostrar su código, con un botón `PREVIEW`/`SOURCE` en la cabecera. HTML y SVG se sirven por `/api/preview` dentro de un marco sellado (`sandbox` sin permisos y una CSP que prohíbe scripts, red, formularios y almacenamiento); Markdown se renderiza en el propio visor. Apagado, esos archivos se muestran como texto con una nota. Sirve para ver lo que los agentes crean en `.pulse/out/` sin salir de la sala.
215
+ - **RIPLEY** (integrado): actívalo en `MODULES` y el visor de archivos renderiza HTML, SVG y Markdown en lugar de mostrar su código, con un botón `PREVIEW`/`SOURCE` en la cabecera. HTML y SVG se sirven por `/preview/project/<ruta>` dentro de un marco sellado: los scripts de la página sí corren, pero el marco no tiene origen propio (`sandbox allow-scripts`, sin `same-origin`), no puede hacer peticiones de red, ni enviar formularios, ni navegar la ventana de MADRE, y solo carga CSS, JS, imágenes y fuentes del propio proyecto a través de MADRE. Las rutas relativas de la página funcionan, el visor tiene atrás y recargar, se recarga solo cuando un agente cambia la página abierta, y los errores de la página aparecen en una franja con `ASK THE ROOM`. Markdown se renderiza en el propio visor. Apagado, esos archivos se muestran como texto con una nota. Sirve para ver lo que los agentes crean en `.pulse/out/` sin salir de la sala.
216
216
  - **AshCode** (integrado, beta): actívalo en `MODULES` para mostrar el botón `$ ash_code` en el campo de texto. Cuando está iluminado, MADRE intenta abreviar localmente mensajes en español o inglés antes de enviarlos a un agente, y pide respuestas concisas. La burbuja muestra el texto enviado y permite desplegar el original; las respuestas abreviadas también conservan el original. Se omite la transformación si detecta código, rutas, enlaces, negaciones, cifras, estructura compleja, idioma incierto o ninguna reducción segura. **Puede cambiar el significado o producir errores: revisa siempre el original y la respuesta.** Menos caracteres no demuestra menos tokens facturados; consulta el uso real del proveedor. Al encenderlo, el campo se ensancha como con CREATE, la etiqueta pasa a `MU/TH/UR · SPECIAL ORDER 937 ›` y la caja hace un guiño a MADRE: un barrido CRT con líneas de fósforo en verde Homebrew (el verde del perfil Homebrew de Terminal.app), que también viste el botón como un prompt de bash con cursor parpadeante. Se apaga desde `ORDER 937` para el siguiente mensaje o se deshabilita en `MODULES`. No requiere npm ni modifica el proyecto: su estado se guarda en la configuración local de MADRE.
217
217
  - **AHP+** (`@jossuealcala/ahp-plus`): estado verificado del proyecto, checkpoints y handoffs entre sesiones de IA, guardado en `.ahp/`. MADRE lo detecta por `.ahp/manifest.json` y lo instala con `npx --yes @jossuealcala/ahp-plus@1.4.1 setup . --platforms <agentes detectados>`, pidiendo adaptadores solo para los agentes presentes en la máquina que AHP+ soporta (Codex, Claude, OpenCode). Una vez instalado, `/ahp status`, `/ahp check` y `/ahp context` consultan su estado desde el campo de texto.
218
218
 
@@ -290,7 +290,7 @@ Todos los adaptadores corren en su propio grupo de procesos. Si un agente no res
290
290
 
291
291
  ## Cambios
292
292
 
293
- Ver [CHANGELOG.md](CHANGELOG.md). La versión actual es 0.2.1, beta pública: el núcleo está probado y bajo CI, la superficie sigue cambiando y las decisiones que aún duelen están escritas en el modelo de amenazas. Los problemas se reportan desde MU/TH/UR (`✎ FEEDBACK` o el sentinel) o en [issues](https://github.com/jossuealcacao-exe/madre/issues); la seguridad, según [SECURITY.md](SECURITY.md).
293
+ Ver [CHANGELOG.md](CHANGELOG.md). La versión publicada es 0.2.2 y la siguiente en preparación es 0.2.3, beta pública: el núcleo está probado y bajo CI, la superficie sigue cambiando y las decisiones que aún duelen están escritas en el modelo de amenazas. Los problemas se reportan desde MU/TH/UR (`✎ FEEDBACK` o el sentinel) o en [issues](https://github.com/jossuealcacao-exe/madre/issues); la seguridad, según [SECURITY.md](SECURITY.md).
294
294
 
295
295
  ## Licencia
296
296
 
package/bin/madre.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { resolve } from 'node:path';
4
- import { stat } from 'node:fs/promises';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { readFile, stat } from 'node:fs/promises';
5
6
  import { startPulse } from '../src/server.mjs';
6
7
  import { detectAgents } from '../src/runtime-detection.mjs';
7
8
  import { probeAll } from '../src/auth-probe.mjs';
@@ -42,6 +43,14 @@ if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 5)) {
42
43
  console.error(`\n MOTHER › MADRE needs Node 22.5 or newer (found ${process.version}): the room's memory runs on node:sqlite.\n`);
43
44
  process.exit(2);
44
45
  }
46
+ if (has('--version') || has('-v') || command === 'version') {
47
+ // Version and commit, so a report can name exactly what ran.
48
+ const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
49
+ let commit = '';
50
+ try { commit = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: new URL('..', import.meta.url).pathname, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { /* installed from npm: no repo */ }
51
+ console.log(`madre ${pkg.version}${commit ? ` · ${commit}` : ''} · node ${process.version}`);
52
+ process.exit(0);
53
+ }
45
54
  const stateRoot = process.env.PULSE_HOME;
46
55
  if (command !== 'help') {
47
56
  const stats = await stat(projectRoot).catch(() => null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jossuealcala/madre",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "MADRE: one local room where the AI coding agents already installed on your machine (Codex, Claude Code, Gemini CLI, OpenCode) work on a project together over the PULSE channel: read-only by default, per-message permission modes up to a checkpointed CONTROL, a shared memory every agent recalls and queries, and NOSTROMO to browse it.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -57,7 +57,8 @@
57
57
  "doctor": "node ./bin/madre.mjs doctor",
58
58
  "test": "node --test",
59
59
  "check": "npm test && node ./bin/madre.mjs doctor --json",
60
- "pack:check": "node ./scripts/pack-check.mjs"
60
+ "pack:check": "node ./scripts/pack-check.mjs",
61
+ "release": "node ./scripts/release.mjs"
61
62
  },
62
63
  "publishConfig": {
63
64
  "access": "public"
package/public/app.js CHANGED
@@ -309,7 +309,7 @@ viewerUI.review?.addEventListener('click', (event) => {
309
309
  if (viewerUI.menu.hidden) showViewerMenu(rect.left, rect.bottom + 6); else hideViewerMenu();
310
310
  });
311
311
  viewer.dialog.addEventListener('click', (event) => { if (!viewerUI.menu.hidden && !viewerUI.menu.contains(event.target) && event.target !== viewerUI.review) hideViewerMenu(); });
312
- viewer.dialog.addEventListener('close', () => { hideViewerMenu(); setSelection(null, null); });
312
+ viewer.dialog.addEventListener('close', () => { hideViewerMenu(); setSelection(null, null); ripley.frame = null; ripley.history = []; ripleyClearErrors(); if (ripley.nav) ripley.nav.hidden = true; });
313
313
 
314
314
  function codeView(text, { line = null, lines = null } = {}) {
315
315
  const pre = el('pre', 'code');
@@ -345,6 +345,9 @@ const RIPLEY_KIND = (name) => (/\.(html?|xhtml)$/i.test(name ?? '') ? 'html' : /
345
345
  const viewerMode = { button: document.querySelector('#viewer-mode'), preview: false, kind: null, text: '', src: '', line: null, lines: null };
346
346
 
347
347
  function showViewerSource() {
348
+ if (ripley.nav) ripley.nav.hidden = true;
349
+ ripleyClearErrors();
350
+ ripley.frame = null;
348
351
  const pre = codeView(viewerMode.text, { line: viewerMode.line, lines: viewerMode.lines });
349
352
  viewer.body.replaceChildren(pre);
350
353
  if (!state.ripley && viewerMode.kind) {
@@ -356,20 +359,115 @@ function showViewerSource() {
356
359
  else if (viewerMode.line) setSelection(viewerMode.line, viewerMode.line);
357
360
  pre.querySelector('.hl')?.scrollIntoView?.({ block: 'center' });
358
361
  }
359
- // The sealed frame: no scripts, no network, no storage; Markdown renders in place.
362
+ // RIPLEY's frame, its small bar (back, reload), its history inside the project,
363
+ // what the page reports up (which page, which errors), and reloads when an
364
+ // agent changes the file on screen.
365
+ const ripley = {
366
+ nav: document.querySelector('#viewer-nav'),
367
+ back: document.querySelector('#viewer-back'),
368
+ reload: document.querySelector('#viewer-reload'),
369
+ errors: document.querySelector('#viewer-errors'),
370
+ frame: null,
371
+ history: [], // preview URLs visited in this session of the viewer
372
+ current: null, // project-relative path of the page on screen
373
+ root: 'project',
374
+ };
375
+ function ripleyPathFromPreview(pathname) {
376
+ const match = String(pathname ?? '').match(/^\/preview\/(project|attachments)\/(.*)$/);
377
+ return match ? { root: match[1], path: decodeURIComponent(match[2]) } : null;
378
+ }
379
+ function ripleyNavigate(url, { push = true } = {}) {
380
+ if (!ripley.frame) return;
381
+ if (push && ripley.history.at(-1) !== url) ripley.history.push(url);
382
+ ripley.frame.src = url;
383
+ ripley.back.disabled = ripley.history.length < 2;
384
+ }
385
+ function ripleyClearErrors() { if (ripley.errors) { ripley.errors.hidden = true; ripley.errors.replaceChildren(); } }
386
+ function ripleyShowError({ message, source, line }) {
387
+ if (!ripley.errors || !viewerMode.preview) return;
388
+ const where = source ? `${ripleyPathFromPreview(new URL(source, window.location.origin).pathname)?.path ?? source}${line ? `:${line}` : ''}` : (ripley.current ? `${ripley.current}${line ? `:${line}` : ''}` : '');
389
+ const row = el('div', 'err-row');
390
+ row.append(el('span', 'msg', message));
391
+ if (where) row.append(el('span', 'where', where));
392
+ const ask = el('button', null, 'ASK THE ROOM');
393
+ ask.type = 'button';
394
+ ask.title = 'Put this error and the file into the composer';
395
+ ask.addEventListener('click', () => {
396
+ const reference = ripley.current ? `!${ripley.current}${line ? `:${line}` : ''} ` : '';
397
+ els.input.value = `${reference}RIPLEY reports an error in the page: ${message}${where ? ` (${where})` : ''}. Find the cause and propose the fix.`;
398
+ viewer.dialog.close();
399
+ autosize();
400
+ els.input.focus();
401
+ });
402
+ row.append(ask);
403
+ ripley.errors.append(row);
404
+ while (ripley.errors.children.length > 3) ripley.errors.firstChild.remove();
405
+ ripley.errors.hidden = false;
406
+ }
407
+ window.addEventListener?.('message', (event) => {
408
+ const data = event.data;
409
+ if (!data || data.ripley !== 1 || !ripley.frame || event.source !== ripley.frame.contentWindow) return;
410
+ if (data.type === 'page') {
411
+ const at = ripleyPathFromPreview(data.path);
412
+ if (at) {
413
+ ripley.current = at.path;
414
+ ripley.root = at.root;
415
+ viewer.path.textContent = `${at.root === 'project' ? '/' : ''}${at.path}${data.title ? ` · ${data.title}` : ''}`;
416
+ viewer.open.href = `/api/files?root=${at.root}&path=${encodeURIComponent(at.path)}`;
417
+ }
418
+ } else if (data.type === 'error') {
419
+ ripleyShowError(data);
420
+ }
421
+ });
422
+ ripley.back?.addEventListener('click', () => {
423
+ if (ripley.history.length < 2) return;
424
+ ripley.history.pop();
425
+ ripleyClearErrors();
426
+ ripleyNavigate(ripley.history.at(-1), { push: false });
427
+ });
428
+ ripley.reload?.addEventListener('click', () => { if (ripley.frame) { ripleyClearErrors(); ripley.frame.src = ripley.frame.src; } });
429
+ // An agent changed what is on screen (CONTROL) or created it (a lease): reload the page.
430
+ function ripleyMaybeReload(paths) {
431
+ if (!viewerMode.preview || !ripley.frame || !ripley.current || !viewer.dialog.open) return;
432
+ const page = ripley.current;
433
+ const dir = page.includes('/') ? page.slice(0, page.lastIndexOf('/') + 1) : '';
434
+ const hit = paths.some((path) => path === page || (dir ? path.startsWith(dir) : !path.includes('/')));
435
+ if (!hit) return;
436
+ ripleyClearErrors();
437
+ ripley.frame.src = ripley.frame.src;
438
+ toast('MU/TH/UR › RIPLEY reloaded the page: an agent changed it.');
439
+ }
440
+
360
441
  function showViewerPreview() {
361
442
  if (viewerMode.kind === 'markdown') {
443
+ ripley.nav.hidden = true;
362
444
  const doc = el('article', 'viewer-markdown bubble');
363
445
  doc.append(renderMarkdown(viewerMode.text));
364
446
  viewer.body.replaceChildren(doc);
365
447
  return;
366
448
  }
449
+ // Scripts may run; the frame has no origin of its own, no network, no forms, no way up to MADRE.
367
450
  const frame = el('iframe', 'ripley');
368
- frame.setAttribute('sandbox', '');
451
+ frame.setAttribute('sandbox', 'allow-scripts');
369
452
  frame.setAttribute('referrerpolicy', 'no-referrer');
370
453
  frame.title = `RIPLEY preview of ${viewer.path.textContent}`;
371
- frame.src = viewerMode.src.replace('/api/files?', '/api/preview?');
454
+ ripley.frame = frame;
455
+ ripley.history = [];
456
+ ripleyClearErrors();
457
+ const start = previewUrl(viewerMode.src);
458
+ const at = ripleyPathFromPreview(start);
459
+ ripley.current = at?.path ?? null;
460
+ ripley.root = at?.root ?? 'project';
372
461
  viewer.body.replaceChildren(frame);
462
+ ripley.nav.hidden = false;
463
+ ripleyNavigate(start);
464
+ }
465
+ // /api/files?root=project&path=a/b.html → /preview/project/a/b.html, so the page's relative links resolve.
466
+ function previewUrl(filesUrl) {
467
+ const params = new URL(filesUrl, window.location.origin).searchParams;
468
+ const root = params.get('root') === 'attachments' ? 'attachments' : 'project';
469
+ const path = (params.get('path') ?? '').split('/').map(encodeURIComponent).join('/');
470
+ return `/preview/${root}/${path}`;
373
471
  }
374
472
  function syncViewerMode() {
375
473
  if (!viewerMode.button) return;
@@ -1698,12 +1796,12 @@ function renderEventNode(event) {
1698
1796
  case 'lease.missing': node = renderLeaseMissing(event); break;
1699
1797
  case 'mode.requested': node = renderModeRequest(event); break;
1700
1798
  case 'control.started': node = renderControlStarted(event); break;
1701
- case 'control.changed': node = renderControlChanged(event); break;
1799
+ case 'control.changed': node = renderControlChanged(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); break;
1702
1800
  case 'control.reverted': node = renderControlReverted(event); break;
1703
1801
  case 'mode.granted':
1704
1802
  case 'mode.denied': settleModeRequest(event); return;
1705
1803
  case 'plan.ignored': node = renderPlanIgnored(event); break;
1706
- case 'artifacts.created': attachArtifacts(event); return;
1804
+ case 'artifacts.created': attachArtifacts(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); return;
1707
1805
  case 'command.output': node = renderCommandCard(event); break;
1708
1806
  default: return;
1709
1807
  }
package/public/index.html CHANGED
@@ -217,6 +217,10 @@
217
217
  </dialog>
218
218
  <dialog id="viewer" class="viewer" aria-label="File viewer">
219
219
  <header class="viewer-head">
220
+ <span class="viewer-nav" id="viewer-nav" hidden>
221
+ <button id="viewer-back" class="nav" type="button" title="Back to the previous page" aria-label="Back" disabled>‹</button>
222
+ <button id="viewer-reload" class="nav" type="button" title="Reload the page" aria-label="Reload">↻</button>
223
+ </span>
220
224
  <span id="viewer-path" class="viewer-path"></span>
221
225
  <span id="viewer-selection" class="viewer-selection" hidden></span>
222
226
  <button id="viewer-mode" class="viewer-review viewer-mode" type="button" hidden>SOURCE</button>
@@ -224,6 +228,7 @@
224
228
  <a id="viewer-open" class="viewer-open" target="_blank" rel="noopener noreferrer">OPEN RAW ↗</a>
225
229
  <button id="viewer-close" class="mother-close" type="button">CLOSE ×</button>
226
230
  </header>
231
+ <div id="viewer-errors" class="viewer-errors" hidden></div>
227
232
  <div id="viewer-body" class="viewer-body"></div>
228
233
  <div id="viewer-menu" class="viewer-menu" role="menu" hidden></div>
229
234
  </dialog>
package/public/styles.css CHANGED
@@ -710,6 +710,17 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
710
710
  .viewer-path { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
711
711
  .viewer-open { color: var(--phosphor); text-decoration: none; letter-spacing: .12em; font-size: 10px; }
712
712
  .viewer-body { height: calc(100% - 46px); overflow: auto; display: grid; place-items: start center; padding: 16px; }
713
+ .viewer-nav { display: inline-flex; gap: 4px; }
714
+ .viewer-nav .nav { width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid var(--line-2); background: transparent; color: var(--text-2); border-radius: 7px; cursor: pointer; font: inherit; font-size: 14px; line-height: 1; }
715
+ .viewer-nav .nav:hover { color: var(--text); border-color: var(--text-3); }
716
+ .viewer-nav .nav:disabled { opacity: .35; cursor: default; }
717
+ .viewer-errors { display: grid; gap: 6px; padding: 8px 16px; border-bottom: 1px solid color-mix(in srgb, var(--danger) 40%, transparent); background: color-mix(in srgb, var(--danger) 7%, transparent); font-family: var(--mono); font-size: 11px; }
718
+ .viewer-errors:not([hidden]) + .viewer-body { height: auto; max-height: calc(100% - 46px); }
719
+ .viewer-errors .err-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
720
+ .viewer-errors .msg { color: var(--danger); overflow-wrap: anywhere; }
721
+ .viewer-errors .where { color: var(--text-3); font-size: 10px; }
722
+ .viewer-errors button { margin-left: auto; border: 1px solid color-mix(in srgb, var(--danger) 55%, transparent); background: transparent; color: var(--danger); font: inherit; font-family: var(--mono); font-size: 9.5px; letter-spacing: .14em; padding: 3px 8px; border-radius: 6px; cursor: pointer; }
723
+ .viewer-errors button:hover { background: color-mix(in srgb, var(--danger) 14%, transparent); }
713
724
  .viewer-body img { max-width: 100%; max-height: 100%; border-radius: 8px; }
714
725
  .viewer-body pre { margin: 0; width: 100%; font-family: var(--mono); font-size: 12.5px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text); }
715
726
  .viewer-body iframe { width: 100%; height: 100%; border: 0; background: #fff; border-radius: 8px; }
@@ -416,8 +416,8 @@ export const CONDITIONS = [
416
416
  severity: 'informational',
417
417
  title: 'RIPLEY: rendering HTML, SVG and Markdown in the viewer',
418
418
  match: /ripley|RIPLEY is off|renders \.html|render(ed|ing)? (the )?(html|svg|markdown)/i,
419
- diagnosis: 'With RIPLEY on (MODULES), the file viewer renders .html and .svg through /api/preview inside a sealed frame (sandbox with no permissions; a policy that allows no scripts, no network, no forms, no storage) and Markdown in place; PREVIEW / SOURCE switches. Off, those files show as text with a note. 412 means RIPLEY is off; 415 means the type is not rendered (only .html, .htm, .svg; .md renders in the viewer itself). Plain /api/files always serves HTML as text.',
420
- remedy: 'Enable RIPLEY in MODULES. If a page looks broken in PREVIEW it is usually because it needs scripts or external resources, which the frame forbids by design: open it with OPEN RAW in a normal tab if you trust it.',
419
+ diagnosis: 'With RIPLEY on (MODULES), the file viewer renders .html and .svg through /preview/project/<path> inside a sealed frame: the page\'s own scripts run, but the frame has no origin, no network, no forms and no way to reach MADRE, and it loads CSS, JS, images and fonts only from the project through MADRE. Relative links work. Markdown renders in place; PREVIEW / SOURCE switches. Off, those files show as text with a note. 412 means RIPLEY is off. Plain /api/files always serves HTML as text.',
420
+ remedy: 'Enable RIPLEY in MODULES. If a page looks broken in PREVIEW it is usually because it fetches something from the internet or another server, which the frame forbids by design: open it with OPEN RAW in a normal tab if you trust it.',
421
421
  fixes: same(['# MODULES → RIPLEY → ENABLE RIPLEY', '# viewer → PREVIEW / SOURCE']),
422
422
  },
423
423
  {
@@ -25,7 +25,8 @@ export function buildClaudeArgs({ prompt, model = null, attachmentsDir = null, l
25
25
  ];
26
26
  const allowed = [
27
27
  'Read', 'Glob', 'Grep',
28
- ...(lease ? [`Write(${lease.outDir}/**)`, `Edit(${lease.outDir}/**)`] : []),
28
+ // Claude Code reads `/path` as relative to the project and `//path` as an absolute path.
29
+ ...(lease ? [`Write(//${lease.outDir}/**)`, `Edit(//${lease.outDir}/**)`] : []),
29
30
  ...(scopes?.web ? ['WebFetch', 'WebSearch'] : []),
30
31
  ...mcpTools,
31
32
  ];
@@ -43,7 +44,7 @@ export function buildClaudeArgs({ prompt, model = null, attachmentsDir = null, l
43
44
  '--tools', tools.join(','),
44
45
  ...(lease || scopes?.web || anyMcp ? ['--allowedTools', allowed.join(',')] : []),
45
46
  // CONTROL: the whole project is writable except MADRE's forbidden zones.
46
- ...(lease?.control ? ['--disallowedTools', ['.git/**', '.pulse/**', '.env', '.env.*', '**/.env', '**/.env.*'].flatMap((glob) => [`Write(${lease.outDir}/${glob})`, `Edit(${lease.outDir}/${glob})`]).join(',')] : []),
47
+ ...(lease?.control ? ['--disallowedTools', ['.git/**', '.pulse/**', '.env', '.env.*', '**/.env', '**/.env.*'].flatMap((glob) => [`Write(//${lease.outDir}/${glob})`, `Edit(//${lease.outDir}/${glob})`]).join(',')] : []),
47
48
  // --safe-mode disables every MCP server, ours included. With a MADRE server
48
49
  // attached we drop it and instead load no setting sources at all: no user
49
50
  // hooks, plugins or MCP servers, only the project's CLAUDE.md and ours.
package/src/server.mjs CHANGED
@@ -15,6 +15,20 @@ import { ErrorSentinel } from './sentinel-errors.mjs';
15
15
 
16
16
  const PACKAGE = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8').catch(() => '{}'));
17
17
  let crashHandlersInstalled = false;
18
+
19
+ // RIPLEY's bridge, injected into every page it renders: reports the page's path
20
+ // and title to the viewer and forwards runtime errors and failed resources.
21
+ // It talks only upward through postMessage; the frame stays an opaque origin.
22
+ export const RIPLEY_BRIDGE = '<script data-ripley>(function(){var p=location.pathname;function send(m){try{parent.postMessage(Object.assign({ripley:1},m),"*")}catch(e){}}send({type:"page",path:p,title:document.title});addEventListener("DOMContentLoaded",function(){send({type:"page",path:p,title:document.title})});addEventListener("error",function(e){if(e.target&&e.target!==window&&!e.message){var u=e.target.src||e.target.href||"";send({type:"error",message:"Failed to load "+(u||e.target.tagName.toLowerCase()),source:u,line:0});return}send({type:"error",message:String(e.message||"error"),source:e.filename||"",line:e.lineno||0})},true);addEventListener("unhandledrejection",function(e){var r=e.reason;send({type:"error",message:"Unhandled promise rejection: "+String(r&&r.message||r),source:"",line:0})})})();</script>';
23
+ export function injectRipleyBridge(body) {
24
+ const text = Buffer.isBuffer(body) ? body.toString('utf8') : String(body);
25
+ if (text.includes('data-ripley')) return Buffer.from(text);
26
+ const head = text.match(/<head[^>]*>/i);
27
+ if (head) return Buffer.from(text.slice(0, head.index + head[0].length) + RIPLEY_BRIDGE + text.slice(head.index + head[0].length));
28
+ const html = text.match(/<html[^>]*>/i);
29
+ if (html) return Buffer.from(text.slice(0, html.index + html[0].length) + RIPLEY_BRIDGE + text.slice(html.index + html[0].length));
30
+ return Buffer.from(RIPLEY_BRIDGE + text);
31
+ }
18
32
  // The author's collector: SEND and AUTO-REPORT are available out of the box; AUTO-REPORT stays off until the human turns it on.
19
33
  const DEFAULT_REPORT_URL = 'https://madre-reports.jossue-alcala-o.workers.dev/v1/reports';
20
34
  import { QuotaMonitor } from './quota-monitor.mjs';
@@ -523,26 +537,34 @@ export async function createPulseServer({
523
537
  });
524
538
  return response.end(file.body);
525
539
  }
526
- // RIPLEY: the same file, served to be rendered, inside a frame that may run
527
- // nothing, load nothing from outside and keep nothing.
528
- if (request.method === 'GET' && url.pathname === '/api/preview') {
540
+ // RIPLEY: the project rendered as a site, inside a frame that runs scripts but
541
+ // reaches nothing: an opaque origin (sandbox without same-origin), no network
542
+ // (connect-src none), no forms, no top navigation, assets only from MADRE's
543
+ // own host. Path-based, so a page's relative links to its CSS, JS and images
544
+ // resolve to the same route.
545
+ const previewMatch = request.method === 'GET' && url.pathname.match(/^\/preview\/(project|attachments)\/(.*)$/);
546
+ if (previewMatch) {
529
547
  if (!ripleyEnabled) return sendJson(response, 412, { error: 'RIPLEY is off. Enable it in MODULES to render files.' });
530
- const which = url.searchParams.get('root') === 'attachments' ? attachmentsRoot : canonicalProjectRoot;
531
- const relative = url.searchParams.get('path') ?? '';
532
- const kind = /\.(html?|xhtml)$/i.test(relative) ? 'text/html; charset=utf-8' : /\.svg$/i.test(relative) ? 'image/svg+xml' : null;
533
- if (!kind) return sendJson(response, 415, { error: 'RIPLEY renders .html and .svg here; Markdown is rendered in the viewer itself.' });
548
+ const which = previewMatch[1] === 'attachments' ? attachmentsRoot : canonicalProjectRoot;
549
+ const relative = decodeURIComponent(previewMatch[2]);
534
550
  const file = await readServable(which, relative);
535
551
  if (file.status !== 200) return sendJson(response, file.status, { error: file.error });
536
- response.writeHead(200, {
537
- 'content-type': kind,
538
- 'content-length': file.size,
552
+ const isPage = /\.(html?|xhtml)$/i.test(relative);
553
+ const origin = `${request.headers['x-forwarded-proto'] ?? 'http'}://${request.headers.host ?? '127.0.0.1'}`;
554
+ // Pages carry RIPLEY's bridge: one inline script that tells the viewer which
555
+ // page loaded and forwards the page's own errors. Nothing else is touched.
556
+ const bodyOut = isPage ? injectRipleyBridge(file.body) : file.body;
557
+ const headers = {
558
+ 'content-type': isPage ? 'text/html; charset=utf-8' : file.contentType,
559
+ 'content-length': Buffer.byteLength(bodyOut),
539
560
  'cache-control': 'no-store',
540
561
  'x-content-type-options': 'nosniff',
541
562
  'referrer-policy': 'no-referrer',
542
- 'content-security-policy': "sandbox; default-src 'none'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; media-src 'self' data:; form-action 'none'; base-uri 'none'",
543
563
  'content-disposition': `inline; filename="${encodeURIComponent(basename(file.path))}"`,
544
- });
545
- return response.end(file.body);
564
+ };
565
+ if (isPage) headers['content-security-policy'] = `sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline' ${origin}; style-src 'unsafe-inline' ${origin}; img-src ${origin} data: blob:; font-src ${origin} data:; media-src ${origin} data: blob:; connect-src 'none'; frame-src 'none'; object-src 'none'; form-action 'none'; base-uri 'none'`;
566
+ response.writeHead(200, headers);
567
+ return response.end(bodyOut);
546
568
  }
547
569
  if (request.method === 'POST' && url.pathname === '/api/attachments') {
548
570
  let bytes;