@jossuealcala/madre 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -133,8 +133,11 @@ const state = {
133
133
  ashInstalled: false,
134
134
  ash: false, // Ash asked for on this message: compact replies, nothing rewritten
135
135
  chosenModel: {}, // id -> model name picked in the composer
136
+ chosenEffort: {}, // id -> effort level picked in the composer (null: the CLI's own)
137
+ efforts: {}, // id -> { levels, default } from /api/models; no levels, no chip
136
138
  };
137
139
  try { state.chosenModel = JSON.parse(localStorage.getItem('pulse.chosenModel') ?? '{}') || {}; } catch { state.chosenModel = {}; }
140
+ try { state.chosenEffort = JSON.parse(localStorage.getItem('pulse.chosenEffort') ?? '{}') || {}; } catch { state.chosenEffort = {}; }
138
141
 
139
142
  // What the human has already dealt with. The ledger is history and is never rewritten, so a
140
143
  // dismissal is a reading preference and lives where reading preferences live: this browser.
@@ -1355,6 +1358,8 @@ function renderPicker() {
1355
1358
  modelChip.addEventListener('click', (event) => { event.stopPropagation(); toggleModelMenu(current.id, modelChip); });
1356
1359
  text.append(modelChip);
1357
1360
  }
1361
+ const effortChip = renderEffortChip(current.id);
1362
+ if (effortChip) text.append(effortChip);
1358
1363
  els.picker.append(text);
1359
1364
  }
1360
1365
  if (typeof renderCreateScopes === 'function') renderCreateScopes();
@@ -1373,6 +1378,7 @@ async function ensureModels(id) {
1373
1378
  try {
1374
1379
  const data = await fetch(`/api/models${id === 'opencode' ? '?opencode=1' : ''}`).then((response) => response.json());
1375
1380
  state.models = { ...state.models, ...(data.models ?? {}) };
1381
+ takeEfforts(data.models);
1376
1382
  } catch { /* offline: fall back to whatever we know */ }
1377
1383
  return state.models[id] ?? { models: [], default: null, note: '' };
1378
1384
  }
@@ -1419,10 +1425,144 @@ function placeModelMenu(anchor) {
1419
1425
  modelMenu.style.left = `${Math.max(12, Math.min(window.innerWidth - width - 12, rect.left))}px`;
1420
1426
  modelMenu.style.top = `${Math.max(12, rect.top - height - 10)}px`;
1421
1427
  }
1422
- function closeModelMenu() { modelMenu.hidden = true; modelMenuFor = null; }
1428
+ function closeModelMenu() { modelMenu.hidden = true; modelMenuFor = null; closeEffortMenu(); }
1423
1429
  document.addEventListener('click', (event) => { if (!modelMenu.hidden && !modelMenu.contains(event.target) && !event.target.closest('.picker')) closeModelMenu(); });
1424
1430
  document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeModelMenu(); });
1425
1431
 
