@jossuealcala/madre 1.0.0 → 1.0.1

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
@@ -190,6 +190,13 @@ const MODES = {
190
190
  4: { key: 'airlock', label: t('FULL ACCESS'), hint: t('Run commands, push, deploy. What leaves the ship does not come back. Unlock, twice.') },
191
191
  };
192
192
 
193
+ // A sign-in in flight. Claude's CLI opens a page and then waits to be told the code that page
194
+ // gives back: with nowhere to type it, the sign-in could only die of its own timeout, which is
195
+ // exactly what it did. This holds what the room knows about the one running — which agent, the
196
+ // address, whether it is waiting — and `.login-ask` is where it is answered.
197
+ const loginNow = { agent: null, method: null, url: null, awaiting: false, asksForCode: false };
198
+ const looksLikeCodePrompt = (line) => /paste\s+(?:the\s+)?code|enter\s+(?:the\s+)?code|c[oó]digo\s*[:>]/i.test(String(line ?? ''));
199
+
193
200
  const state = {
194
201
  agents: new Map(),
195
202
  budget: null,
@@ -1598,6 +1605,18 @@ async function browserServers() {
1598
1605
  catch { menu.replaceChildren(el('div', 'ports-note', t('COULD NOT READ THE PORTS'))); browser.servers?.setAttribute('data-live', 'no'); return; }
1599
1606
  paintPorts(read);
1600
1607
  }
1608
+ async function refreshBrowserServers(button) {
1609
+ button.disabled = true;
1610
+ button.classList.add('checking');
1611
+ try {
1612
+ const response = await fetch('/api/ports', { cache: 'no-store' });
1613
+ paintPorts(await response.json());
1614
+ } catch {
1615
+ button.classList.remove('checking');
1616
+ button.disabled = false;
1617
+ toast(t('Could not read the ports'));
1618
+ }
1619
+ }
1601
1620
 
1602
1621
  // This project's servers, and only them. The servers sentinel says when the list changes, so an
1603
1622
  // open menu redraws itself and the lamp on the button follows without anyone asking again.
@@ -1643,7 +1662,16 @@ function paintPorts(read) {
1643
1662
  // The lamp on the button keeps what the reading found, so the state survives the menu closing.
1644
1663
  browser.servers?.setAttribute('data-live', ports.length ? 'yes' : 'no');
1645
1664
  if (menu.hidden) return;
1646
- menu.replaceChildren(el('div', 'ports-head', t('THIS PROJECT\'S SERVERS')));
1665
+ const head = el('div', 'ports-head');
1666
+ head.append(el('span', null, t('THIS PROJECT\'S SERVERS')));
1667
+ const refresh = el('button', 'ports-refresh');
1668
+ refresh.type = 'button';
1669
+ refresh.setAttribute('aria-label', t('Refresh servers'));
1670
+ refresh.title = t('Refresh servers');
1671
+ refresh.innerHTML = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M16 10a6 6 0 1 1-1.76-4.24M16 3.6v3.6h-3.6" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>';
1672
+ refresh.addEventListener('click', () => void refreshBrowserServers(refresh));
1673
+ head.append(refresh);
1674
+ menu.replaceChildren(head);
1647
1675
  if (!ports.length && !wider.length) menu.append(el('div', 'ports-empty', t('Nothing started from this project is answering. Start your dev server inside the project folder and it shows up here by itself.')));
1648
1676
  for (const found of ports) {
1649
1677
  const row = el('button', `ports-row${found.framable === false ? ' refuses' : ''}`);
@@ -2528,12 +2556,24 @@ async function toggleModelMenu(id, anchor) {
2528
2556
  title.append(el('b', null, `@${id}`), t(' · model for this request'));
2529
2557
  modelMenu.append(title);
2530
2558
  const list = el('div', 'model-list');
2531
- const options = [{ name: null, label: `${t('default')}${info.default ? ` · ${info.default}` : ''}` }, ...info.models.map((name) => ({ name, label: name }))];
2532
- for (const option of options) {
2533
- const button = el('button', `model-option${(state.chosenModel[id] ?? null) === option.name ? ' current' : ''}`, option.label);
2559
+ const option = (name, label) => {
2560
+ const button = el('button', `model-option${(state.chosenModel[id] ?? null) === name ? ' current' : ''}`, label);
2534
2561
  button.type = 'button';
2535
- button.addEventListener('click', () => { rememberModel(id, option.name); closeModelMenu(); renderPicker(); focusComposer(); });
2536
- list.append(button);
2562
+ button.addEventListener('click', () => { rememberModel(id, name); closeModelMenu(); renderPicker(); focusComposer(); });
2563
+ return button;
2564
+ };
2565
+ list.append(option(null, `${t('default')}${info.default ? ` · ${info.default}` : ''}`));
2566
+ // OpenCode answers through many providers: its models come under the provider they belong to,
2567
+ // only the ones with a key or an account and OpenCode's own, the name shown without the prefix.
2568
+ if (Array.isArray(info.groups) && info.groups.length) {
2569
+ for (const group of info.groups) {
2570
+ const head = el('div', `model-group${group.own ? ' own' : ''}`);
2571
+ head.append(el('b', null, group.label), el('span', null, group.own ? t('OpenCode\'s own') : t('{n} models', { n: group.models.length })));
2572
+ list.append(head);
2573
+ for (const name of group.models) list.append(option(name, name.slice(name.indexOf('/') + 1)));
2574
+ }
2575
+ } else {
2576
+ for (const name of info.models) list.append(option(name, name));
2537
2577
  }
2538
2578
  modelMenu.append(list);
2539
2579
  const custom = el('form', 'model-custom');
@@ -2541,7 +2581,12 @@ async function toggleModelMenu(id, anchor) {
2541
2581
  custom.append(input);
2542
2582
  custom.addEventListener('submit', (event) => { event.preventDefault(); const name = input.value.trim(); if (name) { rememberModel(id, name); closeModelMenu(); renderPicker(); } });
2543
2583
  modelMenu.append(custom);
2544
- if (info.note) modelMenu.append(el('div', 'model-note', info.note));
2584
+ if (id === 'opencode') {
2585
+ const manage = el('button', 'model-manage', t('Providers and keys…'));
2586
+ manage.type = 'button';
2587
+ manage.addEventListener('click', () => { closeModelMenu(); openSettingsOn('agents', { anchor: 'sx-opencode' }); });
2588
+ modelMenu.append(manage);
2589
+ } else if (info.note) modelMenu.append(el('div', 'model-note', info.note));
2545
2590
  placeModelMenu(anchor);
2546
2591
  }
2547
2592
  function placeModelMenu(anchor) {
@@ -2982,6 +3027,7 @@ const override = {
2982
3027
  cancelled: null, // called when the human walks away from it, to put back what they wrote
2983
3028
  raise: false, // the agent is capped below the mode: arming also raises its max mode in Settings › Permissions
2984
3029
  mode: 3, // #3 CONTROL or #4 AIRLOCK
3030
+ preserveComposer: false,
2985
3031
  stage: 'designation', // designation · phrase · confirm; #4 asks for the same key a second time
2986
3032
  again: false, // the second time, for #4
2987
3033
  key: 'designation', // the key chosen in AJUSTES › Permissions for this mode
@@ -3058,12 +3104,12 @@ function askOverrideKey(stage, { again = false } = {}) {
3058
3104
  override.form.querySelector('.k').textContent = stage === 'phrase' ? (again ? t('Type your phrase again') : t('Type your phrase')) : again ? t('Type the project name again') : t('Type the project name');
3059
3105
  override.input.placeholder = '';
3060
3106
  }
3061
- function openOverride(id, { raise = false, mode = 3, then = null, cancelled = null } = {}) {
3107
+ function openOverride(id, { raise = false, mode = 3, then = null, cancelled = null, preserveComposer = false, forceKey = false } = {}) {
3062
3108
  if (!override.dialog) return;
3063
3109
  // Inside the grace of an accepted key, the same mode for the same agent is not asked again —
3064
3110
  // unless arming would also raise the agent's ceiling, which is always asked.
3065
- const left = raise ? 0 : overrideGraceLeft(mode, id);
3066
- if (left) { void grantOverride({ agent: id, raise: false, mode, then, delay: 0, left }); return; }
3111
+ const left = raise || forceKey ? 0 : overrideGraceLeft(mode, id);
3112
+ if (left) { void grantOverride({ agent: id, raise: false, mode, then, delay: 0, left, preserveComposer }); return; }
3067
3113
  override.agent = id;
3068
3114
  // What to do once it is armed. The chip's road arms and waits for the human to send; a #3 or #4
3069
3115
  // typed in a message that was sent, and the way up on an agent's card, send right away.
@@ -3072,6 +3118,7 @@ function openOverride(id, { raise = false, mode = 3, then = null, cancelled = nu
3072
3118
  override.cancelled = cancelled;
3073
3119
  override.raise = raise;
3074
3120
  override.mode = mode;
3121
+ override.preserveComposer = preserveComposer;
3075
3122
  override.key = overrideKeyFor(mode);
3076
3123
  askOverrideKey(override.key.startsWith('phrase') ? 'phrase' : override.key === 'confirm' ? 'confirm' : 'designation');
3077
3124
  override.title.textContent = mode === 4 ? t('Open #4 FULL ACCESS for @{agent}', { agent: id }) : t('Unlock #3 EDIT for @{agent}', { agent: id });
@@ -3137,13 +3184,13 @@ override.form?.addEventListener('submit', async (event) => {
3137
3184
  }
3138
3185
  override.reply.textContent = override.mode === 4 ? t('Open for @{agent}.', { agent: override.agent }) : t('Unlocked for @{agent}.', { agent: override.agent });
3139
3186
  override.reply.className = 'key-reply granted';
3140
- const granted = { agent: override.agent, raise: override.raise, mode: override.mode, then: override.then, delay: 900, pass: answer.token };
3187
+ const granted = { agent: override.agent, raise: override.raise, mode: override.mode, then: override.then, delay: 900, pass: answer.token, preserveComposer: override.preserveComposer };
3141
3188
  override.then = null;
3142
3189
  override.cancelled = null;
3143
3190
  void grantOverride(granted);
3144
3191
  });
3145
3192
  // Arms the mode for that agent: after the key, or straight away inside the grace of an earlier one.
3146
- async function grantOverride({ agent, raise, mode, then, delay = 0, left = 0, pass = null }) {
3193
+ async function grantOverride({ agent, raise, mode, then, delay = 0, left = 0, pass = null, preserveComposer = false }) {
3147
3194
  if (pass) overridePasses.set(`${mode}:${agent}`, pass);
3148
3195
  if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
3149
3196
  if (override.dialog?.open) override.dialog.close();
@@ -3158,9 +3205,11 @@ async function grantOverride({ agent, raise, mode, then, delay = 0, left = 0, pa
3158
3205
  }
3159
3206
  const minutes = overrideConfig().grace?.[mode] ?? 0;
3160
3207
  if (!left && minutes > 0) { overrideGrace.set(`${mode}:${agent}`, Date.now() + minutes * 60000); tickGrace(); }
3161
- state.modeArmedFor = agent;
3162
- els.target.value = agent;
3163
- setMode(mode, { wink: true });
3208
+ if (!preserveComposer) {
3209
+ state.modeArmedFor = agent;
3210
+ els.target.value = agent;
3211
+ setMode(mode, { wink: true });
3212
+ }
3164
3213
  if (then) { await then(); return; }
3165
3214
  if (left) { toast(t('Diagnostics › {label} is still open for @{agent} · {left} left.', { label: MODES[mode].label, agent, left: graceWords(left) }), { tone: 'look' }); return; }
3166
3215
  toast(mode === 4
@@ -3317,12 +3366,15 @@ function bridgeCard(agent) {
3317
3366
  else if (stage === 'missing' && agent.install) actions.append(bridgeInstallButton(agent));
3318
3367
  let reveal = null;
3319
3368
  if (stage === 'signed-out' || (stage === 'ready' && agent.login?.headless)) {
3320
- if (agent.login?.headless) actions.append(bridgeSignInButton(agent, stage === 'ready'));
3321
- else if (agent.key) {
3322
- reveal = el('button', 'primary', t('PASTE KEY'));
3369
+ // Every door the CLI has, not just the first one. Claude sells two accounts and MADRE used
3370
+ // to open only the subscription, which left whoever pays for the API with no way in.
3371
+ if (agent.login?.headless) for (const door of loginDoors(agent, stage === 'ready')) actions.append(bridgeSignInButton(agent, door, stage === 'ready'));
3372
+ else if (!agent.key && agent.login) actions.append(el('span', 'note', t('signs in from its own prompt:')));
3373
+ if (agent.key) {
3374
+ reveal = el('button', agent.login?.headless ? null : 'primary', t('PASTE KEY'));
3323
3375
  reveal.type = 'button';
3324
3376
  actions.append(reveal);
3325
- } else if (agent.login) actions.append(el('span', 'note', t('signs in from its own prompt:')));
3377
+ }
3326
3378
  }
3327
3379
  if (actions.childNodes.length) card.append(actions);
3328
3380
  if (reveal) {
@@ -3333,6 +3385,7 @@ function bridgeCard(agent) {
3333
3385
  reveal.addEventListener('click', () => { slot.hidden = !slot.hidden; if (!slot.hidden) slot.querySelector('input')?.focus(); });
3334
3386
  card.append(slot);
3335
3387
  } else if (stage !== 'missing' && agent.login && !agent.login.headless) card.append(commandBlock([agent.login.display, `# ${t(agent.login.note)}`]));
3388
+ if (stage !== 'missing') card.append(loginAskSlot(agent.id));
3336
3389
  const log = el('pre', 'bridge-log');
3337
3390
  log.id = `bridge-log-${agent.id}`;
3338
3391
  for (const entry of state.loginLogs.get(agent.id) ?? []) log.append(loginLine(entry));
@@ -3466,18 +3519,15 @@ function bridgeInstallButton(agent) {
3466
3519
  });
3467
3520
  return button;
3468
3521
  }
3469
- function bridgeSignInButton(agent, again = false) {
3470
- const button = el('button', again ? null : 'primary', again ? t('SIGN IN AGAIN') : t('SIGN IN'));
3522
+ function bridgeSignInButton(agent, door, again = false) {
3523
+ const named = Boolean(agent.login?.methods?.length);
3524
+ const button = el('button', again && !named ? null : 'primary', named ? door.label : again ? t('SIGN IN AGAIN') : t('SIGN IN'));
3471
3525
  button.type = 'button';
3472
- button.title = t(agent.login.note);
3526
+ button.title = t(door.note || agent.login.note);
3473
3527
  button.addEventListener('click', async () => {
3474
3528
  button.disabled = true;
3475
- state.loginLogs.set(agent.id, []);
3476
- try {
3477
- const response = await fetch(`/api/agents/${agent.id}/login`, { method: 'POST' });
3478
- const result = await response.json().catch(() => ({}));
3479
- if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
3480
- } catch (error) { toast(t('Diagnostics › sign-in did not start: {error}', { error: error.message })); renderOnboarding(); }
3529
+ try { await startLogin(agent.id, door.id); }
3530
+ catch (error) { toast(t('Diagnostics › sign-in did not start: {error}', { error: error.message })); renderOnboarding(); }
3481
3531
  });
3482
3532
  return button;
3483
3533
  }
@@ -4686,21 +4736,28 @@ function renderModeRequest(event) {
4686
4736
  quote.title = text;
4687
4737
  node.append(quote);
4688
4738
  const actions = el('div', 'actions');
4689
- const decide = async (decision, button) => {
4739
+ const decide = async (decision, button, key = null) => {
4740
+ if (mode >= 3 && decision !== 'deny' && !key) {
4741
+ const ready = overridePass(mode, agent);
4742
+ if (ready) { void decide(decision, button, ready); return; }
4743
+ openOverride(agent, { mode, raise: mode > ceilingFor(agent) && Boolean(state.capabilities[agent]?.scopes?.write?.capable), preserveComposer: true, forceKey: true, then: () => decide(decision, button, overridePass(mode, agent)) });
4744
+ return;
4745
+ }
4690
4746
  for (const other of actions.querySelectorAll('button')) other.disabled = true;
4691
4747
  try {
4692
- const response = await fetch(`/api/modes/${requestId}/decide`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ decision }) });
4748
+ const response = await fetch(`/api/modes/${requestId}/decide`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ decision, ...(key ? { key } : {}) }) });
4693
4749
  const result = await response.json().catch(() => ({}));
4694
- if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
4750
+ if (!response.ok) { if (result.needsKey) overridePasses.delete(`${mode}:${agent}`); throw new Error(result.error ?? `HTTP ${response.status}`); }
4751
+ if (key && !overrideGraceLeft(mode, agent)) overridePasses.delete(`${mode}:${agent}`);
4695
4752
  button.textContent = decision === 'deny' ? t('DENIED') : t('GRANTED');
4696
4753
  } catch (error) {
4697
4754
  toast(`${t('Diagnostics › ')}${error.message}`);
4698
4755
  for (const other of actions.querySelectorAll('button')) other.disabled = false;
4699
4756
  }
4700
4757
  };
4701
- const once = el('button', 'grant', t('GRANT ONCE')); once.type = 'button'; once.title = `@${agent} may add files to the project for this step only.`;
4702
- const plan = el('button', 'grant plan', t('GRANT FOR PLAN')); plan.type = 'button'; plan.title = t('Every remaining writable step of this plan shares one lease directory.');
4703
- const deny = el('button', 'deny', t('DENY')); deny.type = 'button'; deny.title = `@${agent} answers read-only and says what it would have created.`;
4758
+ const once = el('button', 'grant', t('GRANT ONCE')); once.type = 'button'; once.title = mode >= 3 ? t('Unlock #{mode} for this step only.', { mode }) : t('Allow creation for this step only.');
4759
+ const plan = el('button', 'grant plan', t('GRANT FOR PLAN')); plan.type = 'button'; plan.title = mode >= 3 ? t('Unlock #{mode} for the remaining steps of this plan.', { mode }) : t('Every remaining writable step of this plan shares one lease directory.');
4760
+ const deny = el('button', 'deny', t('DENY')); deny.type = 'button'; deny.title = t('The step continues in read-only mode.');
4704
4761
  once.addEventListener('click', () => decide('once', once));
4705
4762
  plan.addEventListener('click', () => decide('plan', plan));
4706
4763
  deny.addEventListener('click', () => decide('deny', deny));
@@ -5014,6 +5071,7 @@ function renderRunRequest(event) {
5014
5071
  button.title = t('Runs {command} from the project root, with no shell. A restore point is taken first.', { command: command.line });
5015
5072
  button.addEventListener('click', async () => {
5016
5073
  button.disabled = true;
5074
+ markRun({ responseMessageId, index }, 'running', null, event.timestamp);
5017
5075
  try {
5018
5076
  const response = await fetch('/api/runs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ responseMessageId, index }) });
5019
5077
  const result = await response.json().catch(() => ({}));
@@ -5025,27 +5083,39 @@ function renderRunRequest(event) {
5025
5083
  });
5026
5084
  list.append(button);
5027
5085
  });
5028
- 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 project')));
5086
+ node.append(list, el('p', 'run-summary', t('{done} of {total} commands finished', { done: 0, total: commands.filter((one) => one.argv).length })), 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 project')));
5029
5087
  state.lastSender = null;
5030
5088
  return node;
5031
5089
  }
5032
5090
 
5033
5091
  // The state of one line of a run request, from the events that follow it: running, ran, failed.
5034
5092
  // A line that ran can be pressed again; tests are run more than once.
5035
- function markRun({ responseMessageId, index }, status, where = null) {
5093
+ function markRun({ responseMessageId, index }, status, where = null, startedAt = null) {
5036
5094
  const button = document.querySelector(`.run-request[data-run="${CSS.escape(String(responseMessageId))}"] .run-line[data-index="${Number(index)}"]`);
5037
5095
  if (!button || button.classList.contains('refused')) return;
5038
5096
  button.classList.remove('running', 'serving', 'ran', 'failed');
5039
5097
  if (status) button.classList.add(status);
5098
+ if (status === 'running') button.dataset.startedAt = startedAt ?? new Date().toISOString();
5099
+ else delete button.dataset.startedAt;
5040
5100
  button.disabled = status === 'running' || status === 'serving';
5041
5101
  const tag = button.querySelector('.choice-tag');
5042
5102
  if (tag) tag.textContent = { running: t('RUNNING'), serving: t('SERVING'), ran: t('RUN AGAIN'), failed: t('RUN AGAIN') }[status] ?? t('RUN');
5103
+ const group = button.closest('.run-request');
5104
+ const summary = group?.querySelector('.run-summary');
5105
+ if (summary) summary.textContent = t('{done} of {total} commands finished', { done: group.querySelectorAll('.run-line.ran, .run-line.failed').length, total: group.querySelectorAll('.run-line:not(.refused)').length });
5043
5106
  // While it is alive, a line of its own under it: where it serves, and the two things a person
5044
5107
  // does with a running command: look at it, or stop it. It goes when the command ends.
5045
5108
  let live = button.nextElementSibling?.classList.contains('run-live') ? button.nextElementSibling : null;
5046
5109
  if (status !== 'running' && status !== 'serving') { live?.remove(); return; }
5047
5110
  if (!live) { live = el('div', 'run-live'); button.after(live); }
5048
5111
  live.replaceChildren();
5112
+ if (status === 'running') {
5113
+ const progress = el('span', 'run-progress-track');
5114
+ progress.setAttribute('role', 'progressbar');
5115
+ progress.setAttribute('aria-label', t('Command running'));
5116
+ live.append(progress, el('span', 'run-elapsed'));
5117
+ updateRunClock(button);
5118
+ }
5049
5119
  if (status === 'serving' && where?.port) {
5050
5120
  live.append(el('span', 'where', where.network
5051
5121
  ? t('serving on :{port} · listens on the whole network, so the project will not open it', { port: where.port })
@@ -5069,6 +5139,16 @@ function markRun({ responseMessageId, index }, status, where = null) {
5069
5139
  });
5070
5140
  live.append(stop);
5071
5141
  }
5142
+ function updateRunClock(button) {
5143
+ const shown = button.nextElementSibling?.querySelector('.run-elapsed');
5144
+ if (!shown || !button.dataset.startedAt) return;
5145
+ const seconds = Math.max(0, Math.floor((Date.now() - Date.parse(button.dataset.startedAt)) / 1000));
5146
+ shown.textContent = t('Running · {time} elapsed', { time: `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` });
5147
+ }
5148
+ const runClock = setInterval(() => {
5149
+ if (!document.hidden) for (const button of document.querySelectorAll('.run-line.running')) updateRunClock(button);
5150
+ }, 1000);
5151
+ runClock.unref?.();
5072
5152
 
5073
5153
  // BISHOP proposed a plan in the room of Salas (docs/MACROVERSO.md, fases 4b y 4c). One row per
5074
5154
  // order with the room, the agent and the mode in sight, and one per link it proposes. What a press
@@ -5668,11 +5748,13 @@ function renderEventNode(event) {
5668
5748
  switch (event.type) {
5669
5749
  case 'connection.login.started':
5670
5750
  case 'connection.login.finished':
5751
+ // A new attempt starts a clean log: the lines of an earlier one are history in the room.
5752
+ if (event.type === 'connection.login.started') state.loginLogs.set(event.payload.agent, []);
5671
5753
  node = renderLoginEvent(event);
5672
5754
  break;
5673
5755
  case 'connection.login.output': appendLoginOutput(event); return;
5674
5756
  case 'connection.install.started':
5675
- case 'connection.install.finished': if (!replaying) jobProgress(event); node = renderAgentInstall(event); break;
5757
+ case 'connection.install.finished': if (event.type === 'connection.install.started') state.loginLogs.set(event.payload.agent, []); if (!replaying) jobProgress(event); node = renderAgentInstall(event); break;
5676
5758
  case 'connection.install.output': if (!replaying) jobProgress(event); appendLoginOutput(event); return;
5677
5759
  case 'connection.key.set': {
5678
5760
  const { agent, label, provider, detail } = event.payload;
@@ -5681,6 +5763,13 @@ function renderEventNode(event) {
5681
5763
  state.lastSender = null;
5682
5764
  break;
5683
5765
  }
5766
+ case 'connection.key.removed': {
5767
+ const { agent, label, provider } = event.payload;
5768
+ node = paint(el('div', 'system connections'), agent);
5769
+ node.append(el('b', null, t('connections › ')), t('{agent} no longer has the {provider} key', { agent: label ?? agent, provider: provider ?? '' }));
5770
+ state.lastSender = null;
5771
+ break;
5772
+ }
5684
5773
  case 'room.settings': return;
5685
5774
  case 'extension.toggled':
5686
5775
  // A module switched on or off changes what the composer accepts.
@@ -5792,7 +5881,7 @@ function renderEventNode(event) {
5792
5881
  case 'choice.offered': node = renderChoice(event); break;
5793
5882
  case 'chat.activity': onChatActivity(event.payload); return;
5794
5883
  case 'run.requested': node = renderRunRequest(event); break;
5795
- case 'run.started': markRun(event.payload, 'running'); return;
5884
+ case 'run.started': markRun(event.payload, 'running', null, event.timestamp); return;
5796
5885
  case 'run.serving': markRun(event.payload, 'serving', event.payload); return;
5797
5886
  case 'write.waiting': {
5798
5887
  // Two turns writing the project at once could not be told apart afterwards; the second waits.
@@ -5993,9 +6082,15 @@ window.addEventListener?.('storage', (event) => {
5993
6082
  // and whenever it comes back into view, and written when a choice changes. Home does the same
5994
6083
  // (salas-mac.js, pullLooks): the two are twins, because a new shared file would not reach the
5995
6084
  // phone's bridge.
5996
- const SHARED_LOOKS = ['pulse.theme', 'pulse.accent', 'pulse.wallpaper', 'pulse.wallpaper.style', 'pulse.wallpaper.veil', 'pulse.wallpaper.blur', 'pulse.bubbles', 'pulse.text'];
6085
+ // The wallpaper is each page's own (2026-10-13): this project keeps the one chosen here, Home the
6086
+ // one chosen in Home (src/looks.mjs, `rooms`). The rest of the looks are shared.
6087
+ const SHARED_LOOKS = ['pulse.theme', 'pulse.accent', 'pulse.wallpaper.style', 'pulse.wallpaper.veil', 'pulse.wallpaper.blur', 'pulse.bubbles', 'pulse.text'];
5997
6088
  let booted = false;
5998
6089
  let looksPush = 0;
6090
+ // Saved for this project alone, on the server, so it outlives the port the page is served on.
6091
+ function saveOwnWallpaper(value) {
6092
+ void fetch('/api/looks/room', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ wallpaper: value }) }).catch(() => null);
6093
+ }
5999
6094
  function pushLooks() {
6000
6095
  if (SPLIT_CHAT) return;
6001
6096
  clearTimeout(looksPush);
@@ -6009,12 +6104,14 @@ async function pullLooks() {
6009
6104
  let answer = null;
6010
6105
  try { answer = await fetch('/api/looks', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)); } catch { return; }
6011
6106
  if (!answer?.looks) return;
6107
+ // Before a project had its own, it wore the shared one: that is where it starts.
6108
+ const wall = answer.own?.wallpaper ?? answer.looks['pulse.wallpaper'] ?? null;
6012
6109
  try {
6013
6110
  // Nothing kept yet: the first page that has a choice of its own hands it to the others.
6014
6111
  if (!answer.saved) { if (SHARED_LOOKS.some((key) => localStorage.getItem(key) !== null)) pushLooks(); return; }
6015
6112
  let changed = false;
6016
- for (const key of SHARED_LOOKS) {
6017
- const value = answer.looks[key] ?? null;
6113
+ for (const key of [...SHARED_LOOKS, 'pulse.wallpaper']) {
6114
+ const value = key === 'pulse.wallpaper' ? (wall === 'default' ? null : wall) : answer.looks[key] ?? null;
6018
6115
  if (localStorage.getItem(key) === value) continue;
6019
6116
  if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value);
6020
6117
  changed = true;
@@ -6042,7 +6139,9 @@ function saveLook(name, value) {
6042
6139
  if (value === LOOKS[name].values[0]) localStorage.removeItem(LOOKS[name].key);
6043
6140
  else localStorage.setItem(LOOKS[name].key, value);
6044
6141
  } catch { /* the choice lasts as long as the page */ }
6045
- applyLook(name, value); pushLooks();
6142
+ applyLook(name, value);
6143
+ if (name === 'wallpaper') saveOwnWallpaper(value);
6144
+ pushLooks();
6046
6145
  }
6047
6146
  // What is picked on the sheet is a draft: the preview wears it, the room does not, until ACCEPT.
6048
6147
  // Closing the window drops it. The language is part of the draft too, and accepting a new one
@@ -6234,9 +6333,10 @@ function drawCustom() {
6234
6333
  const paperBox = el('div', 'paper-box');
6235
6334
  paperBox.append(papers);
6236
6335
  if (paperTune.children.length) paperBox.append(paperTune);
6237
- sxBlock(page, t('Wallpaper'), PHOTO_WALLPAPERS.has(draft.wallpaper)
6336
+ const paperBlock = sxBlock(page, t('Wallpaper'), PHOTO_WALLPAPERS.has(draft.wallpaper)
6238
6337
  ? t('A photograph wears the project\'s ground as a layer or as a mesh of its own colours: choose how strong it is and how much the photograph blurs.')
6239
- : t('Behind the conversation, in this window and in every conversation beside it. Light and dark each have their own.')).rows.append(paperBox);
6338
+ : t('Behind the conversation, in this window and in every conversation beside it. Light and dark each have their own.'));
6339
+ paperBlock.rows.append(paperBox);
6240
6340
  const talk = sxBlock(page, t('Conversation'));
6241
6341
  sxRow(talk, t('Bubbles'), null, segmented('bubbles'), { stack: true });
6242
6342
  sxRow(talk, t('Text'), null, segmented('text'), { stack: true });
@@ -7029,12 +7129,15 @@ for (const id of ['#tree-settings', '#browser-settings']) document.querySelector
7029
7129
 
7030
7130
  // From MU/TH/UR, a card or a pop-over: AJUSTES comes in front of whatever is open, and an agent
7031
7131
  // named on the way in is where the sheet lands.
7032
- function openSettingsOn(category, { agent = null, part = null } = {}) {
7132
+ function openSettingsOn(category, { agent = null, part = null, anchor = null } = {}) {
7133
+ // An agent named on the way in is opened, not only scrolled to.
7134
+ if (agent) settingsUI.agentsOpen.add(agent);
7135
+ if (anchor === 'sx-opencode') settingsUI.agentsOpen.add('opencode');
7033
7136
  openSettings(true, { category });
7034
- if (!agent && !part) return;
7137
+ if (!agent && !part && !anchor) return;
7035
7138
  let tries = 0;
7036
7139
  const land = () => {
7037
- const card = agent ? document.getElementById(`conn-${agent}`) : state.settingsTargets[part]?.isConnected && state.settingsTargets[part].firstElementChild ? state.settingsTargets[part] : null;
7140
+ const card = anchor ? document.getElementById(anchor) : agent ? document.getElementById(`conn-${agent}`) : state.settingsTargets[part]?.isConnected && state.settingsTargets[part].firstElementChild ? state.settingsTargets[part] : null;
7038
7141
  if (card) { card.scrollIntoView({ block: 'start', behavior: 'smooth' }); card.classList.add('settings-found'); setTimeout(() => card.classList.remove('settings-found'), 1600); return; }
7039
7142
  if ((tries += 1) < 20) setTimeout(land, 150);
7040
7143
  };
@@ -9819,6 +9922,7 @@ document.addEventListener?.('click', (event) => { if (!els.picker?.contains?.(ev
9819
9922
  state.budget = Number.isFinite(initial.softTokenBudget) && initial.softTokenBudget > 0 ? initial.softTokenBudget : null;
9820
9923
  state.timeouts = initial.timeouts ?? {};
9821
9924
  state.sessions = initial.sessions ?? {};
9925
+ readLoginState(initial.login ?? null);
9822
9926
  state.capabilities = initial.capabilities ?? {};
9823
9927
  state.override = initial.override ?? null;
9824
9928
  state.ripleyWeb = Boolean(initial.ripleyWeb);
@@ -13156,6 +13260,16 @@ function ollamaBlock(host, item) {
13156
13260
  const block = sxBlock(host, t('Local brain'), t('Ollama on this computer: no provider tokens, nothing leaves.'));
13157
13261
  const recheck = sxButton(t('Check again'), async () => { recheck.disabled = true; await fetch('/api/ollama/probe', { method: 'POST' }).catch(() => null); await refreshModules(); });
13158
13262
  sxRow(block, t('Server'), info.running ? t('Running · {host}', { host: info.host }) : t('Not running'), recheck);
13263
+ // Whether this project uses it, beside Home's choice for all of them (src/local-ai.mjs).
13264
+ const project = info.project;
13265
+ if (project && !project.home) {
13266
+ const why = !project.master ? t('Local AI is switched off for every project.')
13267
+ : project.mode === 'auto' ? t('Home turns it on in every project, and «Active» switches it for all of them. To choose project by project, go to Home › Settings › Local AI.')
13268
+ : project.mode === 'pick' ? t('Home chooses the projects one by one; «Active» switches this one here too.')
13269
+ : t('Each project decides on its own: «Active» is this project’s switch.');
13270
+ // The switch is the card's own «Active» (src/modules/ollama.mjs, toggle): this row says whose it is.
13271
+ sxRow(block, t('In this project'), why);
13272
+ }
13159
13273
  if (info.models?.length) sxRow(block, t('Models'), info.models.map((model) => model.name).join(' · '));
13160
13274
  if (!info.running) {
13161
13275
  const step = info.binary
@@ -13689,8 +13803,86 @@ const settingsUI = {
13689
13803
  data: null,
13690
13804
  open: false,
13691
13805
  loginLogs: new Map(),
13806
+ // The agents whose card is open in AJUSTES › Agents, kept while the page lives.
13807
+ agentsOpen: new Set(),
13692
13808
  };
13693
13809
 
13810
+ function loginAskSlot(agentId) {
13811
+ const box = el('div', 'login-ask');
13812
+ box.dataset.agent = agentId;
13813
+ fillLoginAsk(box);
13814
+ return box;
13815
+ }
13816
+ function repaintLoginAsk() { for (const box of document.querySelectorAll('.login-ask')) fillLoginAsk(box); }
13817
+ function fillLoginAsk(box) {
13818
+ const agentId = box.dataset.agent;
13819
+ box.replaceChildren();
13820
+ const live = loginNow.agent === agentId;
13821
+ box.hidden = !live;
13822
+ if (!live) return;
13823
+ if (loginNow.url) {
13824
+ const open = el('a', 'sx-btn primary', t('Open the sign-in page'));
13825
+ open.href = loginNow.url;
13826
+ open.target = '_blank';
13827
+ open.rel = 'noopener noreferrer';
13828
+ box.append(open);
13829
+ }
13830
+ if (loginNow.asksForCode || loginNow.awaiting) {
13831
+ const form = el('form', 'login-code');
13832
+ const input = el('input');
13833
+ input.type = 'text';
13834
+ input.autocomplete = 'off';
13835
+ input.spellcheck = false;
13836
+ input.placeholder = t('Paste the code the page gave you');
13837
+ const send = el('button', 'primary', t('Send the code'));
13838
+ send.type = 'submit';
13839
+ const said = el('p', 'key-said');
13840
+ form.append(input, send, said);
13841
+ form.addEventListener('submit', async (event) => {
13842
+ event.preventDefault();
13843
+ send.disabled = true;
13844
+ said.className = 'key-said';
13845
+ said.textContent = t('CHECKING…');
13846
+ try {
13847
+ const response = await fetch(`/api/agents/${agentId}/login/code`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code: input.value }) });
13848
+ const result = await response.json().catch(() => ({}));
13849
+ if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
13850
+ input.value = '';
13851
+ loginNow.awaiting = false;
13852
+ said.textContent = t('Sent. The sign-in finishes on its own.');
13853
+ } catch (error) {
13854
+ said.textContent = error.message;
13855
+ said.className = 'key-said bad';
13856
+ } finally { send.disabled = false; }
13857
+ });
13858
+ box.append(form);
13859
+ }
13860
+ const stop = el('button', 'sx-btn', t('Cancel the sign-in'));
13861
+ stop.type = 'button';
13862
+ stop.addEventListener('click', async () => { stop.disabled = true; await fetch(`/api/agents/${agentId}/login/cancel`, { method: 'POST' }).catch(() => {}); });
13863
+ box.append(stop);
13864
+ }
13865
+ // One door or two: Claude's CLI knows the subscription and the Console, and only the human can
13866
+ // say which account is paying. `null` is the single flow every other CLI has.
13867
+ function loginDoors(agent, signedIn) {
13868
+ const methods = agent.login?.methods;
13869
+ if (!methods?.length) return [{ id: null, label: signedIn ? t('Sign in again') : t('Sign in'), note: agent.login?.note ?? '' }];
13870
+ return methods.map((method) => ({ id: method.id, label: t(method.label), note: method.note ?? '' }));
13871
+ }
13872
+ async function startLogin(agentId, method = null) {
13873
+ state.loginLogs.set(agentId, []);
13874
+ const response = await fetch(`/api/agents/${agentId}/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ method }) });
13875
+ const result = await response.json().catch(() => ({}));
13876
+ if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
13877
+ loginNow.agent = agentId;
13878
+ loginNow.method = method;
13879
+ loginNow.url = null;
13880
+ loginNow.awaiting = false;
13881
+ loginNow.asksForCode = Boolean(result.asksForCode);
13882
+ repaintLoginAsk();
13883
+ return result;
13884
+ }
13885
+
13694
13886
 
13695
13887
  function renderLoginEvent(event) {
13696
13888
  const { agent, command, session, code, error } = event.payload;
@@ -13703,6 +13895,11 @@ function renderLoginEvent(event) {
13703
13895
  ? (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 }) : ''))
13704
13896
  : t('signing in @{agent} · {command}', { agent, command }));
13705
13897
  if (finished) { state.sessions[agent] = session ?? state.sessions[agent]; renderOnboarding(); if (state.settingsOpen) void loadSettings(); }
13898
+ if (!replaying) {
13899
+ if (finished) { if (loginNow.agent === agent) { loginNow.agent = null; loginNow.url = null; loginNow.awaiting = false; } }
13900
+ 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); }
13901
+ repaintLoginAsk();
13902
+ }
13706
13903
  state.lastSender = null;
13707
13904
  return node;
13708
13905
  }
@@ -13723,13 +13920,25 @@ function renderAgentInstall(event) {
13723
13920
 
13724
13921
  function appendLoginOutput(event) {
13725
13922
  const { agent, line, url } = event.payload;
13923
+ if (!replaying && loginNow.agent === agent) {
13924
+ // The address stays on screen instead of scrolling past: the heartbeat used to be the only
13925
+ // line left, so the one thing the human had to open was the first thing MADRE lost.
13926
+ if (url) loginNow.url = url;
13927
+ if (looksLikeCodePrompt(line)) loginNow.awaiting = true;
13928
+ if (url || looksLikeCodePrompt(line)) repaintLoginAsk();
13929
+ }
13930
+ // The heartbeat («… still running · 42s · …») says one thing, how long: it takes the place of
13931
+ // the one before instead of piling up thirty lines over the address the person needs.
13932
+ const beat = HEARTBEAT.test(line ?? '');
13726
13933
  const log = state.loginLogs.get(agent) ?? [];
13934
+ if (beat && HEARTBEAT.test(log.at(-1)?.line ?? '')) log.pop();
13727
13935
  log.push({ line, url });
13728
13936
  state.loginLogs.set(agent, log.slice(-60));
13729
13937
  for (const id of [`login-log-${agent}`, `bridge-log-${agent}`]) {
13730
13938
  const box = document.getElementById(id);
13731
13939
  if (!box) continue;
13732
13940
  box.hidden = false;
13941
+ if (beat && box.lastElementChild?.classList.contains('beat')) box.lastElementChild.remove();
13733
13942
  box.append(loginLine({ line, url }));
13734
13943
  box.scrollTop = box.scrollHeight;
13735
13944
  }
@@ -13737,8 +13946,9 @@ function appendLoginOutput(event) {
13737
13946
  // used to come back as a toast carrying a link that expired hours ago.
13738
13947
  if (url && !replaying) toast(t('@{agent}: open {url} to finish signing in', { agent, url }));
13739
13948
  }
13949
+ const HEARTBEAT = /^… still running · \d+s · /;
13740
13950
  function loginLine({ line, url }) {
13741
- const div = el('div');
13951
+ const div = el('div', HEARTBEAT.test(line ?? '') ? 'beat' : null);
13742
13952
  if (url) {
13743
13953
  const a = el('a', null, line); a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
13744
13954
  div.append(a);
@@ -13750,8 +13960,20 @@ async function loadSettings() {
13750
13960
  settingsUI.data = await fetch('/api/settings').then((response) => response.json());
13751
13961
  if (settingsUI.data?.settings?.override) state.override = settingsUI.data.settings.override;
13752
13962
  state.sessions = settingsUI.data.sessions ?? state.sessions;
13963
+ // The server is the one that knows whether a sign-in is still waiting: a page reloaded in the
13964
+ // middle of one has to find the same address and the same box, not an empty card.
13965
+ readLoginState(settingsUI.data.login ?? null);
13753
13966
  renderSettings();
13754
13967
  }
13968
+ // What the server says about the sign-in running right now, adopted as this page's own.
13969
+ function readLoginState(live) {
13970
+ loginNow.agent = live?.agent ?? null;
13971
+ loginNow.method = live?.method ?? null;
13972
+ loginNow.url = live?.url ?? null;
13973
+ loginNow.awaiting = Boolean(live?.awaitingCode);
13974
+ loginNow.asksForCode = Boolean(live?.asksForCode);
13975
+ repaintLoginAsk();
13976
+ }
13755
13977
 
13756
13978
  /* ---------- the core ---------- */
13757
13979
 
@@ -14927,13 +15149,28 @@ function connectionCard(agent) {
14927
15149
  // amber while it signs in, grey when the CLI is not here.
14928
15150
  const lamp = busy ? 'busy' : !agent.detected ? 'absent' : session?.state === 'signed-in' ? 'on' : session?.state === 'signed-out' ? 'off' : 'unknown';
14929
15151
  const said = busy ? t('Signing in…') : !agent.detected ? t('Not installed') : session ? ({ 'signed-in': t('Signed in'), 'signed-out': t('Signed out') }[session.state] ?? t('Unknown')) : t('Unknown');
15152
+ // Folded by default (2026-10-13): four agents with six settings each made a page to scroll
15153
+ // through. The line says who it is and whether it has a session; a click opens the rest. One
15154
+ // that is signing in, or has no session, opens by itself: that is where the person is needed.
15155
+ const open = settingsUI.agentsOpen.has(agent.id) || busy || (agent.detected && session?.state === 'signed-out');
15156
+ card.classList.toggle('open', open);
14930
15157
  const head = el('header', 'sx-head');
14931
- head.append(avatar(agent.id, { size: 32, status: agent.ready ? 'ready' : agent.detected ? 'detected' : 'offline' }));
15158
+ const toggle = el('button', 'sx-agent-toggle');
15159
+ toggle.type = 'button';
15160
+ toggle.setAttribute('aria-expanded', String(open));
15161
+ toggle.append(avatar(agent.id, { size: 32, status: agent.ready ? 'ready' : agent.detected ? 'detected' : 'offline' }));
14932
15162
  const words = el('div', 'sx-words');
14933
15163
  words.append(el('h3', null, agent.label), el('p', null, vendorWord(agent.id)));
14934
15164
  const chip = sxChip(said, lamp);
14935
15165
  if (lamp !== 'absent') chip.classList.add('session-lamp', lamp === 'on' ? 'ok' : lamp === 'off' ? 'bad' : 'wait');
14936
- head.append(words, chip);
15166
+ toggle.append(words, chip, el('span', 'sx-chevron'));
15167
+ toggle.addEventListener('click', () => {
15168
+ const now = !card.classList.contains('open');
15169
+ card.classList.toggle('open', now);
15170
+ toggle.setAttribute('aria-expanded', String(now));
15171
+ if (now) settingsUI.agentsOpen.add(agent.id); else settingsUI.agentsOpen.delete(agent.id);
15172
+ });
15173
+ head.append(toggle);
14937
15174
  card.append(head);
14938
15175
  // Where it lives, quietly: what is literal is in mono, what is said is not.
14939
15176
  const meta = el('div', 'sx-meta');
@@ -14943,8 +15180,14 @@ function connectionCard(agent) {
14943
15180
  const facts = [session?.detail ? t('Session {detail}', { detail: session.detail }) : null, agent.account ? `${agent.paid === false ? `${t('Free way in')} · ` : ''}${agent.account}` : null].filter(Boolean);
14944
15181
  if (facts.length) meta.append(el('span', null, facts.join(' · ')));
14945
15182
  card.append(meta);
15183
+ const body = el('div', 'sx-agent-body');
15184
+ card.append(body);
14946
15185
  card.rows = el('div', 'sx-rows');
14947
- card.append(card.rows);
15186
+ body.append(card.rows);
15187
+ // Settings in pairs: what it can do, then where its messages start and stop.
15188
+ const abilities = el('div', 'sx-duo');
15189
+ const modes = el('div', 'sx-duo');
15190
+ card.rows.append(abilities, modes);
14948
15191
 
14949
15192
  const agentScopes = settingsUI.data.settings.capabilities?.[agent.id]?.scopes ?? {};
14950
15193
  // Two abilities per agent: images and web. Writing is not an ability, it is the ceiling below.
@@ -14966,7 +15209,7 @@ function connectionCard(agent) {
14966
15209
  toast(t('That permission was not saved: {error}', { error: error.message }));
14967
15210
  } finally { box.disabled = false; }
14968
15211
  }, { disabled: !usable, label: labelText });
14969
- const row = sxRow(card, labelText, why(scope.enabled), toggle, { off: !usable });
15212
+ const row = sxRow(abilities, labelText, why(scope.enabled), toggle, { off: !usable });
14970
15213
  const hint = row.querySelector('.sx-hint');
14971
15214
  if (!scope.capable) {
14972
15215
  const assist = el('button', 'sx-link', t('Ask Diagnostics how'));
@@ -14978,10 +15221,11 @@ function connectionCard(agent) {
14978
15221
  }
14979
15222
  // The ceiling lives with every other agent's in PERMISSIONS: here it is said, and one key goes there.
14980
15223
  const currentCap = agentScopes.maxMode ?? 1;
14981
- sxRow(card, t('Max mode'), `#${currentCap} ${MODES[currentCap].label} · ${MODES[currentCap].hint}`, sxButton(t('Change in Permissions'), () => openSettingsOn('permissions', { part: 'permissions' })));
15224
+ const capRow = sxRow(modes, t('Max mode'), `#${currentCap} ${MODES[currentCap].label}`, sxButton(t('Change it'), () => openSettingsOn('permissions', { part: 'permissions' })));
15225
+ capRow.querySelector('.sx-hint').title = MODES[currentCap].hint;
14982
15226
  // The start: where a message to this agent begins. #2 means every turn may create files without arming CREATE.
14983
15227
  const currentStart = agentScopes.defaultMode ?? 1;
14984
- sxRow(card, t('Starting mode'), currentStart === 2 ? t('Every turn may create files, plan steps too.') : t('Read-only until you unlock CREATE.'), sxSeg([1, 2].map((n) => ({
15228
+ sxRow(modes, t('Starting mode'), currentStart === 2 ? t('Every turn may create files, plan steps too.') : t('Read-only until you unlock CREATE.'), sxSeg([1, 2].map((n) => ({
14985
15229
  value: n, text: `#${n}`, className: `o${n}`, disabled: n > currentCap,
14986
15230
  title: n === 2 ? t('Every message to this agent starts in CREATE: new files where they belong, existing files untouched. Plan steps to it too.') : t('Messages start read-only; unlock CREATE when you want files.'),
14987
15231
  })), currentStart, async (n, seg) => {
@@ -15019,33 +15263,39 @@ function connectionCard(agent) {
15019
15263
  }
15020
15264
  if (agent.detected && agent.login?.headless) {
15021
15265
  const signedIn = session?.state === 'signed-in';
15022
- const login = sxButton(signedIn ? t('Sign in again') : t('Sign in'), async () => {
15023
- login.disabled = true;
15024
- state.loginLogs.set(agent.id, []);
15025
- const response = await fetch(`/api/agents/${agent.id}/login`, { method: 'POST' });
15026
- const result = await response.json().catch(() => ({}));
15027
- if (!response.ok) toast(result.error ?? t('The sign-in could not start.'));
15028
- else { settingsUI.data.loggingIn = agent.id; renderSettings(); }
15029
- }, { tone: signedIn ? null : 'primary', title: t(agent.login.note) });
15030
- login.disabled = Boolean(settingsUI.data.loggingIn);
15031
- acts.append(login);
15266
+ // One button per account the CLI can sign in with. Which one is paying is the human's to say.
15267
+ for (const door of loginDoors(agent, signedIn)) {
15268
+ const login = sxButton(door.label, async () => {
15269
+ login.disabled = true;
15270
+ try { await startLogin(agent.id, door.id); settingsUI.data.loggingIn = agent.id; renderSettings(); }
15271
+ catch (error) { toast(error.message || t('The sign-in could not start.')); login.disabled = false; }
15272
+ }, { tone: signedIn ? null : 'primary', title: t(door.note || agent.login.note) });
15273
+ login.disabled = Boolean(settingsUI.data.loggingIn);
15274
+ acts.append(login);
15275
+ }
15032
15276
  }
15033
15277
  acts.append(recheck);
15034
- card.append(acts);
15278
+ body.append(acts);
15035
15279
  const slot = el('div', 'sx-slot');
15036
- if (agent.detected && agent.key) slot.append(keyForm(agent, { onDone: () => { void loadSettings(); renderOnboarding(); } }));
15037
- if (agent.detected && agent.login && !agent.login.headless) {
15280
+ // OpenCode's keys are its providers', added in the block under its settings, one row for all.
15281
+ if (agent.detected && agent.key && agent.id !== 'opencode') slot.append(keyForm(agent, { onDone: () => { void loadSettings(); renderOnboarding(); } }));
15282
+ // Signed in, OpenCode's providers are added in its block below; the terminal is for a first sign-in.
15283
+ if (agent.detected && agent.login && !agent.login.headless && !(agent.id === 'opencode' && session?.state === 'signed-in')) {
15038
15284
  slot.append(el('p', 'sx-hint', t('It signs in from its own prompt:')));
15039
15285
  slot.append(commandBlock([agent.login.display, `# ${t(agent.login.note)}`]));
15040
15286
  }
15287
+ if (agent.detected && agent.login?.headless) slot.append(loginAskSlot(agent.id));
15041
15288
  const log = state.loginLogs.get(agent.id) ?? [];
15042
15289
  if (log.length || busy) {
15043
15290
  const box = el('div', 'login-log');
15044
15291
  box.id = `login-log-${agent.id}`;
15045
15292
  for (const entry of log) box.append(loginLine(entry));
15046
15293
  slot.append(box);
15294
+ requestAnimationFrame(() => { box.scrollTop = box.scrollHeight; });
15047
15295
  }
15048
- if (slot.children.length) card.append(slot);
15296
+ if (slot.children.length) body.append(slot);
15297
+ // OpenCode answers through other providers: their keys and the chooser live in its own card.
15298
+ if (agent.id === 'opencode' && agent.detected) drawOpenCodeSettings(body, settingsUI.data.settings ?? {});
15049
15299
  return card;
15050
15300
  }
15051
15301
 
@@ -15206,26 +15456,157 @@ function drawRoomSettings(host, data) {
15206
15456
  min: 0, label: t('Retries'), wire: { toPatch: (value) => ({ gemini: { retries: value } }), describe: (value) => t('Gemini retries saved: {n}.', { n: value }) },
15207
15457
  }));
15208
15458
 
15209
- const opencode = sxBlock(host, 'OpenCode', t('The provider and model OpenCode answers with in this project.'));
15210
- const field = el('div', 'sx-field');
15211
- const model = el('input', 'sx-input mono');
15212
- model.value = s.opencodeModel ?? ''; model.placeholder = t('provider/model'); model.spellcheck = false;
15213
- model.setAttribute('aria-label', t('Model'));
15214
- const datalist = el('datalist'); datalist.id = 'opencode-models-settings'; model.setAttribute('list', datalist.id);
15215
- model.addEventListener('change', async () => {
15216
- try { await saveSettingNow({ opencode: { model: model.value.trim() } }, t('OpenCode model saved.')); } catch (error) { toast(t('The setting was not saved: {error}', { error: error.message })); }
15217
- });
15218
- const list = sxButton(t('List models'), async () => {
15219
- list.disabled = true;
15220
- const result = await fetch('/api/agents/opencode/models').then((response) => response.json()).catch(() => ({ models: [] }));
15221
- datalist.replaceChildren();
15222
- for (const name of result.models ?? []) { const option = el('option'); option.value = name; datalist.append(option); }
15223
- list.textContent = t('{n} models listed', { n: (result.models ?? []).length });
15224
- list.disabled = false;
15459
+ }
15460
+
15461
+ // OPENCODE (2026-10-13): the providers it can answer through, inside its own card. The ones with
15462
+ // a key or an account are a line each, with their switch for the chat; the ones without wait in
15463
+ // one row to be added; under them, the model it answers with when none is chosen. What
15464
+ // `opencode models` lists is the truth: a provider's models appear once OpenCode has its key.
15465
+ function drawOpenCodeSettings(host, s) {
15466
+ const block = el('section', 'oc-block');
15467
+ block.id = 'sx-opencode';
15468
+ const head = el('div', 'oc-title');
15469
+ head.append(el('b', null, t('Providers')), el('span', 'sx-hint', t('OpenCode answers through the providers you give it a key for. Choose which ones show in the chat, and the model it uses by default.')));
15470
+ const rows = el('div', 'oc-providers');
15471
+ rows.append(el('p', 'sx-hint', t('Reading OpenCode\'s providers…')));
15472
+ const add = el('form', 'oc-add');
15473
+ add.hidden = true;
15474
+ const field = el('div', 'oc-default-row');
15475
+ const select = el('select', 'sx-input oc-default');
15476
+ select.setAttribute('aria-label', t('Default model'));
15477
+ const other = el('input', 'sx-input mono');
15478
+ other.placeholder = t('or type provider/model'); other.spellcheck = false;
15479
+ other.setAttribute('aria-label', t('Another model'));
15480
+ other.hidden = true;
15481
+ const saveDefault = async (value) => {
15482
+ try { await saveSettingNow({ opencode: { model: value } }, t('OpenCode model saved.')); delete state.models.opencode; }
15483
+ catch (error) { toast(t('The setting was not saved: {error}', { error: error.message })); }
15484
+ };
15485
+ select.addEventListener('change', () => {
15486
+ other.hidden = select.value !== '__other';
15487
+ if (select.value !== '__other') { other.value = ''; void saveDefault(select.value); } else other.focus();
15225
15488
  });
15226
- field.append(model, datalist, list);
15227
- sxRow(opencode, t('Model'), t('Type provider/model, or list what OpenCode offers and pick one.'), field, { stack: true });
15228
- sxFoot(opencode, t('Every change is saved to ~/.pulse/config.json as you make it. Environment variables set before start still win on the next launch.'));
15489
+ other.addEventListener('change', () => { const value = other.value.trim(); if (value) void saveDefault(value); });
15490
+ const defaultWords = el('div', 'sx-words');
15491
+ defaultWords.append(el('b', null, t('Default model')), el('span', 'sx-hint', t('What OpenCode answers with when a message does not choose one.')));
15492
+ const picks = el('div', 'oc-picks');
15493
+ picks.append(select, other);
15494
+ field.append(defaultWords, picks);
15495
+ block.append(head, rows, add, field, el('p', 'sx-foot', t('Keys are written where OpenCode reads them (~/.local/share/opencode/auth.json), readable only by you; MADRE keeps no copy. The rest is saved to ~/.pulse/config.json.')));
15496
+ host.append(block);
15497
+
15498
+ const paintDefault = (providers, current) => {
15499
+ select.replaceChildren();
15500
+ const none = el('option', null, t('OpenCode\'s own default')); none.value = '';
15501
+ select.append(none);
15502
+ let found = !current;
15503
+ for (const provider of providers.filter((one) => one.via && one.models.length)) {
15504
+ const group = el('optgroup'); group.label = provider.label;
15505
+ for (const name of provider.models) {
15506
+ const option = el('option', null, name.slice(name.indexOf('/') + 1)); option.value = name;
15507
+ if (name === current) { option.selected = true; found = true; }
15508
+ group.append(option);
15509
+ }
15510
+ select.append(group);
15511
+ }
15512
+ const typed = el('option', null, t('Another…')); typed.value = '__other';
15513
+ select.append(typed);
15514
+ if (!found) { typed.selected = true; other.value = current; other.hidden = false; }
15515
+ };
15516
+ // One row to add a provider: which one, its key, and where to get it.
15517
+ const paintAdd = (waiting, reload) => {
15518
+ add.replaceChildren();
15519
+ add.hidden = !waiting.length;
15520
+ if (!waiting.length) return;
15521
+ const which = el('select', 'sx-input');
15522
+ which.setAttribute('aria-label', t('Provider'));
15523
+ for (const provider of waiting) { const option = el('option', null, provider.label); option.value = provider.id; which.append(option); }
15524
+ const input = el('input', 'sx-input mono');
15525
+ input.type = 'password'; input.autocomplete = 'off'; input.spellcheck = false;
15526
+ const where = el('a', 'oc-where', t('Where do I get one?'));
15527
+ where.target = '_blank'; where.rel = 'noopener';
15528
+ const sync = () => {
15529
+ const provider = waiting.find((one) => one.id === which.value);
15530
+ input.placeholder = t('{provider} API key', { provider: provider?.label ?? '' });
15531
+ input.setAttribute('aria-label', input.placeholder);
15532
+ where.hidden = !provider?.keyUrl;
15533
+ if (provider?.keyUrl) where.href = provider.keyUrl;
15534
+ };
15535
+ which.addEventListener('change', sync);
15536
+ sync();
15537
+ const save = el('button', 'sx-btn primary', t('Add'));
15538
+ save.type = 'submit';
15539
+ const label = el('b', 'oc-add-label', t('Add a provider'));
15540
+ add.append(label, which, input, save, where);
15541
+ add.onsubmit = async (event) => {
15542
+ event.preventDefault();
15543
+ if (!input.value.trim()) { input.focus(); return; }
15544
+ const provider = waiting.find((one) => one.id === which.value);
15545
+ save.disabled = true;
15546
+ const response = await fetch('/api/agents/opencode/key', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: input.value.trim(), provider: which.value }) }).catch(() => null);
15547
+ input.value = '';
15548
+ const result = await response?.json().catch(() => ({})) ?? {};
15549
+ save.disabled = false;
15550
+ if (!response?.ok) { toast(result.error ?? t('The key was not saved.')); return; }
15551
+ toast(t('{provider} is connected to OpenCode.', { provider: provider?.label ?? which.value }), { tone: 'done' });
15552
+ delete state.models.opencode;
15553
+ void reload();
15554
+ };
15555
+ };
15556
+ const load = async () => {
15557
+ const answer = await fetch('/api/agents/opencode/providers').then((response) => response.json().then((json) => ({ ok: response.ok, json }))).catch(() => ({ ok: false, json: {} }));
15558
+ if (!block.isConnected) return;
15559
+ rows.replaceChildren();
15560
+ if (!answer.ok) { rows.append(el('p', 'sx-hint', answer.json.error ?? t('OpenCode\'s providers could not be read.'))); return; }
15561
+ const { providers, default: current } = answer.json;
15562
+ const hidden = new Set(providers.filter((one) => one.hidden).map((one) => one.id));
15563
+ for (const provider of providers.filter((one) => one.via)) rows.append(openCodeProviderRow(provider, load, hidden));
15564
+ paintAdd(providers.filter((one) => !one.via && one.takesKey), load);
15565
+ paintDefault(providers, current ?? s.opencodeModel ?? '');
15566
+ };
15567
+ void load();
15568
+ }
15569
+ // One line per provider OpenCode can answer through: its name and how it is connected, its
15570
+ // switch for the chat, and the way its key comes out (an account is signed out in OpenCode).
15571
+ function openCodeProviderRow(provider, reload, hidden) {
15572
+ const row = el('div', 'oc-provider');
15573
+ const words = el('div', 'sx-words');
15574
+ const status = provider.via === 'own' ? t('Included with OpenCode · {n} models', { n: provider.models.length })
15575
+ : provider.via === 'api' ? t('Connected with a key · {n} models', { n: provider.models.length })
15576
+ : provider.via === 'oauth' ? t('Connected with its account · {n} models', { n: provider.models.length })
15577
+ : t('Connected · {n} models', { n: provider.models.length });
15578
+ words.append(el('b', null, provider.label), el('span', 'sx-hint', status));
15579
+ if (provider.via === 'oauth') words.lastChild.title = t('Signed in with its account from OpenCode. To sign it out, run `opencode auth logout` in a terminal.');
15580
+ row.append(words);
15581
+ const controls = el('div', 'oc-controls');
15582
+ if (provider.via === 'api') {
15583
+ const remove = sxButton(t('Remove the key'), async () => {
15584
+ if (remove.dataset.sure !== '1') { remove.dataset.sure = '1'; remove.textContent = t('Remove it?'); setTimeout(() => { if (remove.isConnected) { remove.dataset.sure = ''; remove.textContent = t('Remove the key'); } }, 4000); return; }
15585
+ remove.disabled = true;
15586
+ const response = await fetch('/api/agents/opencode/providers/remove', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: provider.id }) }).catch(() => null);
15587
+ const result = await response?.json().catch(() => ({})) ?? {};
15588
+ if (!response?.ok) { remove.disabled = false; toast(result.error ?? t('The key was not removed.')); return; }
15589
+ toast(t('{provider} no longer has a key in OpenCode.', { provider: provider.label }), { tone: 'done' });
15590
+ delete state.models.opencode;
15591
+ void reload();
15592
+ }, { tone: 'ghost' });
15593
+ controls.append(remove);
15594
+ }
15595
+ const shown = sxSwitch(!provider.hidden, async (on, box) => {
15596
+ if (on) hidden.delete(provider.id); else hidden.add(provider.id);
15597
+ try {
15598
+ await saveSettingNow({ opencode: { hidden: [...hidden] } }, on ? t('{provider} shows in the chat.', { provider: provider.label }) : t('{provider} no longer shows in the chat.', { provider: provider.label }));
15599
+ delete state.models.opencode;
15600
+ } catch (error) {
15601
+ if (on) hidden.add(provider.id); else hidden.delete(provider.id);
15602
+ box.checked = !on; toast(t('The setting was not saved: {error}', { error: error.message }));
15603
+ }
15604
+ }, { label: t('Show {provider} in the chat', { provider: provider.label }) });
15605
+ const label = el('label', 'oc-shown');
15606
+ label.append(el('span', null, t('In the chat')), shown);
15607
+ controls.append(label);
15608
+ row.append(controls);
15609
+ return row;
15229
15610
  }
15230
15611
 
15231
15612
  // PERMISSIONS: what unlocks #3 and #4, a phrase of one's own, how long an accepted key holds, and
@@ -16107,8 +16488,19 @@ function stopNostromo() {
16107
16488
  // Drag turns the archive in the hand, Shift+drag pans, wheel zooms about the pointer, a still
16108
16489
  // click selects. Let go and it stays where it was left; RECENTER brings back the side view.
16109
16490
  const drag = { active: false, moved: false, pan: false, x: 0, y: 0 };
16110
- nostromo.canvas?.addEventListener('mousedown', (event) => { drag.active = true; drag.moved = false; drag.pan = event.shiftKey; drag.x = event.clientX; drag.y = event.clientY; });
16491
+ // Floating memory cards and other open windows keep their entire visible rectangle. A click on a
16492
+ // command or a window's glass must never be interpreted as a click on the map behind it.
16493
+ function nostromoCovered(event) {
16494
+ const panels = [...nostromo.cards, ...floatingWindows(), ...document.querySelectorAll('dialog[open]')];
16495
+ return panels.some((panel) => {
16496
+ if (panel.hidden || panel === nostromo.dialog || panel.contains(nostromo.canvas)) return false;
16497
+ const rect = panel.getBoundingClientRect();
16498
+ return event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom;
16499
+ });
16500
+ }
16501
+ nostromo.canvas?.addEventListener('mousedown', (event) => { if (event.button !== 0 || nostromoCovered(event)) return; drag.active = true; drag.moved = false; drag.pan = event.shiftKey; drag.x = event.clientX; drag.y = event.clientY; });
16111
16502
  nostromo.canvas?.addEventListener('mousemove', (event) => {
16503
+ if (nostromoCovered(event)) return;
16112
16504
  if (drag.active) {
16113
16505
  const dx = event.clientX - drag.x, dy = event.clientY - drag.y;
16114
16506
  if (Math.hypot(dx, dy) > 3) drag.moved = true;
@@ -16135,6 +16527,7 @@ nostromo.canvas?.addEventListener('mousemove', (event) => {
16135
16527
  window.addEventListener?.('mouseup', () => { drag.active = false; nostromo.canvas?.classList.remove('dragging'); });
16136
16528
  nostromo.canvas?.addEventListener('mouseleave', () => { nostromo.hover = null; });
16137
16529
  nostromo.canvas?.addEventListener('wheel', (event) => {
16530
+ if (nostromoCovered(event)) return;
16138
16531
  event.preventDefault();
16139
16532
  if (!nostromoLive()) return;
16140
16533
  const rect = nostromo.canvas.getBoundingClientRect();
@@ -16153,8 +16546,9 @@ nostromo.canvas?.addEventListener('wheel', (event) => {
16153
16546
  for (const [type, phase] of [['pointerdown', 'down'], ['pointermove', 'move'], ['pointerup', 'up'], ['pointercancel', 'up']]) {
16154
16547
  nostromo.canvas?.addEventListener(type, (event) => flatTouch(event, phase));
16155
16548
  }
16156
- nostromo.canvas?.addEventListener('click', () => flatPaint());
16549
+ nostromo.canvas?.addEventListener('click', (event) => { if (!nostromoCovered(event)) flatPaint(); });
16157
16550
  nostromo.canvas?.addEventListener('click', (event) => {
16551
+ if (nostromoCovered(event)) return;
16158
16552
  if (drag.moved) { drag.moved = false; return; }
16159
16553
  const at = nostromo.flat ? flatAt(event) : nostromoAt(event);
16160
16554
  // The core is the door. Touching it used to be treated as an attempt on the archive; what is
@@ -16573,6 +16967,9 @@ function maybeStartTour() {
16573
16967
  // and What's new 1.0. Once it was given, only a later version's news come, never the old tour;
16574
16968
  // and the first time the room is here after it, Switch project says what it is.
16575
16969
  void roomTour().loaded.then(async (answer) => {
16970
+ // A new person is welcomed in Home, where the bonjour leads to connecting the agents and adding
16971
+ // the projects before any chapter; a project does not greet them first (2026-10-14).
16972
+ if (answer?.owed && !answer.returning && !answer.state?.path) return;
16576
16973
  if (answer?.owed) { await roomTour().maybeBonjour(); return; }
16577
16974
  if (answer?.state?.bonjourAt) {
16578
16975
  const shown = await maybeShowWhatsNew();
@@ -16616,8 +17013,42 @@ function roomTour() {
16616
17013
 
16617
17014
  // Three buttons open whole workspaces and say nothing about it until you are inside one. The
16618
17015
  // first time each is pressed, the workspace opens as always and a card beside the button says
16619
- // what it is for. Once per button: after that the button is just a button.
17016
+ // what it is for. Once per button: after that the button is just a button. From 1.1 every key of
17017
+ // the bar that opens something says so too (2026-10-14).
16620
17018
  const FIRST_TOUCH = {
17019
+ settings: {
17020
+ button: '#custom-button',
17021
+ panel: '#settings',
17022
+ title: t('Settings'),
17023
+ text: t('Everything this project uses, in one window: appearance, agents, memory, permissions, privacy and version. Press / to search any setting.'),
17024
+ },
17025
+ modules: {
17026
+ button: '#modules-button',
17027
+ panel: '#modules',
17028
+ title: t('Modules'),
17029
+ text: t('Extra features for this project, like apps. Each tile says what it does and what it needs; nothing is installed until you ask.'),
17030
+ },
17031
+ mother: {
17032
+ button: '#mother-button',
17033
+ panel: '#mother',
17034
+ title: t('Diagnostics'),
17035
+ text: t('When something fails, describe it in your own words: Diagnostics says what is going on and gives you the key that fixes it.'),
17036
+ },
17037
+ memory: {
17038
+ button: '#nostromo-bar',
17039
+ title: t('Memory map'),
17040
+ text: t('Everything MADRE remembers about this project, as stars. Drag to turn it, use the wheel to zoom in, and click a star to read that memory.'),
17041
+ },
17042
+ crew: {
17043
+ button: '#agents-compact',
17044
+ title: t('Agents'),
17045
+ text: t('The agents this project can call, and whether each one is ready. Their sessions are set in Settings › Agents.'),
17046
+ },
17047
+ steps: {
17048
+ button: '#steps-pill',
17049
+ title: t('First steps'),
17050
+ text: t('A short list to get the most out of MADRE. Each step is checked off on its own when you do it, and the pill goes away when you are done.'),
17051
+ },
16621
17052
  browser: {
16622
17053
  button: '#browser-button',
16623
17054
  title: t('The browser'),
@@ -16648,6 +17079,11 @@ function firstTouch(id) {
16648
17079
  try { localStorage.setItem(HINTS_KEY, JSON.stringify({ ...seen, [id]: true })); } catch { /* a private window gets the card every time */ }
16649
17080
  const button = document.querySelector(hint.button);
16650
17081
  if (!button) return;
17082
+ // Said once what was pressed has opened: some open after a moment, and a modal window (the
17083
+ // Memory map) makes everything outside it unreachable, so the card goes inside that window.
17084
+ setTimeout(() => showFirstTouch(hint, button), 320);
17085
+ }
17086
+ function showFirstTouch(hint, button) {
16651
17087
  document.querySelector('.hint-card')?.remove();
16652
17088
  const card = el('section', 'hint-card');
16653
17089
  card.setAttribute('role', 'dialog');
@@ -16655,7 +17091,9 @@ function firstTouch(id) {
16655
17091
  const ok = el('button', 'hint-ok', t('Got it'));
16656
17092
  ok.type = 'button';
16657
17093
  card.append(el('h3', null, hint.title), el('p', null, hint.text), ok);
16658
- document.body.append(card);
17094
+ const modal = [...document.querySelectorAll('dialog[open]')].reverse().find((one) => one.matches(':modal'));
17095
+ (modal ?? document.body).append(card);
17096
+ if (modal) card.classList.add('in-modal');
16659
17097
  // Placed once the workspace has opened, because opening it moves things: the bar narrows when
16660
17098
  // the browser docks, and the conversations button steps aside for its own panel. Under the
16661
17099
  // button, with its point aimed at it, while the button is there to point at; beside the panel