openvibers/openvibe.network Commits

Machines: every coding agent on the card, the switches read again on openvibe.link, and the card in tabs (#890)

OP OpenVibers committed 12 h ago · f5d9da79a7 · 5 files, +185 −37

* Machines: every coding agent on the card, the switches read again on openvibe.link, and the card in tabs

The owner, 11-12, on openvibe.link/machines: "These switches could not be read just now", only Muse Code listed with "Status unavailable", native grey checkboxes, and "why are all coding agents not available and interconnected with openvibe.link and openvibe.codes".

- The switches: openvibe.link answered /api/v1/me/nodes/…/access-settings (and the access requests, grants, audit and the agents list) with a 301 to openvibe.network, where this host's session is not sent: 401. The vhost now passes /api/v1/me/(nodes|agents|agent-threads) through like the machines routes. A 401 says to sign in again instead of "could not be read".
- Coding agents: an Agents tab lists every agent OpenVibe.Codes runs (Claude Code, Codex, OpenCode, Command Code, MiniMax Code, Muse Code, DeepSeek, Aider) with this machine's own report of each from Link's fleet probe: Ready, Sign in or Not installed, with its limit windows. Link reports them only from a Codes worker, so a machine without that role says so and offers "Turn on Codes worker". Start a task here (openvibe.codes/conductor?machine=…) and Set up agents on Codes link the two sites; Muse's install steps stay under "Set up an agent on this machine".
- Layout: the card's places are tabs (Agents, Roles, Access, Screen) with counts, one at a time, remembered for the visit, arrows between them; Rename and Revoke moved to one quiet button in the card's head; every on/off setting is a switch (role="switch", drawn by Shared's ov-forms.css) on the right of its row.

Test: my-machines covers the agents list, the Codes worker offer, not-yet-reported and old readings, and the way to Codes.

* Machines: the agent icons are OpenVibe's own glyphs

Changes

ddiff --git a/deploy/nginx/parked-domains.conf b/deploy/nginx/parked-domains.conf
iindex 572d7348..ad5881f6 100644
---- a/deploy/nginx/parked-domains.conf
++++ b/deploy/nginx/parked-domains.conf
@@@ -222,6 +222,19 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
add_header Cache-Control "no-store" always;
}
++ # The hub's account routes on the same session (my/access.js, my/agents): a machine's access switches, requests,
++ # grants and audit, and the person's agents. They answered 301 to openvibe.network, where this host's session is
++ # not sent, so the switches read 401 (11-12).
++ location ~ ^/api/v1/me/(nodes|agents|agent-threads)(/|$) {
++ proxy_pass http://127.0.0.1:4000;
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ add_header Cache-Control "no-store" always;
++ }
# The machine hub on openvibe.link/machines is Network's page, so its API is Network's too, on the host-only
# session this host signs in with: the machines list, the owner's controls and the live screen, plus machine pools.
location ~ ^/api/v1/(machines|machine-pools)(/|$) {
ddiff --git a/public/css/my.css b/public/css/my.css
iindex 85e2c138..6d37734c 100644
---- a/public/css/my.css
++++ b/public/css/my.css
@@@ -412,7 +412,11 @@ button.td-stat:hover span { color: var(--accent-light); }
.mc-roles legend { font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 6px; }
.mc-role { display: grid; grid-template-columns: auto auto minmax(0, 1fr); gap: 10px; align-items: center; min-height: 52px; padding: 8px 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--bg-card); cursor: pointer; }
.mc-role:has(input:checked) { border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); background: color-mix(in srgb, var(--accent) 8%, var(--bg-card)); }
--.mc-role input { width: 18px; height: 18px; accent-color: var(--accent); }
++.mc-role input:not([role="switch"]) { width: 18px; height: 18px; accent-color: var(--accent); }
++.mc-role { grid-template-columns: auto minmax(0, 1fr) auto; }
++.mc-role > input { grid-column: 3; grid-row: 1; justify-self: end; }
++.mc-role > .mc-role-ic { grid-column: 1; grid-row: 1; }
++.mc-role > .mc-role-t { grid-column: 2; grid-row: 1; }
.mc-role-ic { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent-light); }
.mc-role-t { display: grid; gap: 1px; min-width: 0; }
.mc-role-t b { font-size: 13.5px; }
@@@ -815,3 +819,42 @@ body.link-hub.my-machines-open { padding-bottom: 0; }
.ma-grant, .ma-agent { grid-template-columns: auto minmax(0, 1fr); }
.ma-grant .btn, .ma-agent-sw { grid-column: 2; justify-self: start; }
}
++
++/* The machine card, 11-12 (the owner: "improve the layout even further"): its head carries Actions; its places are tabs. */
++.mc-head { grid-template-columns: auto minmax(0, 1fr) auto; }
++.mc-head .mc-meta { grid-column: 2; }
++.mc-head .mc-action-menu { grid-column: 3; grid-row: 1 / span 2; align-self: start; position: relative; margin: 0; }
++.mc-action-menu summary { list-style: none; justify-content: center; width: 40px; height: 40px; min-height: 40px; padding: 0; border-radius: 12px; }
++.mc-action-menu summary::-webkit-details-marker { display: none; }
++.mc-action-menu[open] summary { background: color-mix(in srgb, var(--accent) 14%, transparent); border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
++.mc-head .mc-action-menu .mc-acts { position: absolute; right: 0; top: 46px; z-index: 5; flex-direction: column; align-items: stretch; min-width: 180px; padding: 8px;
++ border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card, var(--bg-secondary)); box-shadow: 0 16px 40px rgba(0, 0, 0, .35); animation: mc-pop .18s cubic-bezier(.2, 1.2, .3, 1) both; }
++.mc-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
++@keyframes mc-pop { from { opacity: 0; transform: translateY(-4px) scale(.97); } }
++.mc-tabs { display: grid; gap: 12px; min-width: 0; }
++.mc-tabbar { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; padding: 4px; margin: 0 -4px; border-radius: 14px; background: color-mix(in srgb, var(--text-primary) 4%, transparent); }
++.mc-tabbar::-webkit-scrollbar { display: none; }
++.mc-tab { flex: 1 0 auto; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 40px; padding: 0 14px; border: 0; border-radius: 10px;
++ background: transparent; color: var(--text-muted); font: inherit; font-size: 13.5px; font-weight: 650; cursor: pointer; transition: background-color .18s ease, color .18s ease, box-shadow .18s ease; }
++.mc-tab:hover { color: var(--text-primary); }
++.mc-tab[aria-selected="true"] { color: var(--text-primary); background: var(--bg-card, var(--bg-secondary)); box-shadow: 0 1px 0 color-mix(in srgb, var(--text-primary) 8%, transparent), 0 4px 14px rgba(0, 0, 0, .22); }
++.mc-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
++.mc-tab-n { min-width: 20px; height: 20px; padding: 0 6px; box-sizing: border-box; border-radius: 999px; display: inline-grid; place-items: center; font-size: 11.5px;
++ background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent-light, var(--accent)); }
++.mc-pane { display: grid; gap: 12px; min-width: 0; animation: mc-fade .2s ease both; }
++.mc-pane[hidden] { display: none; }
++@keyframes mc-fade { from { opacity: 0; transform: translateY(3px); } }
++.mc-agents { display: grid; gap: 10px; }
++.mc-agents-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
++.mc-agents-lede { margin: 0; flex: 1 1 220px; font-size: 13px; color: var(--text-muted); }
++.mc-agent-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 8px; }
++.mc-agent { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: center; min-height: 52px; padding: 8px 12px; border-radius: 12px;
++ border: 1px solid var(--border); background: var(--bg-card, transparent); }
++.mc-agent.is-ok { border-color: color-mix(in srgb, var(--success, #22c55e) 45%, var(--border)); }
++.mc-agent.is-off .mc-role-ic { opacity: .55; }
++.mc-agent-state { font-size: 12px; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; color: var(--text-muted); background: color-mix(in srgb, var(--text-primary) 7%, transparent); }
++.mc-agent-state.is-ok { color: var(--success-text, var(--success, #16a34a)); background: color-mix(in srgb, var(--success, #22c55e) 15%, transparent); }
++.mc-agent-state.is-warn { color: var(--warning-text, var(--warning, #b45309)); background: color-mix(in srgb, var(--warning, #f59e0b) 16%, transparent); }
++.mc-acts .btn { min-height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; text-decoration: none; }
++@media (max-width: 520px) { .mc-tabbar { gap: 2px; } .mc-tab { flex: 1 0 auto; padding: 0 8px; font-size: 13px; gap: 5px; } .mc-acts { display: grid; grid-template-columns: 1fr; } .mc-acts .btn { justify-content: center; } }
++html[data-ov-motion=off] .mc-pane, html[data-ov-motion=off] .mc-head .mc-action-menu .mc-acts { animation: none; }
ddiff --git a/public/js/my/access.js b/public/js/my/access.js
iindex 5107b565..25c62f3d 100644
---- a/public/js/my/access.js
++++ b/public/js/my/access.js
@@@ -257,7 +257,7 @@ function mcAccessSwitches(m) {
const boxes = {};
for (const [key, label, note, icon] of rows) {
const id = `mc-${m.id}-${key}`;
-- const box = mcEl('input', '', [], { type: 'checkbox', id, disabled: '' });
++ const box = mcEl('input', '', [], { type: 'checkbox', role: 'switch', id, disabled: '' });
boxes[key] = box;
box.addEventListener('change', async () => {
box.disabled = true;
@@@ -272,6 +272,6 @@ function mcAccessSwitches(m) {
apiFetch(`/api/v1/me/nodes/${encodeURIComponent(m.principal_id)}/access-settings`).then((out) => {
const s = (out && out.settings) || {};
for (const [key, box] of Object.entries(boxes)) { box.checked = s[key] === true; box.disabled = false; }
-- }).catch(() => { field.append(mcEl('p', 'muted', ['These switches could not be read just now.'])); });
++ }).catch((e) => { field.append(mcEl('p', 'muted', [e && e.status === 401 ? 'Sign in again to change these switches.' : 'These switches could not be read just now.'])); });
return field;
}
ddiff --git a/public/js/my/machines.js b/public/js/my/machines.js
iindex dbc26f02..b436637a 100644
---- a/public/js/my/machines.js
++++ b/public/js/my/machines.js
@@@ -47,10 +47,7 @@ function mcCard(m) {
const head = mcEl('div', 'mc-head', [dot, mcEl('div', 'mc-title', [name, state]), meta]);
card.append(head);
const roleChips = (m.roles || []).map((id) => (mcRoles || []).find((r) => r.id === id)).filter(Boolean);
-- card.append(mcEl('div', 'mc-role-chips', roleChips.length ? roleChips.map((r) => mcEl('span', 'mc-role-chip', [r.name])) : [mcEl('span', 'mc-role-chip is-empty', ['No roles assigned'])]));
-- card.append(mcMuseStatus(m));
-- (m.pools || []).forEach((pool) => card.append(mcPoolBlock(pool)));
--
++ card.append(mcEl('div', 'mc-role-chips', roleChips.length ? roleChips.map((r) => mcEl('span', 'mc-role-chip', [r.name])) : [mcEl('span', 'mc-role-chip is-empty', ['Nothing turned on yet'])]));
if (Array.isArray(m.running) && m.running.length) {
card.append(mcEl('ul', 'mc-running', m.running.slice(0, 6).map((r) => mcEl('li', '', [String((r && (r.title || r.kind || r.id)) || r)]))));
}
@@@ -59,24 +56,24 @@ function mcCard(m) {
const roles = mcEl('fieldset', 'mc-roles mc-role-set', [mcEl('legend', '', ['What it does for you'])]);
(mcRoles || []).forEach((role) => {
const id = `mc-${m.id}-${role.id.replace(/\W/g, '-')}`;
-- const box = mcEl('input', '', [], { type: 'checkbox', id, value: role.id });
++ const box = mcEl('input', '', [], { type: 'checkbox', role: 'switch', id, value: role.id });
box.checked = (m.roles || []).includes(role.id);
box.addEventListener('change', () => mcSaveRoles(card, m));
roles.append(mcEl('label', 'mc-role', [box, mcEl('span', 'mc-role-ic', [mcIcon(MC_ROLE_ICON[role.id] || 'fa-gear')]), mcEl('span', 'mc-role-t', [mcEl('b', '', [role.name]), mcEl('small', '', [role.shows || ''])])], { for: id }));
});
-- card.append(roles);
--
const own = mcOwnerSwitches(m);
-- if (own) card.append(own);
// Who else may ask this machine for access, and its agents (my/access.js).
const access = typeof mcAccessSwitches === 'function' ? mcAccessSwitches(m) : null;
-- if (access) card.append(access);
const screen = mcScreenBlock(card, m);
-- if (screen) card.append(screen);
const links = mcSharesBlock(m);
-- if (links) card.append(links);
const desks = mcDesksBlock(card, m);
-- if (desks) card.append(desks);
++ const ready = (Array.isArray(m.fleet) ? m.fleet : []).filter((a) => a && a.installed && a.signed_in).length;
++ card.append(mcTabs(m.id, [
++ ['agents', 'Agents', [mcAgentsBlock(card, m), ...(m.pools || []).map((pool) => mcPoolBlock(pool))], ready],
++ ['roles', 'Roles', [roles], (m.roles || []).length],
++ ['access', 'Access', [own, access]],
++ ['screen', 'Screen', [screen, links, desks]],
++ ].filter(([, , blocks]) => blocks.some(Boolean))));
const acts = mcEl('div', 'mc-acts');
const rename = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-pen'), ' Rename'], { type: 'button' });
@@@ -84,29 +81,119 @@ function mcCard(m) {
const revoke = mcEl('button', 'btn btn-small mc-revoke', [mcIcon('fa-link-slash'), ' Revoke'], { type: 'button' });
revoke.addEventListener('click', () => mcConfirmRevoke(acts, m));
acts.append(rename, revoke);
++ // Rename and Revoke live in the card's head, behind one quiet button (they used to trail the whole card).
const menu = mcEl('details', 'mc-action-menu');
-- menu.append(mcEl('summary', '', ['Actions']));
++ menu.append(mcEl('summary', '', [mcIcon('fa-ellipsis'), mcEl('span', 'mc-sr', ['Actions for this machine'])], { 'aria-label': 'Actions for this machine', title: 'Rename or revoke' }));
menu.append(acts);
-- card.append(menu);
++ head.append(menu);
return card;
}
--/** Muse runs on this machine under its owner's own sign-in; the last Link reading is only a status. */
--function mcMuseStatus(m) {
-- const muse = Array.isArray(m.fleet) ? m.fleet.find((a) => a.agent === 'muse') : null;
-- const fleetAge = m.fleet_at ? Date.now() - Date.parse(m.fleet_at) : NaN;
-- const stale = m.fleet_at && (!Number.isFinite(fleetAge) || fleetAge > 5 * 60000);
-- const state = !m.online ? 'Offline' : !muse || stale ? 'Status unavailable' : !muse.installed ? 'Not installed' : !muse.signed_in ? 'Sign in' : 'Ready';
-- const row = mcEl('div', 'mc-role', [
-- mcEl('span', 'mc-role-ic', [mcIcon('fa-code')]),
-- mcEl('span', 'mc-role-t', [mcEl('b', '', ['Muse Code']), mcEl('small', '', [state])]),
-- ]);
-- const setup = mcEl('details', '', [mcEl('summary', '', ['Set up Muse Code'])]);
-- setup.append(mcEl('p', '', ['Install Muse Code on this machine, run ', mcEl('code', '', ['muse login']), ', then run the Link installer again with ', mcEl('code', '', ['--plan-logins']), '. Jobs use your own Muse sign-in.']));
++/* ── Coding agents (11-12, the owner: "why are all coding agents not available and interconnected with openvibe.link
++ and openvibe.codes"): every agent OpenVibe.Codes can run, with what this machine says about each. Link reports them
++ only from a Codes worker (its fleet probe asks the Codes runner for its logins), so a machine without the role says
++ so and offers to turn it on; then each agent reads Ready, Sign in or Not installed, with its limit windows. */
++const MC_AGENTS = [
++ ['claude-code', 'Claude Code', 'fa-terminal'], ['codex', 'Codex', 'fa-code'], ['opencode', 'OpenCode', 'fa-code-branch'],
++ ['command-code', 'Command Code', 'fa-keyboard'], ['minimax-code', 'MiniMax Code', 'fa-wand-magic-sparkles'], ['muse', 'Muse Code', 'fa-pen'],
++ ['deepseek', 'DeepSeek', 'fa-water'], ['aider', 'Aider', 'fa-gear'],
++];
++const MC_CODES = 'https://openvibe.codes';
++function mcWindowText(w) {
++ const used = Number(w && w.used), limit = Number(w && w.limit);
++ const pct = Number.isFinite(used) && Number.isFinite(limit) && limit > 0 ? `${Math.round((used / limit) * 100)}%` : Number.isFinite(used) ? `${Math.round(used)}%` : '';
++ return [w && w.name, pct].filter(Boolean).join(' ');
++}
++function mcAgentsBlock(card, m) {
++ const block = mcEl('div', 'mc-agents');
++ const worker = (m.roles || []).includes('codes.worker');
++ const fleet = Array.isArray(m.fleet) ? m.fleet : [];
++ const age = m.fleet_at ? Date.now() - Date.parse(m.fleet_at) : NaN;
++ const fresh = fleet.length && Number.isFinite(age) && age <= 10 * 60000;
++ let lede;
++ if (!m.online) lede = 'Offline: what it has is shown as last reported.';
++ else if (!worker) lede = 'Turn on Codes worker and this machine runs your coding jobs with the agents it has, and says which ones here.';
++ else if (!fleet.length) lede = 'Codes worker is on. This machine has not reported its agents yet: they show a minute after the Link connects.';
++ else lede = fresh ? `Reported ${mcAgo(m.fleet_at)}.` : `Last reported ${mcAgo(m.fleet_at)}.`;
++ const head = mcEl('div', 'mc-agents-head', [mcEl('p', 'mc-agents-lede', [lede])]);
++ if (!worker) {
++ const on = mcEl('button', 'btn btn-primary btn-small', [mcIcon('fa-power-off'), ' Turn on Codes worker'], { type: 'button' });
++ on.addEventListener('click', () => {
++ const box = card.querySelector('input[value="codes.worker"]');
++ if (box && !box.checked) { box.checked = true; box.dispatchEvent(new Event('change', { bubbles: true })); }
++ });
++ head.append(on);
++ }
++ block.append(head);
++ const list = mcEl('ul', 'mc-agent-list', [], { role: 'list' });
++ for (const [id, label, icon] of MC_AGENTS) {
++ const a = fleet.find((x) => x && x.agent === id) || null;
++ const state = !a ? (fleet.length ? 'Not found' : 'Unknown') : !a.installed ? 'Not installed' : !a.signed_in ? 'Sign in' : 'Ready';
++ const tone = state === 'Ready' ? 'is-ok' : state === 'Sign in' ? 'is-warn' : 'is-off';
++ const windows = a && Array.isArray(a.windows) ? a.windows.slice(0, 3).map(mcWindowText).filter(Boolean).join(' · ') : '';
++ const li = mcEl('li', `mc-agent ${tone}`, [
++ mcEl('span', 'mc-role-ic', [mcIcon(icon)]),
++ mcEl('span', 'mc-role-t', [mcEl('b', '', [label]), mcEl('small', '', [windows || (state === 'Sign in' ? 'Installed: sign in on this machine to use it.' : state === 'Ready' ? 'Signed in on this machine.' : state === 'Not installed' ? 'Not on this machine yet.' : '')])]),
++ mcEl('span', `mc-agent-state ${tone}`, [state]),
++ ]);
++ list.append(li);
++ }
++ block.append(list);
++ // How an agent gets here: installed and signed in on the machine itself, then the Link told to use your own plans.
const windows = /windows/i.test(String(m.os || ''));
++ const setup = mcEl('details', 'mc-agent-setup', [mcEl('summary', '', ['Set up an agent on this machine'])]);
++ setup.append(mcEl('p', '', ['Install the agent on this machine and sign in to it there (Muse Code: ', mcEl('code', '', ['muse login']), '), then run the Link installer again with ', mcEl('code', '', ['--plan-logins']), '. Jobs use your own sign-in, never a key of ours.']));
++ setup.append(mcEl('p', '', ['Muse Code on this machine:']));
setup.append(mcEl('pre', '', [mcEl('code', '', [windows ? 'irm https://dev.meta.ai/install.ps1 | iex' : 'curl -fsSL https://dev.meta.ai/install.sh | sh'])]));
-- setup.append(mcEl('p', '', [mcEl('a', '', ['Worker setup'], { href: 'https://github.com/OpenVibers/OpenVibe.Link/blob/main/docs/worker.md' })]));
-- return mcEl('div', 'mc-roles', [row, setup]);
++ block.append(setup);
++ block.append(mcEl('div', 'mc-acts', [
++ mcEl('a', 'btn btn-primary btn-small', [mcIcon('fa-play'), ' Start a task here'], { href: `${MC_CODES}/conductor?machine=${encodeURIComponent(m.id)}` }),
++ mcEl('a', 'btn btn-outline btn-small', [mcIcon('fa-sliders'), ' Set up agents on Codes'], { href: `${MC_CODES}/setup` }),
++ mcEl('a', 'btn btn-outline btn-small', [mcIcon('fa-book'), ' Worker setup'], { href: 'https://github.com/OpenVibers/OpenVibe.Link/blob/main/docs/worker.md' }),
++ ]));
++ return block;
++}
++
++/* ── Tabs inside a card (11-12, the owner: "improve the layout even further"): one place at a time instead of every
++ block in one long column. The choice is kept per machine for the visit; arrows move between tabs. */
++function mcTabs(machineId, panes) {
++ const wrap = mcEl('div', 'mc-tabs');
++ const bar = mcEl('div', 'mc-tabbar', [], { role: 'tablist', 'aria-label': 'This machine' });
++ const key = `mc-tab:${machineId}`;
++ let want = '';
++ try { want = sessionStorage.getItem(key) || ''; } catch { want = ''; }
++ if (!panes.some(([id]) => id === want)) want = panes[0][0];
++ const buttons = [];
++ const bodies = [];
++ panes.forEach(([id, label, blocks, count]) => {
++ const tabId = `mc-${machineId}-tab-${id}`, paneId = `mc-${machineId}-pane-${id}`;
++ const b = mcEl('button', 'mc-tab', [label, count ? mcEl('span', 'mc-tab-n', [String(count)]) : null], { type: 'button', role: 'tab', id: tabId, 'aria-controls': paneId });
++ const pane = mcEl('div', 'mc-pane', blocks.filter(Boolean), { role: 'tabpanel', id: paneId, 'aria-labelledby': tabId });
++ buttons.push(b); bodies.push(pane);
++ bar.append(b);
++ });
++ const show = (id, focus) => {
++ panes.forEach(([pid], i) => {
++ const on = pid === id;
++ buttons[i].setAttribute('aria-selected', on ? 'true' : 'false');
++ buttons[i].tabIndex = on ? 0 : -1;
++ bodies[i].hidden = !on;
++ if (on && focus) buttons[i].focus();
++ });
++ try { sessionStorage.setItem(key, id); } catch { /* private mode */ }
++ };
++ buttons.forEach((b, i) => {
++ b.addEventListener('click', () => show(panes[i][0], false));
++ b.addEventListener('keydown', (e) => {
++ if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
++ e.preventDefault();
++ const n = (i + (e.key === 'ArrowRight' ? 1 : panes.length - 1)) % panes.length;
++ show(panes[n][0], true);
++ });
++ });
++ wrap.append(bar, ...bodies);
++ show(want, false);
++ return wrap;
}
/* ── The owner's switches, the live screen, its links, and the machine's desks ───────────────────────────────
@@@ -116,7 +203,7 @@ function mcMuseStatus(m) {
/** A labelled switch row: a checkbox, an icon, a bold label and the sentence that says what it allows. */
function mcSwitchRow(id, label, note, checked, onChange) {
-- const box = mcEl('input', '', [], { type: 'checkbox', id });
++ const box = mcEl('input', '', [], { type: 'checkbox', role: 'switch', id });
box.checked = checked === true;
box.addEventListener('change', () => onChange(box));
return mcEl('label', 'mc-role', [box, mcEl('span', 'mc-role-ic', [mcIcon(id.endsWith('-agent') ? 'fa-robot' : 'fa-tower-broadcast')]),
ddiff --git a/test/my-machines.test.js b/test/my-machines.test.js
iindex 15dac088..17711f87 100644
---- a/test/my-machines.test.js
++++ b/test/my-machines.test.js
@@@ -85,11 +85,16 @@ assert.ok(NOT_FOUND.ACCOUNT_HUB_PATHS.includes('/machines'), '/machines is the a
assert.deepStrictEqual(cards.map((c) => c.getAttribute('data-machine')), ['dev_B', 'dev_A'], 'online first');
assert.match(cards[0].textContent, /Studio PC.*Online.*Windows 11 · Node 0\.31\.0/);
assert.match(cards[0].textContent, /Fix the WHIP reconnect/, 'what it runs');
-- assert.match(cards[0].textContent, /Muse CodeReady.*muse login.*--plan-logins.*install.ps1/, 'ready and Windows setup on its card');
-- assert.match(p.sandbox.mcMuseStatus({ online: true, fleet: [{ agent: 'muse', installed: true, signed_in: false }] }).textContent, /Muse CodeSign in/);
-- assert.match(p.sandbox.mcMuseStatus({ online: true, fleet: [{ agent: 'muse', installed: false, signed_in: false }] }).textContent, /Muse CodeNot installed/);
-- assert.match(p.sandbox.mcMuseStatus({ online: true }).textContent, /Muse CodeStatus unavailable/);
-- assert.match(p.sandbox.mcMuseStatus({ online: true, fleet: [{ agent: 'muse', installed: true, signed_in: true }], fleet_at: ago(10) }).textContent, /Muse CodeStatus unavailable/, 'old readings are not current readiness');
++ // 11-12: every coding agent Codes runs, with the machine's own report of each; Muse's setup stays on the card.
++ assert.match(cards[0].textContent, /Muse CodeSigned in on this machine\.Ready.*muse login.*--plan-logins.*install.ps1/, 'ready and Windows setup on its card');
++ assert.match(cards[0].textContent, /Claude Code.*Codex.*OpenCode.*Command Code.*MiniMax Code.*Muse Code.*DeepSeek.*Aider/, 'every agent Codes runs');
++ assert.match(cards[0].textContent, /Start a task here.*Set up agents on Codes/, 'the way to openvibe.codes');
++ const noCard = { querySelector: () => null };
++ assert.match(p.sandbox.mcAgentsBlock(noCard, { online: true, roles: ['codes.worker'], fleet: [{ agent: 'muse', installed: true, signed_in: false }] }).textContent, /Muse Code[^]*?Sign in/);
++ assert.match(p.sandbox.mcAgentsBlock(noCard, { online: true, roles: ['codes.worker'], fleet: [{ agent: 'muse', installed: false, signed_in: false }] }).textContent, /Muse Code[^]*?Not installed/);
++ assert.match(p.sandbox.mcAgentsBlock(noCard, { online: true, roles: [] }).textContent, /Turn on Codes worker/, 'a machine that is not a Codes worker offers to become one');
++ assert.match(p.sandbox.mcAgentsBlock(noCard, { online: true, roles: ['codes.worker'] }).textContent, /has not reported its agents yet/);
++ assert.match(p.sandbox.mcAgentsBlock(noCard, { online: true, roles: ['codes.worker'], fleet: [{ agent: 'muse', installed: true, signed_in: true }], fleet_at: ago(30) }).textContent, /Last reported 30 min ago/, 'an old reading says how old it is');
assert.match(cards[1].textContent, /Offline · seen 2 h ago/);
const boxes = cards[0].querySelectorAll('.mc-role input');
assert.deepStrictEqual(boxes.map((b) => [b.value, b.checked]), [['codes.worker', true], ['desks.host', false], ['ai.host', false]], 'a switch per role, the machine\'s own on');