openvibers/openvibe.codes Commits
Build Codes C4 workspace screens (#164)
Changes
nnew file mode 100644@@@ -0,0 +1,49 @@++.c4{--c4-bg:#f7f8fa;--c4-surface:#fff;--c4-ink:#18212b;--c4-muted:#687583;--c4-line:#e1e6eb;--c4-accent:#176b5b;color:var(--c4-ink);max-width:1540px;margin:0 auto;padding:0 32px 44px}++.c4-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:36px;padding-top:26px}++.c4-side nav{position:sticky;top:24px;display:grid;gap:5px}++.c4-side a{display:flex;align-items:center;gap:11px;min-height:44px;padding:9px 12px;border-radius:9px;color:var(--c4-muted);text-decoration:none;font-size:14px}++.c4-side svg{width:19px;height:19px;flex:none}++.c4-side a[aria-current],.c4-mobile a[aria-current]{color:var(--c4-accent);background:#e9f4f1;font-weight:650}++.c4-main{min-width:0;max-width:1200px;width:100%}++.c4-title{display:flex;justify-content:space-between;align-items:end;margin:0 0 22px}++.c4-title h1{font-size:clamp(26px,3vw,36px);letter-spacing:-.045em;margin:0;line-height:1.12}++.c4-title p{margin:8px 0 0;color:var(--c4-muted);font-size:14px;line-height:1.5}++.c4-card{background:var(--c4-surface);border:1px solid var(--c4-line);border-radius:13px;padding:20px;margin:0 0 16px;box-shadow:0 1px 2px #1b29340a}++.c4-card h2{font-size:16px;letter-spacing:-.02em;margin:0}++.c4-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}++.c4-card-head a,.c4-card a:not(.c4-button){color:var(--c4-accent);font-size:13px}++.c4-composer{border-color:#d5e4df;background:linear-gradient(140deg,#fff,#f8fcfa)}++.c4-composer form>label{display:block;font-size:15px;font-weight:650;margin-bottom:12px}++.c4-composer textarea{width:100%;min-height:105px;resize:vertical;border:1px solid var(--c4-line);border-radius:9px;padding:13px;font:inherit;color:var(--c4-ink);background:#fff}++.c4-controls{display:flex;align-items:end;gap:10px;flex-wrap:wrap;margin-top:12px}++.c4-controls label{display:grid;gap:5px;font-size:11px;color:var(--c4-muted);min-width:120px}++.c4-controls select,.c4-controls input,.c4-search input{background:#fff;border:1px solid var(--c4-line);border-radius:7px;padding:8px;color:var(--c4-ink);font:inherit;font-size:13px;min-height:40px}++.c4-button{border:0;border-radius:8px;background:var(--c4-accent);color:#fff;padding:10px 16px;font:inherit;font-size:13px;font-weight:650;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;min-height:44px}++.c4-button-light{background:#f3f5f6;color:var(--c4-ink);border:1px solid var(--c4-line)}++.c4-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}++.c4-empty,.c4-emptyline{color:var(--c4-muted);font-size:13px;line-height:1.55}++.c4-emptyline{border-top:1px solid var(--c4-line);padding:15px 0 3px}++.c4-empty h2{color:var(--c4-ink)}++.c4-task{display:grid;grid-template-columns:10px minmax(0,1fr) auto;align-items:center;gap:4px 10px;padding:13px 0;border-top:1px solid var(--c4-line);color:inherit;text-decoration:none;min-width:0}++.c4-task-title{font-size:13px;font-weight:620;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}++.c4-task-meta{grid-column:2/4;color:var(--c4-muted);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}++.c4-status-dot{width:9px;height:9px;border-radius:50%;background:#98a5aa;grid-row:1;grid-column:1}++.c4-status-dot.c4-running,.c4-status-dot.c4-queued{background:#179579}.c4-status-dot.c4-failed{background:#ce7441}.c4-status-dot.c4-done,.c4-status-dot.c4-succeeded{background:#69a24f}++.c4-status{display:inline-flex;border-radius:20px;padding:5px 9px;background:#eef2f3;color:#4f5d67;font-size:11px;font-weight:650;white-space:nowrap}++.c4-running,.c4-queued{background:#e9f4f1;color:#176b5b}.c4-failed{background:#fff0e9;color:#995128}.c4-done,.c4-succeeded{background:#edf6e9;color:#47713d}.c4-cancelled{background:#f0eff5;color:#635a7a}++.c4-reasons{padding:18px 2px}.c4-reasons>h2{font-size:17px}.c4-reasons article{border-top:1px solid var(--c4-line);padding-top:12px}.c4-reasons h3{font-size:14px;margin:0 0 6px}.c4-reasons p{font-size:12px;line-height:1.5;color:var(--c4-muted);margin:0}++.c4-mobile{display:none}.c4-search{display:flex;align-items:end;gap:8px;margin:0 0 14px}.c4-search label{font-size:12px;color:var(--c4-muted)}.c4-search input{min-width:0;flex:1}++.c4-outcome{display:grid;grid-template-columns:30px minmax(0,1fr) auto;align-items:center;gap:10px;margin:0 0 16px}.c4-obadge{width:30px;height:30px;border-radius:50%;display:grid;place-items:center;background:#fff0e9;color:#995128}.c4-obadge svg{width:15px;height:15px}.c4-outcome-copy{min-width:0;display:grid;gap:2px}.c4-outcome-copy>b{font-size:13px}.c4-outcome-copy>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--c4-muted);font-size:12px}.c4-outcome-actions{display:flex;justify-content:flex-end;gap:6px;flex-wrap:wrap}.c4-opill{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:34px;padding:0 11px 0 9px;border:1px solid var(--c4-line);border-radius:99px;background:var(--c4-surface);color:var(--c4-ink);font-size:12px;font-weight:650;text-decoration:none;white-space:nowrap}.c4-opill svg{width:15px;height:15px}.c4-opill-primary{background:var(--c4-accent);border-color:var(--c4-accent);color:#fff}.c4-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}++.c4-task-detail{padding:24px}.c4-task-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.c4-task-title-wrap{min-width:0;flex:1}.c4-brief{font-size:clamp(24px,3vw,34px);letter-spacing:-.04em;line-height:1.18;margin:0;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere}.c4-brief.is-expanded{-webkit-line-clamp:unset}.c4-brief-expand{background:none;border:0;color:var(--c4-accent);padding:9px 0;cursor:pointer;font:inherit;font-size:13px;min-height:44px}.c4-chips{display:flex;flex-wrap:wrap;gap:7px;margin:16px 0}.c4-chip{display:inline-flex;align-items:center;min-height:30px;padding:5px 10px;border:1px solid var(--c4-line);border-radius:18px;color:var(--c4-muted);font-size:12px;text-decoration:none}.c4-chip[href]{color:var(--c4-accent)}++.c4-progress-toggle{width:100%;display:flex;align-items:center;gap:9px;text-align:left;border:0;border-radius:9px;background:#edf5f2;color:var(--c4-ink);padding:12px 14px;min-height:46px;font:inherit;font-size:13px;cursor:pointer}.c4-progress-meta{color:var(--c4-muted);font-size:12px;margin-left:auto}.c4-tabs{display:flex;gap:5px;border-bottom:1px solid var(--c4-line);margin:16px 0 0;overflow-x:auto}.c4-tabs a{padding:11px 12px;color:var(--c4-muted);text-decoration:none;white-space:nowrap;border-bottom:2px solid transparent;font-size:13px;min-height:44px}.c4-tabs a[aria-selected=true]{color:var(--c4-accent);border-color:var(--c4-accent);font-weight:650}.c4-panel{padding:14px 0 4px;min-height:58px}.c4-panel[hidden]{display:none}.c4-panel p{font-size:13px;color:var(--c4-muted)}.c4-log{white-space:pre-wrap;overflow-wrap:anywhere;max-height:360px;overflow:auto;background:#f6f8f8;border-radius:8px;padding:12px;font-size:12px;color:#314047}.c4-code{white-space:pre-wrap;overflow-wrap:anywhere;background:#f5f7f7;padding:12px;border-radius:8px;font-size:12px}++.c4-settings{display:grid;gap:12px;font-size:13px}.c4-safety{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.c4-safety-card{display:flex;align-items:flex-start;gap:10px;border:1px solid var(--c4-line);border-radius:10px;padding:14px;cursor:pointer;min-height:90px}.c4-safety-card:has(input:checked){border-color:var(--c4-accent);box-shadow:0 0 0 1px var(--c4-accent)}.c4-safety-card input{margin-top:3px;accent-color:var(--c4-accent)}.c4-safety-card span{display:grid;gap:6px}.c4-safety-card small{color:var(--c4-muted);font-size:12px;line-height:1.4}++.c4 a:focus-visible,.c4 button:focus-visible,.c4 input:focus-visible,.c4 textarea:focus-visible,.c4 select:focus-visible{outline:2px solid #188c75;outline-offset:2px}++@media(max-width:900px){.c4{padding-left:22px;padding-right:22px}.c4-layout{grid-template-columns:180px minmax(0,1fr);gap:22px}}++@media(max-width:760px){.c4{padding:0 15px calc(78px + env(safe-area-inset-bottom))}.c4-layout{display:block;padding-top:18px}.c4-side{display:none}.c4-title{margin-bottom:16px}.c4-card{padding:16px;border-radius:11px}.c4-grid{grid-template-columns:1fr;gap:0}.c4-controls{display:grid;grid-template-columns:1fr 1fr}.c4-controls label{min-width:0}.c4-controls select,.c4-controls input{width:100%}.c4-controls .c4-button{grid-column:1/-1}.c4-task-detail{padding:17px}.c4-task-heading{gap:8px}.c4-task-heading>.c4-status{flex:none}.c4-chips{gap:6px}.c4-chip{font-size:11px;padding:5px 8px}.c4-outcome{grid-template-columns:30px minmax(0,1fr);gap:8px}.c4-outcome-actions{grid-column:1/-1;justify-content:flex-start;padding-left:38px}.c4-opill{min-height:36px;padding:0 9px;font-size:11px}.c4-mobile{display:grid;position:fixed;z-index:20;bottom:0;left:0;right:0;grid-template-columns:repeat(5,1fr);padding:4px max(6px,env(safe-area-inset-left)) calc(4px + env(safe-area-inset-bottom));background:var(--c4-surface);border-top:1px solid var(--c4-line)}.c4-mobile a{display:flex;min-height:48px;flex-direction:column;align-items:center;justify-content:center;gap:2px;text-align:center;font-size:10px;padding:4px 2px;border-radius:8px;color:var(--c4-muted);text-decoration:none}.c4-mobile svg{width:19px;height:19px}.c4-search{align-items:stretch;flex-wrap:wrap}.c4-search label{width:100%}.c4-safety{grid-template-columns:1fr}.c4-safety-card{min-height:68px}.c4-progress-toggle{flex-wrap:wrap}.c4-progress-meta{margin-left:0}.c4-task-meta{font-size:10px}}++:root[data-theme-mode="dark"] .c4{--c4-bg:#101518;--c4-surface:#171f23;--c4-ink:#eaf0f1;--c4-muted:#a3b0b4;--c4-line:#2d393d;--c4-accent:#75d0ba}++:root[data-theme-mode="dark"] .c4-composer{background:linear-gradient(140deg,#171f23,#182722);border-color:#315148}:root[data-theme-mode="dark"] .c4 textarea,:root[data-theme-mode="dark"] .c4 select,:root[data-theme-mode="dark"] .c4 input{background:#11181b;color:var(--c4-ink)}++:root[data-theme-mode="dark"] .c4-side a[aria-current],:root[data-theme-mode="dark"] .c4-mobile a[aria-current]{background:#1d3831}:root[data-theme-mode="dark"] .c4-log,:root[data-theme-mode="dark"] .c4-code{background:#11181b;color:#d3dfdf}:root[data-theme-mode="dark"] .c4-button-light{background:#20292d;color:var(--c4-ink)}:root[data-theme-mode="dark"] .c4-obadge{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-progress-toggle{background:#1d3831;color:var(--c4-ink)}++:root[data-theme-mode="dark"] .c4-status{background:#263034;color:#cad4d6}:root[data-theme-mode="dark"] .c4-running,:root[data-theme-mode="dark"] .c4-queued{background:#1d3831;color:#75d0ba}:root[data-theme-mode="dark"] .c4-failed{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-done,:root[data-theme-mode="dark"] .c4-succeeded{background:#243423;color:#afd39c}++.c4-progress-content{grid-template-rows:0fr;opacity:0;overflow:hidden;transition:grid-template-rows .24s ease,opacity .24s ease}.c4-progress-content.is-open,.c4-progress-content[data-live="false"]{grid-template-rows:1fr;opacity:1}.c4-progress-inner{min-height:0;overflow:hidden}.c4-usage-row{grid-template-columns:minmax(0,1fr) auto}.c4-usage-row .c4-task-title,.c4-usage-row .c4-task-meta{grid-column:1}.c4-usage-row>b{grid-column:2;grid-row:1/3;align-self:center}++.page:has(.c4){width:100%;max-width:none;padding:0;min-height:0}.c4-attempts{display:grid;gap:6px;margin:10px 0;padding:0;list-style:none}.c4-attempt:before{content:"";display:inline-block;width:7px;height:7px;margin:0 8px 1px 1px;border-radius:50%;background:var(--c4-accent)}.c4-attempt{color:var(--c4-muted);font-size:12px}.c4-log[hidden]{display:none}++.c4-card .c4-outcome-actions a.c4-opill{color:var(--c4-ink)}.c4-card .c4-outcome-actions a.c4-opill-primary{color:#fff}nnew file mode 100644@@@ -0,0 +1,145 @@++'use strict';++(() => {++ document.querySelectorAll('.c4-tabs').forEach((tabs) => {++ const root = tabs.closest('.c4-task-detail');++ const links = Array.from(tabs.querySelectorAll('[data-c4-tab]'));++ const panels = Array.from(root.querySelectorAll('.c4-panel'));++ const activate = (id) => {++ links.forEach((link) => link.setAttribute('aria-selected', String(link.dataset.c4Tab === id)));++ panels.forEach((panel) => { panel.hidden = panel.id !== id; });++ };++ activate('live');++ links.forEach((link) => link.addEventListener('click', (event) => {++ event.preventDefault();++ activate(link.dataset.c4Tab);++ history.replaceState(null, '', link.getAttribute('href'));++ }));++ });++++ document.querySelectorAll('[data-brief]').forEach((brief) => {++ const button = brief.parentElement.querySelector('.c4-brief-expand');++ if (!button) return;++ requestAnimationFrame(() => {++ if (brief.scrollHeight > brief.clientHeight + 1) button.hidden = false;++ });++ button.addEventListener('click', () => {++ const expanded = brief.classList.toggle('is-expanded');++ button.textContent = expanded ? 'Show less' : 'Show the whole brief';++ });++ });++++ document.querySelectorAll('.c4-progress-toggle').forEach((button) => {++ const content = document.getElementById(button.getAttribute('aria-controls'));++ button.addEventListener('click', () => {++ const open = button.getAttribute('aria-expanded') !== 'true';++ button.setAttribute('aria-expanded', String(open));++ content.classList.toggle('is-open', open);++ });++ });++++ document.querySelectorAll('[data-c4-stream]').forEach((root) => {++ const detail = root.closest('[data-task-api]');++ const out = root.querySelector('[data-task-log]');++ const attemptsNode = root.querySelector('[data-c4-attempts]');++ const label = root.querySelector('[data-live-label]');++ const attemptRows = new Map();++ attemptsNode?.querySelectorAll('[data-attempt-n]').forEach((item) => attemptRows.set(Number(item.dataset.attemptN), item));++ const showAttempt = (attempt) => {++ const number = attempt && (attempt.n ?? attempt.attempt_n);++ if (!number || !attemptsNode) return;++ const key = Number(number);++ let item = attemptRows.get(key);++ if (!item) {++ item = document.createElement('li');++ item.className = 'c4-attempt';++ attemptRows.set(key, item);++ attemptsNode.append(item);++ }++ item.textContent = `Attempt ${key} · ${attempt.agent || 'Auto'} · ${attempt.end_why || attempt.state || 'Queued'}`;++ };++ const updateOutcome = (task) => {++ if (!detail || !task) return;++ const outcome = task.outcome || {};++ const unfinished = outcome.state === 'unfinished' || (task.error && !['succeeded', 'cancelled'].includes(task.state));++ let panel = detail.querySelector('[data-c4-outcome]');++ if (unfinished && !panel) {++ // Server markup provides the no-JS state; this branch covers an outcome reached during the stream.++ panel = document.createElement('div');++ panel.className = 'c4-outcome';++ panel.dataset.c4Outcome = '';++ panel.innerHTML = '<span class="c4-obadge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="m18 6-12 12M6 6l12 12"/></svg></span><div class="c4-outcome-copy"><b>Didn’t finish</b><span data-outcome-reason></span></div>';++ const actions = document.createElement('div');++ actions.className = 'c4-outcome-actions';++ const addPill = (labelText, href, primary, path) => {++ const link = document.createElement('a');++ link.className = `c4-opill${primary ? ' c4-opill-primary' : ''}`;++ link.href = href;++ link.innerHTML = `<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="${path}"/></svg>`;++ const text = document.createElement('span');++ text.textContent = labelText;++ link.append(text);++ actions.append(link);++ };++ addPill('Retry', detail.dataset.retryUrl, true, 'M20 7v5h-5M4 17v-5h5M5.6 9a7 7 0 0 1 11.7-2L20 12M4 12l2.7 5a7 7 0 0 0 11.7-2');++ addPill('Switch', detail.dataset.switchUrl, false, 'm16 3 4 4-4 4M4 7h16M8 21l-4-4 4-4m12 4H4');++ addPill('Fine', '/tasks', false, 'm5 12 4 4L19 6');++ panel.append(actions);++ detail.prepend(panel);++ }++ if (panel) {++ panel.hidden = !unfinished;++ const reason = panel.querySelector('[data-outcome-reason]');++ if (reason) reason.textContent = outcome.note || task.error || 'The task stopped before it was complete.';++ }++ const chip = detail.querySelector('.c4-task-heading > .c4-status');++ if (chip) chip.textContent = task.state || 'Unknown';++ if (label) label.textContent = ['succeeded', 'failed', 'cancelled', 'expired'].includes(task.state) ? 'This task has finished.' : 'Connecting to the live task…';++ };++ if (detail) fetch(detail.dataset.taskApi, { headers: { Accept: 'application/json' }, credentials: 'same-origin' })++ .then((response) => response.ok ? response.json() : null)++ .then((task) => {++ if (!task) return;++ (task.attempts || []).forEach(showAttempt);++ updateOutcome(task);++ }).catch(() => {});++ if (!window.EventSource) return;++ const outText = (line) => {++ const body = line && line.body;++ if (typeof body === 'string') return body;++ if (body && typeof body === 'object') return body.text || body.message || body.command || body.output || JSON.stringify(body);++ return String(body ?? '');++ };++ const title = document.querySelector('[data-progress-title]');++ const stepsNode = document.querySelector('[data-progress-steps]');++ const timeNode = document.querySelector('[data-progress-time]');++ const taskStarted = Number(root.dataset.startedAt);++ const started = Number.isFinite(taskStarted) && taskStarted > 0 ? taskStarted : Date.now();++ let steps = 0;++ const updateTime = () => {++ if (!timeNode) return;++ const mins = Math.max(0, Math.floor((Date.now() - started) / 60000));++ timeNode.textContent = mins >= 60 ? String(Math.floor(mins / 60)) + 'h ' + String(mins % 60) + 'm' : mins ? String(mins) + ' min' : 'now';++ };++ updateTime();++ const timer = window.setInterval(updateTime, 30000);++ const stream = new EventSource(root.dataset.c4Stream, { withCredentials: true });++ stream.addEventListener('line', (event) => {++ let line;++ try { line = JSON.parse(event.data); } catch { return; }++ const text = outText(line).trim();++ if (!text || !out) return;++ out.hidden = false;++ out.textContent += text + '\n';++ steps += 1;++ if (stepsNode) stepsNode.textContent = String(steps) + (steps === 1 ? ' step' : ' steps');++ if (title) title.textContent = text.split(/\s+/).slice(0, 12).join(' ');++ });++ stream.addEventListener('attempt', (event) => {++ try { showAttempt(JSON.parse(event.data)); } catch { /* ignore malformed event */ }++ });++ stream.addEventListener('end', () => { stream.close(); window.clearInterval(timer); if (title) title.textContent = 'Task finished'; });++ stream.addEventListener('error', () => {++ if (stream.readyState === EventSource.CLOSED) { stream.close(); window.clearInterval(timer); }++ });++ });++})();@@@ -201,6 +201,8 @@ async function createApp(opts = {}) {if (req.path !== '/.well-known/agent.json' && !twin) return next();return agentLayer(req, res, next);});++ // C4 workspace pages sit before the legacy marketing pages and share the existing owner-scoped task store.++ app.use(require('./http/c4').createC4Routes(ctx));app.use(pageRoutes);app.use(createRunRoutes({ config, store, sso, harnesses, run, tasks, sessions, keys, fetchImpl: fetchImpl || globalThis.fetch, log }));app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/harnesses">the agents</a>, <a href="/improve">Improve OpenVibe</a>, or the developer platform at <a href="https://openvibe.services">openvibe.services</a>.</p>` }));nnew file mode 100644@@@ -0,0 +1,125 @@++'use strict';++++// Phone-first Codes workspace. Pages stay useful without JavaScript and link to existing task actions.++const { send } = require('../render/layout');++const { raw, esc } = require('../render/html');++const { asyncRouter } = require('./router');++const showcase = require('openvibe-shared/showcase');++const { homeJsonLd } = require('./discovery');++++const LINKS = [++ ['Home', '/', '<path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/>'],++ ['Tasks', '/tasks', '<path d="M8 6h12M8 12h12M8 18h12M3 6h.01M3 12h.01M3 18h.01"/>'],++ ['Agents', '/agents', '<circle cx="12" cy="8" r="3"/><path d="M5 20a7 7 0 0 1 14 0M12 2v3M3 8h3m12 0h3"/>'],++ ['Machines', '/machines', '<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>'],++ ['Usage', '/usage', '<path d="M4 19V5m0 14h17M8 15v-4m5 4V7m5 8v-6"/>'],++ ['Settings', '/settings', '<circle cx="12" cy="12" r="3"/><path d="m19.4 15 .1.1 1.4 1.1-1.4 2.4-1.7-.7a8 8 0 0 1-1.7 1l-.3 1.8h-2.8l-.3-1.8a8 8 0 0 1-1.7-1l-1.7.7-1.4-2.4 1.4-1.1a8 8 0 0 1 0-2l-1.4-1.1 1.4-2.4 1.7.7a8 8 0 0 1 1.7-1l.3-1.8h2.8l.3 1.8a8 8 0 0 1 1.7 1l1.7-.7 1.4 2.4-1.4 1.1a8 8 0 0 1 0 2Z"/>'],++];++const stateLabel = (s) => ({ running: 'Running', queued: 'Queued', placed: 'On its way', done: 'Done', succeeded: 'Done', failed: "Didn't finish", cancelled: 'Stopped by you', expired: 'Expired' }[s] || s || 'Unknown');++const safeId = (s) => /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(s || ''));++const money = (n) => Number.isFinite(Number(n)) ? `$${Number(n).toFixed(2)}` : '—';++const age = (value) => {++ const numeric = Number(value);++ const ms = Number.isFinite(numeric) && numeric > 0 ? numeric : new Date(value).getTime();++ if (!Number.isFinite(ms)) return '';++ const mins = Math.max(0, Math.floor((Date.now() - ms) / 60000));++ return mins < 60 ? `${mins}m ago` : mins < 1440 ? `${Math.floor(mins / 60)}h ago` : `${Math.floor(mins / 1440)}d ago`;++};++const repoDetails = (repo) => {++ try {++ const url = new URL(String(repo || ''));++ if (!['http:', 'https:'].includes(url.protocol)) return null;++ url.username = '';++ url.password = '';++ const parts = url.pathname.split('/').filter(Boolean);++ const label = parts.at(-1)?.replace(/\.git$/i, '') || url.hostname;++ return { label, href: url.toString() };++ } catch { return null; }++};++const icon = (path) => `<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;++++function createC4Routes(ctx) {++ const r = asyncRouter();++ const { config, store, harnesses, tasks } = ctx;++ const db = store.db;++ const page = (req, res, title, body, extra = {}) => send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), css: ['css/c4.css'], scripts: /^\/tasks\/tsk_/.test(req.path) ? ['js/c4.js'] : [], body: raw(body), ...extra });++ const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;++ const wrap = (current, content, signedIn = false) => `<div class="c4"><div class="c4-layout"><aside class="c4-side"><nav aria-label="Workspace">${LINKS.map(([label, href]) => `<a href="${href}"${current === href ? ' aria-current="page"' : ''}>${icon(LINKS.find((x) => x[0] === label)[2])}<span>${label}</span></a>`).join('')}</nav></aside><main class="c4-main">${content}</main></div><nav class="c4-mobile" aria-label="Workspace">${LINKS.slice(0,5).map(([label, href, svg]) => `<a href="${href}"${current === href ? ' aria-current="page"' : ''}>${icon(svg)}<span>${label}</span></a>`).join('')}</nav></div>`;++ const signIn = (req) => `<section class="c4-card c4-empty"><h2>Sign in to see your work</h2><p>Your tasks, machines and usage belong to your OpenVibe account.</p><a class="c4-button" href="/auth/login?next=${encodeURIComponent(req.originalUrl || '/')}">Sign in</a></section>`;++ async function ownTasks(req, limit = 100) {++ if (!viewer(req)) return [];++ return db.many('SELECT * FROM run_tasks WHERE subject = $1 ORDER BY created_at DESC LIMIT $2', [req.viewer.subject, limit]);++ }++ const taskHref = (row) => `/tasks/${encodeURIComponent(row.id)}`;++ const rowHtml = (row) => {++ const repo = repoDetails(row.repo);++ const attempts = Number(row.attempt_count || row.result?.attempts?.length || row.result?.attempts || 0);++ const meta = [repo?.label || 'Project not set', row.agent || 'Auto', age(row.created_at), money(row.result && row.result.cost), attempts > 1 ? `${attempts} attempts` : ''].filter(Boolean).map(esc).join(' · ');++ return `<a class="c4-task" href="${taskHref(row)}"><span class="c4-status-dot c4-${esc(row.state)}" aria-label="${esc(stateLabel(row.state))}"></span><span class="c4-task-title">${esc(row.prompt || repo?.label || 'Coding task')}</span><span class="c4-task-meta">${meta}</span></a>`;++ };++ const header = (title, desc = '') => `<div class="c4-title"><div><h1>${esc(title)}</h1>${desc ? `<p>${esc(desc)}</p>` : ''}</div></div>`;++ const stateChip = (state) => `<span class="c4-status c4-${esc(state)}">${esc(stateLabel(state))}</span>`;++ const taskChips = (row) => {++ const repo = repoDetails(row.repo);++ const chips = [repo ? `<a class="c4-chip" href="${esc(repo.href)}" rel="noopener noreferrer">${esc(repo.label)}</a>` : `<span class="c4-chip">Project not set</span>`, `<span class="c4-chip">${esc(row.ref || 'default branch')}</span>`, `<span class="c4-chip">${esc(row.agent || 'Auto')}</span>`, '<span class="c4-chip">Your computer</span>', `<span class="c4-chip">${esc(money(row.result && row.result.cost))}</span>`];++ return `<div class="c4-chips" aria-label="Task details">${chips.join('')}</div>`;++ };++++ r.get('/', async (req, res) => {++ const rows = await ownTasks(req, 8);++ const signedIn = !!viewer(req);++ const content = `${header(signedIn ? 'Home' : 'Every coding agent. One place.', signedIn ? '' : 'Your keys, your machines. Tell an agent what to do and follow the work from your phone.')}++<section class="c4-card c4-composer"><form method="get" action="/run"><label for="c4-prompt">What should an agent do?</label><textarea id="c4-prompt" name="prompt" placeholder="Describe the change, question or fix" required></textarea><div class="c4-controls"><label>Project<input type="url" name="repo" placeholder="Paste a repository URL" autocomplete="url"></label><label>Agent <select name="agent"><option value="">Auto</option>${(harnesses?.list?.() || []).map((a) => `<option value="${esc(a.id)}">${esc(a.name)}</option>`).join('')}</select></label><label>Machine <select name="machine"><option value="">Auto</option></select></label><label>Cost cap <select name="cap"><option>$1.00</option><option>$5.00</option></select></label><button class="c4-button" type="submit">Start a task</button></div></form></section>++<div class="c4-grid"><section class="c4-card"><div class="c4-card-head"><h2>Running</h2><a href="/tasks?state=running">All tasks</a></div>${rows.filter((x) => !['done','succeeded','failed','cancelled'].includes(x.state)).slice(0,4).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks running yet. <a href="/#c4-prompt">Start a task</a></div>'}</section><section class="c4-card"><div class="c4-card-head"><h2>Needs you</h2><span class="c4-muted">Approvals and questions</span></div><div class="c4-emptyline">Nothing needs your attention. <a href="/tasks">View tasks</a></div></section></div>++<section class="c4-card"><div class="c4-card-head"><h2>Recent tasks</h2><a href="/tasks">View all</a></div>${rows.slice(0,5).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>++${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h2><div class="c4-grid">${[['Any agent','Bring a key or plan. Auto picks a good fit and can hand over when one gets stuck.'],['Your computer or ours','Pair your computer with one command. A hosted sandbox is coming soon.'],['Proof you can inspect','See the changes, tests, screenshots and review before you approve.'],['A cost you can follow','Track task costs and plan limits. Set caps that stop with a wrap-up.']].map(([a,b]) => `<article><h3>${a}</h3><p>${b}</p></article>`).join('')}</div></section>`}`;++ page(req, res, null, wrap('/', content, signedIn), signedIn ? {} : { index: true, styles: [showcase.STYLESHEET], jsonLd: homeJsonLd(config) });++ });++ r.get('/tasks', async (req, res) => {++ const rows = await ownTasks(req);++ const q = String(req.query.q || '').toLowerCase();++ const filtered = rows.filter((x) => `${x.prompt} ${repoDetails(x.repo)?.label || ''} ${x.agent}`.toLowerCase().includes(q));++ const isDone = (x) => ['done', 'succeeded', 'cancelled'].includes(x.state);++ const groups = [++ ['Running', filtered.filter((x) => !isDone(x) && x.state !== 'failed')],++ ['Needs you', []],++ ['Done', filtered.filter(isDone)],++ ["Didn't finish", filtered.filter((x) => x.state === 'failed')],++ ];++ const taskList = filtered.length ? groups.filter(([, items]) => items.length).map(([label, items]) => `<section class="c4-card"><div class="c4-card-head"><h2>${label}</h2></div>${items.map(rowHtml).join('')}</section>`).join('') : `<div class="c4-card c4-emptyline">${q ? 'No matching tasks.' : 'No tasks yet.'} <a href="${q ? '/tasks' : '/#c4-prompt'}">${q ? 'Clear search' : 'Start a task'}</a></div>`;++ const content = `${header('Tasks')}${viewer(req) ? `<form class="c4-search" method="get"><label for="task-search">Search your tasks</label><input id="task-search" name="q" value="${esc(q)}" placeholder="Search by task, project or agent"><button class="c4-button c4-button-light">Search</button></form>${taskList}` : signIn(req)}`;++ page(req, res, 'Tasks', wrap('/tasks', content, !!viewer(req)));++ });++ r.get('/tasks/:id', async (req, res) => {++ if (!viewer(req)) return page(req, res, 'Task', wrap('/tasks', `${header('Task')}${signIn(req)}`));++ if (!safeId(req.params.id)) return res.status(404).send('Not found');++ const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2', [req.params.id, req.viewer.subject]);++ if (!row) return res.status(404).send('Not found');++ const live = ['queued','placed','running'].includes(row.state);++ const attempts = tasks?.attempts ? await tasks.attempts.list(row.id) : [];++ const attemptItems = attempts.map((attempt) => {++ const item = tasks.attempts.present(attempt);++ return `<li class="c4-attempt" data-attempt-n="${Number(item.n)}">Attempt ${Number(item.n)} · ${esc(item.agent || 'Auto')} · ${esc(item.end_why || item.state || 'Queued')}</li>`;++ }).join('');++ const taskApi = `/api/v1/tasks/${encodeURIComponent(row.id)}`;++ const events = `${taskApi}/events`;++ const prompt = encodeURIComponent(row.prompt || '');++ const retry = `/run?prompt=${prompt}&repo=${encodeURIComponent(row.repo || '')}&agent=${encodeURIComponent(row.agent || '')}`;++ const switchAgent = `/run?prompt=${prompt}&repo=${encodeURIComponent(row.repo || '')}`;++ const hasOutcome = row.outcome === 'unfinished' || Boolean(row.error);++ const outcome = hasOutcome ? `<div class="c4-outcome" data-c4-outcome><span class="c4-obadge" aria-hidden="true">${icon('<path d="m18 6-12 12M6 6l12 12"/>')}</span><div class="c4-outcome-copy"><b>Didn't finish</b><span data-outcome-reason>${esc(row.outcome_note || row.error || 'The task stopped before it was complete.')}</span></div><div class="c4-outcome-actions"><a class="c4-opill c4-opill-primary" href="${retry}">${icon('<path d="M20 7v5h-5M4 17v-5h5"/><path d="M5.6 9a7 7 0 0 1 11.7-2L20 12M4 12l2.7 5a7 7 0 0 0 11.7-2"/>')}<span>Retry</span></a><a class="c4-opill" href="${switchAgent}">${icon('<path d="m16 3 4 4-4 4M4 7h16M8 21l-4-4 4-4m12 4H4"/>')}<span>Switch</span></a><a class="c4-opill" href="/tasks">${icon('<path d="m5 12 4 4L19 6"/>')}<span>Fine</span></a></div></div>` : '';++ const progress = live ? `<button class="c4-progress-toggle" type="button" aria-expanded="false" aria-controls="live-content"><span data-progress-title>Working on your task</span><span class="c4-progress-meta"><span data-progress-steps>0 steps</span> · <span data-progress-time>now</span></span><span aria-hidden="true">⌄</span></button>` : '';++ const content = `<section class="c4-card c4-task-detail" data-task-api="${esc(taskApi)}" data-retry-url="${esc(retry)}" data-switch-url="${esc(switchAgent)}">${outcome}<div class="c4-task-heading"><div class="c4-task-title-wrap"><h1 class="c4-brief" data-brief>${esc(row.prompt || 'Task')}</h1><button class="c4-brief-expand" type="button" hidden>Show the whole brief</button></div>${hasOutcome ? '' : stateChip(row.state)}</div>${taskChips(row)}${progress}<nav class="c4-tabs" aria-label="Task details">${[['live','Live'],['changes','Changes'],['proof','Proof'],['pull-request','Pull request']].map(([id, label], i) => `<a href="#${id}" role="tab" aria-selected="${i === 0}" data-c4-tab="${id}">${label}</a>`).join('')}</nav><section id="live" class="c4-panel" role="tabpanel"><div id="live-content" class="c4-progress-content" data-c4-stream="${esc(events)}" data-task="${esc(row.id)}" data-started-at="${esc(row.started_at || row.created_at || '')}" data-live="${live ? 'true' : 'false'}"><div class="c4-progress-inner"><p data-live-label>${live ? 'Connecting to the live task…' : 'This task has finished.'}</p><ol class="c4-attempts" data-c4-attempts aria-live="polite">${attemptItems}</ol><pre class="c4-log" data-task-log aria-live="polite" hidden></pre></div></div></section><section id="changes" class="c4-panel" role="tabpanel"><p>Changes from this task.</p><a href="/run/tasks/${encodeURIComponent(row.id)}">Open changes</a></section><section id="proof" class="c4-panel" role="tabpanel"><p>Test output and screenshots for this task.</p><a href="/run/tasks/${encodeURIComponent(row.id)}">Open task proof</a></section><section id="pull-request" class="c4-panel" role="tabpanel"><p>Pull request details will appear here.</p><a href="/run/tasks/${encodeURIComponent(row.id)}">Open task in Run</a></section>${live ? `<div class="c4-actions"><form method="post" action="/run/tasks/${encodeURIComponent(row.id)}/cancel"><button class="c4-button c4-button-light">Stop task</button></form></div>` : ''}</section>`;++ page(req, res, 'Task', wrap('/tasks', content, true));++ });++ r.get('/agents', (req, res) => page(req, res, 'Agents', wrap('/agents', `${header('Agents')}${viewer(req) ? '<section id="connections" class="c4-card"><h2>Connections</h2><div class="c4-emptyline">No agent connections yet. <a class="c4-button" href="/agents#connections">Connect a key</a></div></section>' : signIn(req)}`, !!viewer(req))));++ r.get('/machines', (req, res) => page(req, res, 'Machines', wrap('/machines', `${header('Machines')}${viewer(req) ? '<section class="c4-card"><h2>Your machines</h2><div class="c4-emptyline">No paired computers yet. <a class="c4-button" href="/machines#pair">Pair this computer</a></div><div id="pair"><pre class="c4-code">curl -fsSL https://openvibe.bot/install.sh | sh -s -- --worker</pre></div></section>' : signIn(req)}`, !!viewer(req))));++ r.get('/usage', async (req, res) => {++ const rows = await ownTasks(req);++ page(req, res, 'Usage', wrap('/usage', `${header('Usage')}${viewer(req) ? `<section class="c4-card"><div class="c4-card-head"><h2>Recent task costs</h2><span>Last ${rows.length} tasks</span></div>${rows.map((x) => `<a class="c4-task c4-usage-row" href="${taskHref(x)}"><span class="c4-task-title">${esc(x.prompt || repoDetails(x.repo)?.label || 'Task')}</span><span class="c4-task-meta">${esc(x.agent || 'Auto')} · ${esc(stateLabel(x.state))}</span><b>${esc(money(x.result && x.result.cost))}</b></a>`).join('') || '<div class="c4-emptyline">No task usage yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>` : signIn(req)}`, !!viewer(req)));++ });++ r.get('/settings', (req, res) => page(req, res, 'Settings', wrap('/settings', `${header('Settings')}${viewer(req) ? '<section class="c4-card"><h2>Safety</h2><div class="c4-safety" role="group" aria-label="Safety level"><label class="c4-safety-card"><input type="radio" name="safety" checked><span><b>Ask me</b><small>Approve commands as they come up.</small></span></label><label class="c4-safety-card"><input type="radio" name="safety"><span><b>Auto in the project</b><small>Run commands within the project automatically.</small></span></label><label class="c4-safety-card"><input type="radio" name="safety"><span><b>Full access</b><small>Let the agent use the whole computer.</small></span></label></div><p class="c4-muted">Arrives with connections.</p></section>' : signIn(req)}`, !!viewer(req))));++ return r;++}++++module.exports = { createC4Routes };@@@ -113,6 +113,8 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`${projects.length ? formHtml({ projects, values, error }) : html`<form class="run-card" method="post" action="/run/project">${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>` : ''}<h2>One thing first: a project</h2><p class="muted">Runs belong to a project on OpenVibe (that is where their usage is counted). You have none you can run jobs in yet.</p>++<input type="hidden" name="prompt" value="${values && values.prompt || ''}">++<input type="hidden" name="repo" value="${values && values.repo || ''}"><label class="run-field"><span>Project name</span><input name="name" value="My code" maxlength="80" required></label><button class="run-btn run-btn-primary" type="submit">Create the project</button></form>`}<h2>Your tasks</h2>@@@ -120,7 +122,7 @@ ${tasksHtml(recent)}`;return page(req, res, { title: 'Run on your machine', body }, status);}-- r.get('/run', (req, res) => runPage(req, res));++ r.get('/run', (req, res) => runPage(req, res, { values: { prompt: String(req.query.prompt || '').slice(0, 16000), repo: String(req.query.repo || '').slice(0, 2000), agent: String(req.query.agent || '').slice(0, 100) } }));r.post('/run/project', form, async (req, res) => {if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Frun');@@@ -136,9 +138,15 @@ ${tasksHtml(recent)}`;if (!out.ok) throw new Error(`Network answered ${out.status}`);});} catch (e) {-- return runPage(req, res, { error: `The project could not be created (${e.message}). Try again.`, status: 502 });++ return runPage(req, res, { values: { prompt: String(req.body && req.body.prompt || '').slice(0, 16000), repo: String(req.body && req.body.repo || '').slice(0, 2000) }, error: `The project could not be created (${e.message}). Try again.`, status: 502 });}-- return res.redirect(303, '/run');++ const prompt = String(req.body && req.body.prompt || '').slice(0, 16000);++ const repo = String(req.body && req.body.repo || '').slice(0, 2000);++ const query = new URLSearchParams();++ if (prompt) query.set('prompt', prompt);++ if (repo) query.set('repo', repo);++ const suffix = query.toString();++ return res.redirect(303, `/run${suffix ? `?${suffix}` : ''}`);});r.post('/run', form, async (req, res) => {@@@ -55,13 +55,15 @@ function renderPage(o) {const viewer = o.viewer || { kind: 'anonymous' };const signedIn = viewer.kind === 'user';const pathNow = o.path || '/';++ const c4Workspace = pathNow === '/' || /^\/(tasks|agents|machines|usage|settings)(\/|$)/.test(pathNow);const loginNext = encodeURIComponent(pathNow);const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;const nav = {service: 'codes',++ brandName: SITE_NAME,apiBase: NETWORK_URL,links: NAV.map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),-- appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Start', href: '/start', icon: 'fa-play' }, { label: 'Run', href: '/run', icon: 'fa-microchip' }, { label: 'Agents', href: '/harnesses', icon: 'fa-robot' }], // phones: the bottom tab bar (Shared 4.20)++ appBar: c4Workspace ? false : [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Start', href: '/start', icon: 'fa-play' }, { label: 'Run', href: '/run', icon: 'fa-microchip' }, { label: 'Agents', href: '/harnesses', icon: 'fa-robot' }], // phones: the bottom tab bar (Shared 4.20)history: { type: 'page', title: o.title || SITE_NAME },silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,sessionUrl: '/auth/me',@@@ -73,7 +75,7 @@ function renderPage(o) {// bar below is only for visitors without JavaScript).// Your projects and apps live on the developer platform (OpenVibe.Services).if (signedIn) nav.menu = { before: [{ label: 'Your projects', href: 'https://openvibe.services/projects', icon: 'fa-folder' }] };-- const footer = { service: 'codes', variant: 'auto', path: o.path, mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }; // full on the front page only (Shared 4.19)++ const footer = { service: 'codes', variant: signedIn && c4Workspace ? 'compact' : 'auto', path: o.path, mount: '#ov-footer', brandName: SITE_NAME, ...(signedIn && c4Workspace ? { shipped: false } : { updates: '/updates' }) }; // workspace pages use a compact frameconst account = signedIn? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`: html`<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;@@@ -113,7 +115,7 @@ ${render(scripts)}<main id="main" class="page">${render(crumbs)}${render(o.body)}--${o.path === '/' ? frame.shipped({ service: 'codes', title: `Recently shipped on ${SITE_NAME}` }) : ''}++${o.path === '/' && !signedIn ? frame.shipped({ service: 'codes', title: `Recently shipped on ${SITE_NAME}` }) : ''}</main><script>window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};nnew file mode 100644@@@ -0,0 +1,59 @@++'use strict';++++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const ada = t.network.addUser('c4-ada');++ try {++ await check('all C4 pages render readable server content without JavaScript', async () => {++ for (const [path, label] of [['/', 'What should an agent do?'], ['/tasks', 'Tasks'], ['/agents', 'Agents'], ['/machines', 'Machines'], ['/usage', 'Usage'], ['/settings', 'Settings']]) {++ const r = await t.get(path);++ assert.strictEqual(r.status, 200, `${path} status`);++ assert.ok(r.text.includes(label), `${path} content`);++ assert.ok(r.text.includes('c4-mobile'), `${path} workspace navigation`);++ }++ const task = await t.get('/tasks/tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV');++ assert.strictEqual(task.status, 200);++ assert.ok(task.text.includes('Sign in to see your work'));++ });++++ await check('task details are owner scoped and management pages honor sign-in', async () => {++ const settings = await t.get('/settings', { as: ada });++ assert.strictEqual(settings.status, 200);++ assert.ok(settings.text.includes('Safety'));++ assert.strictEqual((settings.text.match(/Arrives with connections\./g) || []).length, 1);++ assert.ok(!settings.text.includes('Sign in to see your work'));++ const hidden = await t.get('/tasks/tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV', { as: ada });++ assert.strictEqual(hidden.status, 404);++ });++++ await check('signed-in workspace and task details use the reviewed layout', async () => {++ const home = await t.get('/', { as: ada });++ assert.ok(!home.text.includes('Built for the way you work'));++ assert.ok(!home.text.includes('Recently shipped on'));++ assert.ok(!home.text.includes('OPENVIBE.CODES'));++ assert.ok(!home.text.includes('Your coding workspace'));++ assert.ok(home.text.includes('Paste a repository URL'));++ const id = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV';++ await t.ctx.store.db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, state, result, error, created_at, updated_at)++ VALUES ($1,$2,'prj_01ARZ3NDEKTSV4RRFFQ69G5FAW','https://github.com/OpenVibers/OpenVibe.Codes','main','Fix the full brief and keep the workspace readable on a phone','codex','failed','{"cost":0.42}'::jsonb,'The machine stopped.',$3,$3)`, [id, ada.subject, Date.now()]);++ const task = await t.get(`/tasks/${id}`, { as: ada });++ assert.strictEqual(task.status, 200);++ assert.ok(task.text.includes('Show the whole brief'));++ assert.ok(task.text.includes('aria-selected="true" data-c4-tab="live"'));++ assert.ok(task.text.includes('Retry') && task.text.includes('Switch') && task.text.includes('Fine'));++ assert.ok(task.text.includes('data-task-api="/api/v1/tasks/' + id + '"'));++ assert.ok(task.text.includes('data-c4-stream="/api/v1/tasks/' + id + '/events"'));++ assert.ok(!task.text.includes('Continue or switch agent in Run'));++ assert.ok(!task.text.includes('>Stop task</button>'));++ assert.ok(!task.text.includes('<pre class="c4-log" aria-live="polite"></pre>'));++ assert.ok(task.text.includes('>OpenVibe.Codes</a>'));++ assert.ok(!task.text.includes('>https://github.com/'));++ assert.ok(!task.text.includes('<details open id="live"'));++ assert.ok(!task.text.includes('<details id="changes"'));++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -35,26 +35,16 @@ const { boot, check, done } = require('./helpers/boot');const t = await boot();try {-- await check('the home: quick start, OpenVibe Git, a FAQ and its JSON-LD', async () => {++ await check('the home presents the Codes workspace and its composer', async () => {let r = await t.get('/');assert.strictEqual(r.status, 200);-- assert.match(r.text, /id="quick-start"/);-- assert.match(r.text, /npm install -g https:\/\/codeload\.github\.com\/OpenVibers\/OpenVibe\.Codes\/tar\.gz\/refs\/tags\/v\d+\.\d+\.\d+/);-- assert.match(r.text, /https:\/\/openvibe\.codes\/api\/v1\/harnesses\/route/);-- assert.match(r.text, /id="git"/, 'OpenVibe Git is on the home');-- assert.ok(!/class="codes-repos"/.test(r.text), 'no repository list while the forge has not answered');-- assert.ok(r.text.includes('git clone https://git.openvibe.codes/OpenVibers/OpenVibe.Codes.git'));-- assert.ok(r.text.includes('# an example run'), 'the hero transcript says it is an example');++ assert.match(r.text, /Every coding agent\. One place\./);++ assert.match(r.text, /What should an agent do\?/);++ assert.match(r.text, /Needs you/);++ assert.match(r.text, /Recent tasks/);const blocks = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));-- const faq = blocks.find((b) => b['@type'] === 'FAQPage');-- assert.ok(faq && faq.mainEntity.length === home.faqPairs().length && faq.mainEntity.length >= 5, 'a FAQPage node with every question');-- faq.mainEntity.forEach((q) => assert.ok(r.text.includes(`<h3>${q.name.replace(/'/g, ''')}</h3>`), `the page shows "${q.name}"`));++ assert.ok(blocks.some((b) => b['@type'] === 'WebSite'), 'the home keeps its website metadata');assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');---- t.ctx.git = { peek: () => ({ total: 52, publicUrl: 'https://git.openvibe.codes', repos: [{ name: 'OpenVibers/OpenVibe.Codes', url: 'https://git.openvibe.codes/OpenVibers/OpenVibe.Codes', updated: new Date().toISOString(), language: 'JavaScript' }] }) };-- r = await t.get('/');-- assert.match(r.text, /52 repositories on OpenVibe's own code forge|52 repositories on OpenVibe's own code forge/);-- assert.ok(r.text.includes('href="https://git.openvibe.codes/OpenVibers/OpenVibe.Codes"') && r.text.includes('<small>Build and fix apps with AI</small>'), 'a repository with its service tagline');});} finally { await t.close(); }done();@@@ -46,7 +46,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#maiassert.ok(head.includes('<link rel="canonical" href="https://openvibe.codes/">'), 'canonical');assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);-- assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage', 'FAQPage', 'Organization'], 'the home JSON-LD (the FAQPage repeats the questions the page shows)');++ assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage', 'Organization'], 'the home JSON-LD describes the workspace');for (const tag of ['<meta property="og:title"', 'data-ov-icon="codes"', '/css/codes.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {assert.ok(head.includes(tag), `head has ${tag}`);}@@@ -46,7 +46,8 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual(submit.subject, ada.subject);assert.ok(submit.project, 'a project was created');// The harness's environment (Codes C3) is not part of the task's own arguments: it carries the run token.-- const { env, ...args } = submit.args;++ const { env, ...submitted } = submit.args;++ const args = Object.fromEntries(['repo', 'ref', 'prompt', 'permission', 'push', 'agent', 'model'].map((key) => [key, submitted[key]]));assert.deepStrictEqual(args, { repo: ask.repo, ref: null, prompt: ask.prompt, permission: 'edit', push: null, agent: null, model: null });assert.deepStrictEqual(Object.keys(env || {}).sort(), ['OPENAI_API_KEY', 'OPENAI_BASE_URL'], 'the harness gets a run token and OpenVibe.AI\'s address');});@@@ -14,12 +14,12 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');const BUDGETS = {htmlRawKB: 40, // 35.7 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 31.5 with the home's quick start, OpenVibe Git and six FAQs with their FAQPage JSON-LD (2026-10-22; in production the forge's eight repositories add about 2.5); measured 27.0 (fresh database; the showcase home, 2026-09-29: sections + six inline ring icons)htmlBrotliKB: 11, // 9.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 7.7 with those sections (2026-10-22); 6.7-- jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)++ jsFiles: 6, // C4 adds the live task stream client to the shared theme, navbar, footer and boost scripts.jsRawKB: 317, // 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)jsBrotliKB: 78, // 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)-- cssFiles: 2, // 1-- cssRawKB: 39, // 38.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 36.4 the home's phone row, fold and closed questions (2026-10-29); 32 openvibe-shared 4.26.1 (hero visual); 26.5 with Shared 4.13.0 (2026-10-26: account links); about 19.2 with the repository cards and FAQ styles (2026-10-22); 17.3 (codes.css + the cached /shared/showcase.css)-- cssBrotliKB: 8.3, // 8.1 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 7.7 the home's phone row, fold and closed questions (2026-10-29); raised for openvibe-shared v4.39.0 (+1.5 KB raw of kit/room/showcase CSS); measured 7.3++ cssFiles: 3, // C4 adds its responsive workspace stylesheet alongside codes.css and run.css.++ cssRawKB: 50, // 46 KB after adding the C4 phone-first workspace, cards, task timeline and light/dark styles.++ cssBrotliKB: 11, // 9.9 KB after adding the C4 workspace styles; 1.1 KB headroom for compression drift.externalFiles: 2, // 0};@@@ -43,12 +43,17 @@ const { boot, check, done } = require('./helpers/boot');let project;await check('with no project to run in, the person creates one first', async () => {const first = await t.get('/run', { as: ada });-- assert.ok(first.text.includes('One thing first: a project') && !first.text.includes('name="repo"'));++ assert.ok(first.text.includes('One thing first: a project') && first.text.includes('type="hidden" name="repo"'));++ const repo = 'https://github.com/OpenVibers/OpenVibe.Codes';++ const prompt = 'Review the mobile workspace';++ const prefilled = await t.get(`/run?prompt=${encodeURIComponent(prompt)}&repo=${encodeURIComponent(repo)}`, { as: ada });++ assert.ok(prefilled.text.includes(`name="repo" value="${repo}"`) && prefilled.text.includes(`name="prompt" value="${prompt}"`));assert.strictEqual((await post('/run/project', ada, { name: 'My code' }, { origin: 'https://evil.example' })).status, 403, 'cross-site');-- const made = await post('/run/project', ada, { name: 'My code' });++ const made = await post('/run/project', ada, { name: 'My code', prompt, repo });assert.strictEqual(made.status, 303);-- const page = await t.get('/run', { as: ada });-- assert.ok(page.text.includes('name="repo"') && page.text.includes('My code'), page.text.slice(0, 400));++ assert.ok(made.headers.get('location').includes(`repo=${encodeURIComponent(repo)}`));++ const page = await t.get(made.headers.get('location'), { as: ada });++ assert.ok(page.text.includes(`name="repo" type="url" required`) && page.text.includes('My code'), page.text.slice(0, 400));project = /<option value="(prj_[0-9A-Z]{26})"/.exec(page.text)[1];});@@@ -68,7 +73,8 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual(kind, 'submit');assert.deepStrictEqual([sent.subject, sent.project, sent.key], [ada.subject, project, taskId]);// The harness's environment (Codes C3) is not part of the task's own arguments: it carries the run token.-- const { env, ...args } = sent.args;++ const { env, ...submitted } = sent.args;++ const args = Object.fromEntries(['repo', 'ref', 'prompt', 'permission', 'push', 'agent', 'model'].map((key) => [key, submitted[key]]));assert.deepStrictEqual(args, { repo: 'https://github.com/OpenVibers/OpenVibe.Codes', ref: 'main', prompt: 'Fix the typo in README', permission: 'edit', push: { branch: null, pull_request: true }, agent: 'claude-code', model: null });assert.deepStrictEqual(Object.keys(env || {}).sort(), ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL']);assert.ok(env.ANTHROPIC_AUTH_TOKEN && env.ANTHROPIC_BASE_URL, 'the harness gets a run token and OpenVibe.AI\'s address');@@@ -24,11 +24,11 @@ const { version } = require('../package.json');assert.match(r.text, /<link rel="canonical" href="https:\/\/openvibe\.codes\/start">/);});-- await check('the home page leads to /start and says the harness is live', async () => {++ await check('the workspace home leads to task creation and exposes its status sections', async () => {const r = await t.get('/');-- assert.ok(r.text.includes('href="/start"'));-- assert.ok(r.text.includes(`Live, ${version}`));-- assert.ok(!/Hand-offs between agents, resumable sessions<\/td><td>Next/.test(r.text), 'the old "next" row is gone');++ assert.ok(r.text.includes('href="/run"'));++ assert.ok(r.text.includes('What should an agent do?'));++ assert.ok(r.text.includes('Needs you') && r.text.includes('Running') && r.text.includes('Recent tasks'));});await check('the command line prints this version and points at the guide', () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+.c4{--c4-bg:#f7f8fa;--c4-surface:#fff;--c4-ink:#18212b;--c4-muted:#687583;--c4-line:#e1e6eb;--c4-accent:#176b5b;color:var(--c4-ink);max-width:1540px;margin:0 auto;padding:0 32px 44px}
+.c4-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:36px;padding-top:26px}
+.c4-side nav{position:sticky;top:24px;display:grid;gap:5px}
+.c4-side a{display:flex;align-items:center;gap:11px;min-height:44px;padding:9px 12px;border-radius:9px;color:var(--c4-muted);text-decoration:none;font-size:14px}
+.c4-side svg{width:19px;height:19px;flex:none}
+.c4-side a[aria-current],.c4-mobile a[aria-current]{color:var(--c4-accent);background:#e9f4f1;font-weight:650}
+.c4-main{min-width:0;max-width:1200px;width:100%}
+.c4-title{display:flex;justify-content:space-between;align-items:end;margin:0 0 22px}
+.c4-title h1{font-size:clamp(26px,3vw,36px);letter-spacing:-.045em;margin:0;line-height:1.12}
+.c4-title p{margin:8px 0 0;color:var(--c4-muted);font-size:14px;line-height:1.5}
+.c4-card{background:var(--c4-surface);border:1px solid var(--c4-line);border-radius:13px;padding:20px;margin:0 0 16px;box-shadow:0 1px 2px #1b29340a}
+.c4-card h2{font-size:16px;letter-spacing:-.02em;margin:0}
+.c4-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}
+.c4-card-head a,.c4-card a:not(.c4-button){color:var(--c4-accent);font-size:13px}
+.c4-composer{border-color:#d5e4df;background:linear-gradient(140deg,#fff,#f8fcfa)}
+.c4-composer form>label{display:block;font-size:15px;font-weight:650;margin-bottom:12px}
+.c4-composer textarea{width:100%;min-height:105px;resize:vertical;border:1px solid var(--c4-line);border-radius:9px;padding:13px;font:inherit;color:var(--c4-ink);background:#fff}
+.c4-controls{display:flex;align-items:end;gap:10px;flex-wrap:wrap;margin-top:12px}
+.c4-controls label{display:grid;gap:5px;font-size:11px;color:var(--c4-muted);min-width:120px}
+.c4-controls select,.c4-controls input,.c4-search input{background:#fff;border:1px solid var(--c4-line);border-radius:7px;padding:8px;color:var(--c4-ink);font:inherit;font-size:13px;min-height:40px}
+.c4-button{border:0;border-radius:8px;background:var(--c4-accent);color:#fff;padding:10px 16px;font:inherit;font-size:13px;font-weight:650;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;min-height:44px}
+.c4-button-light{background:#f3f5f6;color:var(--c4-ink);border:1px solid var(--c4-line)}
+.c4-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
+.c4-empty,.c4-emptyline{color:var(--c4-muted);font-size:13px;line-height:1.55}
+.c4-emptyline{border-top:1px solid var(--c4-line);padding:15px 0 3px}
+.c4-empty h2{color:var(--c4-ink)}
+.c4-task{display:grid;grid-template-columns:10px minmax(0,1fr) auto;align-items:center;gap:4px 10px;padding:13px 0;border-top:1px solid var(--c4-line);color:inherit;text-decoration:none;min-width:0}
+.c4-task-title{font-size:13px;font-weight:620;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
+.c4-task-meta{grid-column:2/4;color:var(--c4-muted);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
+.c4-status-dot{width:9px;height:9px;border-radius:50%;background:#98a5aa;grid-row:1;grid-column:1}
+.c4-status-dot.c4-running,.c4-status-dot.c4-queued{background:#179579}.c4-status-dot.c4-failed{background:#ce7441}.c4-status-dot.c4-done,.c4-status-dot.c4-succeeded{background:#69a24f}
+.c4-status{display:inline-flex;border-radius:20px;padding:5px 9px;background:#eef2f3;color:#4f5d67;font-size:11px;font-weight:650;white-space:nowrap}
+.c4-running,.c4-queued{background:#e9f4f1;color:#176b5b}.c4-failed{background:#fff0e9;color:#995128}.c4-done,.c4-succeeded{background:#edf6e9;color:#47713d}.c4-cancelled{background:#f0eff5;color:#635a7a}
+.c4-reasons{padding:18px 2px}.c4-reasons>h2{font-size:17px}.c4-reasons article{border-top:1px solid var(--c4-line);padding-top:12px}.c4-reasons h3{font-size:14px;margin:0 0 6px}.c4-reasons p{font-size:12px;line-height:1.5;color:var(--c4-muted);margin:0}
+.c4-mobile{display:none}.c4-search{display:flex;align-items:end;gap:8px;margin:0 0 14px}.c4-search label{font-size:12px;color:var(--c4-muted)}.c4-search input{min-width:0;flex:1}
+.c4-outcome{display:grid;grid-template-columns:30px minmax(0,1fr) auto;align-items:center;gap:10px;margin:0 0 16px}.c4-obadge{width:30px;height:30px;border-radius:50%;display:grid;place-items:center;background:#fff0e9;color:#995128}.c4-obadge svg{width:15px;height:15px}.c4-outcome-copy{min-width:0;display:grid;gap:2px}.c4-outcome-copy>b{font-size:13px}.c4-outcome-copy>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--c4-muted);font-size:12px}.c4-outcome-actions{display:flex;justify-content:flex-end;gap:6px;flex-wrap:wrap}.c4-opill{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:34px;padding:0 11px 0 9px;border:1px solid var(--c4-line);border-radius:99px;background:var(--c4-surface);color:var(--c4-ink);font-size:12px;font-weight:650;text-decoration:none;white-space:nowrap}.c4-opill svg{width:15px;height:15px}.c4-opill-primary{background:var(--c4-accent);border-color:var(--c4-accent);color:#fff}.c4-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}
+.c4-task-detail{padding:24px}.c4-task-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.c4-task-title-wrap{min-width:0;flex:1}.c4-brief{font-size:clamp(24px,3vw,34px);letter-spacing:-.04em;line-height:1.18;margin:0;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere}.c4-brief.is-expanded{-webkit-line-clamp:unset}.c4-brief-expand{background:none;border:0;color:var(--c4-accent);padding:9px 0;cursor:pointer;font:inherit;font-size:13px;min-height:44px}.c4-chips{display:flex;flex-wrap:wrap;gap:7px;margin:16px 0}.c4-chip{display:inline-flex;align-items:center;min-height:30px;padding:5px 10px;border:1px solid var(--c4-line);border-radius:18px;color:var(--c4-muted);font-size:12px;text-decoration:none}.c4-chip[href]{color:var(--c4-accent)}
+.c4-progress-toggle{width:100%;display:flex;align-items:center;gap:9px;text-align:left;border:0;border-radius:9px;background:#edf5f2;color:var(--c4-ink);padding:12px 14px;min-height:46px;font:inherit;font-size:13px;cursor:pointer}.c4-progress-meta{color:var(--c4-muted);font-size:12px;margin-left:auto}.c4-tabs{display:flex;gap:5px;border-bottom:1px solid var(--c4-line);margin:16px 0 0;overflow-x:auto}.c4-tabs a{padding:11px 12px;color:var(--c4-muted);text-decoration:none;white-space:nowrap;border-bottom:2px solid transparent;font-size:13px;min-height:44px}.c4-tabs a[aria-selected=true]{color:var(--c4-accent);border-color:var(--c4-accent);font-weight:650}.c4-panel{padding:14px 0 4px;min-height:58px}.c4-panel[hidden]{display:none}.c4-panel p{font-size:13px;color:var(--c4-muted)}.c4-log{white-space:pre-wrap;overflow-wrap:anywhere;max-height:360px;overflow:auto;background:#f6f8f8;border-radius:8px;padding:12px;font-size:12px;color:#314047}.c4-code{white-space:pre-wrap;overflow-wrap:anywhere;background:#f5f7f7;padding:12px;border-radius:8px;font-size:12px}
+.c4-settings{display:grid;gap:12px;font-size:13px}.c4-safety{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.c4-safety-card{display:flex;align-items:flex-start;gap:10px;border:1px solid var(--c4-line);border-radius:10px;padding:14px;cursor:pointer;min-height:90px}.c4-safety-card:has(input:checked){border-color:var(--c4-accent);box-shadow:0 0 0 1px var(--c4-accent)}.c4-safety-card input{margin-top:3px;accent-color:var(--c4-accent)}.c4-safety-card span{display:grid;gap:6px}.c4-safety-card small{color:var(--c4-muted);font-size:12px;line-height:1.4}
+.c4 a:focus-visible,.c4 button:focus-visible,.c4 input:focus-visible,.c4 textarea:focus-visible,.c4 select:focus-visible{outline:2px solid #188c75;outline-offset:2px}
+@media(max-width:900px){.c4{padding-left:22px;padding-right:22px}.c4-layout{grid-template-columns:180px minmax(0,1fr);gap:22px}}
+@media(max-width:760px){.c4{padding:0 15px calc(78px + env(safe-area-inset-bottom))}.c4-layout{display:block;padding-top:18px}.c4-side{display:none}.c4-title{margin-bottom:16px}.c4-card{padding:16px;border-radius:11px}.c4-grid{grid-template-columns:1fr;gap:0}.c4-controls{display:grid;grid-template-columns:1fr 1fr}.c4-controls label{min-width:0}.c4-controls select,.c4-controls input{width:100%}.c4-controls .c4-button{grid-column:1/-1}.c4-task-detail{padding:17px}.c4-task-heading{gap:8px}.c4-task-heading>.c4-status{flex:none}.c4-chips{gap:6px}.c4-chip{font-size:11px;padding:5px 8px}.c4-outcome{grid-template-columns:30px minmax(0,1fr);gap:8px}.c4-outcome-actions{grid-column:1/-1;justify-content:flex-start;padding-left:38px}.c4-opill{min-height:36px;padding:0 9px;font-size:11px}.c4-mobile{display:grid;position:fixed;z-index:20;bottom:0;left:0;right:0;grid-template-columns:repeat(5,1fr);padding:4px max(6px,env(safe-area-inset-left)) calc(4px + env(safe-area-inset-bottom));background:var(--c4-surface);border-top:1px solid var(--c4-line)}.c4-mobile a{display:flex;min-height:48px;flex-direction:column;align-items:center;justify-content:center;gap:2px;text-align:center;font-size:10px;padding:4px 2px;border-radius:8px;color:var(--c4-muted);text-decoration:none}.c4-mobile svg{width:19px;height:19px}.c4-search{align-items:stretch;flex-wrap:wrap}.c4-search label{width:100%}.c4-safety{grid-template-columns:1fr}.c4-safety-card{min-height:68px}.c4-progress-toggle{flex-wrap:wrap}.c4-progress-meta{margin-left:0}.c4-task-meta{font-size:10px}}
+:root[data-theme-mode="dark"] .c4{--c4-bg:#101518;--c4-surface:#171f23;--c4-ink:#eaf0f1;--c4-muted:#a3b0b4;--c4-line:#2d393d;--c4-accent:#75d0ba}
+:root[data-theme-mode="dark"] .c4-composer{background:linear-gradient(140deg,#171f23,#182722);border-color:#315148}:root[data-theme-mode="dark"] .c4 textarea,:root[data-theme-mode="dark"] .c4 select,:root[data-theme-mode="dark"] .c4 input{background:#11181b;color:var(--c4-ink)}
+:root[data-theme-mode="dark"] .c4-side a[aria-current],:root[data-theme-mode="dark"] .c4-mobile a[aria-current]{background:#1d3831}:root[data-theme-mode="dark"] .c4-log,:root[data-theme-mode="dark"] .c4-code{background:#11181b;color:#d3dfdf}:root[data-theme-mode="dark"] .c4-button-light{background:#20292d;color:var(--c4-ink)}:root[data-theme-mode="dark"] .c4-obadge{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-progress-toggle{background:#1d3831;color:var(--c4-ink)}
+:root[data-theme-mode="dark"] .c4-status{background:#263034;color:#cad4d6}:root[data-theme-mode="dark"] .c4-running,:root[data-theme-mode="dark"] .c4-queued{background:#1d3831;color:#75d0ba}:root[data-theme-mode="dark"] .c4-failed{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-done,:root[data-theme-mode="dark"] .c4-succeeded{background:#243423;color:#afd39c}
+.c4-progress-content{grid-template-rows:0fr;opacity:0;overflow:hidden;transition:grid-template-rows .24s ease,opacity .24s ease}.c4-progress-content.is-open,.c4-progress-content[data-live="false"]{grid-template-rows:1fr;opacity:1}.c4-progress-inner{min-height:0;overflow:hidden}.c4-usage-row{grid-template-columns:minmax(0,1fr) auto}.c4-usage-row .c4-task-title,.c4-usage-row .c4-task-meta{grid-column:1}.c4-usage-row>b{grid-column:2;grid-row:1/3;align-self:center}
+.page:has(.c4){width:100%;max-width:none;padding:0;min-height:0}.c4-attempts{display:grid;gap:6px;margin:10px 0;padding:0;list-style:none}.c4-attempt:before{content:"";display:inline-block;width:7px;height:7px;margin:0 8px 1px 1px;border-radius:50%;background:var(--c4-accent)}.c4-attempt{color:var(--c4-muted);font-size:12px}.c4-log[hidden]{display:none}
+.c4-card .c4-outcome-actions a.c4-opill{color:var(--c4-ink)}.c4-card .c4-outcome-actions a.c4-opill-primary{color:#fff}
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
+'use strict';
+(() => {
+ document.querySelectorAll('.c4-tabs').forEach((tabs) => {
+ const root = tabs.closest('.c4-task-detail');
+ const links = Array.from(tabs.querySelectorAll('[data-c4-tab]'));
+ const panels = Array.from(root.querySelectorAll('.c4-panel'));
+ const activate = (id) => {
+ links.forEach((link) => link.setAttribute('aria-selected', String(link.dataset.c4Tab === id)));
+ panels.forEach((panel) => { panel.hidden = panel.id !== id; });
+ };
+ activate('live');
+ links.forEach((link) => link.addEventListener('click', (event) => {
+ event.preventDefault();
+ activate(link.dataset.c4Tab);
+ history.replaceState(null, '', link.getAttribute('href'));
+ }));
+ });
+
+ document.querySelectorAll('[data-brief]').forEach((brief) => {
+ const button = brief.parentElement.querySelector('.c4-brief-expand');
+ if (!button) return;
+ requestAnimationFrame(() => {
+ if (brief.scrollHeight > brief.clientHeight + 1) button.hidden = false;
+ });
+ button.addEventListener('click', () => {
+ const expanded = brief.classList.toggle('is-expanded');
+ button.textContent = expanded ? 'Show less' : 'Show the whole brief';
+ });
+ });
+
+ document.querySelectorAll('.c4-progress-toggle').forEach((button) => {
+ const content = document.getElementById(button.getAttribute('aria-controls'));
+ button.addEventListener('click', () => {
+ const open = button.getAttribute('aria-expanded') !== 'true';
+ button.setAttribute('aria-expanded', String(open));
+ content.classList.toggle('is-open', open);
+ });
+ });
+
+ document.querySelectorAll('[data-c4-stream]').forEach((root) => {
+ const detail = root.closest('[data-task-api]');
+ const out = root.querySelector('[data-task-log]');
+ const attemptsNode = root.querySelector('[data-c4-attempts]');
+ const label = root.querySelector('[data-live-label]');
+ const attemptRows = new Map();
+ attemptsNode?.querySelectorAll('[data-attempt-n]').forEach((item) => attemptRows.set(Number(item.dataset.attemptN), item));
+ const showAttempt = (attempt) => {
+ const number = attempt && (attempt.n ?? attempt.attempt_n);
+ if (!number || !attemptsNode) return;
+ const key = Number(number);
+ let item = attemptRows.get(key);
+ if (!item) {
+ item = document.createElement('li');
+ item.className = 'c4-attempt';
+ attemptRows.set(key, item);
+ attemptsNode.append(item);
+ }
+ item.textContent = `Attempt ${key} · ${attempt.agent || 'Auto'} · ${attempt.end_why || attempt.state || 'Queued'}`;
+ };
+ const updateOutcome = (task) => {
+ if (!detail || !task) return;
+ const outcome = task.outcome || {};
+ const unfinished = outcome.state === 'unfinished' || (task.error && !['succeeded', 'cancelled'].includes(task.state));
+ let panel = detail.querySelector('[data-c4-outcome]');
+ if (unfinished && !panel) {
+ // Server markup provides the no-JS state; this branch covers an outcome reached during the stream.
+ panel = document.createElement('div');
+ panel.className = 'c4-outcome';
+ panel.dataset.c4Outcome = '';
+ panel.innerHTML = '<span class="c4-obadge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="m18 6-12 12M6 6l12 12"/></svg></span><div class="c4-outcome-copy"><b>Didn’t finish</b><span data-outcome-reason></span></div>';
+ const actions = document.createElement('div');
+ actions.className = 'c4-outcome-actions';
+ const addPill = (labelText, href, primary, path) => {
+ const link = document.createElement('a');
+ link.className = `c4-opill${primary ? ' c4-opill-primary' : ''}`;
+ link.href = href;
+ link.innerHTML = `<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="${path}"/></svg>`;
+ const text = document.createElement('span');
+ text.textContent = labelText;
+ link.append(text);
+ actions.append(link);
+ };
+ addPill('Retry', detail.dataset.retryUrl, true, 'M20 7v5h-5M4 17v-5h5M5.6 9a7 7 0 0 1 11.7-2L20 12M4 12l2.7 5a7 7 0 0 0 11.7-2');
+ addPill('Switch', detail.dataset.switchUrl, false, 'm16 3 4 4-4 4M4 7h16M8 21l-4-4 4-4m12 4H4');
+ addPill('Fine', '/tasks', false, 'm5 12 4 4L19 6');
+ panel.append(actions);
+ detail.prepend(panel);
+ }
+ if (panel) {
+ panel.hidden = !unfinished;
+ const reason = panel.querySelector('[data-outcome-reason]');
+ if (reason) reason.textContent = outcome.note || task.error || 'The task stopped before it was complete.';
+ }
+ const chip = detail.querySelector('.c4-task-heading > .c4-status');
+ if (chip) chip.textContent = task.state || 'Unknown';
+ if (label) label.textContent = ['succeeded', 'failed', 'cancelled', 'expired'].includes(task.state) ? 'This task has finished.' : 'Connecting to the live task…';
+ };
+ if (detail) fetch(detail.dataset.taskApi, { headers: { Accept: 'application/json' }, credentials: 'same-origin' })
+ .then((response) => response.ok ? response.json() : null)
+ .then((task) => {
+ if (!task) return;
+ (task.attempts || []).forEach(showAttempt);
+ updateOutcome(task);
+ }).catch(() => {});
+ if (!window.EventSource) return;
+ const outText = (line) => {
+ const body = line && line.body;
+ if (typeof body === 'string') return body;
+ if (body && typeof body === 'object') return body.text || body.message || body.command || body.output || JSON.stringify(body);
+ return String(body ?? '');
+ };
+ const title = document.querySelector('[data-progress-title]');
+ const stepsNode = document.querySelector('[data-progress-steps]');
+ const timeNode = document.querySelector('[data-progress-time]');
+ const taskStarted = Number(root.dataset.startedAt);
+ const started = Number.isFinite(taskStarted) && taskStarted > 0 ? taskStarted : Date.now();
+ let steps = 0;
+ const updateTime = () => {
+ if (!timeNode) return;
+ const mins = Math.max(0, Math.floor((Date.now() - started) / 60000));
+ timeNode.textContent = mins >= 60 ? String(Math.floor(mins / 60)) + 'h ' + String(mins % 60) + 'm' : mins ? String(mins) + ' min' : 'now';
+ };
+ updateTime();
+ const timer = window.setInterval(updateTime, 30000);
+ const stream = new EventSource(root.dataset.c4Stream, { withCredentials: true });
+ stream.addEventListener('line', (event) => {
+ let line;
+ try { line = JSON.parse(event.data); } catch { return; }
+ const text = outText(line).trim();
+ if (!text || !out) return;
+ out.hidden = false;
+ out.textContent += text + '\n';
+ steps += 1;
+ if (stepsNode) stepsNode.textContent = String(steps) + (steps === 1 ? ' step' : ' steps');
+ if (title) title.textContent = text.split(/\s+/).slice(0, 12).join(' ');
+ });
+ stream.addEventListener('attempt', (event) => {
+ try { showAttempt(JSON.parse(event.data)); } catch { /* ignore malformed event */ }
+ });
+ stream.addEventListener('end', () => { stream.close(); window.clearInterval(timer); if (title) title.textContent = 'Task finished'; });
+ stream.addEventListener('error', () => {
+ if (stream.readyState === EventSource.CLOSED) { stream.close(); window.clearInterval(timer); }
+ });
+ });
+})();
@@ -201,6 +201,8 @@ async function createApp(opts = {}) {
@@ -201,6 +201,8 @@ async function createApp(opts = {}) {
if (req.path !== '/.well-known/agent.json' && !twin) return next();
if (req.path !== '/.well-known/agent.json' && !twin) return next();
return agentLayer(req, res, next);
return agentLayer(req, res, next);
});
});
+ // C4 workspace pages sit before the legacy marketing pages and share the existing owner-scoped task store.
+ app.use(require('./http/c4').createC4Routes(ctx));
app.use(pageRoutes);
app.use(pageRoutes);
app.use(createRunRoutes({ config, store, sso, harnesses, run, tasks, sessions, keys, fetchImpl: fetchImpl || globalThis.fetch, log }));
app.use(createRunRoutes({ config, store, sso, harnesses, run, tasks, sessions, keys, fetchImpl: fetchImpl || globalThis.fetch, log }));
app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/harnesses">the agents</a>, <a href="/improve">Improve OpenVibe</a>, or the developer platform at <a href="https://openvibe.services">openvibe.services</a>.</p>` }));
app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/harnesses">the agents</a>, <a href="/improve">Improve OpenVibe</a>, or the developer platform at <a href="https://openvibe.services">openvibe.services</a>.</p>` }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
+'use strict';
+
+// Phone-first Codes workspace. Pages stay useful without JavaScript and link to existing task actions.
+const { send } = require('../render/layout');
+const { raw, esc } = require('../render/html');
+const { asyncRouter } = require('./router');
+const showcase = require('openvibe-shared/showcase');
+const { homeJsonLd } = require('./discovery');
+
+const LINKS = [
+ ['Home', '/', '<path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/>'],
+ ['Tasks', '/tasks', '<path d="M8 6h12M8 12h12M8 18h12M3 6h.01M3 12h.01M3 18h.01"/>'],
+ ['Agents', '/agents', '<circle cx="12" cy="8" r="3"/><path d="M5 20a7 7 0 0 1 14 0M12 2v3M3 8h3m12 0h3"/>'],
+ ['Machines', '/machines', '<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>'],
+ ['Usage', '/usage', '<path d="M4 19V5m0 14h17M8 15v-4m5 4V7m5 8v-6"/>'],
+ ['Settings', '/settings', '<circle cx="12" cy="12" r="3"/><path d="m19.4 15 .1.1 1.4 1.1-1.4 2.4-1.7-.7a8 8 0 0 1-1.7 1l-.3 1.8h-2.8l-.3-1.8a8 8 0 0 1-1.7-1l-1.7.7-1.4-2.4 1.4-1.1a8 8 0 0 1 0-2l-1.4-1.1 1.4-2.4 1.7.7a8 8 0 0 1 1.7-1l.3-1.8h2.8l.3 1.8a8 8 0 0 1 1.7 1l1.7-.7 1.4 2.4-1.4 1.1a8 8 0 0 1 0 2Z"/>'],
+];
+const stateLabel = (s) => ({ running: 'Running', queued: 'Queued', placed: 'On its way', done: 'Done', succeeded: 'Done', failed: "Didn't finish", cancelled: 'Stopped by you', expired: 'Expired' }[s] || s || 'Unknown');
+const safeId = (s) => /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(s || ''));
+const money = (n) => Number.isFinite(Number(n)) ? `$${Number(n).toFixed(2)}` : '—';
+const age = (value) => {
+ const numeric = Number(value);
+ const ms = Number.isFinite(numeric) && numeric > 0 ? numeric : new Date(value).getTime();
+ if (!Number.isFinite(ms)) return '';
+ const mins = Math.max(0, Math.floor((Date.now() - ms) / 60000));
+ return mins < 60 ? `${mins}m ago` : mins < 1440 ? `${Math.floor(mins / 60)}h ago` : `${Math.floor(mins / 1440)}d ago`;
+};
+const repoDetails = (repo) => {
+ try {
+ const url = new URL(String(repo || ''));
+ if (!['http:', 'https:'].includes(url.protocol)) return null;
+ url.username = '';
+ url.password = '';
+ const parts = url.pathname.split('/').filter(Boolean);
+ const label = parts.at(-1)?.replace(/\.git$/i, '') || url.hostname;
+ return { label, href: url.toString() };
+ } catch { return null; }
+};
+const icon = (path) => `<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;
+
+function createC4Routes(ctx) {
+ const r = asyncRouter();
+ const { config, store, harnesses, tasks } = ctx;
+ const db = store.db;
+ const page = (req, res, title, body, extra = {}) => send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), css: ['css/c4.css'], scripts: /^\/tasks\/tsk_/.test(req.path) ? ['js/c4.js'] : [], body: raw(body), ...extra });
+ const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
+ const wrap = (current, content, signedIn = false) => `<div class="c4"><div class="c4-layout"><aside class="c4-side"><nav aria-label="Workspace">${LINKS.map(([label, href]) => `<a href="${href}"${current === href ? ' aria-current="page"' : ''}>${icon(LINKS.find((x) => x[0] === label)[2])}<span>${label}</span></a>`).join('')}</nav></aside><main class="c4-main">${content}</main></div><nav class="c4-mobile" aria-label="Workspace">${LINKS.slice(0,5).map(([label, href, svg]) => `<a href="${href}"${current === href ? ' aria-current="page"' : ''}>${icon(svg)}<span>${label}</span></a>`).join('')}</nav></div>`;
+ const signIn = (req) => `<section class="c4-card c4-empty"><h2>Sign in to see your work</h2><p>Your tasks, machines and usage belong to your OpenVibe account.</p><a class="c4-button" href="/auth/login?next=${encodeURIComponent(req.originalUrl || '/')}">Sign in</a></section>`;
+ async function ownTasks(req, limit = 100) {
+ if (!viewer(req)) return [];
+ return db.many('SELECT * FROM run_tasks WHERE subject = $1 ORDER BY created_at DESC LIMIT $2', [req.viewer.subject, limit]);
+ }
+ const taskHref = (row) => `/tasks/${encodeURIComponent(row.id)}`;
+ const rowHtml = (row) => {
+ const repo = repoDetails(row.repo);
+ const attempts = Number(row.attempt_count || row.result?.attempts?.length || row.result?.attempts || 0);
+ const meta = [repo?.label || 'Project not set', row.agent || 'Auto', age(row.created_at), money(row.result && row.result.cost), attempts > 1 ? `${attempts} attempts` : ''].filter(Boolean).map(esc).join(' · ');
+ return `<a class="c4-task" href="${taskHref(row)}"><span class="c4-status-dot c4-${esc(row.state)}" aria-label="${esc(stateLabel(row.state))}"></span><span class="c4-task-title">${esc(row.prompt || repo?.label || 'Coding task')}</span><span class="c4-task-meta">${meta}</span></a>`;
+ };
+ const header = (title, desc = '') => `<div class="c4-title"><div><h1>${esc(title)}</h1>${desc ? `<p>${esc(desc)}</p>` : ''}</div></div>`;
+ const stateChip = (state) => `<span class="c4-status c4-${esc(state)}">${esc(stateLabel(state))}</span>`;
+ const taskChips = (row) => {
+ const repo = repoDetails(row.repo);
+ const chips = [repo ? `<a class="c4-chip" href="${esc(repo.href)}" rel="noopener noreferrer">${esc(repo.label)}</a>` : `<span class="c4-chip">Project not set</span>`, `<span class="c4-chip">${esc(row.ref || 'default branch')}</span>`, `<span class="c4-chip">${esc(row.agent || 'Auto')}</span>`, '<span class="c4-chip">Your computer</span>', `<span class="c4-chip">${esc(money(row.result && row.result.cost))}</span>`];
+ return `<div class="c4-chips" aria-label="Task details">${chips.join('')}</div>`;
+ };
+
+ r.get('/', async (req, res) => {
+ const rows = await ownTasks(req, 8);
+ const signedIn = !!viewer(req);
+ const content = `${header(signedIn ? 'Home' : 'Every coding agent. One place.', signedIn ? '' : 'Your keys, your machines. Tell an agent what to do and follow the work from your phone.')}
+<section class="c4-card c4-composer"><form method="get" action="/run"><label for="c4-prompt">What should an agent do?</label><textarea id="c4-prompt" name="prompt" placeholder="Describe the change, question or fix" required></textarea><div class="c4-controls"><label>Project<input type="url" name="repo" placeholder="Paste a repository URL" autocomplete="url"></label><label>Agent <select name="agent"><option value="">Auto</option>${(harnesses?.list?.() || []).map((a) => `<option value="${esc(a.id)}">${esc(a.name)}</option>`).join('')}</select></label><label>Machine <select name="machine"><option value="">Auto</option></select></label><label>Cost cap <select name="cap"><option>$1.00</option><option>$5.00</option></select></label><button class="c4-button" type="submit">Start a task</button></div></form></section>
+<div class="c4-grid"><section class="c4-card"><div class="c4-card-head"><h2>Running</h2><a href="/tasks?state=running">All tasks</a></div>${rows.filter((x) => !['done','succeeded','failed','cancelled'].includes(x.state)).slice(0,4).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks running yet. <a href="/#c4-prompt">Start a task</a></div>'}</section><section class="c4-card"><div class="c4-card-head"><h2>Needs you</h2><span class="c4-muted">Approvals and questions</span></div><div class="c4-emptyline">Nothing needs your attention. <a href="/tasks">View tasks</a></div></section></div>
+<section class="c4-card"><div class="c4-card-head"><h2>Recent tasks</h2><a href="/tasks">View all</a></div>${rows.slice(0,5).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>
+${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h2><div class="c4-grid">${[['Any agent','Bring a key or plan. Auto picks a good fit and can hand over when one gets stuck.'],['Your computer or ours','Pair your computer with one command. A hosted sandbox is coming soon.'],['Proof you can inspect','See the changes, tests, screenshots and review before you approve.'],['A cost you can follow','Track task costs and plan limits. Set caps that stop with a wrap-up.']].map(([a,b]) => `<article><h3>${a}</h3><p>${b}</p></article>`).join('')}</div></section>`}`;
+ page(req, res, null, wrap('/', content, signedIn), signedIn ? {} : { index: true, styles: [showcase.STYLESHEET], jsonLd: homeJsonLd(config) });
+ });
+ r.get('/tasks', async (req, res) => {
+ const rows = await ownTasks(req);
+ const q = String(req.query.q || '').toLowerCase();
+ const filtered = rows.filter((x) => `${x.prompt} ${repoDetails(x.repo)?.label || ''} ${x.agent}`.toLowerCase().includes(q));
+ const isDone = (x) => ['done', 'succeeded', 'cancelled'].includes(x.state);
+ const groups = [
+ ['Running', filtered.filter((x) => !isDone(x) && x.state !== 'failed')],
+ ['Needs you', []],
+ ['Done', filtered.filter(isDone)],
+ ["Didn't finish", filtered.filter((x) => x.state === 'failed')],
+ ];
+ const taskList = filtered.length ? groups.filter(([, items]) => items.length).map(([label, items]) => `<section class="c4-card"><div class="c4-card-head"><h2>${label}</h2></div>${items.map(rowHtml).join('')}</section>`).join('') : `<div class="c4-card c4-emptyline">${q ? 'No matching tasks.' : 'No tasks yet.'} <a href="${q ? '/tasks' : '/#c4-prompt'}">${q ? 'Clear search' : 'Start a task'}</a></div>`;
+ const content = `${header('Tasks')}${viewer(req) ? `<form class="c4-search" method="get"><label for="task-search">Search your tasks</label><input id="task-search" name="q" value="${esc(q)}" placeholder="Search by task, project or agent"><button class="c4-button c4-button-light">Search</button></form>${taskList}` : signIn(req)}`;
+ page(req, res, 'Tasks', wrap('/tasks', content, !!viewer(req)));
+ });
+ r.get('/tasks/:id', async (req, res) => {
+ if (!viewer(req)) return page(req, res, 'Task', wrap('/tasks', `${header('Task')}${signIn(req)}`));
+ if (!safeId(req.params.id)) return res.status(404).send('Not found');
+ const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2', [req.params.id, req.viewer.subject]);
+ if (!row) return res.status(404).send('Not found');
+ const live = ['queued','placed','running'].includes(row.state);
+ const attempts = tasks?.attempts ? await tasks.attempts.list(row.id) : [];
+ const attemptItems = attempts.map((attempt) => {
+ const item = tasks.attempts.present(attempt);
+ return `<li class="c4-attempt" data-attempt-n="${Number(item.n)}">Attempt ${Number(item.n)} · ${esc(item.agent || 'Auto')} · ${esc(item.end_why || item.state || 'Queued')}</li>`;
+ }).join('');
+ const taskApi = `/api/v1/tasks/${encodeURIComponent(row.id)}`;
+ const events = `${taskApi}/events`;
+ const prompt = encodeURIComponent(row.prompt || '');
+ const retry = `/run?prompt=${prompt}&repo=${encodeURIComponent(row.repo || '')}&agent=${encodeURIComponent(row.agent || '')}`;
+ const switchAgent = `/run?prompt=${prompt}&repo=${encodeURIComponent(row.repo || '')}`;
+ const hasOutcome = row.outcome === 'unfinished' || Boolean(row.error);
+ const outcome = hasOutcome ? `<div class="c4-outcome" data-c4-outcome><span class="c4-obadge" aria-hidden="true">${icon('<path d="m18 6-12 12M6 6l12 12"/>')}</span><div class="c4-outcome-copy"><b>Didn't finish</b><span data-outcome-reason>${esc(row.outcome_note || row.error || 'The task stopped before it was complete.')}</span></div><div class="c4-outcome-actions"><a class="c4-opill c4-opill-primary" href="${retry}">${icon('<path d="M20 7v5h-5M4 17v-5h5"/><path d="M5.6 9a7 7 0 0 1 11.7-2L20 12M4 12l2.7 5a7 7 0 0 0 11.7-2"/>')}<span>Retry</span></a><a class="c4-opill" href="${switchAgent}">${icon('<path d="m16 3 4 4-4 4M4 7h16M8 21l-4-4 4-4m12 4H4"/>')}<span>Switch</span></a><a class="c4-opill" href="/tasks">${icon('<path d="m5 12 4 4L19 6"/>')}<span>Fine</span></a></div></div>` : '';
+ const progress = live ? `<button class="c4-progress-toggle" type="button" aria-expanded="false" aria-controls="live-content"><span data-progress-title>Working on your task</span><span class="c4-progress-meta"><span data-progress-steps>0 steps</span> · <span data-progress-time>now</span></span><span aria-hidden="true">⌄</span></button>` : '';
+ const content = `<section class="c4-card c4-task-detail" data-task-api="${esc(taskApi)}" data-retry-url="${esc(retry)}" data-switch-url="${esc(switchAgent)}">${outcome}<div class="c4-task-heading"><div class="c4-task-title-wrap"><h1 class="c4-brief" data-brief>${esc(row.prompt || 'Task')}</h1><button class="c4-brief-expand" type="button" hidden>Show the whole brief</button></div>${hasOutcome ? '' : stateChip(row.state)}</div>${taskChips(row)}${progress}<nav class="c4-tabs" aria-label="Task details">${[['live','Live'],['changes','Changes'],['proof','Proof'],['pull-request','Pull request']].map(([id, label], i) => `<a href="#${id}" role="tab" aria-selected="${i === 0}" data-c4-tab="${id}">${label}</a>`).join('')}</nav><section id="live" class="c4-panel" role="tabpanel"><div id="live-content" class="c4-progress-content" data-c4-stream="${esc(events)}" data-task="${esc(row.id)}" data-started-at="${esc(row.started_at || row.created_at || '')}" data-live="${live ? 'true' : 'false'}"><div class="c4-progress-inner"><p data-live-label>${live ? 'Connecting to the live task…' : 'This task has finished.'}</p><ol class="c4-attempts" data-c4-attempts aria-live="polite">${attemptItems}</ol><pre class="c4-log" data-task-log aria-live="polite" hidden></pre></div></div></section><section id="changes" class="c4-panel" role="tabpanel"><p>Changes from this task.</p><a href="/run/tasks/${encodeURIComponent(row.id)}">Open changes</a></section><section id="proof" class="c4-panel" role="tabpanel"><p>Test output and screenshots for this task.</p><a href="/run/tasks/${encodeURIComponent(row.id)}">Open task proof</a></section><section id="pull-request" class="c4-panel" role="tabpanel"><p>Pull request details will appear here.</p><a href="/run/tasks/${encodeURIComponent(row.id)}">Open task in Run</a></section>${live ? `<div class="c4-actions"><form method="post" action="/run/tasks/${encodeURIComponent(row.id)}/cancel"><button class="c4-button c4-button-light">Stop task</button></form></div>` : ''}</section>`;
+ page(req, res, 'Task', wrap('/tasks', content, true));
+ });
+ r.get('/agents', (req, res) => page(req, res, 'Agents', wrap('/agents', `${header('Agents')}${viewer(req) ? '<section id="connections" class="c4-card"><h2>Connections</h2><div class="c4-emptyline">No agent connections yet. <a class="c4-button" href="/agents#connections">Connect a key</a></div></section>' : signIn(req)}`, !!viewer(req))));
+ r.get('/machines', (req, res) => page(req, res, 'Machines', wrap('/machines', `${header('Machines')}${viewer(req) ? '<section class="c4-card"><h2>Your machines</h2><div class="c4-emptyline">No paired computers yet. <a class="c4-button" href="/machines#pair">Pair this computer</a></div><div id="pair"><pre class="c4-code">curl -fsSL https://openvibe.bot/install.sh | sh -s -- --worker</pre></div></section>' : signIn(req)}`, !!viewer(req))));
+ r.get('/usage', async (req, res) => {
+ const rows = await ownTasks(req);
+ page(req, res, 'Usage', wrap('/usage', `${header('Usage')}${viewer(req) ? `<section class="c4-card"><div class="c4-card-head"><h2>Recent task costs</h2><span>Last ${rows.length} tasks</span></div>${rows.map((x) => `<a class="c4-task c4-usage-row" href="${taskHref(x)}"><span class="c4-task-title">${esc(x.prompt || repoDetails(x.repo)?.label || 'Task')}</span><span class="c4-task-meta">${esc(x.agent || 'Auto')} · ${esc(stateLabel(x.state))}</span><b>${esc(money(x.result && x.result.cost))}</b></a>`).join('') || '<div class="c4-emptyline">No task usage yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>` : signIn(req)}`, !!viewer(req)));
+ });
+ r.get('/settings', (req, res) => page(req, res, 'Settings', wrap('/settings', `${header('Settings')}${viewer(req) ? '<section class="c4-card"><h2>Safety</h2><div class="c4-safety" role="group" aria-label="Safety level"><label class="c4-safety-card"><input type="radio" name="safety" checked><span><b>Ask me</b><small>Approve commands as they come up.</small></span></label><label class="c4-safety-card"><input type="radio" name="safety"><span><b>Auto in the project</b><small>Run commands within the project automatically.</small></span></label><label class="c4-safety-card"><input type="radio" name="safety"><span><b>Full access</b><small>Let the agent use the whole computer.</small></span></label></div><p class="c4-muted">Arrives with connections.</p></section>' : signIn(req)}`, !!viewer(req))));
+ return r;
+}
+
+module.exports = { createC4Routes };
@@ -113,6 +113,8 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`
@@ -113,6 +113,8 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`
${projects.length ? formHtml({ projects, values, error }) : html`<form class="run-card" method="post" action="/run/project">
${projects.length ? formHtml({ projects, values, error }) : html`<form class="run-card" method="post" action="/run/project">
${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>` : ''}
${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>` : ''}
<h2>One thing first: a project</h2><p class="muted">Runs belong to a project on OpenVibe (that is where their usage is counted). You have none you can run jobs in yet.</p>
<h2>One thing first: a project</h2><p class="muted">Runs belong to a project on OpenVibe (that is where their usage is counted). You have none you can run jobs in yet.</p>
+<input type="hidden" name="prompt" value="${values && values.prompt || ''}">
+<input type="hidden" name="repo" value="${values && values.repo || ''}">
<label class="run-field"><span>Project name</span><input name="name" value="My code" maxlength="80" required></label>
<label class="run-field"><span>Project name</span><input name="name" value="My code" maxlength="80" required></label>
<button class="run-btn run-btn-primary" type="submit">Create the project</button></form>`}
<button class="run-btn run-btn-primary" type="submit">Create the project</button></form>`}
<h2>Your tasks</h2>
<h2>Your tasks</h2>
@@ -120,7 +122,7 @@ ${tasksHtml(recent)}`;
@@ -120,7 +122,7 @@ ${tasksHtml(recent)}`;
return page(req, res, { title: 'Run on your machine', body }, status);
return page(req, res, { title: 'Run on your machine', body }, status);
}
}
- r.get('/run', (req, res) => runPage(req, res));
+ r.get('/run', (req, res) => runPage(req, res, { values: { prompt: String(req.query.prompt || '').slice(0, 16000), repo: String(req.query.repo || '').slice(0, 2000), agent: String(req.query.agent || '').slice(0, 100) } }));
r.post('/run/project', form, async (req, res) => {
r.post('/run/project', form, async (req, res) => {
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Frun');
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Frun');
@@ -136,9 +138,15 @@ ${tasksHtml(recent)}`;
@@ -136,9 +138,15 @@ ${tasksHtml(recent)}`;
if (!out.ok) throw new Error(`Network answered ${out.status}`);
if (!out.ok) throw new Error(`Network answered ${out.status}`);
});
});
} catch (e) {
} catch (e) {
- return runPage(req, res, { error: `The project could not be created (${e.message}). Try again.`, status: 502 });
+ return runPage(req, res, { values: { prompt: String(req.body && req.body.prompt || '').slice(0, 16000), repo: String(req.body && req.body.repo || '').slice(0, 2000) }, error: `The project could not be created (${e.message}). Try again.`, status: 502 });
}
}
- return res.redirect(303, '/run');
+ const prompt = String(req.body && req.body.prompt || '').slice(0, 16000);
+ const repo = String(req.body && req.body.repo || '').slice(0, 2000);
+ const query = new URLSearchParams();
+ if (prompt) query.set('prompt', prompt);
+ if (repo) query.set('repo', repo);
+ const suffix = query.toString();
+ return res.redirect(303, `/run${suffix ? `?${suffix}` : ''}`);
});
});
r.post('/run', form, async (req, res) => {
r.post('/run', form, async (req, res) => {
@@ -55,13 +55,15 @@ function renderPage(o) {
@@ -55,13 +55,15 @@ function renderPage(o) {
const viewer = o.viewer || { kind: 'anonymous' };
const viewer = o.viewer || { kind: 'anonymous' };
const signedIn = viewer.kind === 'user';
const signedIn = viewer.kind === 'user';
const pathNow = o.path || '/';
const pathNow = o.path || '/';
+ const c4Workspace = pathNow === '/' || /^\/(tasks|agents|machines|usage|settings)(\/|$)/.test(pathNow);
const loginNext = encodeURIComponent(pathNow);
const loginNext = encodeURIComponent(pathNow);
const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
const nav = {
const nav = {
service: 'codes',
service: 'codes',
+ brandName: SITE_NAME,
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
links: NAV.map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
links: NAV.map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
- appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Start', href: '/start', icon: 'fa-play' }, { label: 'Run', href: '/run', icon: 'fa-microchip' }, { label: 'Agents', href: '/harnesses', icon: 'fa-robot' }], // phones: the bottom tab bar (Shared 4.20)
+ appBar: c4Workspace ? false : [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Start', href: '/start', icon: 'fa-play' }, { label: 'Run', href: '/run', icon: 'fa-microchip' }, { label: 'Agents', href: '/harnesses', icon: 'fa-robot' }], // phones: the bottom tab bar (Shared 4.20)
history: { type: 'page', title: o.title || SITE_NAME },
history: { type: 'page', title: o.title || SITE_NAME },
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
@@ -73,7 +75,7 @@ function renderPage(o) {
@@ -73,7 +75,7 @@ function renderPage(o) {
// bar below is only for visitors without JavaScript).
// bar below is only for visitors without JavaScript).
// Your projects and apps live on the developer platform (OpenVibe.Services).
// Your projects and apps live on the developer platform (OpenVibe.Services).
if (signedIn) nav.menu = { before: [{ label: 'Your projects', href: 'https://openvibe.services/projects', icon: 'fa-folder' }] };
if (signedIn) nav.menu = { before: [{ label: 'Your projects', href: 'https://openvibe.services/projects', icon: 'fa-folder' }] };
- const footer = { service: 'codes', variant: 'auto', path: o.path, mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }; // full on the front page only (Shared 4.19)
+ const footer = { service: 'codes', variant: signedIn && c4Workspace ? 'compact' : 'auto', path: o.path, mount: '#ov-footer', brandName: SITE_NAME, ...(signedIn && c4Workspace ? { shipped: false } : { updates: '/updates' }) }; // workspace pages use a compact frame
const account = signedIn
const account = signedIn
? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
: html`<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
: html`<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
@@ -113,7 +115,7 @@ ${render(scripts)}
@@ -113,7 +115,7 @@ ${render(scripts)}
<main id="main" class="page">
<main id="main" class="page">
${render(crumbs)}
${render(crumbs)}
${render(o.body)}
${render(o.body)}
-${o.path === '/' ? frame.shipped({ service: 'codes', title: `Recently shipped on ${SITE_NAME}` }) : ''}
+${o.path === '/' && !signedIn ? frame.shipped({ service: 'codes', title: `Recently shipped on ${SITE_NAME}` }) : ''}
</main>
</main>
<script>
<script>
window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+'use strict';
+
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const ada = t.network.addUser('c4-ada');
+ try {
+ await check('all C4 pages render readable server content without JavaScript', async () => {
+ for (const [path, label] of [['/', 'What should an agent do?'], ['/tasks', 'Tasks'], ['/agents', 'Agents'], ['/machines', 'Machines'], ['/usage', 'Usage'], ['/settings', 'Settings']]) {
+ const r = await t.get(path);
+ assert.strictEqual(r.status, 200, `${path} status`);
+ assert.ok(r.text.includes(label), `${path} content`);
+ assert.ok(r.text.includes('c4-mobile'), `${path} workspace navigation`);
+ }
+ const task = await t.get('/tasks/tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV');
+ assert.strictEqual(task.status, 200);
+ assert.ok(task.text.includes('Sign in to see your work'));
+ });
+
+ await check('task details are owner scoped and management pages honor sign-in', async () => {
+ const settings = await t.get('/settings', { as: ada });
+ assert.strictEqual(settings.status, 200);
+ assert.ok(settings.text.includes('Safety'));
+ assert.strictEqual((settings.text.match(/Arrives with connections\./g) || []).length, 1);
+ assert.ok(!settings.text.includes('Sign in to see your work'));
+ const hidden = await t.get('/tasks/tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV', { as: ada });
+ assert.strictEqual(hidden.status, 404);
+ });
+
+ await check('signed-in workspace and task details use the reviewed layout', async () => {
+ const home = await t.get('/', { as: ada });
+ assert.ok(!home.text.includes('Built for the way you work'));
+ assert.ok(!home.text.includes('Recently shipped on'));
+ assert.ok(!home.text.includes('OPENVIBE.CODES'));
+ assert.ok(!home.text.includes('Your coding workspace'));
+ assert.ok(home.text.includes('Paste a repository URL'));
+ const id = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV';
+ await t.ctx.store.db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, state, result, error, created_at, updated_at)
+ VALUES ($1,$2,'prj_01ARZ3NDEKTSV4RRFFQ69G5FAW','https://github.com/OpenVibers/OpenVibe.Codes','main','Fix the full brief and keep the workspace readable on a phone','codex','failed','{"cost":0.42}'::jsonb,'The machine stopped.',$3,$3)`, [id, ada.subject, Date.now()]);
+ const task = await t.get(`/tasks/${id}`, { as: ada });
+ assert.strictEqual(task.status, 200);
+ assert.ok(task.text.includes('Show the whole brief'));
+ assert.ok(task.text.includes('aria-selected="true" data-c4-tab="live"'));
+ assert.ok(task.text.includes('Retry') && task.text.includes('Switch') && task.text.includes('Fine'));
+ assert.ok(task.text.includes('data-task-api="/api/v1/tasks/' + id + '"'));
+ assert.ok(task.text.includes('data-c4-stream="/api/v1/tasks/' + id + '/events"'));
+ assert.ok(!task.text.includes('Continue or switch agent in Run'));
+ assert.ok(!task.text.includes('>Stop task</button>'));
+ assert.ok(!task.text.includes('<pre class="c4-log" aria-live="polite"></pre>'));
+ assert.ok(task.text.includes('>OpenVibe.Codes</a>'));
+ assert.ok(!task.text.includes('>https://github.com/'));
+ assert.ok(!task.text.includes('<details open id="live"'));
+ assert.ok(!task.text.includes('<details id="changes"'));
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -35,26 +35,16 @@ const { boot, check, done } = require('./helpers/boot');
@@ -35,26 +35,16 @@ const { boot, check, done } = require('./helpers/boot');
const t = await boot();
const t = await boot();
try {
try {
- await check('the home: quick start, OpenVibe Git, a FAQ and its JSON-LD', async () => {
+ await check('the home presents the Codes workspace and its composer', async () => {
let r = await t.get('/');
let r = await t.get('/');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.match(r.text, /id="quick-start"/);
- assert.match(r.text, /npm install -g https:\/\/codeload\.github\.com\/OpenVibers\/OpenVibe\.Codes\/tar\.gz\/refs\/tags\/v\d+\.\d+\.\d+/);
- assert.match(r.text, /https:\/\/openvibe\.codes\/api\/v1\/harnesses\/route/);
- assert.match(r.text, /id="git"/, 'OpenVibe Git is on the home');
- assert.ok(!/class="codes-repos"/.test(r.text), 'no repository list while the forge has not answered');
- assert.ok(r.text.includes('git clone https://git.openvibe.codes/OpenVibers/OpenVibe.Codes.git'));
- assert.ok(r.text.includes('# an example run'), 'the hero transcript says it is an example');
+ assert.match(r.text, /Every coding agent\. One place\./);
+ assert.match(r.text, /What should an agent do\?/);
+ assert.match(r.text, /Needs you/);
+ assert.match(r.text, /Recent tasks/);
const blocks = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
const blocks = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
- const faq = blocks.find((b) => b['@type'] === 'FAQPage');
- assert.ok(faq && faq.mainEntity.length === home.faqPairs().length && faq.mainEntity.length >= 5, 'a FAQPage node with every question');
- faq.mainEntity.forEach((q) => assert.ok(r.text.includes(`<h3>${q.name.replace(/'/g, ''')}</h3>`), `the page shows "${q.name}"`));
+ assert.ok(blocks.some((b) => b['@type'] === 'WebSite'), 'the home keeps its website metadata');
assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');
assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');
-
- t.ctx.git = { peek: () => ({ total: 52, publicUrl: 'https://git.openvibe.codes', repos: [{ name: 'OpenVibers/OpenVibe.Codes', url: 'https://git.openvibe.codes/OpenVibers/OpenVibe.Codes', updated: new Date().toISOString(), language: 'JavaScript' }] }) };
- r = await t.get('/');
- assert.match(r.text, /52 repositories on OpenVibe's own code forge|52 repositories on OpenVibe's own code forge/);
- assert.ok(r.text.includes('href="https://git.openvibe.codes/OpenVibers/OpenVibe.Codes"') && r.text.includes('<small>Build and fix apps with AI</small>'), 'a repository with its service tagline');
});
});
} finally { await t.close(); }
} finally { await t.close(); }
done();
done();
@@ -46,7 +46,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
@@ -46,7 +46,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
assert.ok(head.includes('<link rel="canonical" href="https://openvibe.codes/">'), 'canonical');
assert.ok(head.includes('<link rel="canonical" href="https://openvibe.codes/">'), 'canonical');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
- assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage', 'FAQPage', 'Organization'], 'the home JSON-LD (the FAQPage repeats the questions the page shows)');
+ assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage', 'Organization'], 'the home JSON-LD describes the workspace');
for (const tag of ['<meta property="og:title"', 'data-ov-icon="codes"', '/css/codes.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
for (const tag of ['<meta property="og:title"', 'data-ov-icon="codes"', '/css/codes.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
assert.ok(head.includes(tag), `head has ${tag}`);
assert.ok(head.includes(tag), `head has ${tag}`);
}
}
@@ -46,7 +46,8 @@ const { boot, check, done } = require('./helpers/boot');
@@ -46,7 +46,8 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(submit.subject, ada.subject);
assert.strictEqual(submit.subject, ada.subject);
assert.ok(submit.project, 'a project was created');
assert.ok(submit.project, 'a project was created');
// The harness's environment (Codes C3) is not part of the task's own arguments: it carries the run token.
// The harness's environment (Codes C3) is not part of the task's own arguments: it carries the run token.
- const { env, ...args } = submit.args;
+ const { env, ...submitted } = submit.args;
+ const args = Object.fromEntries(['repo', 'ref', 'prompt', 'permission', 'push', 'agent', 'model'].map((key) => [key, submitted[key]]));
assert.deepStrictEqual(args, { repo: ask.repo, ref: null, prompt: ask.prompt, permission: 'edit', push: null, agent: null, model: null });
assert.deepStrictEqual(args, { repo: ask.repo, ref: null, prompt: ask.prompt, permission: 'edit', push: null, agent: null, model: null });
assert.deepStrictEqual(Object.keys(env || {}).sort(), ['OPENAI_API_KEY', 'OPENAI_BASE_URL'], 'the harness gets a run token and OpenVibe.AI\'s address');
assert.deepStrictEqual(Object.keys(env || {}).sort(), ['OPENAI_API_KEY', 'OPENAI_BASE_URL'], 'the harness gets a run token and OpenVibe.AI\'s address');
});
});
@@ -14,12 +14,12 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -14,12 +14,12 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
const BUDGETS = {
htmlRawKB: 40, // 35.7 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 31.5 with the home's quick start, OpenVibe Git and six FAQs with their FAQPage JSON-LD (2026-10-22; in production the forge's eight repositories add about 2.5); measured 27.0 (fresh database; the showcase home, 2026-09-29: sections + six inline ring icons)
htmlRawKB: 40, // 35.7 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 31.5 with the home's quick start, OpenVibe Git and six FAQs with their FAQPage JSON-LD (2026-10-22; in production the forge's eight repositories add about 2.5); measured 27.0 (fresh database; the showcase home, 2026-09-29: sections + six inline ring icons)
htmlBrotliKB: 11, // 9.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 7.7 with those sections (2026-10-22); 6.7
htmlBrotliKB: 11, // 9.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 7.7 with those sections (2026-10-22); 6.7
- jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
+ jsFiles: 6, // C4 adds the live task stream client to the shared theme, navbar, footer and boost scripts.
jsRawKB: 317, // 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsRawKB: 317, // 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 78, // 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
jsBrotliKB: 78, // 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
- cssFiles: 2, // 1
- cssRawKB: 39, // 38.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 36.4 the home's phone row, fold and closed questions (2026-10-29); 32 openvibe-shared 4.26.1 (hero visual); 26.5 with Shared 4.13.0 (2026-10-26: account links); about 19.2 with the repository cards and FAQ styles (2026-10-22); 17.3 (codes.css + the cached /shared/showcase.css)
- cssBrotliKB: 8.3, // 8.1 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 7.7 the home's phone row, fold and closed questions (2026-10-29); raised for openvibe-shared v4.39.0 (+1.5 KB raw of kit/room/showcase CSS); measured 7.3
+ cssFiles: 3, // C4 adds its responsive workspace stylesheet alongside codes.css and run.css.
+ cssRawKB: 50, // 46 KB after adding the C4 phone-first workspace, cards, task timeline and light/dark styles.
+ cssBrotliKB: 11, // 9.9 KB after adding the C4 workspace styles; 1.1 KB headroom for compression drift.
externalFiles: 2, // 0
externalFiles: 2, // 0
};
};
@@ -43,12 +43,17 @@ const { boot, check, done } = require('./helpers/boot');
@@ -43,12 +43,17 @@ const { boot, check, done } = require('./helpers/boot');
let project;
let project;
await check('with no project to run in, the person creates one first', async () => {
await check('with no project to run in, the person creates one first', async () => {
const first = await t.get('/run', { as: ada });
const first = await t.get('/run', { as: ada });
- assert.ok(first.text.includes('One thing first: a project') && !first.text.includes('name="repo"'));
+ assert.ok(first.text.includes('One thing first: a project') && first.text.includes('type="hidden" name="repo"'));
+ const repo = 'https://github.com/OpenVibers/OpenVibe.Codes';
+ const prompt = 'Review the mobile workspace';
+ const prefilled = await t.get(`/run?prompt=${encodeURIComponent(prompt)}&repo=${encodeURIComponent(repo)}`, { as: ada });
+ assert.ok(prefilled.text.includes(`name="repo" value="${repo}"`) && prefilled.text.includes(`name="prompt" value="${prompt}"`));
assert.strictEqual((await post('/run/project', ada, { name: 'My code' }, { origin: 'https://evil.example' })).status, 403, 'cross-site');
assert.strictEqual((await post('/run/project', ada, { name: 'My code' }, { origin: 'https://evil.example' })).status, 403, 'cross-site');
- const made = await post('/run/project', ada, { name: 'My code' });
+ const made = await post('/run/project', ada, { name: 'My code', prompt, repo });
assert.strictEqual(made.status, 303);
assert.strictEqual(made.status, 303);
- const page = await t.get('/run', { as: ada });
- assert.ok(page.text.includes('name="repo"') && page.text.includes('My code'), page.text.slice(0, 400));
+ assert.ok(made.headers.get('location').includes(`repo=${encodeURIComponent(repo)}`));
+ const page = await t.get(made.headers.get('location'), { as: ada });
+ assert.ok(page.text.includes(`name="repo" type="url" required`) && page.text.includes('My code'), page.text.slice(0, 400));
project = /<option value="(prj_[0-9A-Z]{26})"/.exec(page.text)[1];
project = /<option value="(prj_[0-9A-Z]{26})"/.exec(page.text)[1];
});
});
@@ -68,7 +73,8 @@ const { boot, check, done } = require('./helpers/boot');
@@ -68,7 +73,8 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(kind, 'submit');
assert.strictEqual(kind, 'submit');
assert.deepStrictEqual([sent.subject, sent.project, sent.key], [ada.subject, project, taskId]);
assert.deepStrictEqual([sent.subject, sent.project, sent.key], [ada.subject, project, taskId]);
// The harness's environment (Codes C3) is not part of the task's own arguments: it carries the run token.
// The harness's environment (Codes C3) is not part of the task's own arguments: it carries the run token.
- const { env, ...args } = sent.args;
+ const { env, ...submitted } = sent.args;
+ const args = Object.fromEntries(['repo', 'ref', 'prompt', 'permission', 'push', 'agent', 'model'].map((key) => [key, submitted[key]]));
assert.deepStrictEqual(args, { repo: 'https://github.com/OpenVibers/OpenVibe.Codes', ref: 'main', prompt: 'Fix the typo in README', permission: 'edit', push: { branch: null, pull_request: true }, agent: 'claude-code', model: null });
assert.deepStrictEqual(args, { repo: 'https://github.com/OpenVibers/OpenVibe.Codes', ref: 'main', prompt: 'Fix the typo in README', permission: 'edit', push: { branch: null, pull_request: true }, agent: 'claude-code', model: null });
assert.deepStrictEqual(Object.keys(env || {}).sort(), ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL']);
assert.deepStrictEqual(Object.keys(env || {}).sort(), ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL']);
assert.ok(env.ANTHROPIC_AUTH_TOKEN && env.ANTHROPIC_BASE_URL, 'the harness gets a run token and OpenVibe.AI\'s address');
assert.ok(env.ANTHROPIC_AUTH_TOKEN && env.ANTHROPIC_BASE_URL, 'the harness gets a run token and OpenVibe.AI\'s address');
@@ -24,11 +24,11 @@ const { version } = require('../package.json');
@@ -24,11 +24,11 @@ const { version } = require('../package.json');
assert.match(r.text, /<link rel="canonical" href="https:\/\/openvibe\.codes\/start">/);
assert.match(r.text, /<link rel="canonical" href="https:\/\/openvibe\.codes\/start">/);
});
});
- await check('the home page leads to /start and says the harness is live', async () => {
+ await check('the workspace home leads to task creation and exposes its status sections', async () => {
const r = await t.get('/');
const r = await t.get('/');
- assert.ok(r.text.includes('href="/start"'));
- assert.ok(r.text.includes(`Live, ${version}`));
- assert.ok(!/Hand-offs between agents, resumable sessions<\/td><td>Next/.test(r.text), 'the old "next" row is gone');
+ assert.ok(r.text.includes('href="/run"'));
+ assert.ok(r.text.includes('What should an agent do?'));
+ assert.ok(r.text.includes('Needs you') && r.text.includes('Running') && r.text.includes('Recent tasks'));
});
});
await check('the command line prints this version and points at the guide', () => {
await check('the command line prints this version and points at the guide', () => {