@jossuealcala/madre 1.0.2 → 1.0.4

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.
Files changed (77) hide show
  1. package/CHANGELOG.en.md +76 -1
  2. package/CHANGELOG.md +76 -1
  3. package/README.en.md +11 -15
  4. package/README.md +3 -3
  5. package/bin/madre.cjs +40 -0
  6. package/bin/madre.mjs +5 -0
  7. package/docs/REFERENCE.md +29 -3
  8. package/docs/SDK.md +4 -2
  9. package/docs/en/REFERENCE.md +29 -3
  10. package/docs/en/SDK.md +4 -2
  11. package/docs/training/README.md +28 -1
  12. package/docs/training/train.sh +5 -1
  13. package/package.json +3 -3
  14. package/public/app.js +1635 -159
  15. package/public/attribution.js +44 -1
  16. package/public/brands.js +20 -5
  17. package/public/code-tools.js +143 -0
  18. package/public/editor.js +20 -4
  19. package/public/es.js +395 -26
  20. package/public/hardware.js +50 -0
  21. package/public/index.html +5 -1
  22. package/public/macroverso.js +187 -96
  23. package/public/market-art.js +161 -0
  24. package/public/market-view.js +529 -0
  25. package/public/market.js +189 -0
  26. package/public/neural.js +110 -0
  27. package/public/nostromo-engine.js +412 -47
  28. package/public/power.js +100 -0
  29. package/public/resay.js +5 -0
  30. package/public/salas-mac.css +105 -14
  31. package/public/salas-mac.html +9 -1
  32. package/public/salas-mac.js +350 -31
  33. package/public/styles.css +844 -61
  34. package/public/tour.js +48 -6
  35. package/public/troubleshooting.js +6 -1
  36. package/public/wallpaper.js +275 -0
  37. package/src/adapters/cursor.mjs +96 -0
  38. package/src/adapters/gemini.mjs +10 -3
  39. package/src/adapters/madre.mjs +2 -1
  40. package/src/auth-probe.mjs +143 -12
  41. package/src/bridge/policy.mjs +1 -1
  42. package/src/capabilities.mjs +9 -0
  43. package/src/checkpoint.mjs +16 -1
  44. package/src/credentials.mjs +3 -0
  45. package/src/dataset.mjs +76 -4
  46. package/src/enlaces.mjs +208 -2
  47. package/src/extensions.mjs +11 -3
  48. package/src/i18n.mjs +16 -0
  49. package/src/looks.mjs +125 -2
  50. package/src/market-key.mjs +8 -0
  51. package/src/market-remote.mjs +239 -0
  52. package/src/models.mjs +4 -0
  53. package/src/module-scope.mjs +68 -0
  54. package/src/modules/ahp.mjs +102 -7
  55. package/src/modules/bishop-exam.mjs +122 -0
  56. package/src/modules/bishop.mjs +71 -12
  57. package/src/modules/enlaces.mjs +87 -6
  58. package/src/modules/image-studio.mjs +10 -1
  59. package/src/modules/intents.mjs +3 -1
  60. package/src/modules/local-models.mjs +124 -0
  61. package/src/modules/ollama.mjs +12 -3
  62. package/src/modules/playwright.mjs +73 -15
  63. package/src/modules/sdk.mjs +61 -9
  64. package/src/npm-launch.mjs +18 -0
  65. package/src/ollama.mjs +6 -5
  66. package/src/outbound.mjs +9 -1
  67. package/src/ports.mjs +8 -8
  68. package/src/room/control.mjs +1 -1
  69. package/src/room/prompt.mjs +13 -2
  70. package/src/room.mjs +14 -4
  71. package/src/router.mjs +1 -1
  72. package/src/runs.mjs +4 -1
  73. package/src/runtime-detection.mjs +9 -0
  74. package/src/salas.mjs +26 -0
  75. package/src/server-sentinel.mjs +7 -3
  76. package/src/server.mjs +617 -48
  77. package/src/setup.mjs +1 -1