1432
+ /* ---------- effort menu: how hard the model thinks before it answers ---------- */
1433
+
1434
+ // Beside the model, because it is the same kind of decision: what this request is worth. Only
1435
+ // the CLIs that take it per run get the chip (Claude Code's --effort, Codex's reasoning effort);
1436
+ // the levels come from the server so the chip never offers a flag the CLI would refuse.
1437
+ //
1438
+ // "Default" sends nothing, and the CLI does what its own config says. The chip shows that level
1439
+ // dimmed when MADRE can read it (Codex's config.toml), so the human sees what they will get
1440
+ // without having chosen it.
1441
+ const EFFORT_WORDS = {
1442
+ minimal: { label: 'Minimal', hint: 'Answers almost at once. For lookups and one-line edits.' },
1443
+ low: { label: 'Low', hint: 'Quick and light. For clear, contained requests.' },
1444
+ medium: { label: 'Medium', hint: 'The everyday balance of speed and depth.' },
1445
+ high: { label: 'High', hint: 'Thinks it through. For design calls and tricky bugs.' },
1446
+ xhigh: { label: 'Extra high', hint: 'Longer reasoning for hard, multi-step problems.' },
1447
+ max: { label: 'Max', hint: 'Everything it has. The slowest and the most expensive.' },
1448
+ };
1449
+ const effortMenu = el('div', 'model-menu effort-menu');
1450
+ effortMenu.hidden = true;
1451
+ document.body.append(effortMenu);
1452
+ let effortMenuFor = null;
1453
+ let effortsAsked = false;
1454
+
1455
+ function takeEfforts(models) {
1456
+ for (const [id, info] of Object.entries(models ?? {})) {
1457
+ state.efforts[id] = { levels: Array.isArray(info.efforts) ? info.efforts : [], default: info.effortDefault ?? null };
1458
+ }
1459
+ }
1460
+
1461
+ // Asked once per page: the chip has to know whether to exist before anyone opens a menu. The
1462
+ // plain listing is enough — it never runs `opencode models`, which is the slow one.
1463
+ function ensureEfforts() {
1464
+ if (effortsAsked) return;
1465
+ effortsAsked = true;
1466
+ fetch('/api/models').then((response) => response.json()).then((data) => { takeEfforts(data.models); renderPicker(); }).catch(() => {});
1467
+ }
1468
+
1469
+ function effortMeter(levels, level, { ghost = false } = {}) {
1470
+ const meter = el('span', `effort-meter${ghost ? ' ghost' : ''}`);
1471
+ meter.setAttribute('aria-hidden', 'true');
1472
+ const at = levels.indexOf(level);
1473
+ levels.forEach((_, index) => meter.append(el('i', index <= at ? 'on' : null)));
1474
+ meter.style.setProperty('--bars', String(levels.length));
1475
+ return meter;
1476
+ }
1477
+
1478
+ function effortLabel(level) { return t(EFFORT_WORDS[level]?.label ?? level); }
1479
+
1480
+ function renderEffortChip(id) {
1481
+ const info = state.efforts[id];
1482
+ if (!info) { ensureEfforts(); return null; }
1483
+ if (!info.levels.length) return null;
1484
+ const chosen = info.levels.includes(state.chosenEffort[id]) ? state.chosenEffort[id] : null;
1485
+ const shown = chosen ?? info.default;
1486
+ const chip = el('button', `effort-chip${chosen ? ' set' : ''}${shown === info.levels.at(-1) ? ' peak' : ''}`);
1487
+ chip.type = 'button';
1488
+ chip.append(effortMeter(info.levels, shown, { ghost: !chosen }), el('span', 'word', chosen ? effortLabel(chosen) : t('effort')), el('span', 'caret', '▾'));
1489
+ chip.title = chosen
1490
+ ? `${t('Effort for this agent · {level}', { level: effortLabel(chosen) })}\n${t(EFFORT_WORDS[chosen]?.hint ?? '')}`
1491
+ : info.default
1492
+ ? t('Effort for this agent · the CLI default ({level})', { level: effortLabel(info.default) })
1493
+ : t("Effort for this agent · the CLI's own default");
1494
+ chip.setAttribute('aria-haspopup', 'menu');
1495
+ chip.addEventListener('click', (event) => { event.stopPropagation(); toggleEffortMenu(id, chip); });
1496
+ return chip;
1497
+ }
1498
+
1499
+ function rememberEffort(id, level) {
1500
+ if (level) state.chosenEffort[id] = level; else delete state.chosenEffort[id];
1501
+ try { localStorage.setItem('pulse.chosenEffort', JSON.stringify(state.chosenEffort)); } catch { /* private mode */ }
1502
+ }
1503
+
1504
+ function toggleEffortMenu(id, anchor) {
1505
+ if (!effortMenu.hidden && effortMenuFor === id) { closeEffortMenu(); return; }
1506
+ modelMenu.hidden = true; modelMenuFor = null;
1507
+ closeModeMenu();
1508
+ const info = state.efforts[id];
1509
+ if (!info?.levels.length) return;
1510
+ effortMenuFor = id;
1511
+ paint(effortMenu, id);
1512
+ effortMenu.replaceChildren();
1513
+ const title = el('div', 'model-menu-title');
1514
+ title.append(el('b', null, `@${id}`), t(' · effort for this request'));
1515
+ effortMenu.append(title);
1516
+ const chosen = info.levels.includes(state.chosenEffort[id]) ? state.chosenEffort[id] : null;
1517
+ const ladder = el('div', 'effort-ladder');
1518
+ ladder.setAttribute('role', 'menu');
1519
+ const options = [null, ...info.levels];
1520
+ for (const level of options) {
1521
+ const current = level === chosen;
1522
+ const option = el('button', `effort-option${current ? ' current' : ''}${level === null ? ' auto' : ''}${level === info.levels.at(-1) ? ' peak' : ''}`);
1523
+ option.type = 'button';
1524
+ option.setAttribute('role', 'menuitemradio');
1525
+ option.setAttribute('aria-checked', String(current));
1526
+ const body = el('span', 'body');
1527
+ if (level === null) {
1528
+ body.append(
1529
+ el('span', 'name', t('Default')),
1530
+ el('span', 'hint', info.default ? t('Sends nothing: the CLI uses its own setting, {level} right now.', { level: effortLabel(info.default) }) : t('Sends nothing: the CLI uses its own setting.')),
1531
+ );
1532
+ option.append(effortMeter(info.levels, info.default, { ghost: true }), body);
1533
+ } else {
1534
+ body.append(el('span', 'name', effortLabel(level)), el('span', 'hint', t(EFFORT_WORDS[level]?.hint ?? '')));
1535
+ option.append(effortMeter(info.levels, level), body);
1536
+ }
1537
+ const tag = current ? t('NOW') : level !== null && level === info.default ? t('CLI') : '';
1538
+ if (tag) {
1539
+ const label = el('span', 'tag', tag);
1540
+ if (current) label.append(el('span', 'dot'));
1541
+ option.append(label);
1542
+ }
1543
+ option.addEventListener('click', () => { rememberEffort(id, level); closeEffortMenu(); renderPicker(); els.input.focus(); });
1544
+ ladder.append(option);
1545
+ }
1546
+ effortMenu.append(ladder);
1547
+ effortMenu.append(el('div', 'model-note', t('More effort is a slower, longer answer, and on a metered plan a more expensive one. It applies to every message to this agent until you change it.')));
1548
+ effortMenu.hidden = false;
1549
+ const rect = anchor.getBoundingClientRect();
1550
+ const width = effortMenu.offsetWidth || 340;
1551
+ const height = effortMenu.offsetHeight || 300;
1552
+ effortMenu.style.left = `${Math.max(12, Math.min(window.innerWidth - width - 12, rect.left))}px`;
1553
+ effortMenu.style.top = `${Math.max(12, rect.top - height - 10)}px`;
1554
+ ladder.querySelector('.effort-option.current, .effort-option')?.focus({ preventScroll: true });
1555
+ }
1556
+ function closeEffortMenu() { effortMenu.hidden = true; effortMenuFor = null; }
1557
+ document.addEventListener('click', (event) => { if (!effortMenu.hidden && !effortMenu.contains(event.target) && !event.target.closest('.picker')) closeEffortMenu(); });
1558
+ effortMenu.addEventListener('keydown', (event) => {
1559
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
1560
+ event.preventDefault();
1561
+ const items = [...effortMenu.querySelectorAll('.effort-option')];
1562
+ const at = items.indexOf(document.activeElement);
1563
+ items[(at + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length]?.focus();
1564
+ });
1565
+
1426
1566
  /* ---------- mode menu: how far this message may go ---------- */
1427
1567
 
1428
1568
  const modeMenu = el('div', 'model-menu mode-menu');
@@ -1439,6 +1579,7 @@ function defaultModeFor(id) { return state.capabilities[id]?.scopes?.defaultMode
1439
1579
  function toggleModeMenu(id, anchor) {
1440
1580
  if (!modeMenu.hidden && modeMenuFor === id) { closeModeMenu(); return; }
1441
1581
  closeModelMenu();
1582
+ closeEffortMenu();
1442
1583
  modeMenuFor = id;
1443
1584
  paint(modeMenu, id);
1444
1585
  modeMenu.replaceChildren();
@@ -1520,6 +1661,7 @@ const override = {
1520
1661
  cancel: document.querySelector('#override-cancel'),
1521
1662
  frame: document.querySelector('#override .override-frame'),
1522
1663
  agent: null,
1664
+ then: null, // called once armed, by whoever opened it for something more than arming
1523
1665
  raise: false, // the agent is capped below the mode: arming also raises MAX MODE in CONNECTIONS
1524
1666
  mode: 3, // #3 CONTROL or #4 AIRLOCK
1525
1667
  stage: 'designation', // AIRLOCK asks twice: the designation, then the word AIRLOCK
@@ -1531,9 +1673,12 @@ const OVERRIDE_BRIEF = {
1531
1673
  + (raise ? t(' THIS ALSO RAISES @{id} MAX MODE TO #{n} IN CONNECTIONS.', { id, n: 4 }) : '') + t(' TYPE THE PROJECT DESIGNATION, THEN THE WORD AIRLOCK.'),
1532
1674
  };
1533
1675
  function projectDesignation() { return (state.projectRoot ?? '').split('/').filter(Boolean).pop() ?? ''; }
1534
- function openOverride(id, { raise = false, mode = 3 } = {}) {
1676
+ function openOverride(id, { raise = false, mode = 3, then = null } = {}) {
1535
1677
  if (!override.dialog) return;
1536
1678
  override.agent = id;
1679
+ // What to do once it is armed. The composer's own road arms and waits for the human to send;
1680
+ // the way up on an agent's card sends its permission right away.
1681
+ override.then = then;
1537
1682
  override.raise = raise;
1538
1683
  override.mode = mode;
1539
1684
  override.stage = 'designation';
@@ -1547,7 +1692,7 @@ function openOverride(id, { raise = false, mode = 3 } = {}) {
1547
1692
  override.dialog.showModal();
1548
1693
  override.input.focus();
1549
1694
  }
1550
- override.cancel?.addEventListener('click', () => override.dialog.close());
1695
+ override.cancel?.addEventListener('click', () => { override.then = null; override.dialog.close(); });
1551
1696
  override.form?.addEventListener('submit', (event) => {
1552
1697
  event.preventDefault();
1553
1698
  const typed = override.input.value.trim();
@@ -1578,6 +1723,8 @@ override.form?.addEventListener('submit', (event) => {
1578
1723
  const agent = override.agent;
1579
1724
  const raise = override.raise;
1580
1725
  const mode = override.mode;
1726
+ const then = override.then;
1727
+ override.then = null;
1581
1728
  setTimeout(async () => {
1582
1729
  override.dialog.close();
1583
1730
  if (raise) {
@@ -1592,6 +1739,7 @@ override.form?.addEventListener('submit', (event) => {
1592
1739
  state.modeArmedFor = agent;
1593
1740
  els.target.value = agent;
1594
1741
  setMode(mode, { wink: true });
1742
+ if (then) { await then(); return; }
1595
1743
  toast(mode === 4
1596
1744
  ? t('MU/TH/UR › AIRLOCK open for @{agent} for this message.{raised} Files are checkpointed; what leaves the machine is not undone.', { agent, raised: raise ? t(' MAX MODE is now #4 in CONNECTIONS.') : '' })
1597
1745
  : t('MU/TH/UR › CONTROL armed for @{agent} for this message.{raised} A checkpoint is taken before it runs; every change is listed and UNDO is one click.', { agent, raised: raise ? t(' MAX MODE is now #3 in CONNECTIONS.') : '' }));
@@ -1864,8 +2012,9 @@ function row(kind, agentId, { compact = false } = {}) {
1864
2012
  }
1865
2013
 
1866
2014
  function renderUserMessage(event) {
1867
- const { messageId, target, text, ash, model, attachments = [], create, mode } = event.payload;
2015
+ const { messageId, target, text, ash, model, effort, attachments = [], create, mode } = event.payload;
1868
2016
  state.userMessages.set(messageId, { text, target, model, mode: event.payload.mode ?? 1 });
2017
+ settleModeAsksFor(target, event.payload.mode ?? 1);
1869
2018
  const reviewed = state.expendable;
1870
2019
  const node = row('user');
1871
2020
  if (event.ghost || mode === 0) node.classList.add('ghost');
@@ -1882,7 +2031,7 @@ function renderUserMessage(event) {
1882
2031
  if (attachments.length) bubble.append(fileTiles(attachments));
1883
2032
  col.append(bubble);
1884
2033
  const stamp = paint(el('div', 'stamp'), target);
1885
- stamp.append(el('span', 'to', `→ @${target}${model ? ` · ${model}` : ''}`));
2034
+ stamp.append(el('span', 'to', `→ @${target}${model ? ` · ${model}` : ''}${effort ? ` · ${effortLabel(effort).toLowerCase()}` : ''}`));
1886
2035
  stamp.append(el('span', null, formatTime(event.timestamp)));
1887
2036
  if (reviewed) stamp.append(el('span', null, t('acknowledged, human')));
1888
2037
  col.append(stamp);
@@ -1893,7 +2042,7 @@ function renderUserMessage(event) {
1893
2042
  }
1894
2043
 
1895
2044
  function renderAssistantMessage(event) {
1896
- const { messageId, parentMessageId, sender, target, text, ash, status, step, totalSteps, model, mode } = event.payload;
2045
+ const { messageId, parentMessageId, sender, target, text, ash, status, step, totalSteps, model, effort, mode } = event.payload;
1897
2046
  const delegated = status === 'delegated';
1898
2047
  const compact = state.lastSender === sender && !delegated && !ash?.active && !event.ghost;
1899
2048
  const node = row('assistant', sender, { compact });
@@ -1914,6 +2063,7 @@ function renderAssistantMessage(event) {
1914
2063
  }
1915
2064
  if (status === 'handoff') who.append(el('span', 'badge', t('handoff note')));
1916
2065
  if (model) who.append(el('span', 'badge model', model));
2066
+ if (effort) who.append(el('span', 'badge model effort', t('effort {level}', { level: effortLabel(effort).toLowerCase() })));
1917
2067
  if (Number.isInteger(mode) && mode !== 1) who.append(el('span', `badge mode m${mode}`, `#${mode} ${MODES[mode].label}`));
1918
2068
  if (event.payload.escalation) who.append(el('span', 'badge mode m1', event.payload.escalation === 'timeout' ? '#2 not answered · read-only' : event.payload.escalation === 'stopped' ? 'stopped' : '#2 denied · read-only'));
1919
2069
  if (ash?.active) who.append(el('span', 'badge ash', t('ASH')));
@@ -1932,7 +2082,7 @@ function renderAssistantMessage(event) {
1932
2082
  col.append(bubble);
1933
2083
  const used = attachMemoryUsed(event.payload);
1934
2084
  if (used) col.append(used);
1935
- const needs = attachModeAsk(event.payload);
2085
+ const needs = attachModeAsk(event.payload, { sequence: event.sequence });
1936
2086
  if (needs) col.append(needs);
1937
2087
  const stamp = el('div', 'stamp');
1938
2088
  stamp.id = `usage-${messageId}`;
@@ -2331,9 +2481,27 @@ function renderModuleProposed(event) {
2331
2481
  const everyRoom = el('button', 'act-link', t('INSTALL FOR EVERY ROOM')); everyRoom.type = 'button'; everyRoom.addEventListener('click', () => install('user', everyRoom));
2332
2482
  const thisProject = el('button', 'act-link', t('INSTALL FOR THIS PROJECT')); thisProject.type = 'button'; thisProject.addEventListener('click', () => install('project', thisProject));
2333
2483
  node.append(everyRoom, ' · ', thisProject, t(' · a module runs inside MADRE with your permissions'));
2484
+ // An agent asked to fix a module writes a new file beside the old one; the room keeps running
2485
+ // the copy that was installed until this one is. The card says so, or a fixed module looks
2486
+ // installed while the broken one keeps answering.
2487
+ void installedModules().then((list) => {
2488
+ const id = String(name ?? path ?? '').split('/').pop().replace(/\.module\.m?js$/, '');
2489
+ const current = list.find((item) => item.id === id && item.external);
2490
+ if (!current) return;
2491
+ everyRoom.textContent = t('REPLACE FOR EVERY ROOM');
2492
+ thisProject.textContent = t('REPLACE FOR THIS PROJECT');
2493
+ node.append(el('span', 'replaces', t('{name} {version} is already installed for {where}; it keeps running until this file replaces it.', { name: current.name, version: current.version ? `v${current.version}` : '', where: current.origin === 'project' ? t('this project') : t('every room') }).replace(/\s+/g, ' ')));
2494
+ });
2334
2495
  if (responseMessageId) node.dataset.for = responseMessageId;
2335
2496
  return node;
2336
2497
  }
2498
+ // The modules this room has, asked once per page load and shared by every card that wants to know.
2499
+ let installedModulesAsked = null;
2500
+ function installedModules() {
2501
+ if (modules.items?.length) return Promise.resolve(modules.items);
2502
+ installedModulesAsked ??= fetch('/api/extensions').then((response) => response.json()).then((data) => data.extensions ?? []).catch(() => []);
2503
+ return installedModulesAsked;
2504
+ }
2337
2505
  function renderModuleInstalledOrRemoved(event) {
2338
2506
  const { name, origin, by } = event.payload;
2339
2507
  const node = el('div', 'system module-proposed');
@@ -2448,28 +2616,98 @@ function renderDistilled(event) {
2448
2616
  // the wrong one twice while the turn went nowhere. The ladder belongs next to the agent asking
2449
2617
  // to climb it.
2450
2618
  //
2451
- // It offers; it never grants. #2 arms the composer, and #3 and #4 open the same ceremony as
2452
- // always, where the project designation is typed by hand. Nothing here shortens that.
2453
- function attachModeAsk(payload) {
2619
+ // The way up grants and goes on. Pressing it used to arm the composer and stop there: the human
2620
+ // then had to write "permission granted" by hand, in a separate message, to the agent that had just
2621
+ // asked — a second step nobody could guess from a button that said ARM. Now the press is the
2622
+ // answer: it opens the same ceremony as always for #3 and #4, where the project designation is
2623
+ // typed by hand (nothing here shortens that), and then sends the agent its permission, quoting
2624
+ // the reply that asked, at the mode it asked for. WRITE IT MYSELF keeps the old road for a human
2625
+ // who wants to add instructions; NOT NOW folds the card.
2626
+ const modeAsks = []; // { agent, wanted, card } still waiting for an answer in this page
2627
+ function attachModeAsk(payload, { sequence = null } = {}) {
2454
2628
  if (!payload || payload.role !== 'assistant' || payload.sender === 'you') return null;
2455
2629
  const ran = state.userMessages.get(payload.messageId)?.mode ?? payload.mode ?? 1;
2456
2630
  const wanted = modeAsked(payload.text, { ran });
2457
2631
  if (!wanted || wanted <= state.mode) return null;
2458
2632
  const agent = payload.sender;
2459
2633
  if (!state.agents.get(agent)?.ready) return null;
2460
- const row = el('div', 'mode-ask');
2461
- row.append(el('span', 'mode-what', t('@{agent} says this needs #{n} {label}', { agent, n: wanted, label: MODES[wanted]?.label ?? '' })));
2462
- const go = el('button', 'mode-go', wanted >= 3 ? t('ARM #{n}', { n: wanted }) : t('SET #{n}', { n: wanted }));
2634
+ const label = MODES[wanted]?.label ?? '';
2635
+ const card = el('div', 'mode-ask');
2636
+ card.style.setProperty('--agent', agentColor(agent));
2637
+ const what = el('div', 'mode-what');
2638
+ what.append(el('b', 'who', `@${agent}`), t(' asks for #{n} {label} to go on', { n: wanted, label }));
2639
+ const why = el('div', 'mode-why', wanted >= 3
2640
+ ? t('You will type the project designation, as always. Then @{agent} gets your permission and continues.', { agent })
2641
+ : t('@{agent} gets your permission for one message and continues.', { agent }));
2642
+ const actions = el('div', 'mode-actions');
2643
+ const go = el('button', 'mode-go', t('GRANT #{n} AND CONTINUE', { n: wanted }));
2463
2644
  go.type = 'button';
2464
- go.title = wanted >= 3
2465
- ? t('Opens the override: you type the project designation, as always.')
2466
- : t('Arms the composer for your next message. Nothing is sent.');
2467
- go.addEventListener('click', () => {
2468
- if (wanted >= 3) openOverride(agent, { mode: wanted });
2469
- else { setMode(wanted, { wink: true }); els.input.focus(); }
2470
- });
2471
- row.append(go);
2472
- return row;
2645
+ const myself = el('button', 'mode-alt', t('WRITE IT MYSELF'));
2646
+ myself.type = 'button';
2647
+ myself.title = t('Arms #{n} in the box below, so you can add instructions before sending.', { n: wanted });
2648
+ const later = el('button', 'mode-alt', t('NOT NOW'));
2649
+ later.type = 'button';
2650
+ actions.append(go, myself, later);
2651
+ card.append(what, why, actions);
2652
+ const quoted = { sender: agent, sequence, text: (() => { const one = String(payload.text ?? '').replace(/\s+/g, ' ').trim(); return one.length > REPLY_CLIP ? `${one.slice(0, REPLY_CLIP - 1)}…` : one; })() };
2653
+ const ask = { agent, wanted, card };
2654
+ modeAsks.push(ask);
2655
+ const arm = (then) => {
2656
+ if (wanted >= 3) openOverride(agent, { mode: wanted, raise: wanted > ceilingFor(agent) && Boolean(state.capabilities[agent]?.scopes?.write?.capable), then });
2657
+ else { els.target.value = agent; renderPicker(); setMode(wanted, { wink: true }); then?.(); }
2658
+ };
2659
+ go.addEventListener('click', () => arm(async () => {
2660
+ go.disabled = true;
2661
+ const sent = await sendPermission({ agent, mode: wanted, quoted });
2662
+ if (!sent) { go.disabled = false; return; }
2663
+ settleModeAsk(ask, t('#{n} {label} granted · @{agent} continues', { n: wanted, label, agent }));
2664
+ }));
2665
+ myself.addEventListener('click', () => arm(() => {
2666
+ state.replyTo = quoted;
2667
+ renderReplyQuote();
2668
+ els.input.focus();
2669
+ }));
2670
+ later.addEventListener('click', () => settleModeAsk(ask, null));
2671
+ return card;
2672
+ }
2673
+ // A card that has been answered says how, once, and offers nothing more; NOT NOW folds it away.
2674
+ function settleModeAsk(ask, said) {
2675
+ const at = modeAsks.indexOf(ask);
2676
+ if (at >= 0) modeAsks.splice(at, 1);
2677
+ if (!said) { ask.card.remove(); return; }
2678
+ ask.card.classList.add('settled');
2679
+ ask.card.replaceChildren(el('div', 'mode-what', said));
2680
+ }
2681
+ // The human answered the ask their own way — a message to that agent at that mode or above —
2682
+ // and the card stops asking. This is also what quiets old cards when the page is rebuilt.
2683
+ function settleModeAsksFor(target, mode) {
2684
+ for (const ask of [...modeAsks]) {
2685
+ if (ask.agent === target && mode >= ask.wanted) settleModeAsk(ask, t('#{n} {label} granted', { n: ask.wanted, label: MODES[ask.wanted]?.label ?? '' }));
2686
+ }
2687
+ }
2688
+ // The permission itself: a message from the human, quoting the reply that asked, at the mode it
2689
+ // asked for. It goes through the room like any other message, so the ceiling and the one-holder
2690
+ // rule still answer for it; a refusal comes back as a toast and the card stays.
2691
+ async function sendPermission({ agent, mode, quoted }) {
2692
+ const text = `${quoteHead(quoted)}${t('Permission granted: #{n} {label}. Go ahead with what you proposed.', { n: mode, label: MODES[mode]?.label ?? '' })}`;
2693
+ try {
2694
+ const response = await fetch('/api/messages', {
2695
+ method: 'POST',
2696
+ headers: { 'content-type': 'application/json' },
2697
+ body: JSON.stringify({ text, target: agent, model: state.chosenModel[agent] ?? null, effort: state.chosenEffort[agent] ?? null, attachments: [], create: mode === 2, mode, ash: state.ashInstalled && state.ash }),
2698
+ });
2699
+ if (!response.ok) {
2700
+ const result = await response.json().catch(() => ({}));
2701
+ toast(`MU/TH/UR › ${result.error ?? t('The room rejected the message.')}`);
2702
+ setMode(defaultModeFor(els.target.value));
2703
+ return false;
2704
+ }
2705
+ resetModeAfterSend();
2706
+ return true;
2707
+ } catch (error) {
2708
+ toast(t('Could not reach MADRE: {error}', { error: error.message }));
2709
+ return false;
2710
+ }
2473
2711
  }
2474
2712
 
2475
2713
  function attachMemoryUsed(payload) {
@@ -2709,7 +2947,10 @@ function renderControlReverted(event) {
2709
2947
  const { agent, checkpointId, restored = [], removed = [], message } = event.payload;
2710
2948
  const node = el('div', 'system control reverted');
2711
2949
  node.style.setProperty('--agent', agentColor(agent));
2712
- node.append(el('b', null, t('CONTROL')), t("project restored to the checkpoint before @{agent}'s turn · {restored} restored · {removed} removed", { agent, restored: restored.length, removed: removed.length }));
2950
+ const { command } = event.payload;
2951
+ node.append(el('b', null, command ? '/run' : t('CONTROL')), command
2952
+ ? t('project restored to the checkpoint before the command · {restored} restored · {removed} removed', { restored: restored.length, removed: removed.length })
2953
+ : t("project restored to the checkpoint before @{agent}'s turn · {restored} restored · {removed} removed", { agent, restored: restored.length, removed: removed.length }));
2713
2954
  node.title = message;
2714
2955
  const card = document.getElementById(`control-${checkpointId}`);
2715
2956
  card?.querySelector('.undo')?.replaceWith(el('span', 'outcome', t('RESTORED')));
@@ -2731,7 +2972,7 @@ function renderLeaseMissing(event) {
2731
2972
  resend.addEventListener('click', async () => {
2732
2973
  resend.disabled = true;
2733
2974
  try {
2734
- const response = await fetch('/api/messages', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, target: agent, model: state.chosenModel[agent] ?? null, attachments: [], create: true }) });
2975
+ const response = await fetch('/api/messages', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, target: agent, model: state.chosenModel[agent] ?? null, effort: state.chosenEffort[agent] ?? null, attachments: [], create: true }) });
2735
2976
  if (!response.ok) { const result = await response.json().catch(() => ({})); throw new Error(result.error ?? `HTTP ${response.status}`); }
2736
2977
  resend.textContent = t('RESENT WITH CREATE');
2737
2978
  } catch (error) { toast(t('It could not be resent: {error}', { error: error.message })); resend.disabled = false; }
@@ -2945,29 +3186,102 @@ async function renderSettled() {
2945
3186
  box.hidden = false;
2946
3187
  }
2947
3188
 
3189
+ // A decision handed back. Each option is shown whole — it is the sentence the human is about to
3190
+ // send, and the half that got cut was the half that made it an option — and the agent's own pick
3191
+ // leads, marked, so the human can take it with one press or see at once what they are overruling.
2948
3192
  function renderChoice(event) {
2949
- const { question, options = [], agent } = event.payload;
3193
+ const { question, options = [], agent, recommended = null } = event.payload;
2950
3194
  if (!options.length) return null;
2951
3195
  const node = el('div', 'choice');
2952
3196
  node.style.setProperty('--agent', agentColor(agent));
2953
- node.append(el('p', 'choice-q', question));
2954
- const row = el('div', 'choice-row');
2955
- for (const option of options) {
2956
- const button = el('button', 'choice-option', option);
3197
+ const head = el('div', 'choice-head');
3198
+ head.append(el('span', 'choice-kicker', t('@{agent} asks you to choose', { agent })));
3199
+ node.append(head, el('p', 'choice-q', question));
3200
+ const list = el('div', 'choice-list');
3201
+ list.setAttribute('role', 'group');
3202
+ list.setAttribute('aria-label', question);
3203
+ const order = options.map((option, index) => ({ option, index }));
3204
+ const pick = Number.isInteger(recommended) && order[recommended] ? recommended : null;
3205
+ if (pick !== null) order.unshift(...order.splice(pick, 1));
3206
+ order.forEach(({ option, index }, position) => {
3207
+ const lead = index === pick;
3208
+ const button = el('button', `choice-option${lead ? ' recommended' : ''}`);
2957
3209
  button.type = 'button';
2958
- button.title = t('Writes it into the composer · nothing is sent until you press send');
3210
+ button.append(el('span', 'choice-key', String(position + 1)), el('span', 'choice-text', option));
3211
+ if (lead) button.append(el('span', 'choice-tag', t('RECOMMENDED')));
2959
3212
  button.addEventListener('click', () => {
3213
+ for (const other of list.querySelectorAll('.choice-option.picked')) other.classList.remove('picked');
3214
+ button.classList.add('picked');
2960
3215
  els.input.value = option;
2961
3216
  els.input.dispatchEvent(new Event('input', { bubbles: true }));
2962
3217
  els.input.focus();
2963
3218
  });
2964
- row.append(button);
2965
- }
2966
- node.append(row);
3219
+ list.append(button);
3220
+ });
3221
+ node.append(list, el('p', 'choice-foot', t('Writes it into the composer · nothing is sent until you press send')));
3222
+ state.lastSender = null;
3223
+ return node;
3224
+ }
3225
+
3226
+ // An agent asked the human to run commands it cannot run in its mode. One button per line; the
3227
+ // press sends only which request and which line, and the server runs what it recorded the agent
3228
+ // asked for. A line the parser refused is shown with its reason and no button.
3229
+ function renderRunRequest(event) {
3230
+ const { reason, commands = [], agent, responseMessageId } = event.payload;
3231
+ if (!commands.length) return null;
3232
+ const node = el('div', 'choice run-request');
3233
+ node.dataset.run = responseMessageId;
3234
+ node.style.setProperty('--agent', agentColor(agent));
3235
+ const head = el('div', 'choice-head');
3236
+ head.append(el('span', 'choice-kicker', t('@{agent} asks you to run', { agent })));
3237
+ node.append(head, el('p', 'choice-q', reason));
3238
+ const list = el('div', 'choice-list');
3239
+ list.setAttribute('role', 'group');
3240
+ list.setAttribute('aria-label', reason);
3241
+ commands.forEach((command, index) => {
3242
+ const button = el('button', `choice-option run-line${command.argv ? '' : ' refused'}`);
3243
+ button.type = 'button';
3244
+ button.dataset.index = String(index);
3245
+ button.append(el('span', 'choice-key', '$'), el('code', 'choice-text', command.line));
3246
+ if (!command.argv) {
3247
+ button.disabled = true;
3248
+ button.append(el('span', 'choice-tag', t('WILL NOT RUN')));
3249
+ button.title = t('This line does not run: it {why}.', { why: t(command.why, command.whyArgs ?? {}) });
3250
+ list.append(button, el('p', 'run-why', t('Does not run: it {why}.', { why: t(command.why, command.whyArgs ?? {}) })));
3251
+ return;
3252
+ }
3253
+ button.append(el('span', 'choice-tag', t('RUN')));
3254
+ button.title = t('Runs {command} from the project root, with no shell. A checkpoint is taken first.', { command: command.line });
3255
+ button.addEventListener('click', async () => {
3256
+ button.disabled = true;
3257
+ try {
3258
+ const response = await fetch('/api/runs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ responseMessageId, index }) });
3259
+ const result = await response.json().catch(() => ({}));
3260
+ if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
3261
+ } catch (error) {
3262
+ toast(`MU/TH/UR › ${error.message}`);
3263
+ markRun({ responseMessageId, index }, null);
3264
+ }
3265
+ });
3266
+ list.append(button);
3267
+ });
3268
+ node.append(list, el('p', 'choice-foot', t('Each line runs only when you press it · from the project root, no shell · the output comes back to the room')));
2967
3269
  state.lastSender = null;
2968
3270
  return node;
2969
3271
  }
2970
3272
 
3273
+ // The state of one line of a run request, from the events that follow it: running, ran, failed.
3274
+ // A line that ran can be pressed again; tests are run more than once.
3275
+ function markRun({ responseMessageId, index }, status) {
3276
+ const button = document.querySelector(`.run-request[data-run="${CSS.escape(String(responseMessageId))}"] .run-line[data-index="${Number(index)}"]`);
3277
+ if (!button || button.classList.contains('refused')) return;
3278
+ button.classList.remove('running', 'ran', 'failed');
3279
+ if (status) button.classList.add(status);
3280
+ button.disabled = status === 'running';
3281
+ const tag = button.querySelector('.choice-tag');
3282
+ if (tag) tag.textContent = { running: t('RUNNING'), ran: t('RUN AGAIN'), failed: t('RUN AGAIN') }[status] ?? t('RUN');
3283
+ }
3284
+
2971
3285
  function renderAlert(event) {
2972
3286
  const { message } = event.payload;
2973
3287
  if (!replaying) { state.brakeArmed = true; updateStopAll(); }
@@ -3275,6 +3589,8 @@ void renderOpenQuestions(); return;
3275
3589
  node = renderPlanEvent(event);
3276
3590
  break;
3277
3591
  case 'choice.offered': node = renderChoice(event); break;
3592
+ case 'run.requested': node = renderRunRequest(event); break;
3593
+ case 'run.started': markRun(event.payload, 'running'); return;
3278
3594
  case 'room.alert': node = renderAlert(event); break;
3279
3595
  case 'ash.validated': if (event.payload.ok) return; node = renderAshValidation(event); break;
3280
3596
  case 'room.stopped': node = renderHalted(event); break;
@@ -3417,7 +3733,10 @@ translateMarkup(document.body ?? document);
3417
3733
 
3418
3734
  /* ---------- project files panel ---------- */
3419
3735
 
3420
- const tree = { open: false, loaded: new Map() };
3736
+ // `openDirs` survives a reload of the tree: after a copy, a move or a new folder the panel is
3737
+ // rebuilt, and a tree that folds itself shut hides exactly the thing the human just made.
3738
+ // `reveal` is that thing, lit once when its row is drawn again.
3739
+ const tree = { open: false, loaded: new Map(), openDirs: new Set(), reveal: null };
3421
3740
  const formatSize = (bytes) => bytes == null ? '' : bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(bytes < 10240 ? 1 : 0)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
3422
3741
  const CODE_EXT = /\.(m?[jt]sx?|py|rb|go|rs|java|kt|swift|c|h|cpp|hpp|cs|php|sh|zsh|css|scss|html?|json|ya?ml|toml|sql|md)$/i;
3423
3742
 
@@ -3460,40 +3779,49 @@ function finderShowClip() {
3460
3779
  finder.clip.replaceChildren();
3461
3780
  // No t() around a template that is only two slots and a dot: there is nothing in it to say in
3462
3781
  // another language, and the catalogue's own guard rejects an entry whose Spanish is the English.
3463
- finder.clip.append(el('span', 'what', `${finder.held.operation === 'copy' ? t('COPY') : t('MOVE')} · ${finder.held.name}`));
3782
+ const what = el('span', 'what', `${finder.held.operation === 'copy' ? t('COPY') : t('MOVE')} · ${finder.held.name}`);
3783
+ what.title = finder.held.path;
3464
3784
  const cancel = el('button', null, t('CANCEL'));
3465
3785
  cancel.type = 'button';
3466
3786
  cancel.addEventListener('click', () => { finder.held = null; finderShowClip(); });
3467
- finder.clip.append(cancel);
3787
+ // Holding something is half an operation; the other half is a right click, and nothing on the
3788
+ // screen said so.
3789
+ finder.clip.append(what, cancel, el('span', 'how', t('Right-click a folder, or an empty spot for the project root, to paste it there.')));
3468
3790
  finder.clip.hidden = false;
3469
3791
  }
3470
3792
 
3471
3793
  // Asking for a word without leaving the panel. A name, or the designation: both are typed in the
3472
3794
  // same strip under the search box, where the hand already is. A browser prompt would take the
3473
3795
  // whole window for one word and throw away where the human was looking.
3474
- function finderAsk({ label, value = '', hint = '' }) {
3796
+ //
3797
+ // What is being asked is a line of its own, above the box, and it says WHERE: a placeholder is
3798
+ // gone the moment the first letter is typed, and in a narrow panel it was cut off before that.
3799
+ function finderAsk({ label, value = '', hint = '', where = null }) {
3475
3800
  return new Promise((resolve) => {
3476
3801
  if (!finder.clip) { resolve(null); return; }
3477
3802
  const form = el('form', 'tree-ask');
3803
+ const head = el('div', 'ask-label', label);
3804
+ if (where != null) head.append(el('span', 'where', t('in {path}', { path: where === '.' ? t('the project root') : `${where}/` })));
3478
3805
  const input = el('input');
3479
3806
  input.type = 'text';
3480
3807
  input.value = value;
3481
3808
  input.spellcheck = false;
3482
3809
  input.autocomplete = 'off';
3483
- input.placeholder = label;
3484
3810
  input.setAttribute('aria-label', label);
3485
3811
  const ok = el('button', 'go', t('OK'));
3486
3812
  ok.type = 'submit';
3487
3813
  const no = el('button', null, t('CANCEL'));
3488
3814
  no.type = 'button';
3489
- form.append(input, ok, no);
3815
+ const actions = el('div', 'ask-actions');
3816
+ actions.append(no, ok);
3817
+ form.append(head);
3818
+ if (hint) form.append(el('div', 'ask-hint', hint));
3819
+ form.append(input, actions);
3490
3820
  const done = (answer) => { finderShowClip(); resolve(answer); };
3491
3821
  form.addEventListener('submit', (event) => { event.preventDefault(); done(input.value.trim() || null); });
3492
3822
  no.addEventListener('click', () => done(null));
3493
- input.addEventListener('keydown', (event) => { if (event.key === 'Escape') { event.stopPropagation(); done(null); } });
3494
- finder.clip.replaceChildren();
3495
- if (hint) finder.clip.append(el('span', 'what', hint));
3496
- finder.clip.append(form);
3823
+ input.addEventListener('keydown', (event) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); done(null); } });
3824
+ finder.clip.replaceChildren(form);
3497
3825
  finder.clip.hidden = false;
3498
3826
  input.focus();
3499
3827
  // The extension stays out of the selection: renaming is almost always about the name.
@@ -3502,6 +3830,22 @@ function finderAsk({ label, value = '', hint = '' }) {
3502
3830
  });
3503
3831
  }
3504
3832
 
3833
+ const finderDirOf = (path) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.');
3834
+ const finderJoin = (dir, name) => (dir === '.' ? name : `${dir}/${name}`);
3835
+
3836
+ // The panel again, as it was: the folders that were open stay open, the one the result landed in
3837
+ // opens too, and the result itself is lit. A search in progress is asked again instead, so the
3838
+ // list the human was working from does not turn into the whole tree under their hand.
3839
+ function finderRefresh(landed) {
3840
+ if (landed) {
3841
+ for (let dir = finderDirOf(landed); dir !== '.'; dir = finderDirOf(dir)) tree.openDirs.add(dir);
3842
+ tree.reveal = landed;
3843
+ }
3844
+ if (finder.q?.value.trim()) { finder.q.dispatchEvent(new Event('input')); return; }
3845
+ els.treeBody.replaceChildren();
3846
+ void setTree(true);
3847
+ }
3848
+
3505
3849
  // One door for everything that writes: it asks for the designation only when it already knows one
3506
3850
  // will be wanted, so the human is not surprised mid-operation.
3507
3851
  async function finderRun(body, { say }) {
@@ -3514,34 +3858,36 @@ async function finderRun(body, { say }) {
3514
3858
  const response = await fetch('/api/tree/op', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
3515
3859
  const result = await response.json().catch(() => ({}));
3516
3860
  if (!response.ok) { toast(`MU/TH/UR › ${result.error ?? t('That could not be done.')}`); return false; }
3517
- toast(say);
3518
- els.treeBody.replaceChildren();
3519
- void setTree(true);
3861
+ // The server may have named a copy itself ("notes copy.md"); what it says is where it went.
3862
+ const landed = result.to ?? body.to;
3863
+ toast(typeof say === 'function' ? say(landed) : say);
3864
+ finderRefresh(landed);
3520
3865
  return true;
3521
3866
  }
3522
3867
 
3523
- const finderDirOf = (path) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.');
3524
- const finderJoin = (dir, name) => (dir === '.' ? name : `${dir}/${name}`);
3525
-
3526
3868
  async function finderPaste(intoDir) {
3527
3869
  if (!finder.held) return;
3528
3870
  const held = finder.held;
3529
3871
  const to = finderJoin(intoDir, held.name);
3530
- const did = await finderRun({ operation: held.operation, from: held.path, to }, { say: `${held.path} → ${to}` });
3872
+ // Moving a thing to where it already is: nothing to do, and nothing to complain about.
3873
+ if (held.operation === 'move' && to === held.path) { finder.held = null; finderShowClip(); return; }
3874
+ // A copy that lands on a name already taken gets a name of its own, the way a file manager
3875
+ // does it, instead of a refusal.
3876
+ const did = await finderRun({ operation: held.operation, from: held.path, to, unique: held.operation === 'copy' }, { say: (landed) => `${held.path} → ${landed}` });
3531
3877
  if (!did) return;
3532
3878
  finder.held = null;
3533
3879
  finderShowClip();
3534
3880
  }
3535
3881
 
3536
3882
  async function finderRename(path, entry) {
3537
- const name = await finderAsk({ label: t('NEW NAME'), value: entry.name });
3883
+ const name = await finderAsk({ label: t('NEW NAME'), value: entry.name, where: finderDirOf(path) });
3538
3884
  if (!name || name === entry.name) return;
3539
3885
  const to = finderJoin(finderDirOf(path), name);
3540
3886
  await finderRun({ operation: 'move', from: path, to }, { say: `${path} → ${to}` });
3541
3887
  }
3542
3888
 
3543
3889
  async function finderCreate(dir, kind) {
3544
- const name = await finderAsk({ label: kind === 'new-folder' ? t('NEW FOLDER') : t('NEW FILE') });
3890
+ const name = await finderAsk({ label: kind === 'new-folder' ? t('NEW FOLDER') : t('NEW FILE'), where: dir });
3545
3891
  if (!name) return;
3546
3892
  const to = finderJoin(dir, name);
3547
3893
  await finderRun({ operation: kind, to }, { say: to });
@@ -3549,6 +3895,7 @@ async function finderCreate(dir, kind) {
3549
3895
 
3550
3896
  function finderMenu(event, path, entry) {
3551
3897
  event.preventDefault();
3898
+ event.stopPropagation();
3552
3899
  const menu = finder.menu;
3553
3900
  if (!menu) return;
3554
3901
  menu.replaceChildren();
@@ -3560,14 +3907,17 @@ function finderMenu(event, path, entry) {
3560
3907
  };
3561
3908
  // Where the new thing lands: inside the folder that was clicked, or beside the file.
3562
3909
  const here = entry.kind === 'dir' ? path : finderDirOf(path);
3563
- add(t('Copy'), () => { finder.held = { path, name: entry.name, operation: 'copy' }; finderShowClip(); });
3564
- add(t('Move'), () => { finder.held = { path, name: entry.name, operation: 'move' }; finderShowClip(); });
3565
- add(t('Rename'), () => void finderRename(path, entry));
3566
- if (finder.held && entry.kind === 'dir') add(t('Paste here'), () => void finderPaste(path));
3910
+ // The project root is a place to put things, not a thing: it is not copied, moved or renamed.
3911
+ if (!entry.root) {
3912
+ add(t('Copy'), () => { finder.held = { path, name: entry.name, operation: 'copy' }; finderShowClip(); });
3913
+ add(t('Move'), () => { finder.held = { path, name: entry.name, operation: 'move' }; finderShowClip(); });
3914
+ add(t('Rename'), () => void finderRename(path, entry));
3915
+ }
3916
+ if (finder.held && entry.kind === 'dir') add(entry.root ? t('Paste in the project root') : t('Paste here'), () => void finderPaste(path));
3567
3917
  if (finder.held && entry.kind === 'file') add(t('Paste beside it'), () => void finderPaste(here));
3568
3918
  add(t('New file'), () => void finderCreate(here, 'new-file'));
3569
3919
  add(t('New folder'), () => void finderCreate(here, 'new-folder'));
3570
- if (finderGuarded(path)) menu.append(el('div', 'note', t('MADRE GUARDS THIS PATH · IT WILL ASK FOR THE DESIGNATION')));
3920
+ if (!entry.root && finderGuarded(path)) menu.append(el('div', 'note', t('MADRE GUARDS THIS PATH · IT WILL ASK FOR THE DESIGNATION')));
3571
3921
  menu.hidden = false;
3572
3922
  // Opened where the hand is, and kept inside the panel: a menu that spills across the room is a
3573
3923
  // menu the human has to go looking for.
@@ -3577,23 +3927,43 @@ function finderMenu(event, path, entry) {
3577
3927
  menu.style.top = `${Math.max(6, Math.min(event.clientY - panel.top, panel.height - box.height - 6))}px`;
3578
3928
  }
3579
3929
  document.addEventListener('click', (event) => { if (finder.menu && !finder.menu.hidden && !finder.menu.contains(event.target)) finder.menu.hidden = true; });
3930
+ // The space under the last row is the project root. Without it there was nowhere to paste
3931
+ // something back to the top level, or to make a folder there.
3932
+ els.treeBody?.addEventListener('contextmenu', (event) => {
3933
+ if (event.target.closest?.('button.node')) return;
3934
+ finderMenu(event, '.', { kind: 'dir', name: '.', root: true });
3935
+ });
3580
3936
 
3581
3937
  // Searching the project by name, with the same scoring the composer's "!" menu uses.
3938
+ // Each keystroke outdates the one before: an answer that arrives after the box was cleared, or
3939
+ // after another word was typed, is dropped instead of painted over what the human is now asking.
3582
3940
  let finderTimer = null;
3941
+ let finderAsked = 0;
3583
3942
  finder.q?.addEventListener('input', () => {
3584
3943
  clearTimeout(finderTimer);
3944
+ const asked = ++finderAsked;
3585
3945
  finderTimer = setTimeout(async () => {
3586
3946
  const query = finder.q.value.trim();
3587
3947
  if (!query) { els.treeBody.replaceChildren(); void setTree(true); return; }
3588
3948
  let matches = [];
3589
3949
  try { matches = (await fetch(`/api/tree/search?q=${encodeURIComponent(query)}&limit=40`).then((r) => r.json()))?.matches ?? []; }
3590
3950
  catch { return; }
3951
+ if (asked !== finderAsked) return;
3591
3952
  const list = el('ul');
3592
- for (const match of matches) list.append(treeNode(match.path, { kind: 'file', name: match.path, contentType: match.contentType }));
3953
+ for (const match of matches) list.append(treeNode(match.path, { kind: 'file', name: match.path.slice(match.path.lastIndexOf('/') + 1), label: match.path, contentType: match.contentType }));
3593
3954
  els.treeBody.replaceChildren(list);
3594
3955
  if (!matches.length) els.treeBody.append(el('div', 'tree-empty', t('NOTHING MATCHES {q}', { q: query })));
3595
3956
  }, 160);
3596
3957
  });
3958
+ // Escape empties the search and puts the tree back; a second Escape leaves the box. Every browser
3959
+ // draws its own clear button, or none, so the key is the one way that is always there.
3960
+ finder.q?.addEventListener('keydown', (event) => {
3961
+ if (event.key !== 'Escape') return;
3962
+ event.preventDefault();
3963
+ if (!finder.q.value) { finder.q.blur(); return; }
3964
+ finder.q.value = '';
3965
+ finder.q.dispatchEvent(new Event('input'));
3966
+ });
3597
3967
  finder.search?.addEventListener('submit', (event) => event.preventDefault());
3598
3968
 
3599
3969
  function treeNode(path, entry) {
@@ -3609,16 +3979,21 @@ function treeNode(path, entry) {
3609
3979
  const children = el('ul');
3610
3980
  children.hidden = true;
3611
3981
  let opened = false;
3612
- button.addEventListener('click', () => {
3613
- opened = !opened;
3982
+ const show = (open) => {
3983
+ opened = open;
3614
3984
  caret.textContent = opened ? '▾' : '▸';
3615
3985
  children.hidden = !opened;
3616
3986
  item.setAttribute('aria-expanded', String(opened));
3987
+ if (opened) tree.openDirs.add(path); else tree.openDirs.delete(path);
3617
3988
  if (opened && !children.childElementCount) void loadTreeLevel(path, children);
3618
- });
3989
+ };
3990
+ button.addEventListener('click', () => show(!opened));
3619
3991
  item.append(button, children);
3992
+ if (tree.openDirs.has(path)) show(true);
3620
3993
  } else {
3621
- button.append(el('span', 'caret', ''), el('span', 'name', entry.name), el('span', 'size', formatSize(entry.size)));
3994
+ // A search result shows where it lives; its NAME is still only the file's, because that is
3995
+ // what a copy or a rename carries along.
3996
+ button.append(el('span', 'caret', ''), el('span', 'name', entry.label ?? entry.name), el('span', 'size', formatSize(entry.size)));
3622
3997
  const by = touched.get(path);
3623
3998
  if (by) {
3624
3999
  // Its colour is the agent's own, so the tree reads the same way the thread does.
@@ -3629,6 +4004,11 @@ function treeNode(path, entry) {
3629
4004
  button.addEventListener('click', () => { void openViewer({ root: 'project', path, label: path }); });
3630
4005
  item.append(button);
3631
4006
  }
4007
+ if (tree.reveal === path) {
4008
+ tree.reveal = null;
4009
+ button.classList.add('fresh');
4010
+ requestAnimationFrame(() => button.scrollIntoView?.({ block: 'nearest' }));
4011
+ }
3632
4012
  return item;
3633
4013
  }
3634
4014
 
@@ -4568,7 +4948,7 @@ els.composer.addEventListener('submit', async (event) => {
4568
4948
  const response = await fetch('/api/messages', {
4569
4949
  method: 'POST',
4570
4950
  headers: { 'content-type': 'application/json' },
4571
- body: JSON.stringify({ text: outgoing, target: target || null, model: state.chosenModel[target] ?? null, attachments: ready.map((item) => item.id), create: state.create, mode: state.mode, ash: state.ashInstalled && state.ash }),
4951
+ body: JSON.stringify({ text: outgoing, target: target || null, model: state.chosenModel[target] ?? null, effort: state.chosenEffort[target] ?? null, attachments: ready.map((item) => item.id), create: state.create, mode: state.mode, ash: state.ashInstalled && state.ash }),
4572
4952
  });
4573
4953
  if (!response.ok) {
4574
4954
  const result = await response.json().catch(() => ({ error: t('Request failed ({status}).', { status: response.status }) }));
@@ -4972,7 +5352,7 @@ function renderModuleEvent(event) {
4972
5352
  }
4973
5353
 
4974
5354
  function renderCommandCard(event) {
4975
- const { name, title, text, ok, args = [] } = event.payload;
5355
+ const { name, title, text, ok, args = [], run = null } = event.payload;
4976
5356
  const node = el('div', `command-card${ok === false ? ' failed' : ''}`);
4977
5357
  const head = el('div', 'head');
4978
5358
  head.append(el('b', null, `/${name}`), el('span', null, title ?? name), el('span', 'args', args.join(' ')));
@@ -4982,6 +5362,26 @@ function renderCommandCard(event) {
4982
5362
  else pre.append(`${line}\n`);
4983
5363
  }
4984
5364
  node.append(head, pre);
5365
+ if (run) {
5366
+ markRun(run, ok === false ? 'failed' : 'ran');
5367
+ // A command the human ran by hand was photographed first: the same UNDO as a CONTROL turn.
5368
+ if (run.checkpointId && run.files?.length) {
5369
+ node.id = `control-${run.checkpointId}`;
5370
+ const undo = el('button', 'undo', t('UNDO · RESTORE CHECKPOINT'));
5371
+ undo.type = 'button';
5372
+ undo.title = t('Put the project files back exactly as they were before this command. What it sent off this machine does not come back.');
5373
+ undo.addEventListener('click', async () => {
5374
+ undo.disabled = true;
5375
+ try {
5376
+ const response = await fetch(`/api/control/${run.checkpointId}/undo`, { method: 'POST' });
5377
+ const result = await response.json().catch(() => ({}));
5378
+ if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
5379
+ undo.textContent = t('RESTORED');
5380
+ } catch (error) { toast(`MU/TH/UR › ${error.message}`); undo.disabled = false; }
5381
+ });
5382
+ node.append(undo);
5383
+ }
5384
+ }
4985
5385
  state.lastSender = null;
4986
5386
  return node;
4987
5387
  }
@@ -6193,7 +6593,9 @@ function appendLoginOutput(event) {
6193
6593
  box.append(loginLine({ line, url }));
6194
6594
  box.scrollTop = box.scrollHeight;
6195
6595
  }
6196
- if (url) toast(`@${agent}: open ${url} to finish signing in`);
6596
+ // Only while it is happening: a sign-in from earlier in the room, replayed when the page loads,
6597
+ // used to come back as a toast carrying a link that expired hours ago.
6598
+ if (url && !replaying) toast(t('@{agent}: open {url} to finish signing in', { agent, url }));
6197
6599
  }
6198
6600
  function loginLine({ line, url }) {
6199
6601
  const div = el('div');