openvibers/openvibe.network Commits
Machines: every coding agent on the card, the switches read again on openvibe.link, and the card in tabs (#890)
Changes
@@@ -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)(/|$) {@@@ -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; }@@@ -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;}@@@ -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')]),@@@ -85,11 +85,16 @@ assert.ok(NOT_FOUND.ACCOUNT_HUB_PATHS.includes('/machines'), '/machines is the aassert.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');
@@ -222,6 +222,19 @@ server {
@@ -222,6 +222,19 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
add_header Cache-Control "no-store" always;
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
# 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.
# 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)(/|$) {
location ~ ^/api/v1/(machines|machine-pools)(/|$) {
@@ -412,7 +412,11 @@ button.td-stat:hover span { color: var(--accent-light); }
@@ -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-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 { 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: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-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 { display: grid; gap: 1px; min-width: 0; }
.mc-role-t b { font-size: 13.5px; }
.mc-role-t b { font-size: 13.5px; }
@@ -815,3 +819,42 @@ body.link-hub.my-machines-open { padding-bottom: 0; }
@@ -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, .ma-agent { grid-template-columns: auto minmax(0, 1fr); }
.ma-grant .btn, .ma-agent-sw { grid-column: 2; justify-self: start; }
.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; }
@@ -257,7 +257,7 @@ function mcAccessSwitches(m) {
@@ -257,7 +257,7 @@ function mcAccessSwitches(m) {
const boxes = {};
const boxes = {};
for (const [key, label, note, icon] of rows) {
for (const [key, label, note, icon] of rows) {
const id = `mc-${m.id}-${key}`;
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;
boxes[key] = box;
box.addEventListener('change', async () => {
box.addEventListener('change', async () => {
box.disabled = true;
box.disabled = true;
@@ -272,6 +272,6 @@ function mcAccessSwitches(m) {
@@ -272,6 +272,6 @@ function mcAccessSwitches(m) {
apiFetch(`/api/v1/me/nodes/${encodeURIComponent(m.principal_id)}/access-settings`).then((out) => {
apiFetch(`/api/v1/me/nodes/${encodeURIComponent(m.principal_id)}/access-settings`).then((out) => {
const s = (out && out.settings) || {};
const s = (out && out.settings) || {};
for (const [key, box] of Object.entries(boxes)) { box.checked = s[key] === true; box.disabled = false; }
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;
return field;
}
}
@@ -47,10 +47,7 @@ function mcCard(m) {
@@ -47,10 +47,7 @@ function mcCard(m) {
const head = mcEl('div', 'mc-head', [dot, mcEl('div', 'mc-title', [name, state]), meta]);
const head = mcEl('div', 'mc-head', [dot, mcEl('div', 'mc-title', [name, state]), meta]);
card.append(head);
card.append(head);
const roleChips = (m.roles || []).map((id) => (mcRoles || []).find((r) => r.id === id)).filter(Boolean);
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) {
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)]))));
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) {
@@ -59,24 +56,24 @@ function mcCard(m) {
const roles = mcEl('fieldset', 'mc-roles mc-role-set', [mcEl('legend', '', ['What it does for you'])]);
const roles = mcEl('fieldset', 'mc-roles mc-role-set', [mcEl('legend', '', ['What it does for you'])]);
(mcRoles || []).forEach((role) => {
(mcRoles || []).forEach((role) => {
const id = `mc-${m.id}-${role.id.replace(/\W/g, '-')}`;
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.checked = (m.roles || []).includes(role.id);
box.addEventListener('change', () => mcSaveRoles(card, m));
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 }));
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);
const own = mcOwnerSwitches(m);
- if (own) card.append(own);
// Who else may ask this machine for access, and its agents (my/access.js).
// Who else may ask this machine for access, and its agents (my/access.js).
const access = typeof mcAccessSwitches === 'function' ? mcAccessSwitches(m) : null;
const access = typeof mcAccessSwitches === 'function' ? mcAccessSwitches(m) : null;
- if (access) card.append(access);
const screen = mcScreenBlock(card, m);
const screen = mcScreenBlock(card, m);
- if (screen) card.append(screen);
const links = mcSharesBlock(m);
const links = mcSharesBlock(m);
- if (links) card.append(links);
const desks = mcDesksBlock(card, m);
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 acts = mcEl('div', 'mc-acts');
const rename = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-pen'), ' Rename'], { type: 'button' });
const rename = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-pen'), ' Rename'], { type: 'button' });
@@ -84,29 +81,119 @@ function mcCard(m) {
@@ -84,29 +81,119 @@ function mcCard(m) {
const revoke = mcEl('button', 'btn btn-small mc-revoke', [mcIcon('fa-link-slash'), ' Revoke'], { type: 'button' });
const revoke = mcEl('button', 'btn btn-small mc-revoke', [mcIcon('fa-link-slash'), ' Revoke'], { type: 'button' });
revoke.addEventListener('click', () => mcConfirmRevoke(acts, m));
revoke.addEventListener('click', () => mcConfirmRevoke(acts, m));
acts.append(rename, revoke);
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');
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);
menu.append(acts);
- card.append(menu);
+ head.append(menu);
return card;
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 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('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 ───────────────────────────────
/* ── The owner's switches, the live screen, its links, and the machine's desks ───────────────────────────────
@@ -116,7 +203,7 @@ function mcMuseStatus(m) {
@@ -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. */
/** 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) {
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.checked = checked === true;
box.addEventListener('change', () => onChange(box));
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')]),
return mcEl('label', 'mc-role', [box, mcEl('span', 'mc-role-ic', [mcIcon(id.endsWith('-agent') ? 'fa-robot' : 'fa-tower-broadcast')]),
@@ -85,11 +85,16 @@ assert.ok(NOT_FOUND.ACCOUNT_HUB_PATHS.includes('/machines'), '/machines is the a
@@ -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.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, /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, /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/);
assert.match(cards[1].textContent, /Offline · seen 2 h ago/);
const boxes = cards[0].querySelectorAll('.mc-role input');
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');
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');