package/public/app.js CHANGED
@@ -7,11 +7,17 @@ import { allConditions, detectPlatform, diagnose, fixesFor, PLATFORMS, searchCon
7
7
  import { answerFor, INQUIRIES, STRIKES } from './inquiry.js';
8
8
  import { DEFAULT_LANGUAGE, LANGUAGES, browserLanguage, isLanguage, language, pick, setLanguage, t, fill } from './i18n.js';
9
9
  import { createEditor } from './editor.js';
10
+ import { labelPowerKey, powerKey } from './power.js';
11
+ import { drawHardware, hardwareLine } from './hardware.js';
12
+ import { CUSTOM_WALL, addPicture, customTile, customTools, customWall, paintCustomWall, rememberedWall } from './wallpaper.js';
13
+ import { createStrain, inviteNeural, neuralChosen, neuralKey, paintNeuralKey, rememberNeural } from './neural.js';
10
14
  import { resay } from './resay.js';
11
15
  import { startReviewAsk } from './review-ask.js';
12
16
  import { spotlight, startTour as startWelcome } from './tour.js';
13
17
  import { dropStickyHover } from './touch.js';
14
- import { drawAttribution, drawLegal } from './attribution.js';
18
+ import { drawAttribution, drawColophonKeys, drawLegal } from './attribution.js';
19
+ import { MODULE_FACES, appIcon, loadMarket, marketEntry, marketOffer, moduleFace } from './market.js';
20
+ import { openMarket } from './market-view.js';
15
21
  // Where nothing can hover (a phone), a tapped key does not stay lit (public/touch.js).
16
22
  dropStickyHover();
17
23
  // Made the first time the room asks for it (roomTour, below), which can be while this file is
@@ -22,6 +28,7 @@ let welcomeKept;
22
28
  const welcomeDone = () => welcomeKept !== undefined && (welcomeKept === null || Boolean(welcomeKept.finished));
23
29
  import { LOADER_MARK, creep, finishLoader, makeLoader, setLoader, showCurtain } from './loader.js';
24
30
  import { familyOf, languageLabel, tokenize } from './syntax.js';
31
+ import { hunkKey, lineDiff, placeHunks, revertHunk, symbolAt } from './code-tools.js';
25
32
  import { COLLAPSED, MEMORY_COLORS, PITCH_LIMIT, TURN_PER_PIXEL, activityOf, collapsedChip, createNostromo, dwarfChip, hexMix, rawActivity, travels } from './nostromo-engine.js';
26
33
 
27
34
  // The window split in two. The right half is this same page loaded with `?pane=split&chat=<id>`:
@@ -85,6 +92,13 @@ if (SPLIT_CHAT) {
85
92
  let motionScale = null;
86
93
  // The chooser's chevron: one drawing for every menu that opens downward, in the same round line.
87
94
  const ICON_CHEVRON = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.2 6.2 8 10l3.8-3.8" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>';
95
+ // The panels' drawings: a panel at the left, a panel at the right, and the code workspace's side
96
+ // by side. Declared this early because the code workspace is built while the page loads.
97
+ const SPLIT_LEFT_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2.75" y="3.75" width="14.5" height="12.5" rx="3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><rect class="ic-h" x="5" y="6" width="3.6" height="8" rx="1.3" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="M10.6 6.4v7.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
98
+ const SPLIT_RIGHT_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2.75" y="3.75" width="14.5" height="12.5" rx="3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><rect class="ic-h" x="11.4" y="6" width="3.6" height="8" rx="1.3" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="M9.4 6.4v7.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
99
+ // Side by side in the code workspace: the text on the left in lines, what it makes on the right.
100
+ // Not the window's split (two empty halves), which stands two keys further along the same header.
101
+ const BESIDE_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2.75" y="3.75" width="14.5" height="12.5" rx="3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M10 3.75v12.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M5.2 7.6h2.4M5.2 10h1.6M5.2 12.4h2.4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><rect x="11.9" y="8.1" width="3.4" height="3.8" rx="1" fill="currentColor"/></svg>';
88
102
  function chevron(className = 'chev') {
89
103
  const mark = el('span', `${className} chev-icon`);
90
104
  mark.innerHTML = ICON_CHEVRON;
@@ -205,7 +219,7 @@ const MODES = {
205
219
  // gives back: with nowhere to type it, the sign-in could only die of its own timeout, which is
206
220
  // exactly what it did. This holds what the room knows about the one running — which agent, the
207
221
  // address, whether it is waiting — and `.login-ask` is where it is answered.
208
- const loginNow = { agent: null, method: null, url: null, awaiting: false, asksForCode: false };
222
+ const loginNow = { agent: null, method: null, url: null, deviceCode: null, awaiting: false, asksForCode: false };
209
223
  const looksLikeCodePrompt = (line) => /paste\s+(?:the\s+)?code|enter\s+(?:the\s+)?code|c[oó]digo\s*[:>]/i.test(String(line ?? ''));
210
224
 
211
225
  const state = {
@@ -221,6 +235,7 @@ const state = {
221
235
  ollamaAsked: false,
222
236
  ollamaRecommended: null,
223
237
  ratings: new Map(), // messageId → good | bad, from the ledger
238
+ deliveries: new Map(), // D-S17: id of a delivered note → its row
224
239
  ratingNodes: new Map(), // messageId → the buttons of that bubble // messageId -> { text, target }
225
240
  lastSequence: 0,
226
241
  lastSender: null, // for iMessage-style grouping of consecutive bubbles
@@ -339,13 +354,46 @@ function touchedUnder(dir) {
339
354
 
340
355
  /* ---------- helpers ---------- */
341
356
 
357
+ // The signs a key's words may end in, drawn by tailMark (below).
358
+ const TAIL_MARK = /\s*[↗▾]\s*$/;
359
+ const OUT_ICON = '<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M5.2 10.8 10.6 5.4M6.4 5.2h4.4v4.4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
342
360
  const el = (tag, className, text) => {
343
361
  const node = document.createElement(tag);
344
362
  if (className) node.className = className;
345
363
  if (text !== undefined) node.textContent = text;
364
+ if ((tag === 'a' || tag === 'button') && typeof text === 'string' && TAIL_MARK.test(text)) tailMark(node);
346
365
  return node;
347
366
  };
348
367
 
368
+ // A key or a link whose words end in ↗ goes somewhere outside the room, and one that ends in ▾
369
+ // opens a menu (1.0.3): the sign is drawn, at the size of a mark, and the words stay as they are
370
+ // (the catalogue keeps the sign, so a translation never loses it). The page's own markup goes
371
+ // through the same after it is translated, the way its × does.
372
+ function tailMark(node) {
373
+ const last = [...(node?.childNodes ?? [])].reverse().find((one) => one.nodeType === 3 && one.nodeValue.trim());
374
+ const sign = last?.nodeValue.match(TAIL_MARK)?.[0]?.trim();
375
+ if (!sign) return node;
376
+ last.nodeValue = last.nodeValue.replace(TAIL_MARK, ' ');
377
+ const mark = document.createElement('span');
378
+ mark.className = sign === '↗' ? 'tail-mark out' : 'tail-mark menu';
379
+ mark.innerHTML = sign === '↗' ? OUT_ICON : ICON_CHEVRON; // trusted: our own SVG strings
380
+ node.append(mark);
381
+ return node;
382
+ }
383
+
384
+ // A label that opens with ◉ (still open here, decided here, memory saved): the ring and its dot are
385
+ // drawn at the size of a mark, the words after them as written.
386
+ const LEAD_ICON = '<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><circle cx="8" cy="8" r="5.4" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="8" cy="8" r="2.1" fill="currentColor"/></svg>';
387
+ function leadWords(tag, className, said) {
388
+ const node = el(tag, className);
389
+ const words = String(said ?? '').replace(/^◉\s*/u, '');
390
+ if (words === said) { node.textContent = said; return node; }
391
+ const mark = el('span', 'lead-mark');
392
+ mark.innerHTML = LEAD_ICON; // trusted: our own SVG string
393
+ node.append(mark, words);
394
+ return node;
395
+ }
396
+
349
397
  // Every way to close something closes the same way: one × drawn as a line, not a letter, that
350
398
  // turns a quarter and thickens under the pointer — the one the conversations panel already had.
351
399
  // A labelled button keeps its words («CLOSE ×» says what it closes); only its × becomes the mark.
@@ -470,20 +518,48 @@ function toastTone(message, tone) {
470
518
  if (TOAST_LOOK.test(message)) return 'look';
471
519
  return 'done';
472
520
  }
473
- function toast(message, { tone = null } = {}) {
521
+ // `action` ({ label, run }) puts one key at its end: the Market's news says «See it» (2026-10-18).
522
+ function toast(message, { tone = null, action = null } = {}) {
474
523
  placeToast();
475
524
  const text = String(message);
476
- const kind = toastTone(text, tone);
525
+ const kind = action && !tone ? 'news' : toastTone(text, tone);
477
526
  // Who speaks stays in the words a screen reader hears; on screen the dot says enough.
478
527
  // The prefix the room's own notices carry, in the room's language (WEB-14): after MU/TH/UR became
479
528
  // Diagnóstico the old one was looked for, and «Diagnóstico ›» showed in every notice.
480
529
  const from = [t('Diagnostics › '), 'MU/TH/UR › '].find((prefix) => text.startsWith(prefix)) ?? '';
481
530
  els.toast.replaceChildren(el('i', 'toast-dot'), ...(from ? [el('span', 'toast-from', from)] : []), el('span', 'toast-text', text.slice(from.length)));
531
+ if (action) {
532
+ const key = el('button', 'toast-act', action.label);
533
+ key.type = 'button';
534
+ key.addEventListener('click', () => { clearTimeout(toastTimer); els.toast.hidden = true; roomForToast(false); action.run(); });
535
+ els.toast.append(key);
536
+ }
482
537
  els.toast.dataset.tone = kind;
483
538
  els.toast.classList?.toggle('note', kind === 'note');
539
+ els.toast.classList?.remove('leaving');
484
540
  els.toast.hidden = false;
541
+ roomForToast(true);
485
542
  clearTimeout(toastTimer);
486
- toastTimer = setTimeout(() => { els.toast.hidden = true; }, 6000);
543
+ // It closes back into its dot before it goes (styles.css, toast-fold).
544
+ toastTimer = setTimeout(() => {
545
+ els.toast.classList?.add('leaving');
546
+ toastTimer = setTimeout(() => { els.toast.hidden = true; els.toast.classList?.remove('leaving'); roomForToast(false); }, 320);
547
+ }, action ? 10000 : 6000);
548
+ }
549
+ // While a notice floats over the end of the conversation, a reader at the end gets the room it
550
+ // takes: the last line rises above it instead of going under it. The thread follows the column as
551
+ // it grows and shrinks.
552
+ function roomForToast(on) {
553
+ const root = document.documentElement;
554
+ if (!root?.classList || !root.style?.setProperty) return;
555
+ root.classList.toggle('toasting', on);
556
+ if (on) root.style.setProperty('--toast-h', `${Math.ceil(els.toast.offsetHeight || 38)}px`);
557
+ const room = on && pinnedToEnd && !pageScrolls();
558
+ if (root.classList.contains('toast-room') === room) return;
559
+ root.classList.toggle('toast-room', room);
560
+ const until = performance.now() + 340;
561
+ const follow = () => { keepPinned(); if (performance.now() < until) requestAnimationFrame(follow); };
562
+ requestAnimationFrame?.(follow);
487
563
  }
488
564
 
489
565
  // Nothing in the room speaks on hover: not a button, not a chip, not a cut-off title. Every
@@ -514,7 +590,7 @@ const scroller = {
514
590
  set top(value) { if (this.node) this.node.scrollTop = value; },
515
591
  get height() { return this.node?.scrollHeight ?? 0; },
516
592
  get view() { return pageScrolls() ? (window.innerHeight ?? 0) : (els.thread?.clientHeight ?? 0); },
517
- to(options) { (pageScrolls() ? window : els.thread)?.scrollTo?.(options); },
593
+ to(options) { ourScroll(options?.behavior === 'smooth' ? 900 : 160); (pageScrolls() ? window : els.thread)?.scrollTo?.(options); },
518
594
  };
519
595
 
520
596
  // Back to the box after a choice that is not writing (an agent, a model, an effort). On a phone
@@ -523,8 +599,18 @@ function focusComposer() {
523
599
  if (!state.device) els.input?.focus();
524
600
  }
525
601
 
602
+ // What a node says, changed in place: the text it already holds is rewritten rather than replaced.
603
+ // A replaced child is a mutation the window watcher answers with a layout of every window
604
+ // (paintDepth), and the working clock changes once a second.
605
+ function setText(node, value) {
606
+ const first = node.firstChild;
607
+ if (first && first.nodeType === 3 && !first.nextSibling) { if (first.data !== value) first.data = value; return; }
608
+ node.textContent = value;
609
+ }
610
+
526
611
  function scrollToEnd() {
527
- scroller.top = scroller.height;
612
+ scroller.to({ top: scroller.height, behavior: 'instant' });
613
+ setPinned(true);
528
614
  }
529
615
 
530
616
  // The thread is pinned to its own end, and anything that changes how tall it is has to put it
@@ -533,13 +619,30 @@ function scrollToEnd() {
533
619
  // conversation having been lost. The observer is the honest fix — it does not matter what moved,
534
620
  // only that the height is no longer the one the scroll position was computed against.
535
621
  // Whether the human is reading the end or has gone back for something. Only THEIR scrolling
536
- // decides it: a programmatic scroll fires the same event, so reading the position back during a
537
- // relayout turned every adjustment into "they scrolled away" and the thread stopped following.
622
+ // decides it. A programmatic scroll fires the same event, so the room marks its own scrolls for a
623
+ // moment (ourScroll) and only a scroll outside that moment is measured: the wheel, a finger, the
624
+ // scrollbar dragged, the keyboard, ⌘F. It used to be measured 90 ms after a wheel turn, and a
625
+ // relayout inside those 90 ms pulled the thread back to its end before the measure could see the
626
+ // human had gone up — while an agent worked, the thread would not let anyone scroll up.
538
627
  let pinnedToEnd = true;
539
- const measurePin = () => {
540
- pinnedToEnd = scroller.height - scroller.top - scroller.view < 120;
541
- };
542
- for (const how of ['wheel', 'touchmove', 'keydown']) els.thread?.addEventListener(how, () => setTimeout(measurePin, 90), { passive: true });
628
+ let ourScrollUntil = 0;
629
+ function ourScroll(ms = 160) { ourScrollUntil = Math.max(ourScrollUntil, performance.now() + ms); }
630
+ const distanceToEnd = () => scroller.height - scroller.top - scroller.view;
631
+ function setPinned(value) {
632
+ if (pinnedToEnd === value) return;
633
+ pinnedToEnd = value;
634
+ if (value) clearFinishedBelow();
635
+ }
636
+ const measurePin = () => setPinned(distanceToEnd() < 64);
637
+ // A turn of the wheel upward leaves the end at once: nothing may pull the thread back under it.
638
+ els.thread?.addEventListener('wheel', (event) => {
639
+ ourScrollUntil = 0;
640
+ if (event.deltaY < 0 && scroller.top > 0) setPinned(false);
641
+ }, { passive: true });
642
+ for (const how of ['touchstart', 'pointerdown']) els.thread?.addEventListener(how, (event) => { if (how === 'touchstart' || event.target === els.thread) ourScrollUntil = 0; }, { passive: true });
643
+ const onThreadScroll = () => { if (performance.now() >= ourScrollUntil) measurePin(); };
644
+ els.thread?.addEventListener('scroll', onThreadScroll, { passive: true });
645
+ window.addEventListener?.('scroll', () => { if (pageScrolls()) onThreadScroll(); }, { passive: true });
543
646
 
544
647
  // The thread is pinned to its own end, and anything that changes how tall it is has to put it
545
648
  // back there. Opening the browser beside it, dragging the divider, closing it again: each one
@@ -550,6 +653,7 @@ for (const how of ['wheel', 'touchmove', 'keydown']) els.thread?.addEventListene
550
653
  // next frame of it, which is how the thread ended up parked hundreds of pixels short of its end.
551
654
  function keepPinned() {
552
655
  if (!pinnedToEnd || !els.thread) return;
656
+ if (distanceToEnd() < 1) return;
553
657
  scroller.to({ top: scroller.height, behavior: 'instant' });
554
658
  }
555
659
  if (typeof ResizeObserver === 'function' && els.thread) {
@@ -559,6 +663,40 @@ if (typeof ResizeObserver === 'function' && els.thread) {
559
663
  if (els.composer) watch.observe(els.composer);
560
664
  }
561
665
 
666
+ // Gone up to read while an agent works, the human is not pulled down when it answers: a light
667
+ // round button over the box says something finished below — in the colour of who answered — and
668
+ // takes them there. It goes away at the end, however they get there.
669
+ const finishedBelow = { button: null, who: null };
670
+ function toEndButton() {
671
+ if (finishedBelow.button || !document.querySelector?.('.shell')) return finishedBelow.button;
672
+ const button = el('button', 'to-end');
673
+ button.type = 'button';
674
+ button.hidden = true;
675
+ button.innerHTML = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 4.5v10.5M5.2 10.4 10 15.2l4.8-4.8" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg><i class="to-end-dot"></i>'; // trusted: our own SVG string
676
+ button.addEventListener('click', () => {
677
+ setPinned(true);
678
+ scroller.to({ top: scroller.height, behavior: 'smooth' });
679
+ });
680
+ document.querySelector('.shell').append(button);
681
+ finishedBelow.button = button;
682
+ return button;
683
+ }
684
+ function markFinishedBelow(agent, plan = false) {
685
+ const button = toEndButton();
686
+ if (!button || pinnedToEnd) return;
687
+ if (agent) tint(button, agent);
688
+ finishedBelow.who = agent;
689
+ button.setAttribute('aria-label', plan ? t('The plan finished below · go to the end') : t('@{agent} answered below · go to the end', { agent: agent ?? 'madre' }));
690
+ placeToast();
691
+ if (button.hidden) { button.hidden = false; button.classList.remove('leaving'); }
692
+ }
693
+ function clearFinishedBelow() {
694
+ const button = finishedBelow.button;
695
+ if (!button || button.hidden || button.classList.contains('leaving')) return;
696
+ button.classList.add('leaving');
697
+ setTimeout(() => { if (button.classList.contains('leaving')) { button.hidden = true; button.classList.remove('leaving'); } }, 200);
698
+ }
699
+
562
700
  /* ---------- safe Markdown (DOM only, no HTML injection) ---------- */
563
701
 
564
702
  const SAFE_URL = /^https?:\/\//i;
@@ -731,6 +869,7 @@ function paintSelection() {
731
869
  // Nobody guesses that a line number is clickable: while nothing is picked, the status line says how.
732
870
  if (viewerUI.hintSelect) viewerUI.hintSelect.hidden = has || !viewerUI.lineNodes.length;
733
871
  if (has) viewerUI.selection.textContent = runs.map(([lo, hi]) => (lo === hi ? `L${lo}` : `L${lo}–${hi}`)).join(', ');
872
+ if (has) paintCodeCrumbs(runs[0][0]);
734
873
  }
735
874
  function setSelection(from, to) {
736
875
  viewerUI.picked = new Set();
@@ -818,6 +957,14 @@ function showViewerMenu(x, y) {
818
957
  function hideViewerMenu() { try { viewerUI.menu.hidePopover?.(); } catch { /* not open */ } viewerUI.menu.hidden = true; }
819
958
  viewerUI.review?.addEventListener('click', (event) => {
820
959
  const rect = event.currentTarget.getBoundingClientRect();
960
+ // One run of lines picked in a file of the project: the same card the editor opens, with its
961
+ // verbs. Several runs, or a file from elsewhere, keep the list of agents.
962
+ const runs = pickedRuns();
963
+ if (runs.length === 1 && viewerUI.path && viewerMode.editable) {
964
+ const [from, to] = runs[0];
965
+ askAboutCode({ from, to, text: String(viewerMode.text).split('\n').slice(from - 1, to).join('\n'), anchor: rect, dirty: false });
966
+ return;
967
+ }
821
968
  if (viewerUI.menu.hidden) showViewerMenu(rect.left, rect.bottom + 6); else hideViewerMenu();
822
969
  });
823
970
  document.addEventListener('click', (event) => { if (viewerUI.menu && !viewerUI.menu.hidden && !viewerUI.menu.contains(event.target) && !viewerUI.review?.contains(event.target)) hideViewerMenu(); });
@@ -1024,7 +1171,7 @@ function closeAskCard() {
1024
1171
  done?.();
1025
1172
  }
1026
1173
  const ASK_OWN_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M12.7 4.5a1.95 1.95 0 0 1 2.8 2.8l-8.3 8.3-3.6.9.9-3.6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M11.5 5.7l2.8 2.8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
1027
- function openAskCard({ anchor, head, suggestions, about = '', onAsk, onClose = null }) {
1174
+ function openAskCard({ anchor, head, suggestions, about = '', onAsk, onClose = null, actions = [] }) {
1028
1175
  closeAskCard();
1029
1176
  askCardClose = onClose;
1030
1177
  const ready = [...state.agents.values()].filter((agent) => agent.ready);
@@ -1048,6 +1195,18 @@ function openAskCard({ anchor, head, suggestions, about = '', onAsk, onClose = n
1048
1195
  crew.append(pick);
1049
1196
  }
1050
1197
  askCard.append(crew);
1198
+ // What is done most with code, one press each: the verb on the chip, the whole request behind it.
1199
+ if (actions.length) {
1200
+ const row = el('div', 'ask-actions');
1201
+ for (const [label, prompt] of actions) {
1202
+ const chip = el('button', 'ask-action', t(label));
1203
+ chip.type = 'button';
1204
+ chip.title = t(prompt);
1205
+ chip.addEventListener('click', () => ask(t(prompt)));
1206
+ row.append(chip);
1207
+ }
1208
+ askCard.append(row);
1209
+ }
1051
1210
  const group = (title, items) => {
1052
1211
  const box = el('div', 'ask-group');
1053
1212
  box.append(el('div', 'ask-group-title', title));
@@ -1059,7 +1218,9 @@ function openAskCard({ anchor, head, suggestions, about = '', onAsk, onClose = n
1059
1218
  }
1060
1219
  return box;
1061
1220
  };
1062
- askCard.append(group(t('SUGGESTED FOR THIS'), suggestions));
1221
+ const taken = new Set(actions.map(([, prompt]) => t(prompt)));
1222
+ const rest = suggestions.filter((words) => !taken.has(words));
1223
+ if (rest.length) askCard.append(group(t('SUGGESTED FOR THIS'), rest));
1063
1224
  // What the archive holds about it, when the archive is open: asked once, added when it answers.
1064
1225
  const remembered = el('div', 'ask-remembered');
1065
1226
  askCard.append(remembered);
@@ -1158,9 +1319,13 @@ const askLeaveEdits = () => roomAsk({
1158
1319
  body: t('There are unsaved changes in this file. Discard them?'),
1159
1320
  choices: [{ value: 'stay', label: t('KEEP EDITING'), focus: true }, { value: 'leave', label: t('DISCARD THEM'), tone: 'danger' }],
1160
1321
  });
1161
- async function leaveEditor({ ask = true } = {}) {
1322
+ // `stash`: in the code workspace a file left with unsaved changes keeps them in its tab, the way
1323
+ // every editor's tabs do, instead of asking; its tab wears the dot until it is saved or discarded.
1324
+ async function leaveEditor({ ask = true, stash = false } = {}) {
1162
1325
  if (!editing.editor) return true;
1163
- if (ask && editorDirty() && await askLeaveEdits() !== 'leave') return false;
1326
+ const tab = stash && viewerUI.path ? codeTab(viewerUI.path) : null;
1327
+ if (tab) { tab.draft = editorDirty() ? editing.editor.value() : null; tab.mtime = viewerMode.mtime; }
1328
+ else if (ask && editorDirty() && await askLeaveEdits() !== 'leave') return false;
1164
1329
  editing.editor.destroy();
1165
1330
  editing.editor = null;
1166
1331
  viewer.dialog.classList.remove('editing');
@@ -1196,11 +1361,11 @@ function syncEditButton() {
1196
1361
  if (!viewer.dialog || !viewer.body) return;
1197
1362
  const bar = el('div', 'viewer-status');
1198
1363
  const mod = /Mac|iPhone|iPad/.test(navigator.platform ?? '') ? '⌘' : 'Ctrl';
1199
- if (viewerUI.hintSelect) viewerUI.hintSelect.textContent = t('Click a number to pick a line · drag to pick several · Shift for a range · {mod} to add one more', { mod });
1364
+ if (viewerUI.hintSelect) viewerUI.hintSelect.textContent = viewerUI.hintSelect.title = t('Click a number to pick a line · drag to pick several · Shift for a range · {mod} to add one more', { mod });
1200
1365
  for (const part of [viewerUI.selection, viewerUI.review, viewerUI.hintSelect]) if (part) bar.append(part);
1201
1366
  viewer.body.after?.(bar);
1202
1367
  })();
1203
- async function saveEdited(content) {
1368
+ async function saveEdited(content, { said = null } = {}) {
1204
1369
  const send = (extra = {}) => fetch('/api/files', {
1205
1370
  method: 'PUT',
1206
1371
  headers: { 'content-type': 'application/json' },
@@ -1238,7 +1403,13 @@ async function saveEdited(content) {
1238
1403
  if (!response.ok) { toast(`${t('Diagnostics › ')}${result.error ?? t('That could not be saved.')}`); return false; }
1239
1404
  viewerMode.mtime = String(result.mtimeMs ?? '');
1240
1405
  viewerMode.text = content;
1241
- toast(t('Saved · {path}', { path: viewerUI.path }), { tone: 'note' });
1406
+ toast(said ?? t('Saved · {path}', { path: viewerUI.path }), { tone: 'note' });
1407
+ // What is beside the text shows the saved file, and the agent's marks are placed again on it.
1408
+ code.preview?.reload?.();
1409
+ code.preview?.render?.();
1410
+ const tab = codeTab(viewerUI.path);
1411
+ if (tab) tab.draft = null;
1412
+ if (viewerUI.path) void loadCodeChanges(viewerUI.path);
1242
1413
  return true;
1243
1414
  }
1244
1415
  function askAboutLines({ from, to, prompt, dirty, agentId = null }) {
@@ -1267,21 +1438,31 @@ function askAboutCode({ from, to, text, anchor, dirty }) {
1267
1438
  head: from === to ? t('Line {n}', { n: from }) : t('Lines {from}–{to}', { from, to }),
1268
1439
  suggestions: codeSuggestions(text, familyOf(viewerUI.path ?? '')?.id ?? null, viewerUI.path ?? ''),
1269
1440
  about: `${viewerUI.path ?? ''} ${text}`,
1441
+ actions: CODE_ACTIONS,
1270
1442
  onAsk: (agentId, prompt) => askAboutLines({ from, to, prompt, dirty, agentId }),
1271
1443
  });
1272
1444
  }
1273
1445
  function openEditor(line = null) {
1274
1446
  if (editing.editor || !viewerMode.editable) return;
1275
1447
  const name = viewerUI.path ?? '';
1448
+ const tab = codeTab(name);
1276
1449
  editing.editor = createEditor({
1277
- text: viewerMode.text, name, family: familyOf(name), tokenize, t, fill,
1450
+ text: viewerMode.text, draft: tab?.draft ?? null, name, family: familyOf(name), tokenize, t, fill,
1278
1451
  onSave: saveEdited, onAsk: askAboutCode,
1452
+ onDirty: () => drawCodeTabs(),
1453
+ onCaret: (at) => paintCodeCrumbs(at),
1279
1454
  });
1455
+ if (tab) tab.draft = null;
1280
1456
  viewer.body.replaceChildren(editing.editor.node);
1281
1457
  viewer.dialog.classList.add('editing');
1282
1458
  setSelection(null, null);
1283
1459
  if (viewerUI.hintSelect) viewerUI.hintSelect.hidden = true;
1284
1460
  syncEditButton();
1461
+ decorateCode();
1462
+ attachBeside();
1463
+ // A document beside its text follows the typing; a page waits for the save.
1464
+ let typing = 0;
1465
+ editing.editor.node.addEventListener('input', () => { clearTimeout(typing); typing = setTimeout(() => code.preview?.render?.(), 220); });
1285
1466
  requestAnimationFrame(() => editing.editor?.focus(line ?? viewerUI.from ?? null));
1286
1467
  }
1287
1468
  editing.button?.addEventListener('click', async () => {
@@ -1291,11 +1472,15 @@ editing.button?.addEventListener('click', async () => {
1291
1472
  // Closing the viewer, or opening another file in it, never drops unsaved work without asking.
1292
1473
  viewer.dialog?.addEventListener('cancel', (event) => {
1293
1474
  if (!editorDirty()) return;
1475
+ // Docked, the unsaved changes stay in their tab and the viewer closes as asked.
1476
+ let docked = false;
1477
+ try { docked = codeOn(); } catch { /* not yet */ }
1478
+ if (docked && viewerUI.path && codeTab(viewerUI.path)) { void leaveEditor({ stash: true }); return; }
1294
1479
  event.preventDefault();
1295
1480
  void askLeaveEdits().then((answer) => { if (answer === 'leave') { void leaveEditor({ ask: false }); viewer.dialog.close(); } });
1296
1481
  });
1297
1482
  viewer.dialog?.addEventListener('close', () => leaveEditor({ ask: false }));
1298
- window.addEventListener?.('beforeunload', (event) => { if (editorDirty()) { event.preventDefault(); event.returnValue = ''; } });
1483
+ window.addEventListener?.('beforeunload', (event) => { if (editorDirty() || code.tabs.some((tab) => tab.draft !== null)) { event.preventDefault(); event.returnValue = ''; } });
1299
1484
 
1300
1485
  function showViewerSource() {
1301
1486
  if (ripley.nav) ripley.nav.hidden = true;
@@ -1310,8 +1495,619 @@ function showViewerSource() {
1310
1495
  }
1311
1496
  if (viewerMode.lines) setSelection(viewerMode.lines.from, viewerMode.lines.to);
1312
1497
  else if (viewerMode.line) setSelection(viewerMode.line, viewerMode.line);
1498
+ decorateCode();
1499
+ attachBeside();
1500
+ paintCodeCrumbs();
1313
1501
  pre.querySelector('.hl')?.scrollIntoView?.({ block: 'center' });
1314
1502
  }
1503
+ /* ---------- the code workspace (1.0.3) ---------- */
1504
+ //
1505
+ // The viewer docked beside the conversation is a place to work on code, not a sheet over it: the
1506
+ // project's files at its side, the files open as tabs, the path down to the function the caret is
1507
+ // in, what an agent changed marked in the agent's colour — with a way to keep it, take it back or
1508
+ // ask why — and, for a page or a document, the result beside the text. CODE in the bar opens it
1509
+ // docked; the viewer's own split and window buttons lead to the same place. Over the room (its base)
1510
+ // the viewer stays what it always was: one file, read or edited.
1511
+ const CODE_KEY = 'pulse.code';
1512
+ const CODE_TABS = 12;
1513
+ const code = (() => {
1514
+ let saved = {};
1515
+ try { saved = JSON.parse(localStorage.getItem(CODE_KEY) ?? '{}') ?? {}; } catch { /* no storage */ }
1516
+ const tabs = (Array.isArray(saved.tabs) ? saved.tabs : []).filter((path) => typeof path === 'string' && path).slice(-CODE_TABS).map((path) => ({ path, draft: null, mtime: null }));
1517
+ return {
1518
+ tabs,
1519
+ active: tabs.some((tab) => tab.path === saved.active) ? saved.active : tabs.at(-1)?.path ?? null,
1520
+ files: saved.files !== false,
1521
+ beside: saved.beside !== false,
1522
+ changes: new Map(), // path → { agent, checkpointId, at, messageId, hunks } — what an agent left here
1523
+ button: document.querySelector('#code-button'),
1524
+ besideButton: null,
1525
+ bar: null, crumbs: null, shell: null, explorer: null, list: null, search: null,
1526
+ dirs: new Set(),
1527
+ preview: null,
1528
+ line: null,
1529
+ };
1530
+ })();
1531
+ function rememberCode() {
1532
+ try { localStorage.setItem(CODE_KEY, JSON.stringify({ tabs: code.tabs.map((tab) => tab.path), active: code.active, files: code.files, beside: code.beside })); } catch { /* this page only */ }
1533
+ }
1534
+ const viewerSurface = () => surfaces.find((one) => one.key === 'viewer');
1535
+ const codeOn = () => Boolean(viewer.dialog?.open) && modeOf(viewerSurface()) !== 'base';
1536
+ const codeTab = (path) => code.tabs.find((tab) => tab.path === path) ?? null;
1537
+ const codeDrafts = () => code.tabs.filter((tab) => tab.draft !== null);
1538
+ const CODE_ACTIONS = [
1539
+ ['Explain', 'Explain what this does'],
1540
+ ['Improve', 'Improve this without changing what it does'],
1541
+ ['Write tests', 'Write tests for this'],
1542
+ ['Fix', 'Find and fix the bugs here'],
1543
+ ];
1544
+
1545
+ // Where it lives in the viewer: the tabs and the path under its header, the files beside its body.
1546
+ (() => {
1547
+ if (!viewer.dialog || !viewer.body || typeof viewer.body.before !== 'function') return;
1548
+ const head = viewer.dialog.querySelector('.viewer-head');
1549
+ code.bar = el('div', 'code-tabs');
1550
+ code.bar.setAttribute('role', 'tablist');
1551
+ code.bar.setAttribute('aria-label', t('Open files'));
1552
+ code.bar.hidden = true;
1553
+ code.crumbs = el('div', 'code-crumbs');
1554
+ code.crumbs.hidden = true;
1555
+ head?.after?.(code.bar, code.crumbs);
1556
+ code.shell = el('div', 'code-shell');
1557
+ code.explorer = el('aside', 'code-files');
1558
+ code.explorer.hidden = true;
1559
+ const top = el('div', 'code-files-head');
1560
+ top.append(el('span', null, t('Files')));
1561
+ const fold = el('button', 'code-files-fold');
1562
+ fold.type = 'button';
1563
+ fold.setAttribute('aria-label', t('Hide the files'));
1564
+ fold.title = t('Hide the files');
1565
+ fold.innerHTML = SPLIT_LEFT_ICON; // trusted: our own SVG string
1566
+ fold.addEventListener('click', () => { code.files = false; rememberCode(); syncCode(); });
1567
+ top.append(fold);
1568
+ code.search = el('input', 'code-files-search');
1569
+ code.search.type = 'search';
1570
+ code.search.placeholder = t('Find a file…');
1571
+ code.search.setAttribute('aria-label', t('Find a file…'));
1572
+ code.search.autocomplete = 'off';
1573
+ code.search.spellcheck = false;
1574
+ code.list = el('ul', 'code-files-list');
1575
+ code.explorer.append(top, code.search, code.list);
1576
+ let typing = 0;
1577
+ code.search.addEventListener('input', () => { clearTimeout(typing); typing = setTimeout(() => void drawCodeFiles(), 140); });
1578
+ code.search.addEventListener('keydown', (event) => {
1579
+ if (event.key === 'Escape' && code.search.value) { event.preventDefault(); event.stopPropagation(); code.search.value = ''; void drawCodeFiles(); }
1580
+ if (event.key === 'Enter') { const first = code.list.querySelector('.code-file'); if (first) { event.preventDefault(); first.click(); } }
1581
+ });
1582
+ viewer.body.before(code.shell);
1583
+ code.shell.append(code.explorer, viewer.body);
1584
+ // Side by side: the text and what it makes, for a page or a document.
1585
+ const modeButton = document.querySelector('#viewer-mode');
1586
+ code.besideButton = el('button', 'viewer-review viewer-beside');
1587
+ code.besideButton.type = 'button';
1588
+ code.besideButton.hidden = true;
1589
+ // Its words, and its drawing for when the header is too narrow for them.
1590
+ code.besideButton.innerHTML = `${BESIDE_ICON}<span></span>`; // trusted: our own SVG string
1591
+ code.besideButton.addEventListener('click', () => { code.beside = !code.beside; rememberCode(); attachBeside(); });
1592
+ modeButton?.before?.(code.besideButton);
1593
+ if (typeof MutationObserver === 'function') new MutationObserver(() => syncCode()).observe(viewer.dialog, { attributes: true, attributeFilter: ['class', 'open'] });
1594
+ if (typeof ResizeObserver === 'function') new ResizeObserver(() => syncCode()).observe(viewer.dialog);
1595
+ code.button?.addEventListener('click', () => void toggleCode());
1596
+ })();
1597
+
1598
+ function syncCode() {
1599
+ if (!code.bar) return;
1600
+ let on = false;
1601
+ try { on = codeOn(); } catch { return; }
1602
+ viewer.dialog.classList.toggle('code', on);
1603
+ code.bar.hidden = !on || !code.tabs.length;
1604
+ code.crumbs.hidden = !on || !viewerUI.path;
1605
+ // The code comes first: the files beside it only when there is room for them and for the code,
1606
+ // and less room is left when what the code makes is beside it too.
1607
+ const width = viewer.dialog.getBoundingClientRect?.().width ?? 0;
1608
+ const wide = width >= (code.preview ? 1040 : 600);
1609
+ // Too narrow for two columns, what the code makes goes under it instead of squeezing it.
1610
+ viewer.body.classList.toggle('stacked', Boolean(code.preview) && width < 720);
1611
+ const showFiles = on && code.files && wide;
1612
+ if (code.explorer.hidden === showFiles) {
1613
+ code.explorer.hidden = !showFiles;
1614
+ if (showFiles) void drawCodeFiles();
1615
+ }
1616
+ code.button?.setAttribute('aria-pressed', String(on));
1617
+ const kind = viewerMode.kind;
1618
+ code.besideButton.hidden = !on || !kind || viewerMode.preview;
1619
+ const besideWords = code.besideButton.querySelector?.('span');
1620
+ if (besideWords) besideWords.textContent = t('Side by side');
1621
+ else code.besideButton.textContent = t('Side by side');
1622
+ code.besideButton.setAttribute('aria-label', t('Side by side'));
1623
+ code.besideButton.setAttribute('aria-pressed', String(Boolean(code.preview)));
1624
+ code.besideButton.title = kind === 'markdown' ? t('The document beside its text, as you write') : t('The page beside its code, reloaded when you save');
1625
+ drawCodeTabs();
1626
+ paintCodeCrumbs();
1627
+ }
1628
+
1629
+ // CODE in the bar: the viewer docked, on the file last worked on, or on where to start.
1630
+ async function toggleCode() {
1631
+ if (codeOn()) {
1632
+ if (await leaveEditor({ stash: true })) viewer.dialog.close();
1633
+ return;
1634
+ }
1635
+ const surface = viewerSurface();
1636
+ if (surface && modeOf(surface) === 'base') setSurfaceMode(surface, 'split', { animate: false });
1637
+ if (viewer.dialog.open && viewerUI.path) { syncCode(); return; }
1638
+ if (code.active) await openViewer({ root: 'project', path: code.active });
1639
+ else showCodeHome();
1640
+ }
1641
+ async function openCodeFile(path, { line = null } = {}) {
1642
+ const surface = viewerSurface();
1643
+ if (surface && modeOf(surface) === 'base') setSurfaceMode(surface, 'split', { animate: false });
1644
+ await openViewer({ root: 'project', path, line });
1645
+ }
1646
+ // Nothing open yet: where to start, in a sentence and two ways.
1647
+ function showCodeHome() {
1648
+ void leaveEditor({ stash: true });
1649
+ paintViewerPath(t('Code'));
1650
+ viewerUI.path = null;
1651
+ Object.assign(viewerMode, { kind: null, text: '', src: '', preview: false, editable: false });
1652
+ viewer.open.removeAttribute('href');
1653
+ syncViewerMode();
1654
+ syncEditButton();
1655
+ // No file, nothing to open raw: the chip that holds OPEN RAW goes with READ and EDIT.
1656
+ if (editingSeg) editingSeg.seg.hidden = true;
1657
+ const home = el('div', 'code-home');
1658
+ home.append(el('h3', null, t('Open a file')));
1659
+ const mod = /Mac|iPhone|iPad/.test(navigator.platform ?? '') ? '⌘P' : 'Ctrl+P';
1660
+ home.append(el('p', null, t('Choose one in the files, or press {keys} to find it by its name. What an agent changes is marked in its colour.', { keys: mod })));
1661
+ const find = sxButton(t('Find a file'), () => openQuickOpen(), { tone: 'primary' });
1662
+ home.append(find);
1663
+ const recent = [...touched.entries()].sort((a, b) => b[1].at - a[1].at).slice(0, 6);
1664
+ if (recent.length) {
1665
+ home.append(el('h4', null, t('Changed by the agents')));
1666
+ const list = el('ul', 'code-home-list');
1667
+ for (const [path, touch] of recent) {
1668
+ const item = el('li');
1669
+ const go = el('button', 'code-file');
1670
+ go.type = 'button';
1671
+ tint(go, touch.agent);
1672
+ go.append(el('i', 'code-file-agent'), el('span', 'code-file-name', path.split('/').pop()), el('span', 'code-file-dir', path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''), el('small', null, `@${touch.agent} · ${agoWords(new Date(touch.at).toISOString())}`));
1673
+ go.addEventListener('click', () => void openCodeFile(path));
1674
+ item.append(go);
1675
+ list.append(item);
1676
+ }
1677
+ home.append(list);
1678
+ }
1679
+ viewer.body.replaceChildren(home);
1680
+ if (!viewer.dialog.open) viewer.dialog.showModal();
1681
+ syncCode();
1682
+ }
1683
+
1684
+ /* the tabs */
1685
+ function noteCodeTab(path) {
1686
+ let tab = codeTab(path);
1687
+ if (!tab) {
1688
+ tab = { path, draft: null, mtime: null };
1689
+ code.tabs.push(tab);
1690
+ // Past the limit the oldest tab that holds nothing unsaved gives way.
1691
+ while (code.tabs.length > CODE_TABS) {
1692
+ const old = code.tabs.findIndex((one) => one.draft === null && one.path !== path);
1693
+ if (old < 0) break;
1694
+ code.tabs.splice(old, 1);
1695
+ }
1696
+ }
1697
+ code.active = path;
1698
+ rememberCode();
1699
+ return tab;
1700
+ }
1701
+ async function closeCodeTab(path) {
1702
+ const tab = codeTab(path);
1703
+ if (!tab) return;
1704
+ const showing = viewerUI.path === path;
1705
+ const dirty = tab.draft !== null || (showing && editorDirty());
1706
+ if (dirty && await askLeaveEdits() !== 'leave') return;
1707
+ if (showing) await leaveEditor({ ask: false });
1708
+ tab.draft = null;
1709
+ code.tabs = code.tabs.filter((one) => one !== tab);
1710
+ code.changes.delete(path);
1711
+ if (code.active === path) code.active = code.tabs.at(-1)?.path ?? null;
1712
+ rememberCode();
1713
+ if (!showing) { drawCodeTabs(); return; }
1714
+ if (code.active) await openViewer({ root: 'project', path: code.active }); else showCodeHome();
1715
+ }
1716
+ function drawCodeTabs() {
1717
+ if (!code.bar || code.bar.hidden) return;
1718
+ code.bar.replaceChildren();
1719
+ for (const tab of code.tabs) {
1720
+ const on = tab.path === viewerUI.path;
1721
+ const node = el('div', `code-tab${on ? ' on' : ''}`);
1722
+ node.setAttribute('role', 'tab');
1723
+ node.setAttribute('aria-selected', String(on));
1724
+ node.title = tab.path;
1725
+ const name = el('button', 'code-tab-name', tab.path.split('/').pop());
1726
+ name.type = 'button';
1727
+ name.addEventListener('click', () => { if (!on) void openViewer({ root: 'project', path: tab.path }); });
1728
+ node.addEventListener('auxclick', (event) => { if (event.button === 1) { event.preventDefault(); void closeCodeTab(tab.path); } });
1729
+ node.append(name);
1730
+ const change = code.changes.get(tab.path);
1731
+ if (change?.hunks?.length) {
1732
+ const dot = el('i', 'code-tab-agent');
1733
+ tint(dot, change.agent);
1734
+ dot.title = t('@{agent} changed it: {n} to review', { agent: change.agent, n: change.hunks.length });
1735
+ node.append(dot);
1736
+ }
1737
+ if (tab.draft !== null || (on && editorDirty())) {
1738
+ const dot = el('i', 'code-tab-dirty');
1739
+ dot.title = t('Unsaved');
1740
+ node.append(dot);
1741
+ }
1742
+ const shut = closeMark('button', 'code-tab-close', t('Close {file}', { file: tab.path.split('/').pop() }));
1743
+ shut.addEventListener('click', (event) => { event.stopPropagation(); void closeCodeTab(tab.path); });
1744
+ node.append(shut);
1745
+ code.bar.append(node);
1746
+ }
1747
+ code.bar.querySelector('.code-tab.on')?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
1748
+ }
1749
+
1750
+ /* the path above the code: folders quiet, the file, and the function the line is in */
1751
+ function paintCodeCrumbs(line = code.line) {
1752
+ if (!code.crumbs || code.crumbs.hidden || !viewerUI.path) return;
1753
+ code.line = line;
1754
+ code.crumbs.replaceChildren();
1755
+ // The files, put away by hand, come back from here.
1756
+ if (!code.files) {
1757
+ const show = el('button', 'code-files-fold code-files-show');
1758
+ show.type = 'button';
1759
+ show.setAttribute('aria-label', t('Show the files'));
1760
+ show.title = t('Show the files');
1761
+ show.innerHTML = SPLIT_LEFT_ICON; // trusted: our own SVG string
1762
+ show.addEventListener('click', () => { code.files = true; rememberCode(); syncCode(); });
1763
+ code.crumbs.append(show);
1764
+ }
1765
+ const where = el('span', 'code-crumbs-path');
1766
+ const parts = viewerUI.path.split('/');
1767
+ parts.forEach((part, index) => {
1768
+ if (index) where.append(el('i', null, '›'));
1769
+ where.append(el(index === parts.length - 1 ? 'b' : 'span', null, part));
1770
+ });
1771
+ const text = editing.editor ? editing.editor.value() : viewerMode.text;
1772
+ const symbol = line ? symbolAt(String(text ?? '').split('\n'), line - 1, familyOf(viewerUI.path)?.id ?? null) : null;
1773
+ if (symbol) where.append(el('i', null, '›'), el('b', 'code-crumbs-symbol', symbol));
1774
+ code.crumbs.append(where);
1775
+ const change = code.changes.get(viewerUI.path);
1776
+ if (change?.hunks?.length) {
1777
+ const nav = el('span', 'code-crumbs-changes');
1778
+ tint(nav, change.agent);
1779
+ nav.append(el('i', 'code-file-agent'), el('span', null, change.hunks.length === 1 ? t('@{agent} · 1 change to review', { agent: change.agent }) : t('@{agent} · {n} changes to review', { agent: change.agent, n: change.hunks.length })));
1780
+ const step = (direction) => {
1781
+ const marks = change.hunks.map((hunk) => hunk.at + 1);
1782
+ const here = code.line ?? 0;
1783
+ const next = direction > 0 ? marks.find((n) => n > here) ?? marks[0] : [...marks].reverse().find((n) => n < here) ?? marks.at(-1);
1784
+ goToCodeLine(next);
1785
+ };
1786
+ // The chooser's chevron, turned to point the way it goes.
1787
+ const stepKey = (label, direction) => {
1788
+ const key = el('button', `code-crumbs-step ${direction < 0 ? 'back' : 'on'}`);
1789
+ key.type = 'button';
1790
+ key.title = label;
1791
+ key.setAttribute('aria-label', label);
1792
+ key.innerHTML = ICON_CHEVRON; // trusted: our own SVG string
1793
+ key.addEventListener('click', () => step(direction));
1794
+ return key;
1795
+ };
1796
+ nav.append(stepKey(t('Previous change'), -1), stepKey(t('Next change'), 1));
1797
+ code.crumbs.append(nav);
1798
+ }
1799
+ }
1800
+ function goToCodeLine(n) {
1801
+ if (!n) return;
1802
+ code.line = n;
1803
+ if (editing.editor) { editing.editor.focus(n); return; }
1804
+ const card = viewer.body.querySelector(`.code-change[data-at="${n}"]`);
1805
+ const row = viewerUI.lineNodes[n - 1]?.tx;
1806
+ (card ?? row)?.scrollIntoView?.({ block: 'center', behavior: 'smooth' });
1807
+ paintCodeCrumbs(n);
1808
+ }
1809
+
1810
+ /* the files beside: the project's folders, or what matches what is typed */
1811
+ async function drawCodeFiles() {
1812
+ if (!code.list || code.explorer.hidden) return;
1813
+ const query = code.search.value.trim();
1814
+ if (query) {
1815
+ let matches = [];
1816
+ try { matches = (await fetch(`/api/tree/search?q=${encodeURIComponent(query)}&limit=40`).then((response) => response.json()))?.matches ?? []; } catch { /* said below */ }
1817
+ if (code.search.value.trim() !== query) return;
1818
+ code.list.replaceChildren();
1819
+ if (!matches.length) { code.list.append(el('li', 'code-files-empty', t('No file by that name.'))); return; }
1820
+ for (const match of matches) code.list.append(codeFileRow(match.path, 0, true));
1821
+ return;
1822
+ }
1823
+ code.list.replaceChildren();
1824
+ await drawCodeLevel('.', code.list, 0);
1825
+ }
1826
+ async function drawCodeLevel(path, list, depth) {
1827
+ let entries = [];
1828
+ try {
1829
+ const response = await fetch(`/api/tree?path=${encodeURIComponent(path)}`);
1830
+ if (response.ok) entries = (await response.json()).entries ?? [];
1831
+ } catch { /* an empty folder is said below */ }
1832
+ if (!entries.length && depth === 0) { list.append(el('li', 'code-files-empty', t('This project has no files yet.'))); return; }
1833
+ for (const entry of entries) {
1834
+ const full = path === '.' ? entry.name : `${path}/${entry.name}`;
1835
+ if (entry.type === 'dir' || entry.dir || entry.kind === 'dir') {
1836
+ const item = el('li', 'code-dir');
1837
+ const open = code.dirs.has(full);
1838
+ const row = el('button', 'code-file code-folder');
1839
+ row.type = 'button';
1840
+ row.style.setProperty('--depth', String(depth));
1841
+ row.setAttribute('aria-expanded', String(open));
1842
+ // The chooser's chevron: pointing right while closed, down when open (styles.css turns it).
1843
+ const caret = el('i', 'code-folder-caret');
1844
+ caret.innerHTML = ICON_CHEVRON; // trusted: our own SVG string
1845
+ row.append(caret, el('span', 'code-file-name', entry.name));
1846
+ const touch = touchedUnder(full);
1847
+ if (touch) { const dot = el('i', 'code-file-agent'); tint(dot, touch.agent); row.append(dot); }
1848
+ const inner = el('ul', 'code-files-list');
1849
+ row.addEventListener('click', async () => {
1850
+ if (code.dirs.has(full)) { code.dirs.delete(full); inner.replaceChildren(); row.setAttribute('aria-expanded', 'false'); return; }
1851
+ code.dirs.add(full);
1852
+ row.setAttribute('aria-expanded', 'true');
1853
+ await drawCodeLevel(full, inner, depth + 1);
1854
+ });
1855
+ item.append(row, inner);
1856
+ list.append(item);
1857
+ if (open) void drawCodeLevel(full, inner, depth + 1);
1858
+ } else list.append(codeFileRow(full, depth, false));
1859
+ }
1860
+ }
1861
+ function codeFileRow(path, depth, withDir) {
1862
+ const item = el('li');
1863
+ const row = el('button', `code-file${path === viewerUI.path ? ' on' : ''}`);
1864
+ row.type = 'button';
1865
+ row.style.setProperty('--depth', String(depth));
1866
+ row.title = path;
1867
+ row.append(el('span', 'code-file-name', path.split('/').pop()));
1868
+ if (withDir && path.includes('/')) row.append(el('span', 'code-file-dir', path.slice(0, path.lastIndexOf('/'))));
1869
+ const touch = touched.get(path);
1870
+ if (touch) { const dot = el('i', 'code-file-agent'); tint(dot, touch.agent); dot.title = t('Changed by @{agent} {when}', { agent: touch.agent, when: agoWords(new Date(touch.at).toISOString()) }); row.append(dot); }
1871
+ row.addEventListener('click', () => void openCodeFile(path));
1872
+ item.append(row);
1873
+ return item;
1874
+ }
1875
+
1876
+ /* what an agent changed, marked in the code */
1877
+ async function loadCodeChanges(path) {
1878
+ let answer = null;
1879
+ try { answer = await fetch(`/api/files/changes?path=${encodeURIComponent(path)}`).then((response) => (response.ok ? response.json() : null)); } catch { return; }
1880
+ if (viewerUI.path !== path) return;
1881
+ const change = answer?.change;
1882
+ if (!change) { if (code.changes.delete(path)) { decorateCode(); syncCode(); } return; }
1883
+ const current = viewerMode.text;
1884
+ const after = change.after ?? current;
1885
+ const reviewed = new Set(answer.reviewed ?? []);
1886
+ const hunks = placeHunks(current, change.after, lineDiff(change.before, after)).filter((hunk) => !reviewed.has(hunkKey(hunk)));
1887
+ if (hunks.length) code.changes.set(path, { agent: change.agent, checkpointId: change.checkpointId, at: change.at, messageId: change.messageId, hunks });
1888
+ else code.changes.delete(path);
1889
+ decorateCode();
1890
+ syncCode();
1891
+ }
1892
+ // Into what is on screen: the read view gets the marks and a card under each change; the editor
1893
+ // gets the marks in its margin, and the cards wait in the read view.
1894
+ function decorateCode() {
1895
+ const found = code.changes.get(viewerUI.path);
1896
+ if (editing.editor) {
1897
+ editing.editor.setMarks?.((found?.hunks ?? []).filter((hunk) => hunk.after.length).map((hunk) => ({ from: hunk.at + 1, to: hunk.at + hunk.after.length, color: agentColor(found.agent), title: t('Changed by @{agent}', { agent: found.agent }) })));
1898
+ return;
1899
+ }
1900
+ const pre = viewer.body.querySelector('pre.code');
1901
+ if (!pre) return;
1902
+ for (const node of pre.querySelectorAll('.code-change')) node.remove();
1903
+ for (const row of viewerUI.lineNodes) { row.ln.classList.remove('ag'); row.tx.classList.remove('ag'); }
1904
+ if (!found?.hunks?.length) return;
1905
+ const color = agentColor(found.agent);
1906
+ for (const hunk of found.hunks) {
1907
+ const last = hunk.at + hunk.after.length;
1908
+ for (let n = hunk.at + 1; n <= last; n += 1) {
1909
+ const row = viewerUI.lineNodes[n - 1];
1910
+ if (!row) continue;
1911
+ row.ln.classList.add('ag');
1912
+ row.tx.classList.add('ag');
1913
+ row.ln.style.setProperty('--ag', color);
1914
+ row.tx.style.setProperty('--ag', color);
1915
+ }
1916
+ const card = codeChangeCard(found, hunk);
1917
+ const anchor = viewerUI.lineNodes[Math.max(hunk.at, last) - 1]?.tx;
1918
+ if (anchor && hunk.after.length) anchor.after(card);
1919
+ else if (viewerUI.lineNodes[hunk.at - 1]) viewerUI.lineNodes[hunk.at - 1].tx.after(card);
1920
+ else pre.prepend(card);
1921
+ }
1922
+ }
1923
+ function codeChangeCard(found, hunk) {
1924
+ const card = el('div', 'code-change');
1925
+ card.dataset.at = String(hunk.at + 1);
1926
+ tint(card, found.agent);
1927
+ const head = el('div', 'code-change-head');
1928
+ const what = hunk.before.length && hunk.after.length ? t('changed these lines') : hunk.after.length ? t('added these lines') : hunk.before.length === 1 ? t('removed 1 line') : t('removed {n} lines', { n: hunk.before.length });
1929
+ head.append(el('b', 'code-change-who', `@${found.agent}`), el('span', null, `${what} · ${agoWords(found.at)}`));
1930
+ card.append(head);
1931
+ // What was there before, line by line, each with its «−»: read, not crossed out.
1932
+ if (hunk.before.length) {
1933
+ const was = el('pre', 'code-change-was');
1934
+ was.setAttribute('aria-label', t('What was there before'));
1935
+ for (const line of hunk.before) was.append(el('span', null, line || ' '));
1936
+ card.append(was);
1937
+ }
1938
+ const acts = el('div', 'code-change-acts');
1939
+ // Tags of a flat tone, each with its drawing: the accent keeps, the ground undoes, the agent answers.
1940
+ const act = (words, icon, tone, title, click) => {
1941
+ const key = el('button', `code-change-act ${tone}`);
1942
+ key.type = 'button';
1943
+ key.title = title;
1944
+ const mark = el('i', 'code-change-ico');
1945
+ mark.innerHTML = icon; // trusted: our own SVG strings
1946
+ key.append(mark, el('span', null, words));
1947
+ key.addEventListener('click', click);
1948
+ return key;
1949
+ };
1950
+ const keep = act(t('Keep'), ICON_DONE, 'keep', t('It stays as the agent left it, and stops being marked'), () => void keepCodeChange(found, hunk, card));
1951
+ const undo = act(t('Undo'), ICON_REPLY, 'undo', t('Puts back what was there before, in these lines only'), () => void undoCodeChange(found, hunk, undo));
1952
+ const why = act(t('Ask why'), ICON_ASK, 'why', t('Asks @{agent} in the conversation', { agent: found.agent }), () => askAboutLines({ from: hunk.at + 1, to: Math.max(hunk.at + 1, hunk.at + hunk.after.length), prompt: t('Why did you change these lines?'), dirty: false, agentId: found.agent }));
1953
+ acts.append(keep, undo, why);
1954
+ card.append(acts);
1955
+ return card;
1956
+ }
1957
+ async function keepCodeChange(found, hunk, card) {
1958
+ const path = viewerUI.path;
1959
+ try {
1960
+ await fetch('/api/files/changes/review', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path, checkpointId: found.checkpointId, hunk: hunkKey(hunk) }) });
1961
+ } catch { /* kept on this page at least */ }
1962
+ found.hunks = found.hunks.filter((one) => one !== hunk);
1963
+ if (!found.hunks.length) code.changes.delete(path);
1964
+ card.remove();
1965
+ decorateCode();
1966
+ syncCode();
1967
+ }
1968
+ async function undoCodeChange(found, hunk, button) {
1969
+ const path = viewerUI.path;
1970
+ if (editing.editor || codeTab(path)?.draft !== null && codeTab(path)?.draft !== undefined) { toast(t('Save or discard your own changes in this file first.'), { tone: 'note' }); return; }
1971
+ button.disabled = true;
1972
+ const next = revertHunk(viewerMode.text, hunk);
1973
+ const ok = await saveEdited(next, { said: t('Taken back · {path}', { path }) });
1974
+ if (!ok) { button.disabled = false; return; }
1975
+ try { await fetch('/api/files/changes/review', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path, checkpointId: found.checkpointId, hunk: hunkKey(hunk) }) }); } catch { /* the change is gone from the file anyway */ }
1976
+ showViewerSource();
1977
+ void loadCodeChanges(path);
1978
+ }
1979
+
1980
+ /* side by side: the text and what it makes */
1981
+ function attachBeside() {
1982
+ code.preview?.remove();
1983
+ code.preview = null;
1984
+ viewer.body.classList.remove('with-beside');
1985
+ let on = false;
1986
+ try { on = codeOn(); } catch { /* not yet */ }
1987
+ if (on && code.beside && viewerMode.kind && !viewerMode.preview && viewerUI.path) {
1988
+ const pane = el('div', 'code-beside');
1989
+ const head = el('div', 'code-beside-head');
1990
+ head.append(el('b', null, t('Preview')));
1991
+ const text = () => (editing.editor ? editing.editor.value() : viewerMode.text);
1992
+ if (viewerMode.kind === 'markdown') {
1993
+ head.append(el('span', 'code-beside-live', t('as you write')));
1994
+ const doc = el('article', 'viewer-markdown');
1995
+ doc.append(renderMarkdown(text()));
1996
+ pane.append(head, doc);
1997
+ pane.render = () => doc.replaceChildren(renderMarkdown(text()));
1998
+ } else if (!state.ripley) {
1999
+ const off = el('div', 'code-beside-off');
2000
+ off.append(el('p', null, t('Preview shows the page your code makes, in a sealed frame. It is off in this project.')));
2001
+ off.append(sxButton(t('Turn on Preview'), async (event) => {
2002
+ event.currentTarget.disabled = true;
2003
+ try { await switchModule(modules.items.find((one) => one.id === 'ripley') ?? { id: 'ripley', name: 'Preview' }); } catch (error) { toast(`${t('Diagnostics › ')}${error.message}`); }
2004
+ attachBeside();
2005
+ }, { tone: 'primary' }));
2006
+ pane.append(head, off);
2007
+ } else {
2008
+ head.append(el('span', 'code-beside-live', t('reloads when you save')));
2009
+ const frame = el('iframe', 'ripley');
2010
+ frame.setAttribute('sandbox', 'allow-scripts');
2011
+ frame.setAttribute('referrerpolicy', 'no-referrer');
2012
+ frame.title = t('Preview of {path}', { path: viewerUI.path });
2013
+ frame.src = previewUrl(viewerMode.src);
2014
+ pane.append(head, frame);
2015
+ pane.reload = () => { frame.src = `${previewUrl(viewerMode.src)}?v=${Date.now()}`; };
2016
+ }
2017
+ // The pane beside says what Preview is; the old line over the code would say it twice.
2018
+ viewer.body.querySelector(':scope > .viewer-hint')?.remove();
2019
+ viewer.body.append(pane);
2020
+ viewer.body.classList.add('with-beside');
2021
+ code.preview = pane;
2022
+ }
2023
+ code.besideButton?.setAttribute('aria-pressed', String(Boolean(code.preview)));
2024
+ syncCode();
2025
+ }
2026
+
2027
+ /* ⌘P: a file by its name, from anywhere in the room */
2028
+ const quickOpen = (() => {
2029
+ const box = el('div', 'quick-open');
2030
+ box.hidden = true;
2031
+ box.setAttribute('role', 'dialog');
2032
+ box.setAttribute('aria-label', t('Open a file'));
2033
+ box.setAttribute('popover', 'manual');
2034
+ const input = el('input', 'quick-open-input');
2035
+ input.type = 'search';
2036
+ input.autocomplete = 'off';
2037
+ input.spellcheck = false;
2038
+ const list = el('ul', 'quick-open-list');
2039
+ list.setAttribute('role', 'listbox');
2040
+ box.append(input, list);
2041
+ document.body?.append(box);
2042
+ return { box, input, list, at: 0, items: [], typing: 0 };
2043
+ })();
2044
+ function closeQuickOpen() {
2045
+ if (quickOpen.box.hidden) return;
2046
+ quickOpen.box.hidden = true;
2047
+ try { quickOpen.box.hidePopover?.(); } catch { /* not shown */ }
2048
+ }
2049
+ async function fillQuickOpen() {
2050
+ const query = quickOpen.input.value.trim();
2051
+ let paths;
2052
+ if (!query) {
2053
+ const recent = [...code.tabs].reverse().map((tab) => tab.path);
2054
+ const changed = [...touched.entries()].sort((a, b) => b[1].at - a[1].at).map(([path]) => path);
2055
+ paths = [...new Set([...recent, ...changed])].slice(0, 12);
2056
+ } else {
2057
+ try { paths = ((await fetch(`/api/tree/search?q=${encodeURIComponent(query)}&limit=20`).then((response) => response.json()))?.matches ?? []).map((match) => match.path); } catch { paths = []; }
2058
+ if (quickOpen.input.value.trim() !== query) return;
2059
+ }
2060
+ quickOpen.items = paths;
2061
+ quickOpen.at = 0;
2062
+ quickOpen.list.replaceChildren();
2063
+ if (!paths.length) { quickOpen.list.append(el('li', 'quick-open-empty', query ? t('No file by that name.') : t('Type part of a file\'s name.'))); return; }
2064
+ paths.forEach((path, index) => {
2065
+ const item = el('li', `quick-open-item${index === 0 ? ' on' : ''}`);
2066
+ item.setAttribute('role', 'option');
2067
+ item.append(el('b', null, path.split('/').pop()), el('span', null, path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''));
2068
+ const touch = touched.get(path);
2069
+ if (touch) { const dot = el('i', 'code-file-agent'); tint(dot, touch.agent); item.append(dot); }
2070
+ item.addEventListener('pointerdown', (event) => { event.preventDefault(); closeQuickOpen(); void openCodeFile(path); });
2071
+ quickOpen.list.append(item);
2072
+ });
2073
+ }
2074
+ function openQuickOpen() {
2075
+ quickOpen.input.placeholder = t('Open a file by its name…');
2076
+ quickOpen.input.value = '';
2077
+ quickOpen.box.hidden = false;
2078
+ try { quickOpen.box.showPopover?.(); } catch { /* fixed in the page */ }
2079
+ quickOpen.input.focus();
2080
+ void fillQuickOpen();
2081
+ }
2082
+ quickOpen.input.addEventListener('input', () => { clearTimeout(quickOpen.typing); quickOpen.typing = setTimeout(() => void fillQuickOpen(), 110); });
2083
+ quickOpen.input.addEventListener('keydown', (event) => {
2084
+ const rows = [...quickOpen.list.querySelectorAll('.quick-open-item')];
2085
+ if (event.key === 'Escape') { event.preventDefault(); closeQuickOpen(); return; }
2086
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
2087
+ event.preventDefault();
2088
+ if (!rows.length) return;
2089
+ rows[quickOpen.at]?.classList.remove('on');
2090
+ quickOpen.at = (quickOpen.at + (event.key === 'ArrowDown' ? 1 : -1) + rows.length) % rows.length;
2091
+ rows[quickOpen.at].classList.add('on');
2092
+ rows[quickOpen.at].scrollIntoView?.({ block: 'nearest' });
2093
+ return;
2094
+ }
2095
+ if (event.key === 'Enter') {
2096
+ event.preventDefault();
2097
+ const path = quickOpen.items[quickOpen.at];
2098
+ if (!path) return;
2099
+ closeQuickOpen();
2100
+ void openCodeFile(path);
2101
+ }
2102
+ });
2103
+ document.addEventListener?.('pointerdown', (event) => { if (!quickOpen.box.hidden && !quickOpen.box.contains(event.target)) closeQuickOpen(); }, true);
2104
+ document.addEventListener?.('keydown', (event) => {
2105
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key?.toLowerCase() === 'p' && !state.device) {
2106
+ event.preventDefault();
2107
+ if (quickOpen.box.hidden) openQuickOpen(); else closeQuickOpen();
2108
+ }
2109
+ });
2110
+
1315
2111
  // RIPLEY's frame, its small bar (back, reload), its history inside the project,
1316
2112
  // what the page reports up (which page, which errors), and reloads when an
1317
2113
  // agent changes the file on screen.
@@ -1688,7 +2484,9 @@ function paintPorts(read) {
1688
2484
  menu.replaceChildren(el('div', 'ports-note', read.error));
1689
2485
  const go = el('button', 'ports-row');
1690
2486
  go.type = 'button';
1691
- go.append(el('b', null, '⚙'), el('span', null, t('OPEN MODULES')));
2487
+ const gear = el('b', 'ports-gear');
2488
+ gear.innerHTML = GEAR_ICON; // trusted: our own SVG string
2489
+ go.append(gear, el('span', null, t('OPEN MODULES')));
1692
2490
  go.addEventListener('click', () => { portsMenuShown(false); document.querySelector('#modules-button, [aria-controls="modules"]')?.click(); });
1693
2491
  menu.append(go);
1694
2492
  return;
@@ -1715,7 +2513,8 @@ function paintPorts(read) {
1715
2513
  row.type = 'button';
1716
2514
  // A server that sends no Content-Type used to leave the separator hanging: «:5173 node · 200 · ».
1717
2515
  const kind = found.html ? '' : (found.contentType ?? '').trim();
1718
- const what = `${found.command} · ${found.status}${kind ? ` · ${kind}` : ''}`;
2516
+ // Open to the whole network too: it opens here on 127.0.0.1, and the row says the rest.
2517
+ const what = `${found.command} · ${found.status}${kind ? ` · ${kind}` : ''}${found.network ? ` · ${t('your network can reach it too')}` : ''}`;
1719
2518
  row.append(el('i', 'ports-lamp'), el('b', null, `:${found.port}`), el('span', null, found.framable === false ? t('{what} · cannot be shown in here', { what }) : what));
1720
2519
  row.addEventListener('click', () => openPort(found));
1721
2520
  menu.append(row);
@@ -2122,6 +2921,10 @@ function ripleyMaybeReload(paths) {
2122
2921
  }
2123
2922
 
2124
2923
  function showViewerPreview() {
2924
+ // The whole frame for the result: nothing beside it.
2925
+ code.preview = null;
2926
+ viewer.body.classList.remove('with-beside');
2927
+ code.besideButton?.setAttribute('aria-pressed', 'false');
2125
2928
  if (viewerMode.kind === 'markdown') {
2126
2929
  ripley.nav.hidden = true;
2127
2930
  const doc = el('article', 'viewer-markdown bubble');
@@ -2156,7 +2959,7 @@ function syncViewerMode() {
2156
2959
  if (!viewerMode.button) return;
2157
2960
  const can = Boolean(viewerMode.kind) && state.ripley;
2158
2961
  viewerMode.button.hidden = !can;
2159
- viewerMode.button.textContent = viewerMode.preview ? 'SOURCE' : 'PREVIEW';
2962
+ viewerMode.button.textContent = viewerMode.preview ? t('SOURCE') : t('PREVIEW');
2160
2963
  viewerMode.button.title = viewerMode.preview ? t('Show the file as text') : t('Render with Preview in a sealed frame');
2161
2964
  }
2162
2965
  viewerMode.button?.addEventListener('click', async () => { if (!await leaveEditor()) return; viewerMode.preview = !viewerMode.preview; syncViewerMode(); syncEditButton(); if (viewerMode.preview) showViewerPreview(); else showViewerSource(); });
@@ -2173,7 +2976,13 @@ function paintViewerPath(text) {
2173
2976
  viewer.path.setAttribute?.('aria-label', said);
2174
2977
  }
2175
2978
  async function openViewer({ root = 'project', path = null, url = null, label = null, line = null, lines = null }) {
2176
- if (!await leaveEditor()) return;
2979
+ let docked = false;
2980
+ try { docked = codeOn(); } catch { /* the layout is not up yet */ }
2981
+ if (!await leaveEditor({ stash: docked })) return;
2982
+ code.preview?.remove();
2983
+ code.preview = null;
2984
+ viewer.body.classList.remove('with-beside');
2985
+ code.line = line ?? lines?.from ?? null;
2177
2986
  const src = url ?? `/api/files?root=${root}&path=${encodeURIComponent(path)}`;
2178
2987
  paintViewerPath(label ?? path);
2179
2988
  viewer.open.href = src;
@@ -2201,15 +3010,27 @@ async function openViewer({ root = 'project', path = null, url = null, label = n
2201
3010
  // Text of this project, small enough to edit: the editor is offered. Attachments and frames
2202
3011
  // are read, never written.
2203
3012
  const editable = root === 'project' && Boolean(path) && !url && response.headers.has('x-mtime') && text.length < 2 * 1024 * 1024 && !text.includes('\u0000');
2204
- Object.assign(viewerMode, { kind, text, src, line, lines, preview: Boolean(kind) && state.ripley && !line && !lines, mtime: response.headers.get('x-mtime'), editable });
3013
+ // In the code workspace a page or a document opens as its text, with what it makes beside it.
3014
+ let docked = false;
3015
+ try { docked = codeOn(); } catch { /* the layout is not up yet */ }
3016
+ Object.assign(viewerMode, { kind, text, src, line, lines, preview: Boolean(kind) && state.ripley && !line && !lines && !(docked && code.beside), mtime: response.headers.get('x-mtime'), editable });
3017
+ const tab = editable ? noteCodeTab(path) : null;
2205
3018
  syncViewerMode();
2206
3019
  syncEditButton();
2207
3020
  if (viewerMode.preview) showViewerPreview(); else showViewerSource();
3021
+ // A tab left with unsaved changes opens where it was left: in the editor, with them.
3022
+ if (tab && tab.draft !== null) { if (tab.mtime) viewerMode.mtime = tab.mtime; openEditor(line); }
3023
+ if (editable) void loadCodeChanges(path);
3024
+ syncCode();
2208
3025
  } catch (error) {
2209
3026
  viewer.body.replaceChildren(el('div', 'err', error.message));
2210
3027
  }
2211
3028
  }
2212
- viewer.close.addEventListener('click', async () => { if (await leaveEditor()) viewer.dialog.close(); });
3029
+ viewer.close.addEventListener('click', async () => {
3030
+ let docked = false;
3031
+ try { docked = codeOn(); } catch { /* not yet */ }
3032
+ if (await leaveEditor({ stash: docked })) viewer.dialog.close();
3033
+ });
2213
3034
 
2214
3035
  function codeBlock(lines, lang) {
2215
3036
  const wrapper = el('div', `codeblock${lang ? ' has-lang' : ''}`);
@@ -2392,6 +3213,11 @@ function ringPercent(agent) {
2392
3213
  // it, and a row is that agent's own card — the same one a sphere opens, with a way back.
2393
3214
  const agentsCompact = document.querySelector('#agents-compact');
2394
3215
  const agentsCount = document.querySelector('#agents-count');
3216
+ // Whether an agent can answer now: an adapter MADRE can use, and a session. One that cannot is
3217
+ // switched off everywhere: grey in the bar, out of the picker, and a message to it is not sent.
3218
+ function canAnswer(agent) {
3219
+ return Boolean(agent?.ready) && state.sessions?.[agent.id]?.state !== 'signed-out';
3220
+ }
2395
3221
  function renderAgentsCompact() {
2396
3222
  if (!agentsCompact) return;
2397
3223
  const all = [...state.agents.values()];
@@ -2411,15 +3237,19 @@ function renderAgents() {
2411
3237
  for (const agent of state.agents.values()) {
2412
3238
  const percent = ringPercent(agent);
2413
3239
  const official = liveOfficial(agent);
3240
+ // An agent that cannot answer looks switched off, whatever the reason: not installed, no adapter
3241
+ // yet, or installed with no session (2026-10-17). Its colour comes back with its session.
3242
+ const signedOut = agent.ready && !canAnswer(agent);
2414
3243
  const node = avatar(agent.id, {
2415
3244
  size: 26,
2416
- ring: agent.ready,
3245
+ ring: agent.ready && !signedOut,
2417
3246
  pct: percent,
2418
3247
  status: agent.ready ? 'ready' : agent.detected ? 'detected' : 'offline',
2419
3248
  });
3249
+ if (!agent.ready || signedOut) node.classList.add('off');
2420
3250
  node.title = [
2421
3251
  `${agent.label}${vendorWord(agent.id) ? ` · ${vendorWord(agent.id)}` : ''}`,
2422
- agent.ready ? t('Ready') : agent.detected ? t('Detected, but MADRE cannot use it yet') : t('Not installed'),
3252
+ signedOut ? t('Not signed in') : agent.ready ? t('Ready') : agent.detected ? t('Detected, but MADRE cannot use it yet') : t('Not installed'),
2423
3253
  agent.version ? agent.version : null,
2424
3254
  official === null
2425
3255
  ? (agent.tokens ? t('Usage: local 5h window · {pct}% of {total} budget tokens', { pct: Math.round(percent), total: formatTokens(state.budget ?? 0) }) : t('Usage: local window · nothing used yet'))
@@ -2457,7 +3287,7 @@ if (safetyLine && !safetySeen) {
2457
3287
  function renderPicker() {
2458
3288
  els.picker.replaceChildren();
2459
3289
  els.pickOptions?.replaceChildren?.();
2460
- const ready = [...state.agents.values()].filter((agent) => agent.ready);
3290
+ const ready = [...state.agents.values()].filter(canAnswer);
2461
3291
  if (!ready.length) { updateCrewLabel(); return; }
2462
3292
  if (!els.target.value || !ready.some((agent) => agent.id === els.target.value)) els.target.value = ready[0].id;
2463
3293
  const stack = el('div', 'crew-stack');
@@ -3513,7 +4343,7 @@ function bridgeCard(agent) {
3513
4343
  card.append(head);
3514
4344
  const detail = el('div', 'detail');
3515
4345
  if (agent.id === 'madre') detail.append(ollamaDetail(agent));
3516
- else if (stage === 'missing') detail.append(agent.install ? t('Not on this computer · {command}', { command: agent.install.display }) : t('Not on this computer.'));
4346
+ else if (stage === 'missing') detail.append(t('Not on this computer.'));
3517
4347
  else detail.append(`${agent.version ?? t('version unknown')}${session?.detail ? ` · ${session.detail}` : ''}${agent.route?.destination ? ` · ${t('route: {destination}', { destination: agent.route.destination })}` : ''}`);
3518
4348
  card.append(detail);
3519
4349
  // What is behind this door: the account it needs, and whether there is a way in without paying.
@@ -3530,24 +4360,35 @@ function bridgeCard(agent) {
3530
4360
  if (stage === 'signed-out' || (stage === 'ready' && agent.login?.headless)) {
3531
4361
  // Every door the CLI has, not just the first one. Claude sells two accounts and MADRE used
3532
4362
  // to open only the subscription, which left whoever pays for the API with no way in.
3533
- if (agent.login?.headless) for (const door of loginDoors(agent, stage === 'ready')) actions.append(bridgeSignInButton(agent, door, stage === 'ready'));
3534
- else if (!agent.key && agent.login) actions.append(el('span', 'note', t('signs in from your terminal:')));
4363
+ const keyFirst = Boolean(agent.key?.first);
3535
4364
  if (agent.key) {
3536
- reveal = el('button', agent.login?.headless ? null : 'primary', t('PASTE API KEY'));
4365
+ reveal = el('button', agent.login?.headless && !keyFirst ? null : 'primary', t('PASTE API KEY'));
3537
4366
  reveal.type = 'button';
3538
- actions.append(reveal);
3539
4367
  }
4368
+ if (keyFirst) actions.append(reveal);
4369
+ if (agent.login?.headless) {
4370
+ for (const door of loginDoors(agent, stage === 'ready')) {
4371
+ const signIn = bridgeSignInButton(agent, door, stage === 'ready');
4372
+ if (keyFirst) signIn.classList.remove('primary');
4373
+ actions.append(signIn);
4374
+ }
4375
+ }
4376
+ else if (!agent.key && agent.login) actions.append(el('span', 'note', t('signs in from your terminal:')));
4377
+ if (reveal && !keyFirst) actions.append(reveal);
3540
4378
  }
3541
4379
  if (actions.childNodes.length) card.append(actions);
3542
4380
  if (reveal) {
3543
4381
  const slot = el('div', 'key-slot');
3544
4382
  slot.hidden = true;
3545
4383
  slot.append(keyForm(agent, { onDone: () => renderOnboarding() }));
3546
- slot.append(el('p', 'key-note', t('Or do it from a terminal: {command}', { command: agent.login.display })));
3547
4384
  reveal.addEventListener('click', () => { slot.hidden = !slot.hidden; if (!slot.hidden) slot.querySelector('input')?.focus(); });
3548
4385
  card.append(slot);
3549
- } else if (stage !== 'missing' && agent.login && !agent.login.headless) card.append(commandBlock([agent.login.display, `# ${t(agent.login.note)}`]));
4386
+ }
3550
4387
  if (stage !== 'missing') card.append(loginAskSlot(agent.id));
4388
+ if (agent.id !== 'madre') {
4389
+ const way = stage === 'missing' ? installTerminalWay(agent) : stage === 'signed-out' || stage === 'ready' ? signInTerminalWay(agent) : null;
4390
+ if (way) card.append(way);
4391
+ }
3551
4392
  const log = el('pre', 'bridge-log');
3552
4393
  log.id = `bridge-log-${agent.id}`;
3553
4394
  for (const entry of state.loginLogs.get(agent.id) ?? []) log.append(loginLine(entry));
@@ -3682,17 +4523,42 @@ function bridgeInstallButton(agent) {
3682
4523
  return button;
3683
4524
  }
3684
4525
  function bridgeSignInButton(agent, door, again = false) {
3685
- const named = Boolean(agent.login?.methods?.length);
4526
+ const named = Boolean(agent.login?.methods?.length) && !door.again;
3686
4527
  const button = el('button', again && !named ? null : 'primary', named ? door.label : again ? t('SIGN IN AGAIN') : t('SIGN IN'));
3687
4528
  button.type = 'button';
3688
4529
  button.title = t(door.note || agent.login.note);
3689
4530
  button.addEventListener('click', async () => {
4531
+ if (door.choose) { choosingAccount.add(agent.id); renderOnboarding(); return; }
4532
+ choosingAccount.delete(agent.id);
3690
4533
  button.disabled = true;
3691
4534
  try { await startLogin(agent.id, door.id); }
3692
4535
  catch (error) { toast(t('Diagnostics › sign-in did not start: {error}', { error: error.message })); renderOnboarding(); }
3693
4536
  });
3694
4537
  return button;
3695
4538
  }
4539
+ // The other way in: the same install or sign-in typed in a terminal, for whoever prefers it.
4540
+ // Folded, so a person who has never opened a terminal is never asked to.
4541
+ // Cards are drawn again on every sign-in event: which folds are open is kept here, so one never
4542
+ // closes on the person reading it.
4543
+ const openTerminalWays = new Set();
4544
+ function terminalWay(lines) {
4545
+ const fold = el('details', 'terminal-way');
4546
+ const key = lines[0];
4547
+ fold.open = openTerminalWays.has(key);
4548
+ fold.addEventListener('toggle', () => { if (fold.open) openTerminalWays.add(key); else openTerminalWays.delete(key); });
4549
+ fold.append(el('summary', null, t('Prefer your terminal?')));
4550
+ fold.append(commandBlock(lines));
4551
+ return fold;
4552
+ }
4553
+ function installTerminalWay(agent) {
4554
+ if (!agent.install) return null;
4555
+ return terminalWay([agent.install.display, ...(agent.install.alternatives ?? []).map((line) => `# ${t(line)}`)]);
4556
+ }
4557
+ function signInTerminalWay(agent) {
4558
+ const way = agent.login?.terminal;
4559
+ if (!way) return null;
4560
+ return terminalWay([way.command, ...(way.note ? [`# ${t(way.note)}`] : [])]);
4561
+ }
3696
4562
 
3697
4563
  /* ---------- thread rendering ---------- */
3698
4564
 
@@ -3869,6 +4735,8 @@ function iconButton(svg, title, className) {
3869
4735
  const ICON_GOOD = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.5 7.5v5.5H3.2a.7.7 0 0 1-.7-.7V8.2a.7.7 0 0 1 .7-.7h2.3Zm0 0 2.6-4.6a1.3 1.3 0 0 1 2.4.8L10 6.8h2.6a1.3 1.3 0 0 1 1.3 1.5l-.8 3.9a1.3 1.3 0 0 1-1.3 1H5.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>';
3870
4736
  const ICON_BAD = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10.5 8.5V3H12.8a.7.7 0 0 1 .7.7v4.1a.7.7 0 0 1-.7.7h-2.3Zm0 0-2.6 4.6a1.3 1.3 0 0 1-2.4-.8L6 9.2H3.4a1.3 1.3 0 0 1-1.3-1.5l.8-3.9A1.3 1.3 0 0 1 4.2 3h6.3" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>';
3871
4737
  const ICON_PREFER = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M7.45 2.7c.22-.45.88-.45 1.1 0l1.3 2.65 2.92.43c.5.07.7.69.34 1.04l-2.12 2.06.5 2.9c.09.5-.44.88-.89.65L8 11.06l-2.6 1.37c-.45.23-.98-.15-.89-.65l.5-2.9-2.12-2.06c-.36-.35-.16-.97.34-1.04l2.92-.43z" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>';
4738
+ // The settings' gear: the drawing AJUSTES wears in the bar, for any way into Settings.
4739
+ const GEAR_ICON = '<svg class="gear-glyph" viewBox="0 0 20 20" aria-hidden="true"><path fill-rule="evenodd" d="M8.65 3.85L8.92 1.97L11.08 1.97L11.35 3.85L13.40 4.69L14.91 3.56L16.44 5.09L15.31 6.60L16.15 8.65L18.03 8.92L18.03 11.08L16.15 11.35L15.31 13.40L16.44 14.91L14.91 16.44L13.40 15.31L11.35 16.15L11.08 18.03L8.92 18.03L8.65 16.15L6.60 15.31L5.09 16.44L3.56 14.91L4.69 13.40L3.85 11.35L1.97 11.08L1.97 8.92L3.85 8.65L4.69 6.60L3.56 5.09L5.09 3.56L6.60 4.69ZM12.6 10a2.6 2.6 0 1 1-5.2 0a2.6 2.6 0 1 1 5.2 0Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>';
3872
4740
  // A small ×, in the same round line, for an aside that can be put away.
3873
4741
  const ICON_STOP = '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="5" y="5" width="6" height="6" rx="1.3" fill="currentColor"/></svg>';
3874
4742
  const ICON_CLOSE_SMALL = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>';
@@ -4056,6 +4924,12 @@ const WORKING_VOICE = {
4056
4924
  late: [t('Reasoning through it…'), t('Verifying the findings…'), t('Sorting the evidence…')],
4057
4925
  long: [t('Formulating the answer…'), t('Writing it up…'), t('Wrapping up…')],
4058
4926
  },
4927
+ cursor: {
4928
+ early: [t('Reading the repo…'), t('Searching the project…'), t('Grabbing context…')],
4929
+ middle: [t('Thinking…'), t('Digging through the code…'), t('Following the references…'), t('Looking closer…')],
4930
+ late: [t('Working through it…'), t('Double-checking…'), t('Piecing it together…')],
4931
+ long: [t('Writing the response…'), t('Tidying the answer…'), t('Nearly done…')],
4932
+ },
4059
4933
  opencode: {
4060
4934
  early: [t('Reading the repo…'), t('Listing files…'), t('Grabbing context…')],
4061
4935
  middle: [t('Thinking…'), t('Digging through the code…'), t('Following the call chain…'), t('Looking closer…')],
@@ -4082,10 +4956,11 @@ function showReading(event) {
4082
4956
  // The answer as it is written. A CLI that says nothing until it is done shows nothing here,
4083
4957
  // which is the truth rather than a number invented to keep something moving.
4084
4958
  const wrote = out ? ` · ${t('{n} out', { n: (outTokens ?? 0).toLocaleString() || t('{n} ch', { n: out.toLocaleString() }) })}` : '';
4085
- meter.textContent = `~${read}${wrote}`;
4086
- meter.title = tokens
4959
+ // In place, and said without the grey box (see the clock in renderThinking).
4960
+ setText(meter, `~${read}${wrote}`);
4961
+ meter.setAttribute('aria-description', tokens
4087
4962
  ? t("{chars} characters of briefing and transcript, about {tokens} tokens at this project's measured rate{written}. The exact figure arrives when the agent answers.", { chars: chars.toLocaleString(), tokens: tokens.toLocaleString(), written: out ? t(', and {n} characters written back so far', { n: out.toLocaleString() }) : '' })
4088
- : t('{chars} characters. This project has not been billed yet, so there is no rate to convert them at.', { chars: chars.toLocaleString() });
4963
+ : t('{chars} characters. This project has not been billed yet, so there is no rate to convert them at.', { chars: chars.toLocaleString() }));
4089
4964
  }
4090
4965
 
4091
4966
  // The bill arrived: the estimate is replaced by what was actually charged, and stays on the
@@ -4177,6 +5052,8 @@ function renderThinking(event) {
4177
5052
  const status = el('span', 'status');
4178
5053
  const tokens = el('span', 'tokens');
4179
5054
  const elapsed = el('span', 'elapsed');
5055
+ // The clock is not read aloud every second: the region around it is live.
5056
+ elapsed.setAttribute('aria-hidden', 'true');
4180
5057
  // One answer, not the room. PARAR TODO is the master brake and takes down every plan and every
4181
5058
  // agent; this one is for the turn in front of you — the agent that hung, or started down the
4182
5059
  // wrong road, or hit an error and is still being waited on.
@@ -4214,7 +5091,9 @@ function renderThinking(event) {
4214
5091
  phraseChangedAt = seconds;
4215
5092
  phraseIndex = (phraseIndex + 1) % pool.length;
4216
5093
  const next = pool[phraseIndex];
4217
- if (status.textContent !== next) { status.textContent = next; status.style.animation = 'none'; void status.offsetWidth; status.style.animation = ''; }
5094
+ // The phrase changes in place and comes in with its own small animation, without asking the
5095
+ // browser to lay the page out again first (the old restart read offsetWidth to replay it).
5096
+ if (status.textContent !== next) { setText(status, next); status.animate?.([{ opacity: 0, transform: 'translateY(3px)' }, { opacity: 1, transform: 'none' }], { duration: 350, easing: 'ease' }); }
4218
5097
  };
4219
5098
  // The limit the room holds this turn to, said with its start; older records fall back to the setting.
4220
5099
  const limitMs = event.payload.timeoutMs ?? state.timeouts[agent] ?? 180000;
@@ -4230,9 +5109,11 @@ function renderThinking(event) {
4230
5109
  else if ((wasConnected || Date.now() - born > 5000) && timer) { clearInterval(timer); thinkingClocks.delete(timer); return; }
4231
5110
  const seconds = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
4232
5111
  speak(seconds);
4233
- elapsed.textContent = `${seconds}s / ${limit}s`;
4234
- elapsed.classList.toggle('late', seconds >= limit * 0.6);
4235
- bubble.title = t('{label} is reading the project… {seconds}s so far; MADRE gives up at {limit}s.', { label: label(agent), seconds, limit });
5112
+ setText(elapsed, `${seconds}s / ${limit}s`);
5113
+ if (elapsed.classList.contains('late') !== seconds >= limit * 0.6) elapsed.classList.toggle('late', seconds >= limit * 0.6);
5114
+ // Said to a screen reader, never as the browser's grey box: a title written every second came
5115
+ // back under a still pointer however many times it was taken off (adoptTitle).
5116
+ bubble.setAttribute('aria-description', t('{label} is reading the project… {seconds}s so far; MADRE gives up at {limit}s.', { label: label(agent), seconds, limit }));
4236
5117
  };
4237
5118
  tick();
4238
5119
  timer = setInterval(tick, 1000);
@@ -4621,7 +5502,7 @@ function attachMemoryHint(event) {
4621
5502
  for (const child of [...(col.children ?? [])]) if (child.classList?.contains('memory-hint')) child.remove();
4622
5503
  const hint = el('div', 'memory-hint');
4623
5504
  tint(hint, agent);
4624
- hint.append(el('span', 'lead', t('◉ memory saved')));
5505
+ hint.append(leadWords('span', 'lead', t('◉ memory saved')));
4625
5506
  for (const note of notes.slice(0, 3)) {
4626
5507
  const pill = el('button', `pill ${note.kind}`);
4627
5508
  pill.type = 'button';
@@ -4787,13 +5668,35 @@ function renderLeaseRefused(event) {
4787
5668
  // The reply named files and the disk does not have them. Said as the fact it is, without
4788
5669
  // deciding what the agent meant: an answer that claims a file it never wrote and an answer that
4789
5670
  // suggests one both leave the human better off knowing it is not there.
5671
+ // A reply that says it created files the disk does not have (manual tests on 1.0.2, 2026-10-17:
5672
+ // «Creé gastos.html» and no gastos.html). It used to be a small line that folded into a dot ten
5673
+ // seconds later, under a bubble that said the opposite. Now it says it plainly and offers the way
5674
+ // on: the same request back in the box, with a line telling the agent what happened. Sending it is
5675
+ // the human's; the live button keeps the notice open until then.
4790
5676
  function renderCreateMissing(event) {
4791
- const { agent, files = [] } = event.payload;
4792
- const node = el('div', 'system memory warn');
5677
+ const { agent, files = [], requestId = null } = event.payload;
5678
+ const list = files.slice(0, 4).join(', ') + (files.length > 4 ? '…' : '');
5679
+ const node = el('div', 'system memory warn create-missing');
4793
5680
  tint(node, agent);
4794
5681
  node.append(t('create · '), el('b', 'who', `@${agent}`));
4795
- node.append(t(' · this reply names {n} file(s) that are not on disk: {files}', { n: files.length, files: files.slice(0, 4).join(', ') + (files.length > 4 ? '…' : '') }));
4796
- node.append(t(' · nothing was written this turn'));
5682
+ node.append(t(' · said it created {files}, but it is not in your project', { files: list }));
5683
+ node.append(t(' · nothing was written in this reply'));
5684
+ const asked = requestId ? state.userMessages.get(requestId) : null;
5685
+ if (asked && state.agents.has(agent)) {
5686
+ const again = el('button', 'retry-longer', t('ASK AGAIN'));
5687
+ again.type = 'button';
5688
+ again.addEventListener('click', () => {
5689
+ again.disabled = true;
5690
+ if (els.target.value !== agent) { els.target.value = agent; renderPicker(); }
5691
+ const note = t('Last time you said you created {files}, but nothing was written to disk. Create them now and check that they exist before you answer.', { files: list });
5692
+ els.input.value = [asked.text ?? '', note].filter(Boolean).join('\n\n');
5693
+ if ((asked.mode ?? 1) === 2) setMode(2);
5694
+ else if ((asked.mode ?? 1) >= 3) toast(t('Diagnostics › #{n} needs you to confirm again: choose it in the mode selector before sending.', { n: asked.mode }));
5695
+ autosize();
5696
+ focusComposer();
5697
+ });
5698
+ node.append(again);
5699
+ }
4797
5700
  return node;
4798
5701
  }
4799
5702
 
@@ -4829,12 +5732,102 @@ function renderEnlace(event) {
4829
5732
  : t(" · with {room} · both read each other's {kinds}, read-only", { room: other, kinds: what })));
4830
5733
  if (expires) node.append(t(' · until {date}', { date: new Date(expires).toLocaleDateString(language(), { day: 'numeric', month: 'short' }) }));
4831
5734
  if (event.payload.contract?.signed?.by === 'bishop') node.append(t(' · signed by the Coordinator'));
5735
+ if (event.payload.contract?.train) node.append(t(' · the Coordinator learns from it'));
5736
+ if (event.payload.contract?.state) node.append(role === 'serves' ? t(' · it also reads the state of this project') : role === 'reads' ? t(' · this project also reads its state') : t(" · both read each other's state"));
5737
+ if (event.payload.contract?.deliver) node.append(role === 'serves' ? t(' · it can deliver notes here, which you accept') : role === 'reads' ? t(' · this project can deliver notes to it') : t(' · both can deliver notes to each other'));
4832
5738
  }
4833
5739
  node.title = contract?.id ?? (typeof contract === 'string' ? contract : '');
4834
5740
  if (contract?.id) node.dataset.contract = contract.id;
4835
5741
  return node;
4836
5742
  }
4837
5743
 
5744
+ // D-S15: what the Coordinator took to learn from, said in the project that handed it over, and in
5745
+ // Salas, where it was taken. One line each time; the notes themselves are never shown here.
5746
+ function renderLearned(event) {
5747
+ const { count = 0 } = event.payload ?? {};
5748
+ const node = el('div', 'system memory enlace');
5749
+ node.append(el('b', null, t('CONNECTION')));
5750
+ node.append(event.type === 'enlace.corpus'
5751
+ ? t('The Coordinator learned what this project knows: {n} notes, cleaned of private data.', { n: count })
5752
+ : t('Learned from {room}: {n} notes for the training.', { room: event.payload.from ?? '', n: count }));
5753
+ return node;
5754
+ }
5755
+
5756
+ // D-S17: notes another project delivered here. A card with one row per note, each accepted or
5757
+ // declined by you (or by an agent of this project in #3/#4, which the row then shows). Nothing of
5758
+ // them is in the memory until accepted.
5759
+ function renderDelivered(event) {
5760
+ const { name, agent, coordinator, notes = [] } = event.payload ?? {};
5761
+ if (!notes.length) return null;
5762
+ const from = coordinator ? t('the Coordinator') : name;
5763
+ const node = el('div', 'choice run-request bishop-plan delivered');
5764
+ const head = el('div', 'choice-head');
5765
+ head.append(el('span', 'choice-kicker', t('Delivered by {room}{agent}', { room: from, agent: agent ? ` · @${agent}` : '' })));
5766
+ node.append(head, el('p', 'choice-q', notes.length === 1 ? t('One note for this project. It enters the memory only if it is accepted.') : t('{n} notes for this project. Each enters the memory only if it is accepted.', { n: notes.length })));
5767
+ const list = el('div', 'choice-list');
5768
+ for (const note of notes) {
5769
+ const row = el('div', 'choice-option bishop-order');
5770
+ row.dataset.delivery = note.id;
5771
+ row.append(el('span', 'choice-key', kindWord(note.kind).slice(0, 1).toUpperCase()));
5772
+ const what = el('div', 'choice-text');
5773
+ const where = el('div', 'bishop-where');
5774
+ where.append(el('b', null, kindWord(note.kind)));
5775
+ what.append(where, el('div', 'bishop-task', note.text));
5776
+ if (note.why) what.append(el('p', 'run-why', t('Why: {why}', { why: note.why })));
5777
+ row.append(what, el('div', 'choice-tags'));
5778
+ list.append(row);
5779
+ state.deliveries.set(note.id, row);
5780
+ markDelivery({ id: note.id }, row.dataset.status ?? 'pending', row);
5781
+ }
5782
+ node.append(list);
5783
+ state.lastSender = null;
5784
+ return node;
5785
+ }
5786
+ function markDelivery({ id, by = null }, status, row = null) {
5787
+ const target = row ?? state.deliveries.get(id);
5788
+ if (!target) return;
5789
+ target.dataset.status = status;
5790
+ const side = target.querySelector('.choice-tags');
5791
+ side.replaceChildren();
5792
+ if (status !== 'pending') {
5793
+ const who = by && by !== 'you' ? ` · @${by}` : '';
5794
+ side.append(el('span', 'choice-tag', `${status === 'accepted' ? t('ACCEPTED') : t('DECLINED')}${who}`));
5795
+ return;
5796
+ }
5797
+ if (state.device) { side.append(el('span', 'choice-tag', t('FROM THE MAC'))); return; }
5798
+ const accept = bishopButton(t('Accept'));
5799
+ const decline = bishopButton(t('Decline'), true);
5800
+ accept.addEventListener('click', () => bishopPress('/api/enlace/inbox/decide', { id, accept: true }, [accept, decline]));
5801
+ decline.addEventListener('click', () => bishopPress('/api/enlace/inbox/decide', { id, accept: false }, [accept, decline]));
5802
+ side.append(accept, decline);
5803
+ }
5804
+ // D-S16 and D-S17, the lines that say what crossed: the state read, notes delivered elsewhere.
5805
+ function renderCrossing(event) {
5806
+ const payload = event.payload ?? {};
5807
+ const node = el('div', 'system memory enlace');
5808
+ node.append(el('b', null, t('CONNECTION')));
5809
+ const room = payload.name ?? payload.to ?? payload.from ?? '';
5810
+ if (event.type === 'enlace.state') node.append(payload.source ? t('{room} read the state of this project.', { room: payload.to ?? '' }) : t('{room} asked for the state of this project; it keeps none (Continuity is not installed).', { room: payload.to ?? '' }));
5811
+ else if (event.type === 'enlace.state.received') node.append(t('@{agent} read the state of {room}.', { agent: payload.agent ?? '', room }));
5812
+ else node.append(t('@{agent} delivered {n} notes to {room}. They wait there until it accepts them.', { agent: payload.agent ?? '', n: (payload.notes ?? []).length, room }));
5813
+ return node;
5814
+ }
5815
+ // D-S18: the exam of the Coordinator's local model, said in Home as it starts and as it ends.
5816
+ function renderExam(event) {
5817
+ const payload = event.payload ?? {};
5818
+ const node = el('div', `system memory enlace${event.type === 'bishop.exam.failed' || (event.type === 'bishop.exam.finished' && !payload.passed) ? ' warn' : ''}`);
5819
+ node.append(el('b', null, t('EXAM')));
5820
+ if (event.type === 'bishop.exam.started') node.append(t('The Coordinator\'s local model {model} is being examined: {n} questions and orders.', { model: payload.model ?? '', n: (payload.known ?? 0) + (payload.unknown ?? 0) + (payload.orders ?? 0) }));
5821
+ else if (event.type === 'bishop.exam.failed') node.append(t('The exam of {model} could not finish: {why}', { model: payload.model ?? '', why: payload.error ?? '' }));
5822
+ else {
5823
+ const part = (key) => `${payload.scores?.[key]?.passed ?? 0}/${payload.scores?.[key]?.total ?? 0}`;
5824
+ node.append(payload.passed
5825
+ ? t('{model} passed: {known} about the projects, {unknown} said it never discussed, {orders} orders. The Coordinator gives orders with it up to #1.', { model: payload.model ?? '', known: part('known'), unknown: part('unknown'), orders: part('orders') })
5826
+ : t('{model} did not pass: {known} about the projects, {unknown} said it never discussed, {orders} orders. The Coordinator keeps only answering with it.', { model: payload.model ?? '', known: part('known'), unknown: part('unknown'), orders: part('orders') }));
5827
+ }
5828
+ return node;
5829
+ }
5830
+
4838
5831
  function renderControlStarted(event) {
4839
5832
  const { agent, commit, message } = event.payload;
4840
5833
  const node = el('div', 'system control');
@@ -5122,7 +6115,7 @@ async function renderOpenQuestions() {
5122
6115
  try { localStorage.setItem('pulse.openqDismissed', signature); } catch { /* closed for this page only */ }
5123
6116
  box.hidden = true;
5124
6117
  });
5125
- head.append(el('span', 'openq-lead', t('◉ still open here')), close);
6118
+ head.append(leadWords('span', 'openq-lead', t('◉ still open here')), close);
5126
6119
  box.append(head);
5127
6120
  // The pills live in a track of their own so the row can scroll sideways instead of stacking:
5128
6121
  // these are an aside under the thread, and an aside that grows downwards pushes the composer.
@@ -5180,7 +6173,7 @@ async function renderSettled() {
5180
6173
  catch { return; }
5181
6174
  if (!decisions.length) { box.hidden = true; return; }
5182
6175
  box.replaceChildren();
5183
- box.append(el('span', 'settled-lead', t('◉ decided here · {n}', { n: decisions.length })));
6176
+ box.append(leadWords('span', 'settled-lead', t('◉ decided here · {n}', { n: decisions.length })));
5184
6177
  for (const note of decisions) {
5185
6178
  const pill = el('button', 'settled-note');
5186
6179
  pill.type = 'button';
@@ -5391,15 +6384,15 @@ function markRun({ responseMessageId, index }, status, where = null, startedAt =
5391
6384
  updateRunClock(button);
5392
6385
  }
5393
6386
  if (status === 'serving' && where?.port) {
6387
+ // A server open to the whole network opens here too, on 127.0.0.1 (2026-10-18); the line says
6388
+ // that the network can reach it as well.
5394
6389
  live.append(el('span', 'where', where.network
5395
- ? t('serving on :{port} · open to your whole network, so the project will not open it', { port: where.port })
6390
+ ? t('serving on :{port} · your network can reach it too', { port: where.port })
5396
6391
  : t('serving on :{port}', { port: where.port })));
5397
- if (!where.network) {
5398
- const open = el('button', 'run-open', t('OPEN IN YOUR SERVERS'));
5399
- open.type = 'button';
5400
- open.addEventListener('click', () => { browserShow(); void browserOpen(`http://127.0.0.1:${where.port}/`); });
5401
- live.append(open);
5402
- }
6392
+ const open = el('button', 'run-open', t('OPEN IN YOUR SERVERS'));
6393
+ open.type = 'button';
6394
+ open.addEventListener('click', () => { browserShow(); void browserOpen(`http://127.0.0.1:${where.port}/`); });
6395
+ live.append(open);
5403
6396
  }
5404
6397
  const stop = el('button', 'run-stop', t('STOP'));
5405
6398
  stop.type = 'button';
@@ -6190,6 +7183,17 @@ function renderEventNode(event) {
6190
7183
  case 'mode.requested': node = renderModeRequest(event); break;
6191
7184
  case 'control.started': node = renderControlStarted(event); break;
6192
7185
  case 'create.reverted': node = renderCreateReverted(event); break;
7186
+ case 'enlace.corpus':
7187
+ case 'enlace.learned': node = renderLearned(event); break;
7188
+ case 'enlace.delivered': if (!replaying) void refreshDeliveries(); node = renderDelivered(event); break;
7189
+ case 'enlace.accepted': markDelivery(event.payload, 'accepted'); if (!replaying) void refreshDeliveries(); return;
7190
+ case 'enlace.declined': markDelivery(event.payload, 'declined'); if (!replaying) void refreshDeliveries(); return;
7191
+ case 'enlace.state':
7192
+ case 'enlace.state.received':
7193
+ case 'enlace.sent': node = renderCrossing(event); break;
7194
+ case 'bishop.exam.started':
7195
+ case 'bishop.exam.finished':
7196
+ case 'bishop.exam.failed': node = renderExam(event); break;
6193
7197
  case 'enlace.signed':
6194
7198
  case 'enlace.revoked': if (event.type === 'enlace.revoked' && markRevokedLinks(event.payload.contract?.id)) return; node = renderEnlace(event); break;
6195
7199
  case 'bishop.plan.proposed': node = renderBishopPlan(event); break;
@@ -6233,7 +7237,7 @@ function renderEventNode(event) {
6233
7237
  // says something about itself: in a new project @madre's arrival used to take it away before
6234
7238
  // anyone had written a word.
6235
7239
  if (event.type.startsWith('message.') || event.type === 'command.output' || event.type === 'run.requested') removeEmpty();
6236
- const stickToBottom = !drawingRecord && scroller.height - scroller.top - scroller.view < 120;
7240
+ const stickToBottom = !drawingRecord && pinnedToEnd;
6237
7241
  els.column.append(tidySystemLine(node));
6238
7242
  scheduleFold(node);
6239
7243
  if (!drawingRecord) followTurn(event, node, stickToBottom);
@@ -6338,12 +7342,12 @@ function openStrip(strip, open = !strip.classList.contains('open')) {
6338
7342
  // Opening a row is reading it: the thread stops following the end, or the taller row would be
6339
7343
  // scrolled away from under the click.
6340
7344
  const last = !strip.nextElementSibling;
6341
- if (open && !last) pinnedToEnd = false;
7345
+ if (open && !last) setPinned(false);
6342
7346
  strip.classList.toggle('open', open);
6343
7347
  for (const node of strip.querySelectorAll(':scope > .system.folded')) node.classList.remove('quiet');
6344
7348
  groupRepeats(strip);
6345
7349
  countStrip(strip);
6346
- if (open) requestAnimationFrame(() => strip.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' }));
7350
+ if (open) requestAnimationFrame(() => { ourScroll(900); strip.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' }); });
6347
7351
  }
6348
7352
  els.column.addEventListener('click', (event) => {
6349
7353
  const strip = event.target.closest?.('.sys-dots');
@@ -6372,18 +7376,51 @@ function drawRecord(events) {
6372
7376
  drawingRecord = true;
6373
7377
  try { for (const event of events) renderEvent(event); } finally { drawingRecord = false; }
6374
7378
  }
7379
+ // What is off screen is not drawn (content-visibility), and until a row has been drawn once its
7380
+ // height is a guess (contain-intrinsic-size: 140px). A notice folded to dots is 13 px, an answer
7381
+ // can be 2,000: a jump into history that had never been on screen — the scrollbar dragged up, Home,
7382
+ // a search — landed hundreds of pixels off and the conversation jumped under the reader as the rows
7383
+ // near it found their real heights. Once the record is drawn, every row is laid out one time, a
7384
+ // batch while the page is idle, so the browser remembers its real height (contain-intrinsic-size:
7385
+ // auto) and goes back to skipping it. One pass per conversation opened; nothing stays drawn.
7386
+ let measuring = 0;
7387
+ function measureRows() {
7388
+ const run = ++measuring;
7389
+ const rows = [...(els.column?.children ?? [])];
7390
+ const idle = globalThis.requestIdleCallback ? (fn) => requestIdleCallback(fn, { timeout: 400 }) : (fn) => setTimeout(fn, 16);
7391
+ const frame = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (fn) => setTimeout(fn, 16);
7392
+ let next = rows.length;
7393
+ // From the end up: the rows nearest to where the reader is are measured first.
7394
+ const step = () => {
7395
+ if (run !== measuring || next <= 0) return;
7396
+ const batch = rows.slice(Math.max(0, next - 30), next).filter((row) => row.isConnected !== false);
7397
+ next -= 30;
7398
+ for (const row of batch) row.classList?.add('cv-measure');
7399
+ frame(() => frame(() => {
7400
+ for (const row of batch) row.classList?.remove('cv-measure');
7401
+ idle(step);
7402
+ }));
7403
+ };
7404
+ idle(step);
7405
+ }
6375
7406
  function followTurn(event, node, stickToBottom) {
6376
7407
  if (replaying) { if (stickToBottom) scrollToEnd(); return; }
6377
- const isTurn = event.type === 'agent.started' || (event.type === 'message.created' && event.payload.role === 'user');
7408
+ const mine = event.type === 'message.created' && event.payload.role === 'user';
6378
7409
  const isAnswer = event.type === 'message.created' && event.payload.role !== 'user';
7410
+ // What the human just sent takes them to the end, wherever they were reading.
7411
+ if (mine) { setPinned(true); scroller.to({ top: scroller.height, behavior: 'smooth' }); return; }
7412
+ // Gone up to read: nothing below moves them. An answer or a finished plan says so on a button.
7413
+ if (!pinnedToEnd) {
7414
+ if (isAnswer) markFinishedBelow(event.payload.sender);
7415
+ else if (event.type === 'plan.completed') markFinishedBelow(event.payload.orchestrator ?? null, true);
7416
+ return;
7417
+ }
6379
7418
  if (isAnswer) {
7419
+ // A long answer is read from its top: the thread stops following and opens at its start.
6380
7420
  const tall = node.getBoundingClientRect?.().height > scroller.view * 0.7;
6381
- if (tall) node.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
6382
- else scroller.to({ top: scroller.height, behavior: 'smooth' });
6383
- return;
7421
+ if (tall) { setPinned(false); ourScroll(900); node.scrollIntoView?.({ behavior: 'smooth', block: 'start' }); return; }
6384
7422
  }
6385
- if (isTurn) { scroller.to({ top: scroller.height, behavior: 'smooth' }); return; }
6386
- if (stickToBottom) scrollToEnd();
7423
+ scrollToEnd();
6387
7424
  }
6388
7425
 
6389
7426
  /* ---------- theme: auto (system) → light → dark ---------- */
@@ -6442,7 +7479,8 @@ const LOOKS = {
6442
7479
  accent: { key: 'pulse.accent', values: ['madre', 'mountain', 'caribbean', 'phantom', 'malbec', 'dijon', 'pacific', 'love', 'sierra', 'pitahaya', 'papaya', 'agave', 'dinamo', 'marina', 'pulso', 'leal', 'dulce'] },
6443
7480
  bubbles: { key: 'pulse.bubbles', values: ['comfortable', 'large', 'compact', 'fitted'] },
6444
7481
  text: { key: 'pulse.text', values: ['comfortable', 'larger', 'smaller', 'visual'] },
6445
- wallpaper: { key: 'pulse.wallpaper', values: ['default', 'jarrito', 'canela', 'paradise', 'ventura', 'magna', 'biznaga', 'oceano', 'quila', 'xico', 'tropical', 'tradicion', 'urbania'] },
7482
+ // Last, the person's own picture (public/wallpaper.js): a choice only once there is one.
7483
+ wallpaper: { key: 'pulse.wallpaper', values: ['default', 'jarrito', 'canela', 'paradise', 'ventura', 'magna', 'biznaga', 'oceano', 'quila', 'xico', 'tropical', 'tradicion', 'urbania', CUSTOM_WALL] },
6446
7484
  // How a photograph wears the room's ground: a flat layer, or a mesh of its own colours.
6447
7485
  wpStyle: { key: 'pulse.wallpaper.style', values: ['veil', 'mesh'] },
6448
7486
  };
@@ -6452,7 +7490,7 @@ const TUNES = {
6452
7490
  veil: { key: 'pulse.wallpaper.veil', min: 0, max: 140, step: 5, value: 100, apply: (node, v) => node.style.setProperty('--veil-k', String(v / 100)) },
6453
7491
  blur: { key: 'pulse.wallpaper.blur', min: 0, max: 24, step: 1, value: 0, apply: (node, v) => node.style.setProperty('--wp-blur', `${v}px`) },
6454
7492
  };
6455
- const PHOTO_WALLPAPERS = new Set(['jarrito', 'canela', 'paradise', 'ventura', 'magna', 'biznaga', 'oceano', 'quila', 'xico', 'tropical', 'tradicion', 'urbania']);
7493
+ const PHOTO_WALLPAPERS = new Set(['jarrito', 'canela', 'paradise', 'ventura', 'magna', 'biznaga', 'oceano', 'quila', 'xico', 'tropical', 'tradicion', 'urbania', CUSTOM_WALL]);
6456
7494
  function readTune(name) {
6457
7495
  const tune = TUNES[name];
6458
7496
  let raw = null;
@@ -6475,7 +7513,7 @@ const LOOK_WORDS = {
6475
7513
  accent: { madre: () => t('Madre green'), mountain: () => t('Mountain green'), caribbean: () => t('Caribbean blue'), phantom: () => t('Phantom purple'), malbec: () => t('Malbec red'), dijon: () => t('Dijon yellow'), pacific: () => t('Pacific blue'), love: () => t('Love pink'), sierra: () => t('Sierra blue'), pitahaya: () => 'Pitahaya', papaya: () => 'Papaya', agave: () => 'Agave', dinamo: () => t('Dynamo black'), marina: () => t('Marina blue'), pulso: () => t('Pulse green'), leal: () => t('Loyal orange'), dulce: () => t('Sweet yellow') },
6476
7514
  bubbles: { comfortable: () => t('Comfortable'), large: () => t('Large'), compact: () => t('Compact'), fitted: () => t('Fitted') },
6477
7515
  text: { comfortable: () => t('Comfortable'), larger: () => t('Larger'), smaller: () => t('Smaller'), visual: () => t('Visual') },
6478
- wallpaper: { default: () => t('Default'), jarrito: () => 'Jarrito', canela: () => 'Canela', paradise: () => t('Paradise'), ventura: () => 'Ventura', magna: () => 'Magna', biznaga: () => 'Biznaga', oceano: () => 'Oceano', quila: () => 'Quila', xico: () => 'Xico', tropical: () => 'Tropical', tradicion: () => 'Tradicion', urbania: () => 'Urbania' },
7516
+ wallpaper: { default: () => t('Default'), jarrito: () => 'Jarrito', canela: () => 'Canela', paradise: () => t('Paradise'), ventura: () => 'Ventura', magna: () => 'Magna', biznaga: () => 'Biznaga', oceano: () => 'Oceano', quila: () => 'Quila', xico: () => 'Xico', tropical: () => 'Tropical', tradicion: () => 'Tradicion', urbania: () => 'Urbania', custom: () => t('Your picture') },
6479
7517
  wpStyle: { veil: () => t('Layer'), mesh: () => t('Mesh') },
6480
7518
  };
6481
7519
  // Jarrito took MADRE's place among the wallpapers and Canela took Autumn's: a choice saved under
@@ -6485,6 +7523,8 @@ function readLook(name) {
6485
7523
  let value = null;
6486
7524
  try { value = localStorage.getItem(LOOKS[name].key); } catch { /* no storage */ }
6487
7525
  if (name === 'wallpaper' && RENAMED_LOOKS[value]) value = RENAMED_LOOKS[value];
7526
+ // Chosen, and then taken away (here or on another page): the room as it always was.
7527
+ if (name === 'wallpaper' && value === CUSTOM_WALL && !customWall()) value = null;
6488
7528
  return LOOKS[name].values.includes(value) ? value : LOOKS[name].values[0];
6489
7529
  }
6490
7530
  function applyLook(name, value) {
@@ -6492,6 +7532,8 @@ function applyLook(name, value) {
6492
7532
  if (!rootElement) return;
6493
7533
  if (value === LOOKS[name].values[0]) delete rootElement.dataset[name]; else rootElement.dataset[name] = value;
6494
7534
  }
7535
+ // The person's picture as this browser last knew it, before the first look is painted.
7536
+ paintCustomWall(rememberedWall());
6495
7537
  for (const name of Object.keys(LOOKS)) applyLook(name, readLook(name));
6496
7538
  for (const name of Object.keys(TUNES)) applyTune(name, readTune(name));
6497
7539
  // The half on the right and the conversations in windows read the same choices, the moment they
@@ -6509,7 +7551,7 @@ window.addEventListener?.('storage', (event) => {
6509
7551
  // phone's bridge.
6510
7552
  // The wallpaper is each page's own (2026-10-13): this project keeps the one chosen here, Home the
6511
7553
  // one chosen in Home (src/looks.mjs, `rooms`). The rest of the looks are shared.
6512
- const SHARED_LOOKS = ['pulse.theme', 'pulse.accent', 'pulse.wallpaper.style', 'pulse.wallpaper.veil', 'pulse.wallpaper.blur', 'pulse.bubbles', 'pulse.text'];
7554
+ const SHARED_LOOKS = ['pulse.theme', 'pulse.accent', 'pulse.wallpaper.style', 'pulse.wallpaper.veil', 'pulse.wallpaper.blur', 'pulse.bubbles', 'pulse.text', 'pulse.neural'];
6513
7555
  let booted = false;
6514
7556
  let looksPush = 0;
6515
7557
  // Saved for this project alone, on the server, so it outlives the port the page is served on.
@@ -6529,8 +7571,12 @@ async function pullLooks() {
6529
7571
  let answer = null;
6530
7572
  try { answer = await fetch('/api/looks', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)); } catch { return; }
6531
7573
  if (!answer?.looks) return;
7574
+ // The person's own picture, kept once for every page (public/wallpaper.js): painted as it is now.
7575
+ const before = customWall()?.v ?? null;
7576
+ paintCustomWall(answer.custom ?? null);
6532
7577
  // Before a project had its own, it wore the shared one: that is where it starts.
6533
- const wall = answer.own?.wallpaper ?? answer.looks['pulse.wallpaper'] ?? null;
7578
+ let wall = answer.own?.wallpaper ?? answer.looks['pulse.wallpaper'] ?? null;
7579
+ if (wall === CUSTOM_WALL && !answer.custom) wall = null;
6534
7580
  try {
6535
7581
  // Nothing kept yet: the first page that has a choice of its own hands it to the others.
6536
7582
  if (!answer.saved) { if (SHARED_LOOKS.some((key) => localStorage.getItem(key) !== null)) pushLooks(); return; }
@@ -6541,7 +7587,7 @@ async function pullLooks() {
6541
7587
  if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value);
6542
7588
  changed = true;
6543
7589
  }
6544
- if (changed) reapplyLooks();
7590
+ if (changed || before !== (answer.custom?.v ?? null)) reapplyLooks();
6545
7591
  } catch { /* no storage: this page keeps the look it has */ }
6546
7592
  }
6547
7593
  function reapplyLooks() {
@@ -6551,6 +7597,8 @@ function reapplyLooks() {
6551
7597
  applyTheme(themeMode);
6552
7598
  for (const name of Object.keys(LOOKS)) applyLook(name, readLook(name));
6553
7599
  for (const name of Object.keys(TUNES)) applyTune(name, readTune(name));
7600
+ // The maps' neural mode, chosen on another page (public/neural.js).
7601
+ if (booted) syncNostromoNeural();
6554
7602
  if (booted) { renderAgents(); renderPicker(); syncSettingsCards(); }
6555
7603
  }
6556
7604
  void pullLooks();
@@ -6687,6 +7735,16 @@ function drawCustom() {
6687
7735
  const papers = el('div', 'custom-papers');
6688
7736
  papers.setAttribute('role', 'radiogroup');
6689
7737
  for (const value of LOOKS.wallpaper.values) {
7738
+ // The person's own picture: chosen like any other once it is here; until then, the way to add it.
7739
+ if (value === CUSTOM_WALL) {
7740
+ const mine = customWall();
7741
+ const paper = mine ? radio(el('button', 'paper'), draft.wallpaper === CUSTOM_WALL, () => setDraft('wallpaper', CUSTOM_WALL)) : el('button', 'paper');
7742
+ paper.type = 'button';
7743
+ customTile(paper, mine);
7744
+ if (!mine) paper.addEventListener('click', () => void addPicture({ busy: paper, chosen: () => setDraft('wallpaper', CUSTOM_WALL), failed: (message) => toast(message, { tone: 'note' }) }));
7745
+ papers.append(paper);
7746
+ continue;
7747
+ }
6690
7748
  const paper = radio(el('button', 'paper'), value === draft.wallpaper, () => setDraft('wallpaper', value));
6691
7749
  paper.dataset.wp = value;
6692
7750
  paper.append(el('span', 'paper-swatch'), el('span', 'paper-name', LOOK_WORDS.wallpaper[value]()));
@@ -6721,6 +7779,15 @@ function drawCustom() {
6721
7779
  return row;
6722
7780
  };
6723
7781
  paperTune.append(dial('veil', t('Intensity'), (v) => `${v}%`), dial('blur', t('Blur'), (v) => `${v}px`));
7782
+ // Your picture: where the screen falls on it, and changing it or taking it away.
7783
+ if (draft.wallpaper === CUSTOM_WALL && customWall()) {
7784
+ paperTune.append(customTools(customWall(), {
7785
+ button: (words, click) => sxButton(words, click),
7786
+ changed: (kept, { reframed }) => { if (!reframed) drawCustom(); },
7787
+ removed: () => setDraft('wallpaper', LOOKS.wallpaper.values[0]),
7788
+ failed: (message) => toast(message, { tone: 'note' }),
7789
+ }));
7790
+ }
6724
7791
  }
6725
7792
  const themes = el('div', 'custom-seg three');
6726
7793
  themes.setAttribute('role', 'radiogroup');
@@ -6879,7 +7946,7 @@ customWindow.node?.addEventListener('close', () => { customButton?.setAttribute(
6879
7946
  // from the same code and the same answers (state.settingsTargets), wearing the room's ink.
6880
7947
  const SETTINGS_SECTIONS = [
6881
7948
  ['You', 'How it looks, how it works and your files', ['look', 'room', 'files']],
6882
- ['The agents', 'Who works, what they remember and which outside tools they use', ['agents', 'memory', 'mcp']],
7949
+ ['The agents', 'Who works, what they remember and which outside tools they use', ['agents', 'memory', 'local', 'mcp']],
6883
7950
  ['System', 'What agents may do, privacy and updates', ['permissions', 'privacy', 'release']],
6884
7951
  ];
6885
7952
  const SETTINGS_CATS = {
@@ -6887,13 +7954,16 @@ const SETTINGS_CATS = {
6887
7954
  room: { name: 'Work and limits', color: '#d97757', icon: '<rect x="3.5" y="3.5" width="5.5" height="5.5" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="11" y="3.5" width="5.5" height="5.5" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="3.5" y="11" width="5.5" height="5.5" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="11" y="11" width="5.5" height="5.5" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.6"/>' },
6888
7955
  agents: { name: 'Agents', color: '#10a37f', icon: '<circle cx="7" cy="8" r="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="13.6" cy="8" r="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M2.8 16c.6-2.3 2.2-3.6 4.2-3.6s3.6 1.3 4.2 3.6M9.6 13c.9-.4 2.4-.6 4-.6 2 0 3.6 1.3 4.2 3.6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
6889
7956
  memory: { name: 'What it remembers', color: '#3fb6d9', icon: '<rect x="5" y="5" width="10" height="10" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="8.1" y="8.1" width="3.8" height="3.8" rx=".6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M8 2.5V5M12 2.5V5M8 15v2.5M12 15v2.5M2.5 8H5M2.5 12H5M15 8h2.5M15 12h2.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
7957
+ // Local AI, also here and not only in Modules (2026-10-16): the same card, and what this
7958
+ // computer can run and train with it.
7959
+ local: { name: 'Local AI', color: '#7f77dd', icon: '<rect x="5" y="5" width="10" height="10" rx="2.4" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M8 2.6v2.4M12 2.6v2.4M8 15v2.4M12 15v2.4M2.6 8h2.4M2.6 12h2.4M15 8h2.4M15 12h2.4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="10" cy="10" r="1.8" fill="currentColor"/>' },
6890
7960
  mcp: { name: 'Outside tools (MCP)', color: '#5b8def', icon: '<path d="M7.2 3.2v3.3M12.8 3.2v3.3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M5.2 6.5h9.6v2.4a4.8 4.8 0 0 1-9.6 0z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M10 13.7v3.1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
6891
7961
  files: { name: 'Files and local sites', color: '#c8955a', icon: '<path d="M3.5 6.2c0-.9.7-1.6 1.6-1.6h3l1.6 1.8h5.2c.9 0 1.6.7 1.6 1.6v6.4c0 .9-.7 1.6-1.6 1.6H5.1c-.9 0-1.6-.7-1.6-1.6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>' },
6892
7962
  permissions: { name: 'Permissions', color: '#e5484d', icon: '<path d="M10 2.8 4.2 5v4.6c0 3.6 2.4 6.4 5.8 7.6 3.4-1.2 5.8-4 5.8-7.6V5z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><circle cx="10" cy="9" r="1.7" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 10.7v2.6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
6893
7963
  privacy: { name: 'Privacy', color: '#d4a72c', icon: '<rect x="4.5" y="8.5" width="11" height="8" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M7 8.5V6.6a3 3 0 0 1 6 0v1.9" fill="none" stroke="currentColor" stroke-width="1.6"/>' },
6894
7964
  release: { name: 'Updates and errors', color: '#4caf3a', icon: '<path d="M15.6 10a5.6 5.6 0 1 1-1.64-3.96" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M15.7 3.6v3.2h-3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' },
6895
7965
  };
6896
- const SETTINGS_ORDER = ['look', 'room', 'files', 'agents', 'memory', 'mcp', 'permissions', 'privacy', 'release'];
7966
+ const SETTINGS_ORDER = ['look', 'room', 'files', 'agents', 'memory', 'local', 'mcp', 'permissions', 'privacy', 'release'];
6897
7967
  // The words the search knows, each pointing to the category that holds it and to the words on the
6898
7968
  // screen that carry it there, so a result opens the category and lands on the setting.
6899
7969
  const SETTINGS_INDEX = [
@@ -6933,6 +8003,12 @@ function settingsStatus(id) {
6933
8003
  return { line: `${finder} · ${ripley ? (ripley.status?.installed ? t('preview on') : t('preview off')) : t('Reading…')}` };
6934
8004
  }
6935
8005
  if (id === 'mcp') return mcpStatus();
8006
+ if (id === 'local') {
8007
+ const item = modules.items.find((one) => one.id === 'ollama');
8008
+ if (!item) return { line: t('Reading…') };
8009
+ const daily = item.recommended?.hardware?.recommended?.run;
8010
+ return { line: [item.status?.detail, daily ? t('your computer runs {model}', { model: daily.label }) : null].filter(Boolean).join(' · ') };
8011
+ }
6936
8012
  if (!data) return { line: t('Reading…') };
6937
8013
  if (id === 'agents') {
6938
8014
  const here = data.agents.filter((agent) => agent.detected);
@@ -7290,6 +8366,7 @@ function settingsDetail(id) {
7290
8366
  if (id === 'release') { part('release'); part('sentinel'); }
7291
8367
  if (id === 'files') part('files');
7292
8368
  if (id === 'mcp') part('mcp');
8369
+ if (id === 'local') part('local');
7293
8370
  return detail;
7294
8371
  }
7295
8372
 
@@ -7297,6 +8374,7 @@ function settingsDetail(id) {
7297
8374
  function fillSettingsCategory(id) {
7298
8375
  if (id === 'files') { drawFilesTarget(); void refreshModules(); }
7299
8376
  if (id === 'mcp') { drawMcpTarget(); void loadMcp(); }
8377
+ if (id === 'local') { drawLocalTarget(); void refreshModules(); }
7300
8378
  if (['agents', 'room', 'memory', 'privacy', 'permissions'].includes(id)) { if (settingsUI.data) renderSettings(); void loadSettings(); }
7301
8379
  if (id === 'room' || id === 'release') { if (turnsUI.turns || sentinelUI.settings) renderMotherSentinel(); void loadTurns(); void loadSentinel(); }
7302
8380
  if (id === 'release') renderUpdate();
@@ -7638,6 +8716,8 @@ for (const button of document.querySelectorAll?.('button') ?? []) {
7638
8716
  if (!words.trim()) button.classList.add('x-only');
7639
8717
  button.append(xIcon());
7640
8718
  }
8719
+ // And its ↗ and ▾, drawn (tailMark).
8720
+ for (const node of document.querySelectorAll?.('a, button') ?? []) tailMark(node);
7641
8721
 
7642
8722
  /* ---------- project files panel ---------- */
7643
8723
 
@@ -7902,7 +8982,8 @@ function treeNode(path, entry) {
7902
8982
  button.title = path;
7903
8983
  button.addEventListener('contextmenu', (event) => finderMenu(event, path, entry));
7904
8984
  if (entry.kind === 'dir') {
7905
- const caret = el('span', 'caret', '▸');
8985
+ const caret = el('span', 'caret');
8986
+ caret.innerHTML = ICON_CHEVRON; // trusted: our own SVG string · turned by styles.css
7906
8987
  button.append(caret, el('span', 'name', entry.name));
7907
8988
  // Something under this folder changed: the dot of whoever touched it last, the way down.
7908
8989
  const under = touchedUnder(path);
@@ -7916,7 +8997,6 @@ function treeNode(path, entry) {
7916
8997
  let opened = false;
7917
8998
  const show = (open) => {
7918
8999
  opened = open;
7919
- caret.textContent = opened ? '▾' : '▸';
7920
9000
  children.hidden = !opened;
7921
9001
  item.setAttribute('aria-expanded', String(opened));
7922
9002
  if (opened) tree.openDirs.add(path); else tree.openDirs.delete(path);
@@ -7952,8 +9032,6 @@ function treeNode(path, entry) {
7952
9032
  // The two buttons every surface carries in its header, beside its ×: split, and window. Declared
7953
9033
  // before the surfaces are built, which happens while this file is still being read.
7954
9034
  const FLOAT_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3.25 12.25v-6.5a2.5 2.5 0 0 1 2.5-2.5h6.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><rect class="ic-f" x="6.25" y="6.25" width="10.5" height="10.5" rx="2.3" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>';
7955
- const SPLIT_LEFT_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2.75" y="3.75" width="14.5" height="12.5" rx="3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><rect class="ic-h" x="5" y="6" width="3.6" height="8" rx="1.3" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="M10.6 6.4v7.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
7956
- const SPLIT_RIGHT_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2.75" y="3.75" width="14.5" height="12.5" rx="3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><rect class="ic-h" x="11.4" y="6" width="3.6" height="8" rx="1.3" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="M9.4 6.4v7.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
7957
9035
 
7958
9036
  /* ---------- surfaces: everything that can stand beside the conversation ---------- */
7959
9037
 
@@ -8032,7 +9110,8 @@ function sideWidthFor(surface) {
8032
9110
  const vw = window.innerWidth;
8033
9111
  if (surface.key === 'chats') return Math.max(SIDE_MIN, Math.min(420, vw * .24));
8034
9112
  if (surface.key === 'tree') return Math.max(SIDE_MIN, Math.min(460, vw * .26));
8035
- return Math.max(420, Math.min(880, vw * .42));
9113
+ // The viewer docked is the code workspace: the files, the code and what it makes need room.
9114
+ return Math.max(420, Math.min(980, vw * .5));
8036
9115
  }
8037
9116
  const splitOn = (side) => surfaces.find((one) => one.side === side && isSplit(one) && (one.kind === 'dialog' ? one.node()?.open : !one.node()?.hidden) && !one.node()?.classList.contains('leaving'));
8038
9117
 
@@ -8801,7 +9880,12 @@ function windowBoxFor(surface) {
8801
9880
  const vw = window.innerWidth;
8802
9881
  const width = Number.isFinite(set) ? set : surface.key === 'chats' ? Math.max(320, Math.min(460, vw / 2 - 468)) : Math.max(300, Math.min(460, vw / 2 - 476));
8803
9882
  const left = surface.side === 'left' ? room.left + RAIL : room.right - RAIL - width;
8804
- return { x: left + (surface.side === 'left' ? 36 : -36), y: room.top + 42, w: width, h: Math.min(room.height - 28, 620) };
9883
+ // A room not laid out yet (a page still loading its conversation) is measured short and low; the
9884
+ // window then opens under the bar, as tall as the screen allows, instead of at the room's foot.
9885
+ const settled = room.height >= 360 && room.top < window.innerHeight / 3;
9886
+ const y = settled ? room.top + 42 : 96;
9887
+ const h = settled ? Math.min(room.height - 28, 620) : Math.max(WINDOW_MIN_H, Math.min(620, window.innerHeight - y - 120));
9888
+ return { x: left + (surface.side === 'left' ? 36 : -36), y, w: width, h };
8805
9889
  }
8806
9890
 
8807
9891
  // First, last, invert, play: the surface is moved to where it now belongs at once, then drawn from
@@ -8908,7 +9992,9 @@ function setSurfaceMode(surface, next, { animate = true } = {}) {
8908
9992
  if (next !== 'window') dropWindow(node);
8909
9993
  for (const property of ['left', 'top', 'width', 'height']) node.style.removeProperty(property);
8910
9994
  if (surface.kind === 'dialog') reseatDialog(node, next === 'base');
8911
- if (next === 'window') { placeWindow(surface, windowBoxFor(surface)); bringWindowForward(node); }
9995
+ // Its box is kept the moment it becomes a window, not only when it is first carried: the mode
9996
+ // was remembered and the place was not, so a reload brought it back with nowhere to be.
9997
+ if (next === 'window') { placeWindow(surface, windowBoxFor(surface)); bringWindowForward(node); rememberSurfaces(); }
8912
9998
  layoutPanels();
8913
9999
  };
8914
10000
  if (animate && panelShown(surface)) flipPanel(node, change); else change();
@@ -9000,7 +10086,14 @@ if (shellBox && !SPLIT_CHAT) {
9000
10086
  node.classList.toggle('window', isWindow(surface));
9001
10087
  node.classList.toggle('split', isSplit(surface));
9002
10088
  }
9003
- if (isWindow(surface) && windowBoxes[surface.key]?.w) placeWindow(surface, windowBoxes[surface.key]);
10089
+ // A window remembered without its box (the mode was kept, the place was not) comes back where a
10090
+ // new window opens. It used to come back at the corner, with nothing to carry it by: «New
10091
+ // conversation» reloads the page, and the conversations window was stuck there (2026-10-18).
10092
+ if (isWindow(surface)) {
10093
+ if (windowBoxes[surface.key]?.w) placeWindow(surface, windowBoxes[surface.key]);
10094
+ // Measured once the room has its shape: on the first frames the thread is not laid out yet.
10095
+ else requestAnimationFrame(() => requestAnimationFrame(() => { placeWindow(surface, windowBoxFor(surface)); rememberSurfaces(); }));
10096
+ }
9004
10097
  carrySurface(surface);
9005
10098
  surfaceButtons(surface);
9006
10099
  sideGrip(surface);
@@ -9759,7 +10852,11 @@ function carryWindowBy(head, node, { active = () => true, box, place, done }) {
9759
10852
  event.preventDefault();
9760
10853
  const startX = event.clientX;
9761
10854
  const startY = event.clientY;
9762
- const from = { ...box() };
10855
+ // Where it is on screen, whenever what was kept of it is not a whole box: a window is always
10856
+ // carried from where the person sees it.
10857
+ const kept = box() ?? {};
10858
+ const seen = node.getBoundingClientRect();
10859
+ const from = [kept.x, kept.y, kept.w, kept.h].every(Number.isFinite) ? { ...kept } : { ...kept, x: seen.left, y: seen.top, w: seen.width, h: seen.height };
9763
10860
  let carrying = false;
9764
10861
  let frame = 0;
9765
10862
  let to = null;
@@ -9955,10 +11052,10 @@ function measureDock() {
9955
11052
  const field = els.composer.querySelector?.('.field')?.getBoundingClientRect?.();
9956
11053
  if (field && !pageScrolls()) shell.style.setProperty('--type-gap', `${Math.max(0, Math.ceil(shell.getBoundingClientRect().bottom - field.top))}px`);
9957
11054
  if (height === measureDock.last) return;
9958
- const stay = atBottom();
9959
11055
  measureDock.last = height;
9960
11056
  shell.style.setProperty('--dock-h', `${height}px`);
9961
- if (stay) scrollToEnd();
11057
+ placeToast();
11058
+ keepPinned();
9962
11059
  }
9963
11060
  if (document.querySelector('.shell') && els.composer?.getBoundingClientRect) {
9964
11061
  measureDock();
@@ -10201,7 +11298,7 @@ if (SPLIT_CHAT && !Array.isArray(initial.events)) {
10201
11298
  }
10202
11299
  // The corner of the room: which MADRE this is, and whose it is, readable on any wallpaper, and a
10203
11300
  // link to its author (public/attribution.js: NOTICE's attribution, the same on Home).
10204
- if (initial.version) drawAttribution(document.querySelector('#colophon'), initial.version);
11301
+ if (initial.version) { drawAttribution(document.querySelector('#colophon'), initial.version); void drawColophonKeys(document.querySelector('#colophon')); }
10205
11302
  void adoptLanguage(initial.language, initial.languageChosen);
10206
11303
  // Whether the copilot is on for this project, so the robot and the mode say so from the start.
10207
11304
  void loadCopilot().then(() => showNewHints());
@@ -10323,6 +11420,7 @@ function boot() {
10323
11420
  syncAshUI(Boolean(initial.ash?.enabled));
10324
11421
  void renderSettled();
10325
11422
  void renderOpenQuestions();
11423
+ void refreshDeliveries();
10326
11424
  updateStopAll();
10327
11425
  if (typeof setInterval === 'function') { const ticker = setInterval(refreshBudgetWindow, 60000); ticker.unref?.(); }
10328
11426
  renderAgents();
@@ -10340,6 +11438,7 @@ function boot() {
10340
11438
  try { performance.measure?.('madre:history', 'madre:history-start', 'madre:history-end'); } catch { /* no timeline */ }
10341
11439
  setLoader(arrival, 0.9);
10342
11440
  scrollToEnd();
11441
+ measureRows();
10343
11442
  matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { applyTheme(themeMode); renderAgents(); renderPicker(); });
10344
11443
 
10345
11444
  // The live light sits on the conversation it is live for, at the end of the path in the bar.
@@ -10449,7 +11548,7 @@ function forgetConversation() {
10449
11548
  state.userMessages.clear();
10450
11549
  state.runsReturned.clear();
10451
11550
  state.ratings.clear();
10452
- state.ratingNodes.clear();
11551
+ state.ratingNodes.clear(); state.deliveries.clear();
10453
11552
  state.running.clear();
10454
11553
  state.plansRunning.clear();
10455
11554
  state.agentStats.clear();
@@ -10490,6 +11589,7 @@ async function paintConversation(payload) {
10490
11589
  renderOnboarding();
10491
11590
  void renderOpenQuestions();
10492
11591
  scrollToEnd();
11592
+ measureRows();
10493
11593
  connectStream();
10494
11594
  }
10495
11595
 
@@ -10508,7 +11608,12 @@ function autosize() {
10508
11608
  els.highlight.style.height = els.input.style.height;
10509
11609
  renderHighlight();
10510
11610
  }
10511
- function syncHighlightScroll() { els.highlight.scrollTop = els.input.scrollTop; }
11611
+ function syncHighlightScroll() {
11612
+ els.highlight.scrollTop = els.input.scrollTop;
11613
+ // Scrolled inside, the first line it shows fades in under the box's edge instead of being cut
11614
+ // in half against it (styles.css, .editor.scrolled).
11615
+ els.input.parentElement?.classList?.toggle('scrolled', els.input.scrollTop > 1);
11616
+ }
10512
11617
  els.input.addEventListener('input', autosize);
10513
11618
  els.input.addEventListener('scroll', syncHighlightScroll);
10514
11619
  autosize();
@@ -11243,12 +12348,15 @@ function updateCrewLabel() {
11243
12348
  // The copilot reads as a link of the chain: HUMAN › COPILOT › TOKEN SAVER › @CODEX ›.
11244
12349
  const piloting = copilotSends(els.target?.value);
11245
12350
  const armed = state.mode >= 3 && !piloting;
11246
- els.crewLabel.textContent = piloting ? `${t('HUMAN ›')} ${t('COPILOT ›')}${order ? ` ${t('TOKEN SAVER ›')}` : ''}` : state.intruder && state.mode < 3 ? t('INTRUDER ›') : armed ? `${t('DIAGNOSTICS')} · ${MODES[state.mode].label} @${(state.modeArmedFor ?? els.target.value ?? '').toUpperCase()} ›`
12351
+ const path = piloting ? `${t('HUMAN ›')} ${t('COPILOT ›')}${order ? ` ${t('TOKEN SAVER ›')}` : ''}` : state.intruder && state.mode < 3 ? t('INTRUDER ›') : armed ? `${t('DIAGNOSTICS')} · ${MODES[state.mode].label} @${(state.modeArmedFor ?? els.target.value ?? '').toUpperCase()} ›`
11247
12352
  : state.mode === 0 ? t('HUMAN · INCOGNITO ›')
11248
12353
  : state.moduleCommand ? (state.create ? t('DIAGNOSTICS · MODULE · CREATE ›') : t('DIAGNOSTICS · MODULE · NEEDS #2 ›'))
11249
12354
  : order ? `${t('HUMAN ›')} ${state.create ? t('TOKEN SAVER · CREATE ›') : t('TOKEN SAVER ›')}`
11250
12355
  : state.create ? t('HUMAN · CREATE ›')
11251
12356
  : state.expendable ? t('EXPENDABLE ›') : t('HUMAN ›');
12357
+ // The address is two pieces so a narrow box cuts the path with «…» and never who it goes to.
12358
+ if (typeof els.crewLabel.replaceChildren === 'function') els.crewLabel.replaceChildren(el('span', 'crew-path', path));
12359
+ else els.crewLabel.textContent = path;
11252
12360
  // Who the message goes to closes the line, in that agent's colour: HUMAN › TOKEN SAVER › @CODEX ›.
11253
12361
  const to = els.target?.value;
11254
12362
  if (to && !armed && (piloting || !state.intruder) && typeof els.crewLabel.append === 'function') {
@@ -11448,6 +12556,14 @@ els.composer.addEventListener('submit', async (event) => {
11448
12556
  // and the effort are the ones chosen for THAT agent; the chip's would be another agent's.
11449
12557
  const mention = outgoing.match(/^@([a-z0-9_-]+)\b/i)?.[1]?.toLowerCase();
11450
12558
  const addressed = mention && state.agents.has(mention) ? mention : target;
12559
+ // An agent that is switched off (no session, or nothing MADRE can run) is not written to: the
12560
+ // message stays in the box and Settings opens on that agent, where it is connected.
12561
+ if (addressed && state.agents.has(addressed) && !canAnswer(state.agents.get(addressed))) {
12562
+ const off = state.agents.get(addressed);
12563
+ toast(off.ready ? t('@{agent} is not signed in. Connect it in Settings › Agents and send again.', { agent: addressed }) : t('@{agent} is not available on this computer. Set it up in Settings › Agents.', { agent: addressed }), { tone: 'note' });
12564
+ openSettingsOn('agents', { agent: addressed });
12565
+ return;
12566
+ }
11451
12567
  // CONTROL and AIRLOCK are armed for one agent. A message that ends up addressed to another one
11452
12568
  // is asked for again, for that one; the keys typed for @gemini never open the project to @claude.
11453
12569
  const piloted = copilotSends(addressed);
@@ -11487,6 +12603,7 @@ els.composer.addEventListener('submit', async (event) => {
11487
12603
  els.input.value = '';
11488
12604
  state.replyTo = null;
11489
12605
  renderReplyQuote();
12606
+ offerModule(outgoing);
11490
12607
  state.pending = [];
11491
12608
  renderPendingAttachments();
11492
12609
  // The try went with the message; the next one goes at the rung chosen before it.
@@ -11649,7 +12766,7 @@ function roomRecords() {
11649
12766
  // word, the ones sharing the most of them come first: «codex no responde timeout» still finds the
11650
12767
  // timeout. An agent's name keeps to that agent and the general ones; little words do not count.
11651
12768
  const LOOSE_SKIP = new Set(['the', 'and', 'not', 'for', 'with', 'que', 'los', 'las', 'del', 'por', 'una', 'uno', 'con', 'sin', 'esta', 'este', 'muy', 'mas', 'pero']);
11652
- const LOOSE_AGENTS = new Set(['codex', 'claude', 'gemini', 'opencode', 'madre', 'ollama']);
12769
+ const LOOSE_AGENTS = new Set(['codex', 'claude', 'gemini', 'opencode', 'cursor', 'madre', 'ollama']);
11653
12770
  function looseConditions(text) {
11654
12771
  const words = settingsFold(text).split(/[^a-z0-9#.@_-]+/).map((word) => word.replace(/^@/, '')).filter((word) => word.length >= 3 && !LOOSE_SKIP.has(word));
11655
12772
  const agent = words.find((word) => LOOSE_AGENTS.has(word)) ?? null;
@@ -12077,6 +13194,7 @@ function renderTerminalCard(event) {
12077
13194
  undo.textContent = t('RESTORED');
12078
13195
  } catch (error) { toast(`${t('Diagnostics › ')}${error.message}`); undo.disabled = false; }
12079
13196
  });
13197
+ node.classList.add('has-undo');
12080
13198
  node.append(undo);
12081
13199
  }
12082
13200
  }
@@ -12092,7 +13210,7 @@ function renderEyecat(event) {
12092
13210
  const node = el('div', 'command-card eyecat');
12093
13211
  node.dataset.eyecat = finding.key;
12094
13212
  const head = el('div', 'head');
12095
- head.append(el('b', null, t('◉ MEMORY REVIEWER')), el('span', null, finding.kind === 'unsupported' ? t('a note its own sources do not support') : t('two memories that cannot both be true')));
13213
+ head.append(leadWords('b', null, t('◉ MEMORY REVIEWER')), el('span', null, finding.kind === 'unsupported' ? t('a note its own sources do not support') : t('two memories that cannot both be true')));
12096
13214
  if (finding.confidence !== null && finding.confidence !== undefined) head.append(el('span', 'args', t('{pct}% · judged by @{judge}', { pct: Math.round(finding.confidence * 100), judge: finding.judge })));
12097
13215
  else head.append(el('span', 'args', t('judged by @{judge}', { judge: finding.judge })));
12098
13216
  node.append(head);
@@ -12162,6 +13280,9 @@ function appendModuleOutput(event) {
12162
13280
  // One way to turn a module on or off, whoever asks: its card, its tile on the board, AJUSTES. The
12163
13281
  // server's switch flips what is there; what each module changes in the page follows it here.
12164
13282
  async function switchModule(item) {
13283
+ // Turning the test browser on may download its browser first (src/modules/playwright.mjs): said
13284
+ // before the wait, so a switch that takes a minute is not mistaken for one that hung.
13285
+ if (item.id === 'playwright' && !item.status?.installed) toast(t('Getting the test browser ready: MADRE downloads what it is missing. It can take a few minutes.'), { tone: 'note' });
12165
13286
  const response = await fetch(`/api/extensions/${item.id}/install`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: true }) });
12166
13287
  const result = await response.json().catch(() => ({}));
12167
13288
  if (!response.ok) throw new Error(result.error ?? t('{name} could not be turned on or off.', { name: item.name }));
@@ -12471,8 +13592,19 @@ async function refreshModules() {
12471
13592
  else renderModuleWindows();
12472
13593
  barModulesChanged();
12473
13594
  drawFilesTarget();
13595
+ drawLocalTarget();
12474
13596
  syncSettingsCards();
12475
13597
  }
13598
+ // AJUSTES › Local AI: the module's own blocks (Modules › Local AI), drawn here too.
13599
+ function drawLocalTarget() {
13600
+ const target = state.settingsTargets.local;
13601
+ if (!target?.isConnected) return;
13602
+ const item = modules.items.find((one) => one.id === 'ollama');
13603
+ if (!item) return;
13604
+ target.replaceChildren();
13605
+ ollamaBlock(target, item);
13606
+ hardwareBlock(target, item.recommended?.hardware);
13607
+ }
12476
13608
 
12477
13609
  // What the version on a card means. A module that ships with MADRE has its own version, written
12478
13610
  // in its file and starting at 1.0.0; it cannot update by itself, because it arrives in a release.
@@ -12521,7 +13653,11 @@ function cardFold(card, label, { key, count = null, open = false } = {}) {
12521
13653
  head.append(el('b', null, label));
12522
13654
  if (count !== null) head.append(el('span', 'count', String(count)));
12523
13655
  const caret = el('span', 'caret');
12524
- const mark = () => { caret.textContent = box.open ? t('▾ COLLAPSE') : t('▸ EXPAND'); };
13656
+ const turn = el('span', 'caret-mark');
13657
+ turn.innerHTML = ICON_CHEVRON; // trusted: our own SVG string · turned by styles.css
13658
+ const said = el('span', 'caret-words');
13659
+ caret.append(said, turn);
13660
+ const mark = () => { said.textContent = (box.open ? t('▾ COLLAPSE') : t('▸ EXPAND')).replace(/^[▸▾]\s*/u, ''); };
12525
13661
  mark();
12526
13662
  head.append(caret);
12527
13663
  const body = el('div', 'card-fold-body');
@@ -12603,7 +13739,7 @@ function cardShell(item, { on, state: stateText }) {
12603
13739
  head.append(top);
12604
13740
  // What it does comes right under its name, in the reading face: it is what decides whether you
12605
13741
  // want it, and it used to sit three floors down under the version and the vendor.
12606
- if (item.summary) head.append(el('p', 'summary', item.summary));
13742
+ if (item.summary) head.append(el('p', 'summary', t(item.summary)));
12607
13743
  // Who makes it, which version and whether it is current: one quiet line, not three.
12608
13744
  const line = el('div', 'version');
12609
13745
  if (item.vendor) line.append(el('span', 'vendor', item.vendor));
@@ -12650,7 +13786,7 @@ function cardShell(item, { on, state: stateText }) {
12650
13786
  line.append(get);
12651
13787
  }
12652
13788
  head.append(line);
12653
- if (item.status?.detail) head.append(el('div', 'detail', item.status.detail));
13789
+ if (item.status?.detail) head.append(el('div', 'detail', t(item.status.detail)));
12654
13790
  if (item.external) head.append(el('div', 'detail', `${item.origin === 'project' ? t('THIS PROJECT') : t('EVERY PROJECT')} · ${item.file}`));
12655
13791
  card.append(head);
12656
13792
 
@@ -12658,8 +13794,8 @@ function cardShell(item, { on, state: stateText }) {
12658
13794
 
12659
13795
  // FLOOR 3 · what it touches, folded, with the count and nothing else.
12660
13796
  const bullets = [
12661
- ...(item.creates ?? []).map((text) => [t('WRITES'), text]),
12662
- ...(item.requires ?? []).map((text) => [t('NEEDS'), text]),
13797
+ ...(item.creates ?? []).map((text) => [t('WRITES'), t(text)]),
13798
+ ...(item.requires ?? []).map((text) => [t('NEEDS'), t(text)]),
12663
13799
  ];
12664
13800
  if (bullets.length) {
12665
13801
  const body = cardFold(card, t('WHAT IT TOUCHES'), { key: `mod.${item.id}.touches`, count: bullets.length });
@@ -13710,13 +14846,29 @@ function moduleCard(item) {
13710
14846
  const warn = el('div', 'confirm');
13711
14847
  warn.append(el('span', 'warn', t('CANNOT INSTALL HERE YET')));
13712
14848
  for (const problem of item.preflight.problems) warn.append(el('p', null, problem));
14849
+ // What MADRE can put right itself (Continuity on a Mac with no git): one key, never a terminal.
14850
+ if (item.preflight.fix) {
14851
+ const fix = el('button', 'primary', item.preflight.fix.label ?? t('Set it up'));
14852
+ fix.type = 'button';
14853
+ fix.addEventListener('click', async () => {
14854
+ fix.disabled = true;
14855
+ const response = await reach(`/api/extensions/${item.id}/fix`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
14856
+ const result = await response.json().catch(() => ({}));
14857
+ toast(response.ok ? t('Apple\'s installer opened. When it finishes, come back here and install.') : (result.error ?? t('It could not be started.')));
14858
+ fix.disabled = false;
14859
+ });
14860
+ warn.append(fix);
14861
+ }
13713
14862
  panel.append(warn);
13714
14863
  }
13715
14864
  if (modules.confirming === item.id && !blocked) {
13716
14865
  const confirm = el('div', 'confirm');
13717
- confirm.append(el('span', 'warn', t('THIS WRITES INTO THE PROJECT. MADRE WILL RUN, IN THE PROJECT FOLDER:')));
13718
- confirm.append(commandBlock([item.install.display]));
13719
- confirm.append(el('span', 'note', item.install.platforms?.length
14866
+ // Written as it is said, names and all: the card's lowercase used to read «madre» and «(ide)».
14867
+ confirm.append(el('span', 'warn as-written', t('This writes into the project. MADRE will run, in the project folder:')));
14868
+ const first = item.install.prepare ?? [];
14869
+ confirm.append(commandBlock([...first.map((step) => step.display), item.install.display]));
14870
+ for (const step of first) if (step.note) confirm.append(el('span', 'note as-written', step.note));
14871
+ confirm.append(el('span', 'note as-written', item.install.platforms?.length
13720
14872
  ? t('IDE adapters for the agents detected here: {agents}.', { agents: item.install.platforms.join(', ') })
13721
14873
  : t('No detected agent has an adapter for this module; it installs without IDE adapters.')));
13722
14874
  const row = el('div', 'actions');
@@ -13766,22 +14918,6 @@ function moduleCard(item) {
13766
14918
  // opens the module's card in a sheet, the way a category of AJUSTES opens; the ones with a reading
13767
14919
  // can also float as windows of their own. MADRE decides which, for its own modules: nothing in the
13768
14920
  // SDK asks for it.
13769
- const MODULE_FACES = {
13770
- ash: { line: 'Compact replies from every agent', wide: true, window: true, icon: '<path class="ic-f" d="M4 5.5h12M4 9.5h9M4 13.5h5.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M13.2 13.5l2.3 2.3 2.3-2.3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.5 9.8v6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
13771
- intents: { line: 'How hard each agent should think', wide: true, window: true, icon: '<path d="M3.5 14.5a6.5 6.5 0 0 1 13 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path class="ic-f" d="M3.5 14.5a6.5 6.5 0 0 1 4.2-6.1l2.3 6.1z" fill="currentColor" fill-opacity=".22" stroke="none"/><path d="M10 14.5l3.2-4.6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="10" cy="14.5" r="1.3" fill="currentColor"/>' },
13772
- ollama: { line: 'Memory by meaning, on this machine', wide: true, window: true, icon: '<rect x="3.5" y="4" width="13" height="9.5" rx="2.4" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M7.5 16.5h5M10 13.5v3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path class="ic-f" d="M10 6.4c-1.6 0-2.6 1-2.6 2.3 0 .9.5 1.5 1.1 1.8v.8h3v-.8c.6-.3 1.1-.9 1.1-1.8 0-1.3-1-2.3-2.6-2.3z" fill="currentColor" fill-opacity=".22" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>' },
13773
- 'image-studio': { line: 'Images for the agents, with your key', icon: '<rect x="3.25" y="4.25" width="13.5" height="11.5" rx="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><path class="ic-f" d="M3.8 14.2l3.7-3.6 2.6 2.4 1.9-1.6 4 3.3H5.9z" fill="currentColor" fill-opacity=".22" stroke="none"/><path d="M3.8 14.2l3.7-3.6 2.6 2.4 1.9-1.6 4 3.3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/><path d="M13.6 6.1v2.4M12.4 7.3h2.4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>' },
13774
- playwright: { line: 'A browser the agents can look through', icon: '<rect x="3" y="4" width="14" height="12" rx="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M3 7.6h14" fill="none" stroke="currentColor" stroke-width="1.6"/><path class="ic-f" d="M9 9.6v5.2l1.4-1.4 1 2.1 1-.5-1-2.1h2z" fill="currentColor" fill-opacity=".22" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>' },
13775
- ripley: { line: 'Preview of HTML, SVG and Markdown', icon: '<path d="M6 3.5h5.5l3.5 3.5v9.5H6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M11.5 3.5V7H15" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path class="ic-f" d="M7.6 12s1.1-2.1 2.9-2.1 2.9 2.1 2.9 2.1-1.1 2.1-2.9 2.1-2.9-2.1-2.9-2.1z" fill="currentColor" fill-opacity=".22" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>' },
13776
- 'git-pulse': { line: 'The repository, posted to the project', icon: '<circle cx="5.5" cy="5" r="1.7" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="5.5" cy="15" r="1.7" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M5.5 6.7v6.6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path class="ic-f" d="M8.8 11h2l1.2-3.4 1.6 6 1.2-2.6h1.7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' },
13777
- runs: { line: 'Commands an agent asks you to run', icon: '<rect x="3" y="4" width="14" height="12" rx="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><path class="ic-f" d="M7.6 8.1v4.6l3.8-2.3z" fill="currentColor" fill-opacity=".22" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>' },
13778
- choices: { line: 'Decisions as buttons, not paragraphs', icon: '<rect x="3.5" y="3.8" width="13" height="3.6" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/><rect class="ic-f" x="3.5" y="8.2" width="13" height="3.6" rx="1.8" fill="currentColor" fill-opacity=".22" stroke="currentColor" stroke-width="1.5"/><rect x="3.5" y="12.6" width="13" height="3.6" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/>' },
13779
- ahp: { line: 'Project state and handoffs in .ahp/', icon: '<path d="M4 8.5h9.5M10.5 5.5l3 3-3 3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path class="ic-f" d="M16 11.5H6.5M9.5 8.5l-3 3 3 3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' },
13780
- correo: { line: 'Send one email from your account', icon: '<rect x="3" y="5" width="14" height="10" rx="2.4" fill="none" stroke="currentColor" stroke-width="1.6"/><path class="ic-f" d="M3.6 6.2l6.4 4.6 6.4-4.6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>' },
13781
- github: { line: 'Pull requests, issues and CI, in the project', icon: '<circle cx="5.5" cy="4.8" r="1.8" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="5.5" cy="15.2" r="1.8" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M5.5 6.6v6.8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M14.5 13.4V8.2a2.6 2.6 0 0 0-2.6-2.6H9.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M11 3.6l-2 2 2 2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><circle class="ic-f" cx="14.5" cy="15.2" r="1.8" fill="currentColor" fill-opacity=".22" stroke="currentColor" stroke-width="1.6"/>' },
13782
- };
13783
- const MODULE_ANY = '<path d="M7.5 4.5h2.2a1.6 1.6 0 1 1 3.1 0H15v3.2a1.6 1.6 0 1 1 0 3.1V15h-3.2a1.6 1.6 0 1 0-3.1 0H5.5v-3.6a1.6 1.6 0 1 0 0-3.1V4.5z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>';
13784
- const moduleFace = (item) => MODULE_FACES[item.id] ?? { line: null, icon: MODULE_ANY };
13785
14921
  function moduleIcon(item) {
13786
14922
  const face = moduleFace(item);
13787
14923
  const span = el('span', 'settings-icon');
@@ -13817,9 +14953,9 @@ function moduleTile(item) {
13817
14953
  top.append(sxChip(item.fixed ? t('Always') : item.kind === 'builtin' ? (on ? t('Turned on') : t('Turned off')) : on ? t('Installed') : t('Not installed'), on ? 'on' : ''));
13818
14954
  }
13819
14955
  tile.append(top);
13820
- const name = el('b', 'mods-name', t(item.name));
14956
+ const name = el('b', 'mods-name', face.name ?? t(item.name));
13821
14957
  if (item.external) name.append(el('span', 'mods-dev', t('Your module')));
13822
- tile.append(name, el('span', 'mods-line', face.line ? t(face.line) : item.summary));
14958
+ tile.append(name, el('span', 'mods-line', face.line ? t(face.line) : t(item.summary ?? '')));
13823
14959
  // The ones with a reading of their own say so: their card can float as a small app.
13824
14960
  if (face.window) tile.append(el('span', 'mods-app', t('Opens as a window')));
13825
14961
  const open = () => openModuleSheet(item.id);
@@ -13876,7 +15012,7 @@ function moduleGlance(item) {
13876
15012
  }
13877
15013
  // What it touches and what it adds to the composer, as blocks of the room instead of the card's folds.
13878
15014
  function moduleFacts(host, item) {
13879
- const touches = [...(item.creates ?? []).map((text) => [t('Writes'), text]), ...(item.requires ?? []).map((text) => [t('Needs'), text])];
15015
+ const touches = [...(item.creates ?? []).map((text) => [t('Writes'), t(text)]), ...(item.requires ?? []).map((text) => [t('Needs'), t(text)])];
13880
15016
  if (touches.length) {
13881
15017
  const block = sxBlock(host, t('What it touches'), t('Everything this module writes or needs, said before it runs.'));
13882
15018
  for (const [tag, text] of touches) {
@@ -13901,17 +15037,19 @@ const sentenceCase = (text) => { const words = String(text ?? ''); return words
13901
15037
  function moduleSheet(host, item) {
13902
15038
  host.append(moduleGlance(item));
13903
15039
  if (item.kind !== 'builtin') {
13904
- const install = sxBlock(host, t('What it does'), item.summary, { className: 'mods-legacy' });
15040
+ const install = sxBlock(host, t('What it does'), t(item.summary ?? ''), { className: 'mods-legacy' });
13905
15041
  install.rows.append(moduleCard(item));
13906
15042
  moduleFacts(host, item);
13907
15043
  return;
13908
15044
  }
13909
15045
  const on = moduleOn(item);
13910
- const about = sxBlock(host, t('What it does'), item.summary);
13911
- if (item.fixed) sxRow(about, t('Always on'), item.status?.detail ? sentenceCase(item.status.detail) : null);
15046
+ const about = sxBlock(host, t('What it does'), moduleFace(item).summary ?? t(item.summary ?? ''));
15047
+ if (item.fixed) sxRow(about, t('Always on'), item.status?.detail ? sentenceCase(t(item.status.detail)) : null);
13912
15048
  else {
13913
15049
  const blocked = !on && item.preflight && !item.preflight.ok;
13914
- sxRow(about, t('Active'), blocked ? item.preflight.problems.join(' ') : item.status?.detail ? sentenceCase(item.status.detail) : null, sxSwitch(on, async (next, box) => {
15050
+ // A project's switch is that project's (src/module-scope.mjs); every project's is moved from Home.
15051
+ const ownSwitch = item.scope && item.id !== 'ollama' && item.id !== 'bishop';
15052
+ sxRow(about, ownSwitch ? t('On in this project') : t('Active'), blocked ? item.preflight.problems.join(' ') : item.status?.detail ? sentenceCase(t(item.status.detail)) : null, sxSwitch(on, async (next, box) => {
13915
15053
  box.disabled = true;
13916
15054
  try { await switchModule(item); } catch (error) { box.checked = !next; box.disabled = false; toast(error.message); }
13917
15055
  }, { disabled: blocked, label: item.name }), { off: blocked });
@@ -13985,7 +15123,7 @@ function moduleSheet(host, item) {
13985
15123
  const block = sxBlock(host, t('What your use taught'), `${t('Per agent and per kind of request: the lowest effort that was enough in your own turns, learned for no tokens. Below, a test that learns faster, for a price.')} ${t('When a lower level might be enough, MADRE offers to try it on a single message and watches how it goes; if it falls short, the next one goes back up.')}`, { className: 'mods-reading mods-intents' });
13986
15124
  intentsReading(block.rows);
13987
15125
  }
13988
- if (item.id === 'ollama') ollamaBlock(host, item);
15126
+ if (item.id === 'ollama') { ollamaBlock(host, item); hardwareBlock(host, item.recommended?.hardware); }
13989
15127
 
13990
15128
  moduleFacts(host, item);
13991
15129
  if (item.external) {
@@ -13996,6 +15134,9 @@ function moduleSheet(host, item) {
13996
15134
  }
13997
15135
  }
13998
15136
 
15137
+ // What this computer can do with a local AI (public/hardware.js), drawn with this page's rows.
15138
+ const hardwareBlock = (host, fit) => drawHardware(host, fit, { el, sxBlock, sxRow, sxChip });
15139
+
13999
15140
  // Ollama's local brain as rows: the server, its models, the roles it takes in this room.
14000
15141
  function ollamaBlock(host, item) {
14001
15142
  const info = item.ollama ?? { running: false, models: [], settings: {} };
@@ -14066,7 +15207,7 @@ function moduleDetail(item) {
14066
15207
  back.append(chevron('settings-back-icon'));
14067
15208
  back.addEventListener('click', () => openModuleSheet(null));
14068
15209
  title.append(back);
14069
- title.append(moduleIcon(item), el('b', null, t(item.name)));
15210
+ title.append(moduleIcon(item), el('b', null, moduleFace(item).name ?? t(item.name)));
14070
15211
  if (item.external) title.append(el('span', 'mods-dev', t('Your module')));
14071
15212
  if (moduleFace(item).window) {
14072
15213
  const float = sxButton(t('Open as a window'), () => floatModule(item.id));
@@ -14159,7 +15300,7 @@ function openModuleWindow(id) {
14159
15300
  node.setAttribute('aria-label', t(item.name));
14160
15301
  const head = el('header', 'module-window-head custom-head');
14161
15302
  const title = el('div', 'module-window-title');
14162
- title.append(moduleIcon(item), el('b', null, t(item.name)));
15303
+ title.append(moduleIcon(item), el('b', null, moduleFace(item).name ?? t(item.name)));
14163
15304
  const group = el('span', 'surface-modes');
14164
15305
  // The same keys as every window: arrange, then close.
14165
15306
  const arrange = el('button', 'surface-mode window');
@@ -14331,6 +15472,52 @@ function readyModules() {
14331
15472
  modulesWindow.observer.observe(node);
14332
15473
  }
14333
15474
  }
15475
+ // THE MARKET'S OFFER (2026-10-18, market.js). A message that asks for what a module does, while it
15476
+ // is off, gets one card under it: what would help, the key that turns it on here, the module's page
15477
+ // in the Market, and «No, thanks», which is kept. Read from the person's own words; once a page.
15478
+ const MARKET_DECLINED = 'pulse.market.declined';
15479
+ const marketShown = [];
15480
+ const marketDeclined = () => { try { return JSON.parse(localStorage.getItem(MARKET_DECLINED) ?? '[]') ?? []; } catch { return []; } };
15481
+ function offerModule(text) {
15482
+ if (state.device) return;
15483
+ const offer = marketOffer(text, modules.items ?? [], { declined: marketDeclined(), shown: marketShown });
15484
+ if (!offer) return;
15485
+ marketShown.push(offer.item.id);
15486
+ setTimeout(() => {
15487
+ const { item, says } = offer;
15488
+ const card = el('div', 'system market-offer');
15489
+ const mark = appIcon(item, 40);
15490
+ const words = el('div', 'market-offer-words');
15491
+ words.append(el('b', null, t(item.name)), el('span', null, t(says)));
15492
+ const acts = el('div', 'market-offer-acts');
15493
+ const switchable = moduleSwitches(item) && marketEntry(item.id).scope !== 'project';
15494
+ const on = el('button', 'primary', switchable ? t('Turn on') : t('Install'));
15495
+ on.type = 'button';
15496
+ on.addEventListener('click', async () => {
15497
+ if (!switchable) { openModules(); await refreshModules(); renderModules(); openModuleSheet(item.id); return; }
15498
+ on.disabled = true;
15499
+ try {
15500
+ await switchModule(item);
15501
+ card.replaceChildren(mark, el('div', 'market-offer-words', null));
15502
+ card.lastChild.append(el('b', null, t(item.name)), el('span', null, t('Turned on. Ask again and the agents can use it.')));
15503
+ } catch (error) { on.disabled = false; toast(error.message); }
15504
+ });
15505
+ const more = el('button', null, t('See it in the Market'));
15506
+ more.type = 'button';
15507
+ more.addEventListener('click', () => openRoomMarket(item.id));
15508
+ const no = el('button', 'quiet', t('No, thanks'));
15509
+ no.type = 'button';
15510
+ no.addEventListener('click', () => {
15511
+ try { localStorage.setItem(MARKET_DECLINED, JSON.stringify([...new Set([...marketDeclined(), item.id])])); } catch { /* asked again next page */ }
15512
+ card.remove();
15513
+ });
15514
+ acts.append(on, more, no);
15515
+ card.append(mark, words, acts);
15516
+ document.querySelector('#thread-inner')?.append(card);
15517
+ card.scrollIntoView?.({ block: 'nearest', behavior: reducedMotion() ? 'auto' : 'smooth' });
15518
+ }, motion(700));
15519
+ }
15520
+
14334
15521
  function openModules() {
14335
15522
  const node = modules.dialog;
14336
15523
  readyModules();
@@ -14358,6 +15545,47 @@ modules.button.addEventListener('click', async () => {
14358
15545
  renderModules();
14359
15546
  });
14360
15547
  modules.close.addEventListener('click', () => closeModules());
15548
+ // THE MARKET in a project (2026-10-18): the same store as Home's, full screen, where what you get
15549
+ // is for this project. A module that writes into the folder comes back here, to its card, so its
15550
+ // commands are read before they run.
15551
+ function openRoomMarket(focus = null) {
15552
+ return openMarket({
15553
+ where: 'room',
15554
+ focus,
15555
+ project: { name: projectDesignation() || t('this project') },
15556
+ host: {
15557
+ localAi: () => openSettingsOn('local'),
15558
+ toast: (words) => toast(words),
15559
+ installHere: async (item) => { openModules(); await refreshModules(); renderModules(); openModuleSheet(item.id); },
15560
+ changed: async () => { await refreshModules(); if (modules.dialog.open) renderModules(); barModulesChanged(); },
15561
+ },
15562
+ });
15563
+ }
15564
+ {
15565
+ const head = modules.dialog?.querySelector('.mods-head');
15566
+ const add = head?.querySelector('#modules-add');
15567
+ if (head && add) {
15568
+ const market = el('button', 'sx-btn mods-market', t('Market'));
15569
+ market.type = 'button';
15570
+ market.title = t('Every module MADRE can add to this project');
15571
+ market.addEventListener('click', () => openRoomMarket());
15572
+ add.before(market);
15573
+ }
15574
+ }
15575
+ // The Market's catalog from madre.run: the faces of the modules that came after this MADRE, and what
15576
+ // it updated on its own since, said once. Not on a phone: it has no Market.
15577
+ if (!state.device) void loadMarket({ toast: (words, action) => toast(words, { action }), open: (id) => openRoomMarket(id) }).then(() => { if (modules.dialog.open) renderModules(); });
15578
+ // The Market's address in a project: #mercado, or #mercado=<module> on its page.
15579
+ if (/^#mercado(=|$)/.test(globalThis.location?.hash ?? '')) {
15580
+ const id = decodeURIComponent(location.hash.split('=')[1] ?? '') || null;
15581
+ setTimeout(() => openRoomMarket(id), motion(400));
15582
+ }
15583
+ // Sent here from the Market with a module to install in this project: its card, open.
15584
+ if (/^#modulo=/.test(globalThis.location?.hash ?? '')) {
15585
+ const id = decodeURIComponent(location.hash.split('=')[1] ?? '');
15586
+ history.replaceState(null, '', location.pathname + location.search);
15587
+ setTimeout(async () => { openModules(); await refreshModules(); renderModules(); openModuleSheet(id); }, motion(600));
15588
+ }
14361
15589
  // Escape closes the open module before the board, and the search before either.
14362
15590
  modules.dialog.addEventListener('cancel', (event) => {
14363
15591
  event.preventDefault();
@@ -14443,8 +15671,11 @@ function openCrewPop(anchor) {
14443
15671
  list.append(row);
14444
15672
  }
14445
15673
  pop.append(list);
14446
- const go = el('button', 'crew-go', t('⚙ AGENTS IN SETTINGS'));
15674
+ const go = el('button', 'crew-go');
14447
15675
  go.type = 'button';
15676
+ const gear = el('span', 'crew-go-icon');
15677
+ gear.innerHTML = GEAR_ICON; // trusted: our own SVG string
15678
+ go.append(gear, el('span', null, t('Agents in Settings')));
14448
15679
  go.addEventListener('click', () => {
14449
15680
  closeAgentPop();
14450
15681
  openSettings(true, { category: 'agents' });
@@ -14593,6 +15824,19 @@ function fillLoginAsk(box) {
14593
15824
  open.rel = 'noopener noreferrer';
14594
15825
  box.append(open);
14595
15826
  }
15827
+ // A device sign-in (GitHub Copilot through OpenCode): the code is MADRE's to show, the page's
15828
+ // to be typed into. Nothing comes back here; the sign-in ends on its own once it is typed.
15829
+ if (loginNow.deviceCode) {
15830
+ const said = el('div', 'login-device');
15831
+ said.append(el('span', null, t('Type this code on that page:')), el('code', null, loginNow.deviceCode));
15832
+ const copy = el('button', 'sx-btn', t('Copy'));
15833
+ copy.type = 'button';
15834
+ copy.addEventListener('click', async () => {
15835
+ try { await navigator.clipboard.writeText(loginNow.deviceCode ?? ''); copy.textContent = t('Copied'); } catch { copy.textContent = t('Select it'); }
15836
+ });
15837
+ said.append(copy);
15838
+ box.append(said);
15839
+ }
14596
15840
  if (loginNow.asksForCode || loginNow.awaiting) {
14597
15841
  const form = el('form', 'login-code');
14598
15842
  const input = el('input');
@@ -14630,9 +15874,15 @@ function fillLoginAsk(box) {
14630
15874
  }
14631
15875
  // One door or two: Claude's CLI knows the subscription and the Console, and only the human can
14632
15876
  // say which account is paying. `null` is the single flow every other CLI has.
15877
+ // With a session, every agent offers the same «sign in again» as Codex. A single door (Gemini's
15878
+ // Google account) is that door; two doors (Claude's subscription and Console) are chosen after the
15879
+ // click, and which agent is choosing is kept here so a repaint does not fold the choice away.
15880
+ const choosingAccount = new Set();
14633
15881
  function loginDoors(agent, signedIn) {
14634
15882
  const methods = agent.login?.methods;
14635
15883
  if (!methods?.length) return [{ id: null, label: signedIn ? t('Sign in again') : t('Sign in'), note: agent.login?.note ?? '' }];
15884
+ if (signedIn && methods.length === 1) return [{ id: methods[0].id, label: t('Sign in again'), note: methods[0].note ?? '', again: true }];
15885
+ if (signedIn && !choosingAccount.has(agent.id)) return [{ id: null, label: t('Sign in again'), note: agent.login?.note ?? '', again: true, choose: true }];
14636
15886
  return methods.map((method) => ({ id: method.id, label: t(method.label), note: method.note ?? '' }));
14637
15887
  }
14638
15888
  async function startLogin(agentId, method = null) {
@@ -14643,6 +15893,7 @@ async function startLogin(agentId, method = null) {
14643
15893
  loginNow.agent = agentId;
14644
15894
  loginNow.method = method;
14645
15895
  loginNow.url = null;
15896
+ loginNow.deviceCode = null;
14646
15897
  loginNow.awaiting = false;
14647
15898
  loginNow.asksForCode = Boolean(result.asksForCode);
14648
15899
  repaintLoginAsk();
@@ -14660,10 +15911,10 @@ function renderLoginEvent(event) {
14660
15911
  node.append(finished
14661
15912
  ? (ok ? t('@{agent} signed in', { agent }) + (session?.detail ? ` · ${session.detail}` : '') : t('@{agent} sign-in did not complete', { agent }) + (error ? ` · ${error}` : code ? t(' · exit {code}', { code }) : ''))
14662
15913
  : t('signing in @{agent} · {command}', { agent, command }));
14663
- if (finished) { state.sessions[agent] = session ?? state.sessions[agent]; renderOnboarding(); if (state.settingsOpen) void loadSettings(); }
15914
+ if (finished) { state.sessions[agent] = session ?? state.sessions[agent]; renderAgents(); renderPicker(); renderOnboarding(); if (state.settingsOpen) void loadSettings(); }
14664
15915
  if (!replaying) {
14665
- if (finished) { if (loginNow.agent === agent) { loginNow.agent = null; loginNow.url = null; loginNow.awaiting = false; } }
14666
- else { loginNow.agent = agent; loginNow.method = event.payload.method ?? null; loginNow.url = null; loginNow.awaiting = false; loginNow.asksForCode = Boolean(state.agents.get(agent)?.login?.asksForCode); }
15916
+ if (finished) { if (loginNow.agent === agent) { loginNow.agent = null; loginNow.url = null; loginNow.deviceCode = null; loginNow.awaiting = false; } }
15917
+ else { loginNow.agent = agent; loginNow.method = event.payload.method ?? null; loginNow.url = null; loginNow.deviceCode = null; loginNow.awaiting = false; loginNow.asksForCode = Boolean(state.agents.get(agent)?.login?.asksForCode); }
14667
15918
  repaintLoginAsk();
14668
15919
  }
14669
15920
  state.lastSender = null;
@@ -14685,13 +15936,15 @@ function renderAgentInstall(event) {
14685
15936
  }
14686
15937
 
14687
15938
  function appendLoginOutput(event) {
14688
- const { agent, line, url } = event.payload;
15939
+ const { agent, line, url, deviceCode } = event.payload;
14689
15940
  if (!replaying && loginNow.agent === agent) {
14690
15941
  // The address stays on screen instead of scrolling past: the heartbeat used to be the only
14691
15942
  // line left, so the one thing the human had to open was the first thing MADRE lost.
14692
15943
  if (url) loginNow.url = url;
14693
- if (looksLikeCodePrompt(line)) loginNow.awaiting = true;
14694
- if (url || looksLikeCodePrompt(line)) repaintLoginAsk();
15944
+ // "Enter code: 3475-C193" is a code to show, not a question: no box to paste into.
15945
+ if (deviceCode) loginNow.deviceCode = deviceCode;
15946
+ else if (looksLikeCodePrompt(line)) loginNow.awaiting = true;
15947
+ if (url || deviceCode || looksLikeCodePrompt(line)) repaintLoginAsk();
14695
15948
  }
14696
15949
  // The heartbeat («… still running · 42s · …») says one thing, how long: it takes the place of
14697
15950
  // the one before instead of piling up thirty lines over the address the person needs.
@@ -14726,6 +15979,8 @@ async function loadSettings() {
14726
15979
  settingsUI.data = await fetch('/api/settings').then((response) => response.json());
14727
15980
  if (settingsUI.data?.settings?.override) state.override = settingsUI.data.settings.override;
14728
15981
  state.sessions = settingsUI.data.sessions ?? state.sessions;
15982
+ renderAgents();
15983
+ renderPicker();
14729
15984
  // The server is the one that knows whether a sign-in is still waiting: a page reloaded in the
14730
15985
  // middle of one has to find the same address and the same box, not an empty card.
14731
15986
  readLoginState(settingsUI.data.login ?? null);
@@ -14736,6 +15991,7 @@ function readLoginState(live) {
14736
15991
  loginNow.agent = live?.agent ?? null;
14737
15992
  loginNow.method = live?.method ?? null;
14738
15993
  loginNow.url = live?.url ?? null;
15994
+ loginNow.deviceCode = live?.deviceCode ?? null;
14739
15995
  loginNow.awaiting = Boolean(live?.awaitingCode);
14740
15996
  loginNow.asksForCode = Boolean(live?.asksForCode);
14741
15997
  repaintLoginAsk();
@@ -16032,25 +17288,28 @@ function connectionCard(agent) {
16032
17288
  // One button per account the CLI can sign in with. Which one is paying is the human's to say.
16033
17289
  for (const door of loginDoors(agent, signedIn)) {
16034
17290
  const login = sxButton(door.label, async () => {
17291
+ if (door.choose) { choosingAccount.add(agent.id); renderSettings(); return; }
17292
+ choosingAccount.delete(agent.id);
16035
17293
  login.disabled = true;
16036
17294
  try { await startLogin(agent.id, door.id); settingsUI.data.loggingIn = agent.id; renderSettings(); }
16037
17295
  catch (error) { toast(error.message || t('The sign-in could not start.')); login.disabled = false; }
16038
- }, { tone: signedIn ? null : 'primary', title: t(door.note || agent.login.note) });
17296
+ }, { tone: (signedIn && !choosingAccount.has(agent.id)) || agent.key?.first ? null : 'primary', title: t(door.note || agent.login.note) });
16039
17297
  login.disabled = Boolean(settingsUI.data.loggingIn);
16040
17298
  acts.append(login);
16041
17299
  }
16042
17300
  }
16043
17301
  acts.append(recheck);
16044
- body.append(acts);
16045
17302
  const slot = el('div', 'sx-slot');
16046
17303
  // OpenCode's keys are its providers', added in the block under its settings, one row for all.
16047
17304
  if (agent.detected && agent.key && agent.id !== 'opencode') slot.append(keyForm(agent, { onDone: () => { void loadSettings(); renderOnboarding(); } }));
16048
- // Signed in, OpenCode's providers are added in its block below; the terminal is for a first sign-in.
16049
- if (agent.detected && agent.login && !agent.login.headless && !(agent.id === 'opencode' && session?.state === 'signed-in')) {
16050
- slot.append(el('p', 'sx-hint', t('To sign in, copy this into your Terminal app:')));
16051
- slot.append(commandBlock([agent.login.display, `# ${t(agent.login.note)}`]));
16052
- }
17305
+ // Gemini's key is its way in (agent.key.first): the field before the Google door.
17306
+ if (agent.key?.first && slot.firstChild) { const first = el('div', 'sx-slot'); first.append(slot.firstChild); body.append(first); }
17307
+ body.append(acts);
16053
17308
  if (agent.detected && agent.login?.headless) slot.append(loginAskSlot(agent.id));
17309
+ // The same, typed in a terminal, folded for whoever prefers it. Signed in, OpenCode's providers
17310
+ // are added in its block below; its terminal is for a first sign-in.
17311
+ const way = !agent.detected ? installTerminalWay(agent) : agent.id === 'opencode' && session?.state === 'signed-in' ? null : signInTerminalWay(agent);
17312
+ if (way) slot.append(way);
16054
17313
  const log = state.loginLogs.get(agent.id) ?? [];
16055
17314
  if (log.length || busy) {
16056
17315
  const box = el('div', 'login-log');
@@ -16073,7 +17332,66 @@ function renderMemoryPanel(data) {
16073
17332
  const target = state.settingsTargets.memory;
16074
17333
  if (target?.isConnected) { target.replaceChildren(); drawMemory(target, data); }
16075
17334
  }
17335
+ // D-S17, also in AJUSTES › What it remembers: the notes other projects delivered here, waiting
17336
+ // for you, and what became of the last ones. The same rows as the card in the conversation.
17337
+ const deliveries = { pending: 0 };
17338
+ async function readDeliveries() {
17339
+ try { return (await fetch('/api/enlace/inbox').then((response) => response.json())).notes ?? []; } catch { return null; }
17340
+ }
17341
+ function drawDelivered(host) {
17342
+ const block = sxBlock(host, t('Delivered notes'), t('Notes other projects handed to this one. Each enters the memory only if it is accepted.'));
17343
+ block.id = 'sx-delivered';
17344
+ const fill = async () => {
17345
+ const notes = await readDeliveries();
17346
+ if (!block.isConnected) return;
17347
+ block.rows.replaceChildren();
17348
+ if (!notes) { sxRow(block, t('Could not read them.'), null); return; }
17349
+ const waiting = notes.filter((note) => note.status === 'pending');
17350
+ if (!waiting.length) sxRow(block, t('Nothing waiting'), notes.length ? t('{n} decided before.', { n: notes.length }) : t('No project has delivered notes here yet.'));
17351
+ for (const note of waiting) {
17352
+ const decide = (accept) => async () => {
17353
+ try {
17354
+ const response = await fetch('/api/enlace/inbox/decide', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: note.id, accept }) });
17355
+ const result = await response.json().catch(() => ({}));
17356
+ if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
17357
+ } catch (error) { toast(`${t('Diagnostics › ')}${error.message}`); }
17358
+ void fill();
17359
+ void refreshDeliveries();
17360
+ };
17361
+ const actions = el('div', 'sx-chips');
17362
+ actions.append(sxButton(t('Accept'), decide(true), { tone: 'primary' }), sxButton(t('Decline'), decide(false)));
17363
+ sxRow(block, `${kindWord(note.kind)} · ${note.text}`, [t('Delivered by {room}{agent}', { room: note.name, agent: note.agent ? ` · @${note.agent}` : '' }), note.why ? t('Why: {why}', { why: note.why }) : null].filter(Boolean).join(' · '), actions, { stack: true });
17364
+ }
17365
+ };
17366
+ void fill();
17367
+ }
17368
+ // The bar says how many wait, and goes to them.
17369
+ async function refreshDeliveries() {
17370
+ const notes = await readDeliveries();
17371
+ if (!notes) return;
17372
+ deliveries.pending = notes.filter((note) => note.status === 'pending').length;
17373
+ let pill = document.querySelector('#inbox-pill');
17374
+ if (!pill) {
17375
+ const anchor = document.querySelector('#stop-all');
17376
+ if (!anchor) return;
17377
+ // Shaped like the handoff pill beside it: an icon and its words, never squeezed to a dot.
17378
+ pill = el('button', 'stop-all handoff-pill inbox-pill');
17379
+ pill.id = 'inbox-pill';
17380
+ pill.type = 'button';
17381
+ pill.innerHTML = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3.2 11.2 5.4 4.6c.2-.6.8-1 1.4-1h6.4c.6 0 1.2.4 1.4 1l2.2 6.6v3.6c0 .9-.7 1.6-1.6 1.6H4.8c-.9 0-1.6-.7-1.6-1.6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M3.4 11.2h3.8l1 2h3.6l1-2h3.8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg><span class="handoff-words"></span><span class="inbox-count"></span>'; // trusted: our own SVG string
17382
+ pill.addEventListener('click', () => openSettingsOn('memory', { anchor: 'sx-delivered' }));
17383
+ anchor.before(pill);
17384
+ }
17385
+ pill.hidden = state.device || !deliveries.pending;
17386
+ const said = deliveries.pending === 1 ? t('1 note to accept') : t('{n} notes to accept', { n: deliveries.pending });
17387
+ pill.querySelector('.handoff-words').textContent = said;
17388
+ // Its short form, for a bar too narrow for the words (barFit, bar-tight): the tray and the number.
17389
+ pill.querySelector('.inbox-count').textContent = String(deliveries.pending);
17390
+ pill.setAttribute('aria-label', said);
17391
+ pill.title = t('Notes other projects delivered here, waiting for you');
17392
+ }
16076
17393
  function drawMemory(host, data) {
17394
+ drawDelivered(host);
16077
17395
  const mem = data?.settings?.memory;
16078
17396
  if (!mem) { sxBlock(host, t('What it remembers'), t('This project has no memory yet.')); return; }
16079
17397
  const save = async (memoryPatch, describe) => { try { await saveSettingNow({ memory: memoryPatch }, describe); await loadSettings(); } catch (error) { toast(t('Memory setting was not saved: {error}', { error: error.message })); } };
@@ -16150,28 +17468,60 @@ function drawMemory(host, data) {
16150
17468
  const recipe = el('details', 'sx-more');
16151
17469
  recipe.append(el('summary', null, t('Step by step')));
16152
17470
  let platform = /Mac/i.test(navigator.platform ?? '') || !/Linux/i.test(navigator.userAgent ?? '') ? 'mac' : 'linux';
17471
+ // The size to train: the largest this computer trains and then runs, unless the human picks
17472
+ // another (to train it on a larger machine, say). Every size is offered; the note says what fits.
17473
+ let size = null;
17474
+ let measured = false;
16153
17475
  const steps = el('ol', 'sx-steps');
16154
17476
  const pickRow = el('div', 'sx-train-platform');
16155
17477
  const platformNote = el('p', 'sx-hint');
16156
- recipe.append(pickRow, platformNote, steps);
17478
+ const sizeRow = el('div', 'sx-train-platform');
17479
+ const sizeNote = el('p', 'sx-hint');
17480
+ recipe.append(pickRow, platformNote, sizeRow, sizeNote, steps);
16157
17481
  train.append(recipe);
16158
17482
  let lastPayload = null;
16159
17483
  const drawPlatform = () => {
16160
17484
  pickRow.replaceChildren(sxSeg([{ value: 'mac', text: t('Mac with Apple chip') }, { value: 'linux', text: t('Linux with NVIDIA') }], platform, (value) => { platform = value; showDataset(lastPayload); }, { label: t('Your computer') }));
16161
17485
  platformNote.textContent = platform === 'mac'
16162
- ? t('Any Mac with an M chip. 16 GB of memory is enough for the small model.')
17486
+ ? t('Any Mac with an M chip. Its memory decides the size you can train, below.')
16163
17487
  : t('Experimental: MLX runs on Linux with an NVIDIA card (CUDA 12, compute capability 7.5 or newer, glibc 2.35, Python 3.10), but mlx-lm only promises Apple chips. Step 2 checks the card first.');
16164
17488
  };
17489
+ const drawSize = (fit) => {
17490
+ const models = fit?.models ?? [];
17491
+ if (!models.length) { sizeRow.replaceChildren(); sizeNote.textContent = ''; return null; }
17492
+ const chosen = models.find((model) => model.id === size) ?? models.find((model) => model.id === fit.recommended?.train?.id) ?? models[1] ?? models[0];
17493
+ sizeRow.replaceChildren(sxSeg(models.map((model) => ({ value: model.id, text: model.label.replace(/^Qwen2\.5\s*/, ''), title: model.train === 'yes' ? t('Trains on this computer') : t('Needs {gb} GB to train', { gb: model.trainGb }) })), chosen.id, (value) => { size = value; showDataset(lastPayload); }, { label: t('Model size') }));
17494
+ const here = hardwareLine(fit.hardware);
17495
+ sizeNote.textContent = chosen.train === 'yes'
17496
+ ? t('{model} trains on this computer ({here}). Needs about {disk} GB free on disk.', { model: chosen.label, here, disk: Math.ceil(chosen.params * 5.5) + 2 })
17497
+ : fit.accelerated
17498
+ ? t('{model} needs {gb} GB to train and this computer has {have} GB: train it on a larger one, or pick a smaller size.', { model: chosen.label, gb: chosen.trainGb, have: fit.usableGb })
17499
+ : t('This computer has no Apple chip or NVIDIA card to train with ({here}). The recipe is the one for a computer that has one.', { here });
17500
+ return chosen;
17501
+ };
16165
17502
  const showDataset = (payload) => {
16166
17503
  lastPayload = payload;
16167
17504
  const d = payload?.dataset;
16168
17505
  const exported = d ? t('Last export {when} · {train} to train · {valid} to check', { when: new Date(d.exportedAt).toLocaleString(), train: d.train, valid: d.valid }) : t('Not exported yet.');
16169
17506
  const trained = payload?.trained ? t('Trained model {model} in use.', { model: payload.trained }) : t('No trained model yet.');
16170
- datasetHint.textContent = `${exported} ${trained}`;
16171
- drawPlatform();
17507
+ // D-S15: in Home, which projects the Coordinator learned from this time, and how.
17508
+ const sources = (d?.linked ?? []).map((one) => (one.source === 'missing'
17509
+ ? t('{room}: did not answer and nothing was kept', { room: one.name })
17510
+ : one.source === 'kept'
17511
+ ? t('{room}: {n} notes from the last time it answered', { room: one.name, n: one.notes })
17512
+ : t('{room}: {n} notes', { room: one.name, n: one.notes })));
17513
+ datasetHint.textContent = `${exported} ${trained}${sources.length ? ` ${t('Learned from your projects: {list}.', { list: sources.join(' · ') })}` : ''}`;
16172
17514
  const info = payload?.training;
17515
+ // The first reading sets the recipe for this computer: an NVIDIA card goes the Linux way.
17516
+ if (!measured && info?.fit?.hardware) { measured = true; if (info.fit.hardware.accelerator === 'nvidia') platform = 'linux'; else if (info.fit.hardware.accelerator === 'apple') platform = 'mac'; }
17517
+ drawPlatform();
17518
+ const chosen = drawSize(info?.fit);
16173
17519
  steps.replaceChildren();
16174
17520
  if (!info) { steps.append(el('li', 'sx-hint', t('The training recipe is unavailable.'))); return; }
17521
+ // A base trained in 4 bits (3B and up) is joined back at full precision, so llama.cpp can read
17522
+ // it, and Ollama shrinks it to 4 bits again; the small ones stay at 8.
17523
+ const base = chosen?.base ?? info.baseModel;
17524
+ const quantized = Boolean(chosen?.quantized);
16175
17525
  const quote = (path) => `"${path.replace(/\/$/, '')}"`;
16176
17526
  const venv = 'source ~/.madre-train/bin/activate';
16177
17527
  const setup = platform === 'mac'
@@ -16186,11 +17536,40 @@ function drawMemory(host, data) {
16186
17536
  };
16187
17537
  step(t('Export the training data'), t('Use «Export» above. Do it again whenever the project has grown.'));
16188
17538
  step(t('Prepare Python, once'), platform === 'mac' ? t('A separate environment with mlx-lm, so nothing else on your computer changes.') : t('A separate environment with MLX for CUDA and mlx-lm. It must print «CUDA: True»; if not, stop here.'), setup);
16189
- step(t('Train · base {model} for {gb} GB', { model: info.baseModel, gb: info.memoryGb }), t('From 10 to 40 minutes. It learns from the exported pairs and leaves an adjustment («adapters»).'), `${venv} && bash ${quote(`${info.recipeDir}train.sh`)} ${quote(info.roomDir)} ${info.baseModel}`);
16190
- step(t('Join the adjustment with the base model'), null, `${venv} && cd ${quote(info.roomDir)} && mlx_lm.fuse --model ${info.baseModel} --adapter-path adapters --save-path fused`);
16191
- step(t('Turn it into a file Ollama reads (GGUF)'), t('The first time, it downloads llama.cpp\'s converter.'), `${venv} && cd ${quote(info.roomDir)} && git clone --depth 1 https://github.com/ggml-org/llama.cpp && pip install -r llama.cpp/requirements.txt && python3 llama.cpp/convert_hf_to_gguf.py fused --outfile madre.gguf --outtype q8_0`);
16192
- step(t('Register it in Ollama as {model}', { model: info.modelName }), t('@madre switches to it at the next recheck of Local AI.'), `cd ${quote(info.roomDir)} && cp ${quote(`${info.recipeDir}Modelfile`)} . && ollama create ${info.modelName} -f Modelfile`);
17539
+ step(t('Train · base {model}', { model: chosen?.label ?? base }), (chosen?.params ?? 0) > 3 ? t('An hour or more, depending on the size and on how many pairs there are. It learns from the exported pairs and leaves an adjustment («adapters»).') : t('From 10 to 40 minutes. It learns from the exported pairs and leaves an adjustment («adapters»).'), `${venv} && bash ${quote(`${info.recipeDir}train.sh`)} ${quote(info.roomDir)} ${base}`);
17540
+ step(t('Join the adjustment with the base model'), null, `${venv} && cd ${quote(info.roomDir)} && mlx_lm.fuse --model ${base} --adapter-path adapters --save-path fused${quantized ? ' --de-quantize' : ''}`);
17541
+ step(t('Turn it into a file Ollama reads (GGUF)'), t('The first time, it downloads llama.cpp\'s converter.'), `${venv} && cd ${quote(info.roomDir)} && git clone --depth 1 https://github.com/ggml-org/llama.cpp && pip install -r llama.cpp/requirements.txt && python3 llama.cpp/convert_hf_to_gguf.py fused --outfile madre.gguf --outtype ${quantized ? 'f16' : 'q8_0'}`);
17542
+ step(t('Register it in Ollama as {model}', { model: info.modelName }), quantized ? t('Ollama keeps it in 4 bits, the size the table counts on. @madre switches to it at the next recheck of Local AI.') : t('@madre switches to it at the next recheck of Local AI.'), `cd ${quote(info.roomDir)} && cp ${quote(`${info.recipeDir}Modelfile`)} . && ollama create ${info.modelName} -f Modelfile${quantized ? ' --quantize q4_K_M' : ''}`);
16193
17543
  step(t('Try it before trusting it'), t('Ask @madre ten things the project decided and five it never discussed. It should get the first right and say it never discussed the others; if it makes things up, remove it with «ollama rm {model}» and train with more pairs.', { model: info.modelName }));
17544
+ if (state.salas) drawExam();
17545
+ };
17546
+ // D-S18 · in Home, the exam the Coordinator's local model passes before it gives orders: the
17547
+ // same test as the step above, run by MADRE, with a fixed bar and kept for that exact model.
17548
+ const exam = el('div', 'sx-exam');
17549
+ if (state.salas) train.append(exam);
17550
+ let examTimer = null;
17551
+ const drawExam = async () => {
17552
+ const reading = await fetch('/api/x/bishop/exam').then((response) => (response.ok ? response.json() : null)).catch(() => null);
17553
+ exam.replaceChildren();
17554
+ if (!reading) return;
17555
+ const last = reading.exam;
17556
+ const part = (key) => `${last?.scores?.[key]?.passed ?? 0}/${last?.scores?.[key]?.total ?? 0}`;
17557
+ const said = reading.running ? t('Examining {model}… a local model answers one question at a time; it takes a few minutes.', { model: reading.model?.name ?? '' })
17558
+ : !last ? t('Not examined yet. Until it passes, the Coordinator only answers with @madre.')
17559
+ : reading.holds ? t('{model} passed on {date}: {known} about the projects, {unknown} said it never discussed, {orders} orders. The Coordinator gives orders with it up to #1.', { model: last.model, date: new Date(last.at).toLocaleDateString(language()), known: part('known'), unknown: part('unknown'), orders: part('orders') })
17560
+ : last.passed ? t('{model} passed, but the model in use is another one now ({now}). Examine it again.', { model: last.model, now: reading.model?.name ?? '—' })
17561
+ : t('{model} did not pass on {date}: {known} about the projects, {unknown} said it never discussed, {orders} orders.{why}', { model: last.model, date: new Date(last.at).toLocaleDateString(language()), known: part('known'), unknown: part('unknown'), orders: part('orders'), why: last.why?.length ? ` ${last.why.map((one) => t(one)).join(' · ')}` : '' });
17562
+ const run = sxButton(t('Examine'), async () => {
17563
+ run.disabled = true;
17564
+ const response = await fetch('/api/x/bishop/exam', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }).catch(() => null);
17565
+ const result = await response?.json().catch(() => ({}));
17566
+ if (!response?.ok) { toast(`${t('Diagnostics › ')}${result?.error ?? t('could not start')}`); run.disabled = false; return; }
17567
+ void drawExam();
17568
+ }, { title: t('Questions about the projects, questions about things nobody discussed, and orders: the bar is fixed and the result is kept for this model') });
17569
+ run.disabled = Boolean(reading.running) || !reading.model;
17570
+ sxRow(exam, t('Exam before giving orders'), said, run);
17571
+ clearTimeout(examTimer);
17572
+ if (reading.running) examTimer = setTimeout(() => { if (exam.isConnected) void drawExam(); }, 8000);
16194
17573
  };
16195
17574
  fetch('/api/dataset').then((response) => response.json()).then(showDataset).catch(() => showDataset(null));
16196
17575
  }
@@ -16699,7 +18078,33 @@ nostromo.covered = () => {
16699
18078
  };
16700
18079
  nostromo.onClang = () => { const frame = document.querySelector('#nostromo .nostromo-frame'); frame?.classList.remove('shake'); void frame?.offsetWidth; frame?.classList.add('shake'); };
16701
18080
  // The physics and the painters, closed over this scene (nostromo-engine.js).
16702
- const { buildNodes, sizeNostromo, stepNostromo, focusNostromo, projectNostromo, fitNostromo, drawNostromo, toScreen, toWorld, nostromoLive, nostromoAt, beginImplosion } = createNostromo(nostromo);
18081
+ const { buildNodes, sizeNostromo, stepNostromo, focusNostromo, projectNostromo, fitNostromo, drawNostromo, toScreen, toWorld, nostromoLive, nostromoAt, beginImplosion, setNeural } = createNostromo(nostromo);
18082
+ // Neural mode (public/neural.js): the same map drawn as the neurons of a brain, chosen from its head.
18083
+ // The choice is the person's, shared with Home's Projects map (looks.json, `pulse.neural`); when this
18084
+ // machine does not give the map its frames, the key invites to try it, once a session.
18085
+ nostromo.neuralKey = document.querySelector('#nostromo-neural');
18086
+ nostromo.neural = neuralChosen();
18087
+ if (nostromo.neuralKey) {
18088
+ neuralKey(nostromo.neuralKey);
18089
+ paintNeuralKey(nostromo.neuralKey, nostromo.neural);
18090
+ nostromo.neuralKey.addEventListener('click', () => setNostromoNeural(!nostromo.neural));
18091
+ }
18092
+ function setNostromoNeural(on) {
18093
+ setNeural(on, performance.now() / 1000);
18094
+ rememberNeural(on);
18095
+ paintNeuralKey(nostromo.neuralKey, on);
18096
+ nostromo.dialog?.querySelector('.neural-invite')?.remove();
18097
+ pushLooks();
18098
+ }
18099
+ function syncNostromoNeural() {
18100
+ const want = neuralChosen();
18101
+ if (Boolean(nostromo.neural) === want) return;
18102
+ setNeural(want, performance.now() / 1000);
18103
+ paintNeuralKey(nostromo.neuralKey, want);
18104
+ }
18105
+ nostromo.strain = createStrain({
18106
+ onStrain: () => { if (!nostromo.neural && !state.device) inviteNeural({ key: nostromo.neuralKey, host: nostromo.dialog, map: 'memory', onTry: () => setNostromoNeural(true) }); },
18107
+ });
16703
18108
  // One frame of the map on demand, what startNostromo's loop does sixty times a second: for whoever
16704
18109
  // measures it (`__pulse.nostromoTick`), without waiting for the screen.
16705
18110
  function nostromoTick(now) {
@@ -17299,15 +18704,18 @@ function startNostromo() {
17299
18704
  if (!nostromo.cage?.closed) nostromo.cage = null;
17300
18705
  window.addEventListener('resize', sizeNostromo);
17301
18706
  nostromo.last = performance.now();
18707
+ nostromo.strain?.reset();
17302
18708
  const frame = (now) => {
17303
18709
  const dt = Math.min(48, now - nostromo.last) / 16;
17304
18710
  nostromo.last = now;
17305
18711
  const clock = now / 1000;
18712
+ const started = performance.now();
17306
18713
  stepNostromo(dt, clock);
17307
18714
  focusNostromo(dt, clock);
17308
18715
  projectNostromo();
17309
18716
  fitNostromo();
17310
18717
  drawNostromo(clock);
18718
+ nostromo.strain?.frame(performance.now() - started, now);
17311
18719
  nostromo.raf = requestAnimationFrame(frame);
17312
18720
  };
17313
18721
  nostromo.raf = requestAnimationFrame(frame);
@@ -18453,11 +19861,12 @@ settingsUI.button?.addEventListener('click', () => openSettingsOn('release', { p
18453
19861
  bar, and in which order, is this browser's own: a convenience, kept in localStorage. */
18454
19862
  const BAR_KEY = 'pulse.barItems';
18455
19863
  const BAR_FIXED = 'settings';
18456
- const BAR_DEFAULT = ['salas', 'macroverso', 'crew', 'modules', 'mother', 'nostromo', 'reload', 'servers', 'finder', 'phone'];
19864
+ const BAR_DEFAULT = ['salas', 'macroverso', 'crew', 'code', 'modules', 'mother', 'nostromo', 'reload', 'servers', 'finder', 'phone'];
18457
19865
  // The keys that are in the markup. The crew is two elements — five spheres when there is room, one
18458
19866
  // button when there is not — and one key: taking it off takes both.
18459
19867
  const BAR_BUILT = {
18460
19868
  crew: { nodes: ['#agents', '#agents-compact'], name: () => t('Agents') },
19869
+ code: { nodes: ['#code-button'], name: () => t('Code') },
18461
19870
  modules: { nodes: ['#modules-button'], name: () => t('Modules') },
18462
19871
  mother: { nodes: ['#mother-button'], name: () => t('Diagnostics') },
18463
19872
  nostromo: { nodes: ['#nostromo-bar'], name: () => t('Memory map') },
@@ -18472,6 +19881,8 @@ const BAR_CONTEXTUAL = ['#steps-pill', '#tour-pill-slot', '#handoff-pill', '#upd
18472
19881
  // A module's name before the board has answered; once it has, the module says its own.
18473
19882
  const BAR_MODULE_NAMES = { ash: 'Token saver', ollama: 'Local AI', intents: 'Suggested effort' };
18474
19883
  const BAR_MINUS = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 8h7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
19884
+ // Its mirror on a key that can still go on the bar, in the card under it.
19885
+ const BAR_PLUS = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 4.5v7M4.5 8h7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
18475
19886
  const barKit = {
18476
19887
  node: document.querySelector('#bar'),
18477
19888
  order: [],
@@ -18491,7 +19902,7 @@ const barKit = {
18491
19902
 
18492
19903
  // Keys that joined the bar after a person arranged theirs. Each is put at the end once, so it can
18493
19904
  // be found; taken off after that, it stays off.
18494
- const BAR_JOINED = ['phone', 'salas', 'macroverso'];
19905
+ const BAR_JOINED = ['phone', 'salas', 'macroverso', 'code'];
18495
19906
  // The MACROVERSO: an orbit, a sun and two worlds. Only the room of SALAS carries the key.
18496
19907
  const MACROVERSO_ICON = '<ellipse cx="10" cy="10" rx="8.2" ry="3.6" fill="none" stroke="currentColor" stroke-width="1.4" opacity=".6"/><circle cx="10" cy="10" r="2.2" fill="currentColor"/><circle cx="16.4" cy="8.6" r="1.5" fill="currentColor"/><circle cx="4.2" cy="11.6" r="1.1" fill="currentColor" opacity=".7"/>';
18497
19908
  // SALAS: three rooms of one Mac, and what joins them.
@@ -18505,7 +19916,8 @@ function barStored() {
18505
19916
  const met = JSON.parse(localStorage.getItem('pulse.barMet') ?? '[]');
18506
19917
  const fresh = BAR_JOINED.filter((id) => !met.includes(id));
18507
19918
  if (fresh.length) {
18508
- for (const id of fresh) if (!list.includes(id)) list.push(id);
19919
+ // CODE stands beside the agents, where it is seen first; the others join at the end.
19920
+ for (const id of fresh) if (!list.includes(id)) { if (id === 'code' && list.includes('crew')) list.splice(list.indexOf('crew') + 1, 0, id); else list.push(id); }
18509
19921
  localStorage.setItem(BAR_KEY, JSON.stringify(list));
18510
19922
  localStorage.setItem('pulse.barMet', JSON.stringify([...met, ...fresh]));
18511
19923
  }
@@ -18522,6 +19934,8 @@ function barCatalogue() {
18522
19934
  for (const [id, spec] of Object.entries(BAR_BUILT)) {
18523
19935
  // Reload lives in the second bar on the Mac; it is not a key of this one there.
18524
19936
  if (id === 'reload' && homeOn()) continue;
19937
+ // The code workspace docks beside the conversation: a phone has no room beside it.
19938
+ if (id === 'code' && state.device) continue;
18525
19939
  const nodes = spec.nodes.map((query) => document.querySelector(query)).filter(Boolean);
18526
19940
  if (nodes.length) out.set(id, { id, name: spec.name(), nodes });
18527
19941
  }
@@ -18634,6 +20048,7 @@ function barPlace() {
18634
20048
  // «‹ Salas» at its start. The three are not keys you place, so the bar's card never offers them.
18635
20049
  const HOME_ICON = '<path d="M3.4 9.2 10 3.6l6.6 5.6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path class="ic-f" d="M5.2 7.9v7.5a1.2 1.2 0 0 0 1.2 1.2h2.3v-4a1.3 1.3 0 0 1 2.6 0v4h2.3a1.2 1.2 0 0 0 1.2-1.2V7.9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>';
18636
20050
  const homeOn = () => !state.device && Boolean(document.querySelector('#bar-home'));
20051
+ let homePower = null;
18637
20052
  function homeBar() {
18638
20053
  const home = document.querySelector('#bar-home');
18639
20054
  const bar = document.querySelector('#bar');
@@ -18670,6 +20085,10 @@ function homeBar() {
18670
20085
  key.classList.remove('bar-off', 'bar-overflow');
18671
20086
  if (key.parentNode !== home) home.append(key);
18672
20087
  }
20088
+ // Last, after the settings: turning MADRE off (public/power.js). On the Mac only.
20089
+ if (!homePower) { homePower = powerKey('tree-button'); homePower.id = 'power-button'; }
20090
+ labelPowerKey(homePower);
20091
+ if (homePower.parentNode !== home || (home.lastElementChild && home.lastElementChild !== homePower)) home.append(homePower);
18673
20092
  home.hidden = false;
18674
20093
  bar.classList.add('with-home');
18675
20094
  // The bar beside it leaves exactly its width free, measured whenever it changes (fonts, language).
@@ -18905,6 +20324,8 @@ function barLayout() {
18905
20324
  else if (on) { const key = barKit.extras.get(id); key.title = entry.name; key.setAttribute('aria-label', entry.name); }
18906
20325
  }
18907
20326
  for (const [id, node] of barKit.extras) if (!barKit.order.includes(id)) { node.remove(); barKit.extras.delete(id); }
20327
+ // A key the catalogue leaves out on this device (CODE on a phone) is not drawn at all.
20328
+ for (const [id, spec] of Object.entries(BAR_BUILT)) if (!catalogue.has(id) && id !== 'reload') for (const query of spec.nodes) document.querySelector(query)?.classList.add('bar-off');
18908
20329
  barPlace();
18909
20330
  if (barKit.editing) { barBadges(); barDrawCard(); }
18910
20331
  barFit();
@@ -18941,6 +20362,7 @@ function barSpills(bar) {
18941
20362
  function barFit() {
18942
20363
  const bar = barKit.node;
18943
20364
  if (!bar || !barKit.more || typeof bar.getBoundingClientRect !== 'function') return;
20365
+ bar.classList?.remove('bar-tight');
18944
20366
  for (const node of bar.querySelectorAll?.('.bar-overflow') ?? []) node.classList.remove('bar-overflow');
18945
20367
  barKit.hidden = [];
18946
20368
  barKit.more.hidden = true;
@@ -18956,6 +20378,9 @@ function barFit() {
18956
20378
  }
18957
20379
  if (!barKit.hidden.length) { barKit.more.hidden = true; barCloseMore(); }
18958
20380
  else if (barKit.moreCard && !barKit.moreCard.hidden) barDrawMore();
20381
+ // Everything that can go is behind «More» and the project's name is still cut: the pills that
20382
+ // stay (notes to accept) take their short form, so the name reads whole.
20383
+ if (barSpills(bar)) bar.classList?.add('bar-tight');
18959
20384
  }
18960
20385
  function barDrawMore() {
18961
20386
  const catalogue = barCatalogue();
@@ -18990,6 +20415,20 @@ function barDrawMore() {
18990
20415
  card.style.left = `${Math.round(Math.max(16, Math.min(window.innerWidth - width - 16, key.right - width)))}px`;
18991
20416
  barKit.more.setAttribute('aria-expanded', 'true');
18992
20417
  }
20418
+ // The bar folds its keys into 34 px squares at 980 px of its own width (styles.css, @container
20419
+ // bar); the house's keys sit in a bar of their own and fold with it, so one row never holds two
20420
+ // sizes. The conversation's width goes to the root for what the window's width cannot tell.
20421
+ function roomWidth() {
20422
+ const bar = barKit.node;
20423
+ if (!bar || typeof getComputedStyle !== 'function') return;
20424
+ const style = getComputedStyle(bar);
20425
+ const inner = bar.clientWidth - (parseFloat(style.paddingLeft) || 0) - (parseFloat(style.paddingRight) || 0);
20426
+ document.querySelector('#bar-home')?.classList.toggle('keys-square', inner > 0 && inner <= 980);
20427
+ const room = threadBox?.getBoundingClientRect?.().width ?? 0;
20428
+ const root = document.documentElement;
20429
+ root?.classList?.toggle('room-720', room > 0 && room <= 720);
20430
+ root?.classList?.toggle('room-680', room > 0 && room <= 680);
20431
+ }
18993
20432
  function barCloseMore() {
18994
20433
  if (barKit.moreCard) barKit.moreCard.hidden = true;
18995
20434
  barKit.more?.setAttribute('aria-expanded', 'false');
@@ -19008,6 +20447,8 @@ function barSave(order) {
19008
20447
  }
19009
20448
  // The board answered: modules of yours can take their places, and their names are their own.
19010
20449
  function barModulesChanged() {
20450
+ // A module's key beside the version comes and goes with the module (attribution.js).
20451
+ void drawColophonKeys(document.querySelector('#colophon'));
19011
20452
  if (modules.items?.length) barKit.modulesSeen = true;
19012
20453
  barLayout();
19013
20454
  }
@@ -19115,10 +20556,14 @@ function barDrawCard() {
19115
20556
  document.body.append(card);
19116
20557
  }
19117
20558
  const card = barKit.card;
20559
+ card.classList.add('bar-add');
20560
+ // The title and, under it, how the bar is arranged; Done at their right. The keys below.
19118
20561
  const head = el('header', 'bar-card-head');
19119
20562
  const done = sxButton(t('Done'), () => barEdit(false), { tone: 'primary' });
19120
20563
  done.classList.add('bar-done');
19121
- head.append(el('h3', null, t('Add to the bar')), done);
20564
+ const titles = el('div', 'bar-card-titles');
20565
+ titles.append(el('h3', null, t('Add to the bar')), el('p', 'bar-card-sub', t('Drag a key to move it. Right-click the bar to come back here.')));
20566
+ head.append(titles, done);
19122
20567
  const grid = el('div', 'bar-grid');
19123
20568
  for (const [id, entry] of barCatalogue()) {
19124
20569
  if (id === BAR_FIXED || barKit.order.includes(id)) continue;
@@ -19131,12 +20576,16 @@ function barDrawCard() {
19131
20576
  // The crew's own drawing is on its one-button form, not on the spheres.
19132
20577
  const drawn = [...(entry.nodes ?? [])].reverse().map((node) => node.querySelector('svg')).find(Boolean)?.cloneNode?.(true);
19133
20578
  if (drawn) { drawn.removeAttribute?.('class'); glyph.append(drawn); } else if (entry.icon) glyph.innerHTML = `<svg viewBox="0 0 20 20" aria-hidden="true">${entry.icon}</svg>`; // trusted: our own SVG strings
20579
+ // The «+» on its corner, the mirror of the «−» on the keys of the bar.
20580
+ const plus = el('i', 'bar-tile-plus');
20581
+ plus.innerHTML = BAR_PLUS; // trusted: our own SVG string
20582
+ glyph.append(plus);
19134
20583
  tile.append(glyph, el('span', 'bar-tile-name', entry.name));
19135
20584
  tile.addEventListener('click', () => barAdd(id));
19136
20585
  grid.append(tile);
19137
20586
  }
19138
20587
  const focused = card.contains?.(document.activeElement) ? document.activeElement?.dataset?.barItem ?? 'done' : null;
19139
- card.replaceChildren(head, grid.children.length ? grid : el('p', 'bar-card-empty', t('Everything is already on the bar.')), el('p', 'bar-card-hint', t('Drag a key to move it. Right-click the bar to come back here.')));
20588
+ card.replaceChildren(head, grid.children.length ? grid : el('p', 'bar-card-empty', t('Everything is already on the bar.')));
19140
20589
  card.hidden = false;
19141
20590
  barPlaceCard();
19142
20591
  // Focus stays in the card: on the tile that took the place of the one just used, or on Done.
@@ -19199,7 +20648,7 @@ function barDragStart(event, id) {
19199
20648
  if (event.button !== 0 || barKit.drag) return;
19200
20649
  const node = barShown(id);
19201
20650
  if (!node || event.target?.closest?.('.bar-badge')) return;
19202
- const drag = { id, node, x0: event.clientX, base: 0, moved: false };
20651
+ const drag = { id, node, x0: event.clientX, scroll0: barKit.node.scrollLeft || 0, base: 0, moved: false };
19203
20652
  barKit.drag = drag;
19204
20653
  drag.move = (moved) => barDragMove(moved);
19205
20654
  drag.up = () => barDragEnd({ save: true });
@@ -19210,13 +20659,27 @@ function barDragStart(event, id) {
19210
20659
  function barDragMove(event) {
19211
20660
  const drag = barKit.drag;
19212
20661
  if (!drag) return;
19213
- const dx = event.clientX - drag.x0;
20662
+ const bar = barKit.node;
19214
20663
  if (!drag.moved) {
19215
- if (Math.abs(dx) < 5) return;
20664
+ if (Math.abs(event.clientX - drag.x0) < 5) return;
19216
20665
  drag.moved = true;
19217
20666
  drag.base = drag.node.offsetLeft;
19218
20667
  drag.node.classList.add('bar-lifted');
19219
20668
  }
20669
+ // Near an edge of a bar wider than its box, the bar runs under the key (styles.css: while it is
20670
+ // being arranged it scrolls sideways instead of spilling out of its box).
20671
+ const box = bar.getBoundingClientRect?.();
20672
+ if (box && bar.scrollWidth > bar.clientWidth + 1) {
20673
+ const edge = 36;
20674
+ if (event.clientX < box.left + edge) bar.scrollLeft -= 10;
20675
+ else if (event.clientX > box.right - edge) bar.scrollLeft += 10;
20676
+ }
20677
+ // The key is held between the first and the last key of the bar: it never leaves it.
20678
+ const placed = barKit.order.map((one) => barShown(one)).filter(Boolean);
20679
+ const lo = Math.min(...placed.map((node) => node === drag.node ? drag.base : node.offsetLeft));
20680
+ const hi = Math.max(...placed.map((node) => (node === drag.node ? drag.base : node.offsetLeft) + node.offsetWidth)) - drag.node.offsetWidth;
20681
+ const wanted = drag.base + (event.clientX - drag.x0) + ((bar.scrollLeft || 0) - drag.scroll0);
20682
+ const dx = Math.min(Math.max(wanted, lo), Math.max(lo, hi)) - drag.base;
19220
20683
  const centre = drag.base + dx + drag.node.offsetWidth / 2;
19221
20684
  const others = barKit.order.filter((one) => one !== drag.id).map((one) => [one, barShown(one)]).filter(([, node]) => node);
19222
20685
  const past = others.find(([, node]) => centre < node.offsetLeft + node.offsetWidth / 2)?.[0] ?? null;
@@ -19264,6 +20727,11 @@ if (barKit.node) {
19264
20727
  let fitting = 0;
19265
20728
  const refit = () => { cancelAnimationFrame?.(fitting); fitting = requestAnimationFrame?.(() => barFit()) ?? 0; };
19266
20729
  window.addEventListener?.('resize', refit);
20730
+ // The bar is as wide as the room, not the window (2026-10-17): the code workspace docked beside
20731
+ // the conversation narrows it with the window unmoved, and MODULES ended up under the house's
20732
+ // keys. Measured whenever the bar's own width changes: what fits, the house's keys at the size of
20733
+ // the bar's, and the room's width for the rules that read it (styles.css, room-720 · room-680).
20734
+ if (typeof ResizeObserver === 'function') new ResizeObserver(() => { roomWidth(); refit(); }).observe(barKit.node);
19267
20735
  // A key that comes or goes on its own (STOP ALL while agents work, a pill) changes what fits, and
19268
20736
  // so does the bar's own font arriving. «More» is left out: it is the fitting's own doing.
19269
20737
  if (typeof MutationObserver === 'function') {
@@ -19276,6 +20744,14 @@ if (barKit.node) {
19276
20744
  // Right-click on the bar between keys, while it is open, closes it, as a second right-click on a
19277
20745
  // key does. While it is open a key does not do what it does: it is being placed, not pressed.
19278
20746
  barKit.node.addEventListener('contextmenu', (event) => { if (barKit.editing && !event.barHandled) { event.preventDefault(); barEdit(false); } });
20747
+ // Being arranged, a bar wider than its box scrolls sideways; a mouse's wheel only turns up and
20748
+ // down, so it is turned into sideways there.
20749
+ barKit.node.addEventListener('wheel', (event) => {
20750
+ const bar = barKit.node;
20751
+ if (!barKit.editing || bar.scrollWidth <= bar.clientWidth + 1 || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
20752
+ bar.scrollLeft += event.deltaY;
20753
+ event.preventDefault();
20754
+ }, { passive: false });
19279
20755
  barKit.node.addEventListener('click', (event) => {
19280
20756
  if (!barKit.editing && !barKit.swallow) return;
19281
20757
  if (barKit.swallow) { barKit.swallow = false; event.preventDefault(); event.stopPropagation(); return; }