@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/CHANGELOG.md +45 -1
- package/CONTRIBUTING.md +5 -1
- package/NOTICE +20 -1
- package/README.md +8 -7
- package/TRADEMARKS.md +65 -0
- package/docs/INTERNALS.md +2 -2
- package/docs/REFERENCE.md +6 -5
- package/docs/madre-banner.png +0 -0
- package/package.json +4 -3
- package/public/app.js +529 -91
- package/public/es.js +1330 -1070
- package/public/macroverso.js +22 -2
- package/public/salas-mac.css +518 -24
- package/public/salas-mac.html +25 -21
- package/public/salas-mac.js +1454 -125
- package/public/styles.css +167 -3
- package/public/tour-chapters.js +162 -52
- package/public/tour.js +48 -12
- package/public/troubleshooting.js +2 -2
- package/src/auth-probe.mjs +29 -6
- package/src/credentials.mjs +80 -0
- package/src/directives.mjs +6 -6
- package/src/extensions.mjs +31 -10
- package/src/heartbeat.mjs +55 -0
- package/src/local-ai.mjs +48 -0
- package/src/looks.mjs +38 -5
- package/src/models.mjs +38 -0
- package/src/modules/ollama.mjs +14 -2
- package/src/outbound.mjs +17 -0
- package/src/room/escalation.mjs +1 -0
- package/src/room.mjs +60 -19
- package/src/server.mjs +350 -27
- package/src/tour.mjs +24 -9
- package/src/whats-new.mjs +10 -10
- package/docs/madre-banner.svg +0 -25
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
|
-
|
|
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
|
|
2532
|
-
|
|
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,
|
|
2536
|
-
|
|
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 (
|
|
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
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
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
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
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
|
-
}
|
|
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
|
|
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
|
-
|
|
3476
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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);
|
|
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.'))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
15023
|
-
|
|
15024
|
-
|
|
15025
|
-
|
|
15026
|
-
|
|
15027
|
-
|
|
15028
|
-
|
|
15029
|
-
|
|
15030
|
-
|
|
15031
|
-
|
|
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
|
-
|
|
15278
|
+
body.append(acts);
|
|
15035
15279
|
const slot = el('div', 'sx-slot');
|
|
15036
|
-
|
|
15037
|
-
if (agent.detected && agent.
|
|
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)
|
|
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
|
-
|
|
15210
|
-
|
|
15211
|
-
|
|
15212
|
-
|
|
15213
|
-
|
|
15214
|
-
|
|
15215
|
-
|
|
15216
|
-
|
|
15217
|
-
|
|
15218
|
-
const
|
|
15219
|
-
|
|
15220
|
-
|
|
15221
|
-
|
|
15222
|
-
|
|
15223
|
-
|
|
15224
|
-
|
|
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
|
-
|
|
15227
|
-
|
|
15228
|
-
|
|
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
|
-
|
|
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.
|
|
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
|