openvibers/openvibe.codes Commits
Guided setup (/setup), and agent pickers that know what is set up (#270)
Changes
@@@ -60,6 +60,7 @@"the conductor (C9b): one Claude Code session on the person's own machine, driven from the browser or the phone, with its approvals","the job runner (CLI 0.6.0): each turn at the effort a person picks, in each agent's own words, and a conversation's next turn starting from the work before it (the last diff applied with git apply --3way, and the conversation so far), on any agent","the job runner (CLI 0.6.1): \"edit\" means edits on the person's own computer (OpenCode and Command Code run unattended only in a Node sandbox or for full; Aider suggests no shell commands there), and a private repository clones with a token the machine holds (GITHUB_TOKEN, GITLAB_TOKEN, OPENVIBE_GIT_CREDENTIALS) sent as a header, never in the URL or .git/config",++ "The guided setup (/setup, owner 2026-11-03): one page of five steps read live: your machine (paired, online), your agents (installed, signed in, usage; the install and sign-in commands for the ones missing), your AI keys on Your AI (optional), OpenVibe Git (optional), your choices (default agent and machine, the conductor's fallbacks, safety); the home says how far along until it is done; every agent picker (Settings, the setup, the composer) says what each agent is ready for, drawn by Shared's select (data-state, data-badge, data-desc, a setup link)","The conductor's failover (CLI 0.7.0, owner 2026-11-03): Settings \u2192 Conductor picks up to three fallback agents (Codex, OpenCode) with a model each and a switch point (90% of a usage window by default); before each turn the conductor's own agent and the fallbacks are read against the machine's usage windows, the turn runs on the first with room and returns to the primary once its window frees; each agent resumes its own session and the one taking over is told what happened while it was away; the conductor's page shows each agent's usage and which takes the next turn","OpenVibe Git through the person's own account (/git, CLI 0.6.2): link once (the forge's OAuth2 consent with PKCE, tokens sealed with CODES_SEAL_KEY), make a repository in your name and start a chat on it; a task on a forge repository takes the person's credential line to their machine for git only (never the agent), and the branch and its pull request open on OpenVibe Git"],@@@ -163,3 +163,10 @@ html[data-ov-motion=reduced] .ch-spark-list[aria-hidden],html[data-ov-motion=red.ch .ch-usage-note{margin:8px 0 0;padding:8px 10px;border-radius:8px;background:var(--ch-hover);color:var(--ch-soft);font-size:12px;line-height:1.45}.ch .ch-usage-foot{margin:8px 0 0;color:var(--ch-muted);font-size:12px;line-height:1.45}.ch .ch-usage-foot a{color:var(--ch-accent)}++/* The home's line to the guided setup (2026-11-03): how far along, and what is next. */++.ch .ch-setup-nudge{display:inline-flex;align-items:center;gap:10px;margin-top:10px;padding:9px 12px 9px 14px;min-height:44px;border:1px solid var(--ch-line);border-radius:999px;background:var(--ch-card);color:var(--ch-soft);font-size:13px;text-decoration:none;max-width:100%}++.ch .ch-setup-nudge b{color:var(--ch-ink)}++.ch .ch-setup-nudge:hover{border-color:color-mix(in srgb,var(--ch-accent) 50%,var(--ch-line))}++.ch .ch-setup-nudge-bar{flex:none;width:42px;height:6px;border-radius:3px;background:var(--ch-hover);overflow:hidden}++.ch .ch-setup-nudge-bar>span{display:block;height:100%;border-radius:3px;background:var(--ch-accent)}++.ch .ch-setup-nudge-go{flex:none;width:16px;height:16px;transform:rotate(-90deg)}nnew file mode 100644@@@ -0,0 +1,49 @@++/* Codes' guided setup (/setup, 2026-11-03): the progress, the step chips, and each step's card. Loaded on /setup only. */++.c4-setup{display:grid;gap:0}++.c4-setup-head{display:grid;gap:12px;margin:0 0 16px}++.c4-setup-head .c4-title{margin:0}++.c4-setup-progress{display:flex;align-items:center;gap:12px}++.c4-setup-bar{flex:1;max-width:420px;height:8px;border-radius:99px;background:var(--c4-quiet-bg);overflow:hidden}++.c4-setup-bar>span{display:block;height:100%;border-radius:99px;background:var(--c4-accent);transition:width .4s cubic-bezier(.2,.8,.2,1)}++.c4-setup-count{color:var(--c4-muted);font-size:13px;font-weight:600;white-space:nowrap}++.c4-setup-nav{display:flex;gap:8px;margin:0;padding:2px 0 4px;list-style:none;overflow-x:auto;scrollbar-width:none}++.c4-setup-nav::-webkit-scrollbar{display:none}++.c4-setup-chip{display:inline-flex;align-items:center;gap:8px;min-height:40px;padding:0 14px 0 6px;border:1px solid var(--c4-line);border-radius:999px;background:var(--c4-surface);color:var(--c4-ink);font-size:13px;font-weight:600;text-decoration:none;white-space:nowrap}++.c4-setup-chip-n{display:grid;place-items:center;width:28px;height:28px;border-radius:50%;background:var(--c4-quiet-bg);color:var(--c4-muted);font-size:12px;font-weight:700}++.c4-setup-chip-n svg,.c4-setup-n svg{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:2.6;stroke-linecap:round;stroke-linejoin:round}++.c4-setup-chip.is-done .c4-setup-chip-n{background:var(--c4-ok-bg);color:var(--c4-ok-ink)}++.c4-setup-chip.is-next{border-color:var(--c4-accent);box-shadow:0 0 0 1px var(--c4-accent)}++.c4-setup-chip.is-next .c4-setup-chip-n{background:var(--c4-accent);color:var(--c4-on-accent)}++.c4-setup-step{scroll-margin-top:84px}++.c4-setup-step.is-next{border-color:var(--c4-accent);box-shadow:0 0 0 1px var(--c4-accent),0 8px 24px -14px var(--c4-accent)}++.c4-setup-step-head{display:grid;grid-template-columns:40px minmax(0,1fr) auto;gap:14px;align-items:start}++.c4-setup-n{display:grid;place-items:center;width:40px;height:40px;border-radius:50%;background:var(--c4-quiet-bg);color:var(--c4-muted);font-size:16px;font-weight:750}++.c4-setup-step.is-next .c4-setup-n{background:var(--c4-accent);color:var(--c4-on-accent)}++.c4-setup-step.is-done .c4-setup-n{background:var(--c4-ok-bg);color:var(--c4-ok-ink)}++.c4-setup-step-title h2{margin:0;font-size:17px;letter-spacing:-.01em}++.c4-setup-step-title p{margin:4px 0 0;color:var(--c4-muted);font-size:13px;line-height:1.45}++.c4-setup-body{margin:14px 0 0 54px;display:grid;gap:12px;min-width:0}++.c4-setup-body .c4-hint{margin:0}++.c4-setup-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}++.c4-setup-how{margin:0;padding-left:20px;display:grid;gap:10px;font-size:13.5px;line-height:1.5}++.c4-setup-how a{color:var(--c4-accent)}++.c4-setup-cmd{display:grid;gap:4px;margin-top:6px;min-width:0}++.c4-setup-cmd-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--c4-muted)}++.c4-setup-cmd code{display:block;padding:10px 12px;border-radius:9px;background:var(--c4-ghost);border:1px solid var(--c4-line);font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--c4-ink);overflow-x:auto;white-space:pre;user-select:all;-webkit-user-select:all}++.c4-setup-cmd code:focus-visible{outline:2px solid var(--c4-accent);outline-offset:2px}++.c4-setup-agent{scroll-margin-top:84px}++.c4-setup-agent:target{background:var(--c4-open-bg);border-radius:10px}++.c4-setup-agent-help{grid-column:1/-1;display:grid;gap:4px;padding:2px 0 4px 20px;min-width:0}++.c4-setup-more summary{cursor:pointer;color:var(--c4-accent);font-size:13px;min-height:32px;display:flex;align-items:center}++.c4-setup-ready{border-color:var(--c4-dot-ok);background:linear-gradient(135deg,var(--c4-ok-bg),var(--c4-surface) 60%)}++.c4-setup-ready h2{margin:0 0 4px;font-size:19px}++.c4-setup-ready p{margin:0 0 12px;color:var(--c4-muted)}++.c4-setup-link{margin:0 0 14px}++@media (max-width:640px){++ .c4-setup-step-head{grid-template-columns:34px minmax(0,1fr);gap:12px}++ .c4-setup-step-head>.c4-pill{grid-column:2;justify-self:start}++ .c4-setup-n{width:34px;height:34px;font-size:14px}++ .c4-setup-body{margin-left:0}++ .c4-setup-agent-help{padding-left:0}++}++@media (prefers-reduced-motion:reduce){.c4-setup-bar>span{transition:none}}nnew file mode 100644@@@ -0,0 +1,61 @@++'use strict';++++/**++ * What a picker says about each agent (owner 2026-11-03: the selects "should be aware of what the user has set up"):++ * from the fleet's rows (fleet.agents), one state, a short badge, a line and, when the agent is not ready, where to make++ * it ready. Shared's ov-forms.js draws them on an <option> (data-state, data-badge, data-desc, data-href); without it++ * the badge rides in the option's own words.++ *++ * readiness(rows) → Map agent → { state, badge, desc, href, hrefLabel, ready }++ * optionAttrs(info) → ' data-state="…" data-badge="…" …' (escaped)++ * SETUP → how each plan agent is installed and signed in, for the guided setup++ */++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++/**++ * The plan agents a machine can sign in to, and the two commands that make one ready. Only commands each CLI documents++ * itself; an agent with none here says to follow its own guide.++ */++const SETUP = {++ 'claude-code': { name: 'Claude Code', plan: 'a Claude Pro or Max plan', install: 'npm install -g @anthropic-ai/claude-code', signIn: 'claude', signInNote: 'and sign in when it asks', docs: 'https://docs.claude.com/en/docs/claude-code/setup' },++ codex: { name: 'Codex', plan: 'a ChatGPT Plus or Pro plan', install: 'npm install -g @openai/codex', signIn: 'codex login', docs: 'https://github.com/openai/codex' },++ opencode: { name: 'OpenCode', plan: 'OpenCode Go, or your own keys', install: 'npm install -g opencode-ai', signIn: 'opencode auth login', docs: 'https://opencode.ai/docs' },++ 'command-code': { name: 'Command Code', plan: 'a Command Code plan', install: null, signIn: null, docs: 'https://commandcode.ai' },++ 'minimax-code': { name: 'MiniMax Code', plan: 'a MiniMax coding plan', install: null, signIn: null, docs: 'https://www.minimax.io' },++};++++function readiness(rows) {++ const out = new Map();++ for (const row of rows || []) {++ if (!row || !row.agent) continue;++ const used = Number.isFinite(Number(row.used)) ? Math.round(Number(row.used)) : 0;++ const where = (row.machines || []).filter((m) => m && m.room).map((m) => m.name || m.machine).filter(Boolean).slice(0, 2).join(', ');++ const setupHref = `/setup#agent-${row.agent}`;++ let info;++ switch (row.state) {++ case 'ok': info = { state: 'ok', badge: used ? `${used}% used` : 'Ready', desc: where ? `Signed in on ${where}` : 'Signed in on your machine', ready: true }; break;++ case 'low': info = { state: 'warn', badge: `${used}% used`, desc: 'Nearly at its limit', ready: true }; break;++ case 'limited': info = { state: 'bad', badge: 'Full', desc: 'Its usage window is spent for now', ready: false }; break;++ case 'empty': info = { state: 'bad', badge: 'Out of credit', desc: 'Its balance is nearly gone', ready: false }; break;++ case 'login': info = { state: 'bad', badge: 'Sign in', desc: 'Installed, not signed in', href: setupHref, hrefLabel: 'Sign in', ready: false }; break;++ case 'absent': info = { state: 'off', badge: 'Not installed', desc: 'Not on your machine yet', href: setupHref, hrefLabel: 'Set up', ready: false }; break;++ default: info = { state: 'off', badge: 'No reading', desc: 'Your machine has not reported it', ready: false };++ }++ out.set(row.agent, info);++ }++ return out;++}++++/** The data-* attributes an <option> carries for ov-forms.js. */++function optionAttrs(info) {++ if (!info) return '';++ return [++ info.state ? ` data-state="${esc(info.state)}"` : '',++ info.badge ? ` data-badge="${esc(info.badge)}"` : '',++ info.desc ? ` data-desc="${esc(info.desc)}"` : '',++ info.href ? ` data-href="${esc(info.href)}" data-href-label="${esc(info.hrefLabel || 'Set up')}"` : '',++ ].join('');++}++++module.exports = { readiness, optionAttrs, SETUP };@@@ -29,6 +29,7 @@ const MODELS = [['opus', 'Opus 5.5 (default)'], ['sonnet', 'Sonnet 5.5'], ['haikconst { CONDUCTOR_AGENTS, AGENT_NAMES: failoverNames } = require('../domain/conductor-failover');/** The agents a conductor may fall back to: every conductor agent but Claude Code, which is its own. */const CONDUCTOR_FALLBACKS = CONDUCTOR_AGENTS.filter((a) => a !== 'claude-code');++const { readiness: agentReadiness, optionAttrs } = require('../domain/readiness');const modelLabel = (m) => ({ opus: 'Opus 5.5', sonnet: 'Sonnet 5.5', haiku: 'Haiku 5.5' }[m] || m || 'Opus 5.5');const age = (value) => {const numeric = Number(value);@@@ -90,12 +91,14 @@ function createC4Routes(ctx) {const db = store.db;const form = express.urlencoded({ extended: false, limit: '16kb' });const page = (req, res, title, body, extra = {}) => {++ // A page may add its own stylesheet (the guided setup's), so c4.css stays the size every page pays for.++ const css = ['css/c4.css', ...(Array.isArray(extra.css) ? extra.css : [])];if (viewer(req) && ctx.chatShell && !extra.index) {const here = '/' + (String(req.path || '/').split('/')[1] || '');-- return ctx.chatShell(req, res, { title: title || 'Codes', body: String(body), css: ['css/c4.css'], scripts: ['js/c4.js'], here });++ return ctx.chatShell(req, res, { title: title || 'Codes', body: String(body), css, scripts: ['js/c4.js'], here });}const wantsScript = req.path === '/' || /^\/tasks\/tsk_/.test(req.path) || /^\/(agents|machines|conductor)/.test(req.path);-- return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), css: ['css/c4.css'], scripts: wantsScript ? ['js/c4.js'] : [], body: raw(body), ...extra });++ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), scripts: wantsScript ? ['js/c4.js'] : [], body: raw(body), ...extra, css });};const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;/**@@@ -756,7 +759,10 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-const settings = viewer(req) ? await userSettings.get(req.viewer.subject) : null;const mine = viewer(req) ? await machinesOf(req, res) : { machines: [] };const used = viewer(req) ? await lastUsedMachines(req) : [];-- const agentOptions = (harnesses?.list?.() || []).map((a) => `<option value="${esc(a.id)}"${settings && settings.default_agent === a.id ? ' selected' : ''}>${esc(a.name)}</option>`).join('');++ // Each agent says what it is ready for (domain/readiness.js), drawn by Shared's select as a badge and a line.++ const fleetRows = viewer(req) && fleet ? await fleet.agents(req.viewer.subject, harnesses).catch(() => []) : [];++ const aware = agentReadiness(fleetRows);++ const agentOptions = (harnesses?.list?.() || []).map((a) => `<option value="${esc(a.id)}"${settings && settings.default_agent === a.id ? ' selected' : ''}${optionAttrs(aware.get(a.id))}>${esc(a.name)}</option>`).join('');// The same order the Home picker shows: the machines their work ran on last come first.const machineOptions = orderMachines((mine.machines || []).filter((m) => m.state !== 'revoked'), used).map((m) => machineOption(m, settings)).join('');// Settings → Limits (C9a): steps and minutes per task, for every agent at once; No limit means none.@@@ -767,18 +773,10 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-};// Settings → Conductor (the failover): the agents a conductor can fall back to, each said with what the person's// machines report about it (ready, how full, signed out), so a pick that cannot run is visible before it is made.-- const fleetRows = viewer(req) && fleet ? await fleet.agents(req.viewer.subject, harnesses).catch(() => []) : [];-- const fleetBy = new Map(fleetRows.map((r) => [r.agent, r]));-- const readiness = (agent) => {-- const r = fleetBy.get(agent);-- if (!r) return 'not reported';-- const [word] = FLEET_STATE[r.state] || FLEET_STATE.unknown;-- return r.used ? `${word.toLowerCase()}, ${Math.round(r.used)}% used` : word.toLowerCase();-- };const fallbacks = (settings && settings.conductor_fallbacks) || [];const fallbackRow = (i) => {const f = fallbacks[i] || { agent: '', model: '' };-- const options = CONDUCTOR_FALLBACKS.map((a) => `<option value="${a}"${f.agent === a ? ' selected' : ''} data-state="${esc((fleetBy.get(a) || {}).state || 'unknown')}">${esc(failoverNames[a])} — ${esc(readiness(a))}</option>`).join('');++ const options = CONDUCTOR_FALLBACKS.map((a) => `<option value="${a}"${f.agent === a ? ' selected' : ''}${optionAttrs(aware.get(a))}>${esc(failoverNames[a])}</option>`).join('');return `<div class="c4-fallback"><span class="c4-fallback-n" aria-hidden="true">${i + 1}</span><label>Fallback ${i + 1} <select name="fallback_agent_${i + 1}"><option value="">None</option>${options}</select></label><label>Model <input name="fallback_model_${i + 1}" value="${esc(f.model || '')}" placeholder="its default" autocomplete="off" spellcheck="false" maxlength="100"></label></div>`;};const switchPct = (settings && settings.conductor_switch_pct) || 90;@@@ -787,7 +785,7 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-<div class="c4-fallbacks">${[0, 1, 2].map(fallbackRow).join('')}</div><div class="c4-form-grid"><label>Switch at <select name="conductor_switch_pct">${[70, 80, 85, 90, 95].map((v) => `<option value="${v}"${switchPct === v ? ' selected' : ''}>${v}% of a window</option>`).join('')}</select></label></div><p class="c4-hint">Codex and OpenCode have no approval prompt: on "Ask first" they work with edits in the folder only. Your machine needs OpenVibe Node with Codes 0.7 or newer to switch.</p>` : '';-- const content = `${header('Settings', 'Your choices, remembered for your account.')}${viewer(req) ? `${flash(req)}<section class="c4-card"><form class="c4-form" method="post" action="/settings">++ const content = `${header('Settings', 'Your choices, remembered for your account.')}${viewer(req) ? `${flash(req)}<p class="c4-hint c4-setup-link">New here, or something not working? <a href="/setup">The guided setup</a> checks your machine, agents and keys step by step.</p><section class="c4-card"><form class="c4-form" method="post" action="/settings"><div class="c4-form-grid"><label>Default agent <select name="default_agent"><option value="">Auto</option>${agentOptions}</select></label><label>Default machine <select name="default_machine"><option value="">Auto</option>${machineOptions}</select></label>@@@ -846,6 +844,15 @@ ${conductorSection}// module and borrow this one's kit (the page, the frame, the card, the pill, the flash and the same-origin check).r.use(require('./memory').createMemoryRoutes(ctx, { page, wrap, header, pill, flash, sameOrigin, viewer, form, signIn, note }));++ // The guided setup (/setup): machine, agents, keys, accounts and choices, each step read live. Its home-page card++ // reads the database only, through ctx.setupProgress (the chat app's home asks it).++ const setupRoutes = require('./setup').createSetupRoutes(ctx, {++ page: (req, res, title, body) => page(req, res, title, body, { css: ['css/setup.css'] }),++ wrap, header, pill, flash, sameOrigin, viewer, form, signIn, note, machinesOf, usageMeter, SAFETY_OPTIONS, YOUR_AI, PROVIDER_NAMES,++ });++ r.use(setupRoutes);++ ctx.setupProgress = setupRoutes.setupProgress;++// The front door a visitor gets at /, as a page for the layout: the Markdown twin of / is this page// (server/agent-pages.js), so the twin says what a visitor reads (SEO-1; it was the old marketing home).r.visitorHome = () => ({ css: ['css/c4.css'], scripts: ['js/c4.js'], body: raw(wrap(visitorHome())), index: true, agent: true, styles: [showcase.STYLESHEET], jsonLd: homeJsonLd(config) });@@@ -23,6 +23,7 @@ const { sessionLogOf } = require('../domain/run-tasks');const models = require('../data/models');const kit = require('openvibe-shared/kit');const thread = require('openvibe-shared/thread');++const { readiness, optionAttrs } = require('../domain/readiness');const start = require('./start');const LIVE = ['queued', 'placed', 'running'];@@@ -129,7 +130,9 @@ const repoLabel = (repo) => { try { return new URL(String(repo)).pathname.split(function createChatRoutes(ctx) {const r = asyncRouter();-- const { config, store, harnesses, tasks, conductor, approvals, userSettings, github, machines } = ctx;++ const { config, store, harnesses, tasks, conductor, approvals, userSettings, github, machines, fleet } = ctx;++ /** What each agent is ready for on the person's machines (the stored fleet: no call out), for the pickers. */++ const readyFor = async (req) => (fleet && viewer(req) ? readiness(await fleet.agents(req.viewer.subject, harnesses).catch(() => [])) : new Map());const db = store.db;const form = express.urlencoded({ extended: false, limit: '32kb' });const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;@@@ -341,19 +344,20 @@ function createChatRoutes(ctx) {}// ── The composer ─────────────────────────────────────────────────────────────────────────────────────────────-- function whoPicker(selected, { label = 'Who works on it', auto = null, cls = '' } = {}) {++ function whoPicker(selected, { label = 'Who works on it', auto = null, cls = '', ready = null } = {}) {const all = models.choices(harnesses);const groups = new Map();for (const c of all.slice(1)) { if (!groups.has(c.group)) groups.set(c.group, []); groups.get(c.group).push(c); }-- const opt = (c) => `<option value="${esc(c.value)}"${selected === c.value ? ' selected' : ''}${c.note ? ` title="${esc(c.note)}"` : ''}>${esc(c.label)}</option>`;++ // Each agent and model says what it is ready for on the person's machines (Shared's select draws it).++ const opt = (c) => `<option value="${esc(c.value)}"${selected === c.value ? ' selected' : ''}${c.note ? ` title="${esc(c.note)}"` : ''}${ready ? optionAttrs(ready.get(String(c.value).split('|')[0])) : ''}>${esc(c.label)}</option>`;const first = auto ? `<option value="">${esc(auto)}</option>` : opt(all[0]);-- return `<label class="ch-pick${cls ? ` ${cls}` : ''}" title="${esc(label)}"><span class="ch-sr">${esc(label)}</span>${ico('agents')}<select name="who" data-ch-who>${first}${[...groups].map(([g, list]) => list.length > 1 ? `<optgroup label="${esc(g)}">${list.map(opt).join('')}</optgroup>` : opt(list[0])).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;++ return `<label class="ch-pick${cls ? ` ${cls}` : ''}" title="${esc(label)}"><span class="ch-sr">${esc(label)}</span>${ico('agents')}<select name="who" data-ch-who data-ov-select="bare" data-ov-title="${esc(label)}">${first}${[...groups].map(([g, list]) => list.length > 1 ? `<optgroup label="${esc(g)}">${list.map(opt).join('')}</optgroup>` : opt(list[0])).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;}// The effort in plain words (the shared ladder underneath; each agent gets the nearest level it accepts).const EFFORT = [['', 'Effort: auto'], ['low', 'Quick'], ['medium', 'Balanced'], ['high', 'Thorough'], ['max', 'Deepest']];const effortWord = (v) => (EFFORT.find(([k]) => k === v) || [null, v])[1];function effortPicker(selected = '', cls = '') {-- return `<label class="ch-pick${cls ? ` ${cls}` : ''}" title="How hard it thinks"><span class="ch-sr">Effort</span>${ico('sub')}<select name="effort" data-ch-effort>${EFFORT.map(([v, l]) => `<option value="${v}"${selected === v ? ' selected' : ''}>${l}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;++ return `<label class="ch-pick${cls ? ` ${cls}` : ''}" title="How hard it thinks"><span class="ch-sr">Effort</span>${ico('sub')}<select name="effort" data-ch-effort data-ov-select="bare" data-ov-title="Effort">${EFFORT.map(([v, l]) => `<option value="${v}"${selected === v ? ' selected' : ''}>${l}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;}/** The kit's composer with Codes' pickers in its tools, where the work runs under the bar, and its JSON route. */function composer({ action, api = '', placeholder, pickers = '', live = false, stopAction = '', note = '', id = null, where = '' }) {@@@ -472,11 +476,12 @@ function createChatRoutes(ctx) {const modes = `<fieldset class="ch-modes"><legend class="ch-sr">What are you making?</legend>${MODE_CARDS.map(([v, icon, title, line]) => `<label class="ch-mode"><input type="radio" name="mode" value="${v}" form="ch-start"${mode === v ? ' checked' : ''} data-ch-mode><span class="ch-mode-i">${ico(icon)}</span><b>${esc(title)}</b><small>${esc(line)}</small></label>`).join('')}</fieldset>`;// The pickers: each shows for the way it belongs to (CSS reads the checked card).-- const kindPick = `<label class="ch-pick ch-for-new" title="What it is"><span class="ch-sr">What it is</span>${ico('web')}<select name="kind" data-ch-kind>${Object.entries(start.KINDS).map(([v, k]) => `<option value="${v}"${kind === v ? ' selected' : ''}>${esc(k.label)}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;++ const kindPick = `<label class="ch-pick ch-for-new" title="What it is"><span class="ch-sr">What it is</span>${ico('web')}<select name="kind" data-ch-kind data-ov-select="bare" data-ov-title="What it is">${Object.entries(start.KINDS).map(([v, k]) => `<option value="${v}"${kind === v ? ' selected' : ''}>${esc(k.label)}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;const groups = [...new Set(start.PROJECTS.map((p) => p.group))];-- const projectPick = `<label class="ch-pick ch-for-openvibe" title="Which OpenVibe project"><span class="ch-sr">Which OpenVibe project</span>${ico('ov')}<select name="project" data-ch-project><option value="auto"${chosenProject === 'auto' ? ' selected' : ''}>Codes picks for you</option>${groups.map((g) => `<optgroup label="${esc(g)}">${start.PROJECTS.filter((p) => p.group === g).map((p) => `<option value="${esc(p.repository)}"${chosenProject === p.repository ? ' selected' : ''}${p.line ? ` title="${esc(p.line)}"` : ''}>${esc(p.name)}${p.domain ? ` · ${esc(p.domain)}` : ''}</option>`).join('')}</optgroup>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;++ const projectPick = `<label class="ch-pick ch-for-openvibe" title="Which OpenVibe project"><span class="ch-sr">Which OpenVibe project</span>${ico('ov')}<select name="project" data-ch-project data-ov-select="bare" data-ov-title="Which OpenVibe project"><option value="auto"${chosenProject === 'auto' ? ' selected' : ''}>Codes picks for you</option>${groups.map((g) => `<optgroup label="${esc(g)}">${start.PROJECTS.filter((p) => p.group === g).map((p) => `<option value="${esc(p.repository)}"${chosenProject === p.repository ? ' selected' : ''}${p.line ? ` title="${esc(p.line)}"` : ''}>${esc(p.name)}${p.domain ? ` · ${esc(p.domain)}` : ''}</option>`).join('')}</optgroup>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;const repoPick = `<label class="ch-pick ch-pick-repo ch-for-code" title="Repository"><span class="ch-sr">Repository: owner/name or any Git URL</span>${ico('repo')}<input name="repo" type="text" inputmode="url" spellcheck="false" autocapitalize="off" value="${esc(repo)}" placeholder="owner/name or any Git URL" list="ch-repos" autocomplete="off" data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>`;-- const pickers = kindPick + projectPick + repoPick + whoPicker(settings.default_agent ? `${settings.default_agent}|` : '', { cls: 'ch-for-agents' }) + effortPicker('', 'ch-for-agents');++ const ready = await readyFor(req);++ const pickers = kindPick + projectPick + repoPick + whoPicker(settings.default_agent ? `${settings.default_agent}|` : '', { cls: 'ch-for-agents', ready }) + effortPicker('', 'ch-for-agents');// The repositories a person used last are a tap away (the box also searches every one they can reach).const newRepo = `<a class="ch-chip ch-chip-new" href="/git">${ico('new')}New on OpenVibe Git</a>`;const recent = `<div class="ch-recent ch-for-code" role="group" aria-label="Your repositories">${newRepo}${repos.length > 1 ? repos.map((x) => `<button class="ch-chip" type="button" data-ch-saved-repo="${esc(x.url)}">${ico('repo')}${esc(x.full_name)}</button>`).join('') : ''}</div>`;@@@ -491,7 +496,10 @@ function createChatRoutes(ctx) {const rail = (list) => `<ul class="ch-spark-list">${list.map((i) => card(i, false)).join('')}</ul><ul class="ch-spark-list" aria-hidden="true">${list.map((i) => card(i, true)).join('')}</ul>`;const ideas = `<section class="ch-spark" aria-label="Ideas to start from" data-ch-ideas data-ch-ideasets="${esc(JSON.stringify(sets))}"><div class="ch-spark-view"><div class="ch-spark-track" data-ch-ideas-track>${rail(sets[mode])}</div></div><template data-ch-idea-icons>${['web', 'game', 'app', 'ov', 'spark'].map((n) => ico(n).replace('<svg ', `<svg data-icon="${n}" `)).join('')}</template><button class="ch-icon-btn ch-spark-pause" type="button" data-ch-ideas-pause aria-pressed="false" aria-label="Pause the ideas" title="Pause the ideas">${ico('pause', 'ch-i-pause')}${ico('play', 'ch-i-play')}</button></section>`;-- const hello = `<div class="ch-hello"><span class="ov-mark" data-size="56" aria-hidden="true"></span><h1>${name ? `What should we build, ${esc(name)}?` : 'What should we build?'}</h1><p>Say it the way you would to a friend. Pick what it is, and Codes sends it to the right place.</p>${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}${modes}</div>`;++ // Not set up yet: one line to the guided setup, saying how far along the person is (database reads only).++ const progress = ctx.setupProgress ? await ctx.setupProgress(req).catch(() => null) : null;++ const nudge = progress ? `<a class="ch-setup-nudge" href="/setup"><span class="ch-setup-nudge-bar" aria-hidden="true"><span style="width:${Math.round((progress.done / progress.of) * 100)}%"></span></span><span><b>Finish setting up</b> · ${progress.done} of ${progress.of} done · next: ${esc(progress.next)}</span>${ico('chevron', 'ch-setup-nudge-go')}</a>` : '';++ const hello = `<div class="ch-hello"><span class="ov-mark" data-size="56" aria-hidden="true"></span><h1>${name ? `What should we build, ${esc(name)}?` : 'What should we build?'}</h1><p>Say it the way you would to a friend. Pick what it is, and Codes sends it to the right place.</p>${nudge}${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}${modes}</div>`;return frame(req, res, {title: 'New chat', heading: 'New chat', start: true, empty: hello,composerHtml: composer({ action: '/chat', id: 'ch-start', placeholder: mode === 'new' ? 'Describe your website, game or app' : 'Describe the change, question or fix', pickers, where }) + ideas,@@@ -596,7 +604,7 @@ function createChatRoutes(ctx) {// The stream starts after the last line this page already shows.const seqRow = live ? await db.maybe('SELECT max(seq) AS seq FROM codes_session_lines WHERE task_id = $1', [row.id]) : null;const lastSeq = seqRow && seqRow.seq != null ? Number(seqRow.seq) : 0;-- const pickers = whoPicker('', { label: 'Who carries on', auto: `Same agent (${agentOf(row.agent, agentNames()).name})` }) + effortPicker('');++ const pickers = whoPicker('', { label: 'Who carries on', auto: `Same agent (${agentOf(row.agent, agentNames()).name})`, ready: await readyFor(req) }) + effortPicker('');return frame(req, res, {currentId: row.id, title: titleOf(row), heading: titleOf(row), state, panel: await sideHtml(row),turns: [...errorTurns, ...await taskThread(row)],nnew file mode 100644@@@ -0,0 +1,229 @@++'use strict';++++/**++ * The guided setup (owner 2026-11-03: "a guided setup which is intuitive and very user friendly for the user to configure++ * their API keys, accounts, harnesses, etc."): /setup, one page of five steps, each read live from where it lives —++ *++ * 1 Your machine paired and online (Network, with the person's token)++ * 2 Your agents installed and signed in on it, with what each has left (the fleet, read from Bot now)++ * 3 Your AI keys optional: keys on Your AI (OpenVibe.AI) for the agents and models paid by use++ * 4 Your accounts optional: OpenVibe Git, linked once++ * 5 Your choices the default agent and machine, the conductor's fallbacks and its switch point, the safety level++ *++ * Nothing is stored about the setup itself: a step is done when the thing it sets up is there, so the page is right++ * however the person got there. Every step is on the page at once (nothing folded away); the first that needs the++ * person is marked Next. The pickers say what each choice is ready for (domain/readiness.js), and Shared's ov-forms.js++ * draws them as OpenVibe's own select.++ *++ * GET /setup the page++ * POST /setup/choices step 5's form → codes_user_settings, back to /setup#step-choices++ * setupProgress(req) what the home page's "Finish setting up" card says (database reads only)++ */++++const { asyncRouter } = require('./router');++const { esc } = require('../render/html');++const { readiness, optionAttrs, SETUP } = require('../domain/readiness');++const { CONDUCTOR_AGENTS, AGENT_NAMES } = require('../domain/conductor-failover');++++const CHECK = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>';++++function createSetupRoutes(ctx, kit) {++ const { page, wrap, pill, flash, sameOrigin, viewer, form, signIn, note, machinesOf, usageMeter, SAFETY_OPTIONS = [], YOUR_AI, PROVIDER_NAMES = {} } = kit;++ const { store, fleet, harnesses, connections, sso, forge, userSettings } = ctx;++ const db = store.db;++ const r = asyncRouter();++++ const command = (text, label) => `<div class="c4-setup-cmd"><span class="c4-setup-cmd-label">${esc(label)}</span><code tabindex="0">${esc(text)}</code></div>`;++++ /** Everything the page says, read once per view. */++ async function readAll(req, res) {++ const subject = req.viewer.subject;++ const machineList = await machinesOf(req, res);++ let fleetProblem = null;++ if (fleet) {++ try {++ const out = await sso.asViewer(req, res, (token) => fleet.refresh(subject, token));++ if (out && out.problem) fleetProblem = out.problem;++ } catch (e) { fleetProblem = { detail: String((e && e.message) || 'the fleet could not be read').slice(0, 200) }; }++ }++ const rows = fleet ? await fleet.agents(subject, harnesses).catch(() => []) : [];++ let keys = null;++ try { keys = connections ? await sso.asViewer(req, res, (token) => connections.mine(token)) : null; } catch { keys = null; }++ let git = null;++ try { git = forge && forge.enabled ? await forge.link(subject) : null; } catch { git = null; }++ const settings = await userSettings.get(subject);++ const saved = !!(await db.maybe('SELECT 1 AS one FROM codes_user_settings WHERE subject = $1', [subject]));++ return { machines: machineList, rows, fleetProblem, keys, git, settings, saved };++ }++++ function stepsOf(d) {++ const live = (d.machines.machines || []).filter((m) => m.state !== 'revoked');++ const online = live.filter((m) => m.state === 'online');++ const ready = d.rows.filter((x) => x.state === 'ok' || x.state === 'low');++ const credentials = (d.keys && d.keys.credentials) || [];++ return [++ { id: 'machine', title: 'Your machine', short: 'Machine', done: online.length > 0, warn: !online.length && live.length > 0, required: true },++ { id: 'agents', title: 'Your agents', short: 'Agents', done: ready.length > 0, required: true },++ { id: 'keys', title: 'Your AI keys', short: 'AI keys', done: credentials.some((k) => k.status !== 'failing'), optional: true },++ { id: 'accounts', title: 'Your accounts', short: 'Accounts', done: !!d.git, optional: true },++ { id: 'choices', title: 'Your choices', short: 'Choices', done: d.saved, required: true },++ ];++ }++++ function stepCard(step, n, next, body, sub) {++ const state = step.done ? pill('Done', 'ok') : step.id === next ? pill('Next', 'open') : step.optional ? pill('Optional', 'skip') : pill('To do', 'skip');++ return `<section class="c4-card c4-setup-step${step.done ? ' is-done' : ''}${step.id === next ? ' is-next' : ''}" id="step-${step.id}" aria-labelledby="step-${step.id}-h">++<div class="c4-setup-step-head"><span class="c4-setup-n" aria-hidden="true">${step.done ? CHECK : n}</span><div class="c4-setup-step-title"><h2 id="step-${step.id}-h">${esc(step.title)}</h2><p>${sub}</p></div>${state}</div>++<div class="c4-setup-body">${body}</div></section>`;++ }++++ function machineBody(d) {++ const list = (d.machines.machines || []).filter((m) => m.state !== 'revoked');++ const rows = list.map((m) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-${m.state === 'online' ? 'ok' : 'skip'}" aria-hidden="true"></span><span class="c4-row-label">${esc(m.name || m.node_id || m.id)}</span><span class="c4-row-note">${m.state === 'online' ? 'Online: ready for your work' : 'Offline: start it, and it comes back here'}</span></li>`).join('');++ const problem = d.machines.ok === false && d.machines.problem ? note(esc(d.machines.problem.detail || 'Your machines could not be read.'), 'bad') : '';++ const how = `<ol class="c4-setup-how">++<li><b>Get a one-time code</b> on <a href="https://openvibe.bot/machines" rel="noopener">openvibe.bot/machines</a>. It lasts ten minutes.</li>++<li><b>Run the installer on that computer</b> and give it the code:${command('curl -fsSL https://openvibe.bot/install | sh -s -- --worker', 'On the machine')}</li>++<li><b>It pairs itself</b> and shows up here as Online. It runs your work and nobody else's.</li></ol>`;++ return `${problem}${rows ? `<ul class="c4-rows">${rows}</ul>` : ''}${list.length ? `<details class="c4-setup-more"><summary>Pair another machine</summary>${how}</details>` : how}++<div class="c4-setup-actions"><a class="c4-button${list.length ? ' c4-button-light' : ''}" href="/machines#pair">${list.length ? 'Manage machines' : 'I have a code'}</a><a class="c4-opill" href="/setup#step-machine">Check again</a></div>`;++ }++++ function agentsBody(d) {++ const by = new Map(d.rows.map((x) => [x.agent, x]));++ const ids = [...new Set([...Object.keys(SETUP), ...d.rows.map((x) => x.agent)])];++ const items = ids.map((id) => {++ const row = by.get(id);++ const how = SETUP[id] || null;++ const name = (row && row.name) || (how && how.name) || id;++ const st = row ? row.state : 'absent';++ const word = { ok: ['Ready', 'ok'], low: ['Nearly full', 'skip'], limited: ['Full for now', 'bad'], empty: ['Out of credit', 'bad'], login: ['Sign in', 'bad'], absent: ['Not installed', 'skip'], unknown: ['No reading', 'skip'] }[st] || ['No reading', 'skip'];++ const meters = row && row.windows && row.windows.length ? `<div class="c4-fleet-windows">${row.windows.map(usageMeter).join('')}</div>` : '';++ let help = '';++ if (st === 'absent' || st === 'login') {++ help = how && how.install++ ? `<div class="c4-setup-agent-help">${st === 'absent' ? command(how.install, '1 · Install') : ''}${command(how.signIn, `${st === 'absent' ? '2' : '1'} · Sign in`)}${how.signInNote ? `<p class="c4-hint">Run it ${esc(how.signInNote)}, on the machine, as you. It uses ${esc(how.plan)}.</p>` : `<p class="c4-hint">On the machine, as you. It uses ${esc(how.plan)}.</p>`}</div>`++ : `<div class="c4-setup-agent-help"><p class="c4-hint">Install it and sign in on the machine${how && how.docs ? ` (<a href="${esc(how.docs)}" rel="noopener">its guide</a>)` : ''}; Codes sees it on the machine's next heartbeat.</p></div>`;++ }++ return `<li class="c4-row c4-fleet-row c4-setup-agent" id="agent-${esc(id)}"><span class="c4-row-dot${st === 'ok' ? ' c4-dot-ok' : st === 'login' || st === 'limited' || st === 'empty' ? ' c4-dot-bad' : ''}" aria-hidden="true"></span><span class="c4-row-label">${esc(name)}</span><span class="c4-row-note">${esc(how ? how.plan : 'on your machine')}</span>${pill(word[0], word[1])}${meters}${help}</li>`;++ }).join('');++ const problem = d.fleetProblem ? note(`Your machines' agents could not be read just now: ${esc(d.fleetProblem.detail || 'Bot did not answer')}. The last reading is below.`, 'bad') : '';++ return `${problem}<p class="c4-hint">One signed-in agent is enough to start; two let your conductor carry on when one runs out (step 5).</p><ul class="c4-rows c4-fleet">${items}</ul>++<div class="c4-setup-actions"><a class="c4-opill" href="/setup#step-agents">Check again</a><a class="c4-opill" href="/agents">All agents and their usage</a></div>`;++ }++++ function keysBody(d) {++ const list = (d.keys && d.keys.credentials) || [];++ const rows = list.map((k) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot${k.status === 'ok' ? ' c4-dot-ok' : k.status === 'failing' ? ' c4-dot-bad' : ''}" aria-hidden="true"></span><span class="c4-row-label">${esc(PROVIDER_NAMES[k.provider] || k.provider)}</span><span class="c4-row-note">…${esc(k.hint || '????')}</span>${k.status === 'ok' ? pill('Working', 'ok') : k.status === 'failing' ? pill('Not working', 'bad') : pill('Not tested', 'skip')}</li>`).join('');++ return `<p class="c4-hint">Only for agents and models you pay for by use (DeepSeek, OpenAI, Anthropic, Google and more). An agent signed in with a plan needs no key. Keys live on Your AI, sealed, and every OpenVibe site uses the same ones.</p>++${rows ? `<ul class="c4-rows">${rows}</ul>` : d.keys ? '<div class="c4-emptyline">No keys yet.</div>' : '<div class="c4-emptyline">Your keys could not be read just now.</div>'}++<div class="c4-setup-actions"><a class="c4-button${rows ? ' c4-button-light' : ''}" href="${esc(YOUR_AI)}" rel="noopener">${rows ? 'Manage keys' : 'Add a key'} ↗</a></div>`;++ }++++ function accountsBody(d) {++ if (!forge || !forge.enabled) return '<p class="c4-hint">OpenVibe Git opens here once it trusts Codes. Your tasks can already work on any GitHub or OpenVibe Git repository you can reach.</p>';++ return d.git++ ? `<ul class="c4-rows"><li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-ok" aria-hidden="true"></span><span class="c4-row-label">OpenVibe Git</span><span class="c4-row-note">Linked as ${esc(d.git.forge_user || 'you')}</span>${pill('Linked', 'ok')}</li></ul><div class="c4-setup-actions"><a class="c4-opill" href="/git">Your repositories</a></div>`++ : `<p class="c4-hint">Link OpenVibe Git once, and Codes can make repositories in your name and push your tasks' branches there. It is one consent with your OpenVibe account.</p><div class="c4-setup-actions"><a class="c4-button c4-button-light" href="/git/connect">Link OpenVibe Git</a></div>`;++ }++++ function choicesBody(d, machines) {++ const s = d.settings;++ const ready = readiness(d.rows);++ const offers = (harnesses && harnesses.list ? harnesses.list() : []).filter((a) => a && a.id);++ const agentOption = (id, name, selected) => `<option value="${esc(id)}"${selected ? ' selected' : ''}${optionAttrs(ready.get(id))}>${esc(name)}</option>`;++ const agents = offers.map((a) => agentOption(a.id, a.name || a.id, s.default_agent === a.id)).join('');++ const machineOptions = (machines.machines || []).filter((m) => m.state !== 'revoked').map((m) => `<option value="${esc(m.node_id || m.id)}"${s.default_machine && (s.default_machine === m.node_id || s.default_machine === m.id) ? ' selected' : ''} data-state="${m.state === 'online' ? 'ok' : 'off'}" data-badge="${m.state === 'online' ? 'Online' : 'Offline'}">${esc(m.name || m.node_id || m.id)}</option>`).join('');++ const fallbacks = s.conductor_fallbacks || [];++ const fallbackRow = (i) => {++ const f = fallbacks[i] || { agent: '', model: '' };++ const options = CONDUCTOR_AGENTS.filter((a) => a !== 'claude-code').map((a) => agentOption(a, AGENT_NAMES[a], f.agent === a)).join('');++ return `<div class="c4-fallback"><span class="c4-fallback-n" aria-hidden="true">${i + 1}</span><label>Fallback ${i + 1} <select name="fallback_agent_${i + 1}" data-ov-title="Fallback ${i + 1}"><option value="">None</option>${options}</select></label><label>Model <input name="fallback_model_${i + 1}" value="${esc(f.model || '')}" placeholder="its default" autocomplete="off" spellcheck="false" maxlength="100"></label></div>`;++ };++ const pct = s.conductor_switch_pct || 90;++ return `<form class="c4-form" method="post" action="/setup/choices">++<div class="c4-form-grid">++<label>Default agent <select name="default_agent" data-ov-title="Default agent"><option value="" data-desc="Codes picks the agent with room for each task">Auto</option>${agents}</select></label>++<label>Default machine <select name="default_machine" data-ov-title="Default machine"><option value="" data-desc="Your machine with room, else OpenVibe's">Auto</option>${machineOptions}</select></label>++</div>++<h3 class="c4-subhead">When your conductor's agent runs low</h3>++<p class="c4-hint">Your conductor runs Claude Code. When it nears a usage limit, the next turn moves to the first fallback with room, and comes back once the window frees.</p>++<div class="c4-fallbacks">${[0, 1, 2].map(fallbackRow).join('')}</div>++<div class="c4-form-grid"><label>Switch at <select name="conductor_switch_pct">${[70, 80, 85, 90, 95].map((v) => `<option value="${v}"${pct === v ? ' selected' : ''}>${v}% of a window</option>`).join('')}</select></label></div>++<h3 class="c4-subhead">Safety</h3>++<div class="c4-safety" role="group" aria-label="Safety level">${SAFETY_OPTIONS.map(([value, title, desc]) => `<label class="c4-safety-card"><input type="radio" name="safety" value="${esc(value)}"${s.safety === value ? ' checked' : ''}><span><b>${esc(title)}</b><small>${esc(desc)}</small></span></label>`).join('')}</div>++<div class="c4-setup-actions"><button class="c4-button" type="submit">Save my choices</button><a class="c4-opill" href="/settings">All settings</a></div>++</form>`;++ }++++ r.get('/setup', async (req, res) => {++ if (!viewer(req)) return page(req, res, 'Set up Codes', wrap(`<div class="c4-title"><div><h1>Set up Codes</h1><p>Your machine, your agents, your keys: five short steps.</p></div></div>${signIn(req)}`));++ const d = await readAll(req, res);++ const steps = stepsOf(d);++ const next = (steps.find((s) => !s.done && s.required) || steps.find((s) => !s.done && !s.optional) || {}).id || null;++ const done = steps.filter((s) => s.done).length;++ const requiredLeft = steps.filter((s) => s.required && !s.done).length;++ const pctDone = Math.round((done / steps.length) * 100);++ const subs = {++ machine: 'Where your agents run: one of your own computers, paired once.',++ agents: 'The coding agents on it, signed in with your own plans.',++ keys: 'Keys for the models you pay for by use.',++ accounts: 'Where your work is pushed.',++ choices: 'What Codes uses when you do not say.',++ };++ const bodies = { machine: machineBody(d), agents: agentsBody(d), keys: keysBody(d), accounts: accountsBody(d), choices: choicesBody(d, d.machines) };++ const nav = steps.map((s, i) => `<li><a class="c4-setup-chip${s.done ? ' is-done' : ''}${s.id === next ? ' is-next' : ''}" href="#step-${s.id}"><span class="c4-setup-chip-n" aria-hidden="true">${s.done ? CHECK : i + 1}</span>${esc(s.short)}<span class="c4-sr-only">${s.done ? ', done' : s.optional ? ', optional' : ''}</span></a></li>`).join('');++ const ready = requiredLeft === 0++ ? `<section class="c4-card c4-setup-ready"><h2>You are set</h2><p>Your machine, an agent and your choices are in place.${steps.some((s) => s.optional && !s.done) ? ' The optional steps can wait.' : ''}</p><div class="c4-setup-actions"><a class="c4-button" href="/">Start a task</a><a class="c4-opill" href="/conductor">Start your conductor</a></div></section>`++ : '';++ const content = `<div class="c4-setup">++<div class="c4-setup-head"><div class="c4-title"><div><h1>Set up Codes</h1><p>${requiredLeft ? `${requiredLeft} step${requiredLeft === 1 ? '' : 's'} left before your first task.` : 'Everything your tasks need is ready.'} Each step checks itself; come back any time.</p></div></div>++<div class="c4-setup-progress"><div class="c4-setup-bar" role="progressbar" aria-label="Setup" aria-valuemin="0" aria-valuemax="${steps.length}" aria-valuenow="${done}"><span style="width:${pctDone}%"></span></div><span class="c4-setup-count">${done} of ${steps.length} done</span></div>++<nav aria-label="Setup steps"><ol class="c4-setup-nav">${nav}</ol></nav></div>++${flash(req)}${ready}${steps.map((s, i) => stepCard(s, i + 1, next, bodies[s.id], subs[s.id])).join('')}</div>`;++ page(req, res, 'Set up Codes', wrap(content));++ });++++ r.post('/setup/choices', form, async (req, res) => {++ if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/setup')}`);++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ const b = req.body || {};++ try {++ await userSettings.save(req.viewer.subject, {++ default_agent: b.default_agent === undefined ? undefined : b.default_agent,++ default_machine: b.default_machine === undefined ? undefined : b.default_machine,++ ...(b.safety ? { safety: b.safety } : {}),++ ...(b.fallback_agent_1 !== undefined ? {++ conductor_fallbacks: [1, 2, 3].map((n) => ({ agent: b[`fallback_agent_${n}`] || '', model: b[`fallback_model_${n}`] || '' })).filter((f) => f.agent),++ } : {}),++ ...(b.conductor_switch_pct !== undefined ? { conductor_switch_pct: b.conductor_switch_pct } : {}),++ });++ return res.redirect(303, '/setup?ok=Your%20choices%20are%20saved#step-choices');++ } catch (e) {++ return res.redirect(303, `/setup?error=${encodeURIComponent(String((e && e.message) || 'your choices could not be saved').slice(0, 180))}#step-choices`);++ }++ });++++ /**++ * The home page's card: how far the person is, from the database alone (the stored fleet and settings; no call to++ * Network or Bot on the home page). null when the setup needs nothing more.++ */++ r.setupProgress = async (req) => {++ if (!viewer(req)) return null;++ const subject = req.viewer.subject;++ const latest = fleet ? await fleet.latest(subject).catch(() => ({ machines: [] })) : { machines: [] };++ const rows = fleet ? await fleet.agents(subject, harnesses).catch(() => []) : [];++ const saved = !!(await db.maybe('SELECT 1 AS one FROM codes_user_settings WHERE subject = $1', [subject]));++ const steps = [++ { title: 'Your machine', done: (latest.machines || []).length > 0 },++ { title: 'Your agents', done: rows.some((x) => x.state === 'ok' || x.state === 'low') },++ { title: 'Your choices', done: saved },++ ];++ const left = steps.filter((s) => !s.done);++ return left.length ? { done: steps.length - left.length, of: steps.length, next: left[0].title } : null;++ };++++ return r;++}++++module.exports = { createSetupRoutes };@@@ -135,7 +135,7 @@ const { stepKind } = require('../server/http/chat');await check('an OpenVibe site picked from the words: one project, several, or a question when none is named', async () => {const home = await t.get('/', { as: ada });-- assert.match(home.text, /<select name="project" data-ch-project><option value="auto" selected>Codes picks for you<\/option>/, 'the default is to work it out');++ assert.match(home.text, /<select name="project" data-ch-project[^>]*><option value="auto" selected>Codes picks for you<\/option>/, 'the default is to work it out');assert.match(home.text, /data-ch-picked data-ch-match-url="\/chat\/match"/);const m = await t.get(`/chat/match?text=${encodeURIComponent('Make the navbar smaller')}`, { as: ada });assert.deepStrictEqual(JSON.parse(m.text).projects.map((p) => p.name), ['Shared']);@@@ -200,7 +200,7 @@ const HOUR = 3600_000;assert.match(page.text, /class="ovt-divider"><span>.*Claude Code is at 93% of its week/, 'the switch is a line in the thread');const settings = await t.get('/settings', { as: ada });assert.match(settings.text, /id="conductor"/);-- assert.match(settings.text, /<option value="codex" selected data-state="ok">Codex — has room, 30% used<\/option>/);++ assert.match(settings.text, /<option value="codex" selected data-state="ok" data-badge="30% used"[^>]*>Codex<\/option>/, 'the fallback picker says what each agent is ready for');assert.match(settings.text, /name="fallback_model_1" value="gpt-5\.5"/);});} finally {nnew file mode 100644@@@ -0,0 +1,90 @@++'use strict';++++/**++ * The guided setup (owner 2026-11-03): /setup's five steps, each read live; the pickers that say what each agent is++ * ready for; the choices it saves; the home's line to it while it is unfinished.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { readiness, optionAttrs } = require('../server/domain/readiness');++++const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2S1';++const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2S1';++++(async () => {++ await check('readiness: a state, a badge, a line and where to fix it', async () => {++ const r = readiness([++ { agent: 'claude-code', state: 'ok', used: 41, machines: [{ name: 'ada-laptop', room: true }] },++ { agent: 'codex', state: 'absent', used: 0, machines: [] },++ { agent: 'opencode', state: 'login', used: 0, machines: [] },++ { agent: 'minimax-code', state: 'low', used: 88, machines: [] },++ ]);++ assert.deepStrictEqual(r.get('claude-code'), { state: 'ok', badge: '41% used', desc: 'Signed in on ada-laptop', ready: true });++ assert.strictEqual(r.get('codex').href, '/setup#agent-codex');++ assert.strictEqual(r.get('opencode').hrefLabel, 'Sign in');++ assert.strictEqual(r.get('minimax-code').state, 'warn');++ assert.strictEqual(optionAttrs({ state: 'ok', badge: 'a "b"', desc: '<x>' }), ' data-state="ok" data-badge="a "b"" data-desc="<x>"');++ });++++ const t = await boot({});++ const ada = t.network.addUser('ada');++ try {++ await check('a new person: every step to do, the machine first, and the home says so', async () => {++ const res = await t.get('/setup', { as: ada });++ assert.strictEqual(res.status, 200, res.text);++ for (const id of ['machine', 'agents', 'keys', 'accounts', 'choices']) assert.match(res.text, new RegExp(`id="step-${id}"`));++ assert.match(res.text, /0 of 5 done/);++ assert.match(res.text, /class="c4-card c4-setup-step is-next" id="step-machine"/, 'the machine is next');++ assert.match(res.text, /curl -fsSL https:\/\/openvibe\.bot\/install \| sh -s -- --worker/);++ assert.match(res.text, /npm install -g @openai\/codex/, 'an agent that is not there says how to install it');++ assert.match(res.text, /href="\/css\/setup\.css|css\/setup\.css/, 'the page brings its own stylesheet');++ const home = await t.get('/', { as: ada });++ assert.match(home.text, /class="ch-setup-nudge" href="\/setup"/);++ assert.match(home.text, /0 of 3 done · next: Your machine/);++ });++++ await check('a paired machine with Claude Code signed in: two steps done, the pickers know', async () => {++ t.network.addNode(ada.subject, { principal: NODE, node_id: 'node_ada', name: 'ada-laptop', status: 'active', last_seen_at: new Date().toISOString() });++ t.bot.addMachine({ id: DEVICE, node_id: NODE, name: 'ada-laptop', online: true, fleet_at: new Date().toISOString(), fleet: [++ { agent: 'claude-code', installed: true, signed_in: true, windows: [{ name: 'week', pct: 41, resets_at: new Date(Date.now() + 86400000).toISOString() }] },++ { agent: 'opencode', installed: true, signed_in: false, windows: [] },++ ] });++ const res = await t.get('/setup', { as: ada });++ assert.match(res.text, /class="c4-card c4-setup-step is-done" id="step-machine"/);++ assert.match(res.text, /class="c4-card c4-setup-step is-done" id="step-agents"/);++ assert.match(res.text, /class="c4-card c4-setup-step is-next" id="step-choices"/, 'the choices are next');++ assert.match(res.text, /opencode auth login/, 'a signed-out agent says how to sign in');++ assert.match(res.text, /<option value="claude-code"[^>]*data-state="ok" data-badge="41% used"/, 'the default agent picker says what is ready');++ assert.match(res.text, /<option value="opencode"[^>]*data-state="bad" data-badge="Sign in"[^>]*data-href="\/setup#agent-opencode"/);++ const home = await t.get('/', { as: ada });++ assert.match(home.text, /2 of 3 done · next: Your choices/);++ assert.match(home.text, /<option value="claude-code\|[^"]*"[^>]*data-state="ok"/, 'the composer\'s agent picker knows too');++ assert.match(home.text, /data-ch-who data-ov-select="bare"/, 'the composer\'s chips use the bare select');++ });++++ await check('saving the choices finishes the setup', async () => {++ const res = await t.get('/setup/choices', { as: ada, form: { default_agent: 'claude-code', default_machine: NODE, fallback_agent_1: 'codex', fallback_model_1: 'gpt-5.5', fallback_agent_2: '', fallback_model_2: '', fallback_agent_3: '', fallback_model_3: '', conductor_switch_pct: '85', safety: 'project' } });++ assert.strictEqual(res.status, 303, res.text);++ assert.match(res.headers.get('location'), /^\/setup\?ok=/);++ const s = await t.ctx.userSettings.get(ada.subject);++ assert.strictEqual(s.default_agent, 'claude-code');++ assert.deepStrictEqual(s.conductor_fallbacks, [{ agent: 'codex', model: 'gpt-5.5' }]);++ assert.strictEqual(s.conductor_switch_pct, 85);++ assert.strictEqual(s.safety, 'project');++ const page = await t.get('/setup', { as: ada });++ assert.match(page.text, /You are set/);++ assert.match(page.text, /class="c4-card c4-setup-step is-done" id="step-choices"/);++ const home = await t.get('/', { as: ada });++ assert.doesNotMatch(home.text, /ch-setup-nudge/, 'a finished setup leaves the home alone');++ });++++ await check('signed out: the page asks to sign in', async () => {++ const res = await t.get('/setup');++ assert.strictEqual(res.status, 200);++ assert.match(res.text, /Set up Codes/);++ });++ } finally {++ await t.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -60,6 +60,7 @@
@@ -60,6 +60,7 @@
"the conductor (C9b): one Claude Code session on the person's own machine, driven from the browser or the phone, with its approvals",
"the conductor (C9b): one Claude Code session on the person's own machine, driven from the browser or the phone, with its approvals",
"the job runner (CLI 0.6.0): each turn at the effort a person picks, in each agent's own words, and a conversation's next turn starting from the work before it (the last diff applied with git apply --3way, and the conversation so far), on any agent",
"the job runner (CLI 0.6.0): each turn at the effort a person picks, in each agent's own words, and a conversation's next turn starting from the work before it (the last diff applied with git apply --3way, and the conversation so far), on any agent",
"the job runner (CLI 0.6.1): \"edit\" means edits on the person's own computer (OpenCode and Command Code run unattended only in a Node sandbox or for full; Aider suggests no shell commands there), and a private repository clones with a token the machine holds (GITHUB_TOKEN, GITLAB_TOKEN, OPENVIBE_GIT_CREDENTIALS) sent as a header, never in the URL or .git/config",
"the job runner (CLI 0.6.1): \"edit\" means edits on the person's own computer (OpenCode and Command Code run unattended only in a Node sandbox or for full; Aider suggests no shell commands there), and a private repository clones with a token the machine holds (GITHUB_TOKEN, GITLAB_TOKEN, OPENVIBE_GIT_CREDENTIALS) sent as a header, never in the URL or .git/config",
+ "The guided setup (/setup, owner 2026-11-03): one page of five steps read live: your machine (paired, online), your agents (installed, signed in, usage; the install and sign-in commands for the ones missing), your AI keys on Your AI (optional), OpenVibe Git (optional), your choices (default agent and machine, the conductor's fallbacks, safety); the home says how far along until it is done; every agent picker (Settings, the setup, the composer) says what each agent is ready for, drawn by Shared's select (data-state, data-badge, data-desc, a setup link)",
"The conductor's failover (CLI 0.7.0, owner 2026-11-03): Settings \u2192 Conductor picks up to three fallback agents (Codex, OpenCode) with a model each and a switch point (90% of a usage window by default); before each turn the conductor's own agent and the fallbacks are read against the machine's usage windows, the turn runs on the first with room and returns to the primary once its window frees; each agent resumes its own session and the one taking over is told what happened while it was away; the conductor's page shows each agent's usage and which takes the next turn",
"The conductor's failover (CLI 0.7.0, owner 2026-11-03): Settings \u2192 Conductor picks up to three fallback agents (Codex, OpenCode) with a model each and a switch point (90% of a usage window by default); before each turn the conductor's own agent and the fallbacks are read against the machine's usage windows, the turn runs on the first with room and returns to the primary once its window frees; each agent resumes its own session and the one taking over is told what happened while it was away; the conductor's page shows each agent's usage and which takes the next turn",
"OpenVibe Git through the person's own account (/git, CLI 0.6.2): link once (the forge's OAuth2 consent with PKCE, tokens sealed with CODES_SEAL_KEY), make a repository in your name and start a chat on it; a task on a forge repository takes the person's credential line to their machine for git only (never the agent), and the branch and its pull request open on OpenVibe Git"
"OpenVibe Git through the person's own account (/git, CLI 0.6.2): link once (the forge's OAuth2 consent with PKCE, tokens sealed with CODES_SEAL_KEY), make a repository in your name and start a chat on it; a task on a forge repository takes the person's credential line to their machine for git only (never the agent), and the branch and its pull request open on OpenVibe Git"
],
],
@@ -163,3 +163,10 @@ html[data-ov-motion=reduced] .ch-spark-list[aria-hidden],html[data-ov-motion=red
@@ -163,3 +163,10 @@ html[data-ov-motion=reduced] .ch-spark-list[aria-hidden],html[data-ov-motion=red
.ch .ch-usage-note{margin:8px 0 0;padding:8px 10px;border-radius:8px;background:var(--ch-hover);color:var(--ch-soft);font-size:12px;line-height:1.45}
.ch .ch-usage-note{margin:8px 0 0;padding:8px 10px;border-radius:8px;background:var(--ch-hover);color:var(--ch-soft);font-size:12px;line-height:1.45}
.ch .ch-usage-foot{margin:8px 0 0;color:var(--ch-muted);font-size:12px;line-height:1.45}
.ch .ch-usage-foot{margin:8px 0 0;color:var(--ch-muted);font-size:12px;line-height:1.45}
.ch .ch-usage-foot a{color:var(--ch-accent)}
.ch .ch-usage-foot a{color:var(--ch-accent)}
+/* The home's line to the guided setup (2026-11-03): how far along, and what is next. */
+.ch .ch-setup-nudge{display:inline-flex;align-items:center;gap:10px;margin-top:10px;padding:9px 12px 9px 14px;min-height:44px;border:1px solid var(--ch-line);border-radius:999px;background:var(--ch-card);color:var(--ch-soft);font-size:13px;text-decoration:none;max-width:100%}
+.ch .ch-setup-nudge b{color:var(--ch-ink)}
+.ch .ch-setup-nudge:hover{border-color:color-mix(in srgb,var(--ch-accent) 50%,var(--ch-line))}
+.ch .ch-setup-nudge-bar{flex:none;width:42px;height:6px;border-radius:3px;background:var(--ch-hover);overflow:hidden}
+.ch .ch-setup-nudge-bar>span{display:block;height:100%;border-radius:3px;background:var(--ch-accent)}
+.ch .ch-setup-nudge-go{flex:none;width:16px;height:16px;transform:rotate(-90deg)}
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+/* Codes' guided setup (/setup, 2026-11-03): the progress, the step chips, and each step's card. Loaded on /setup only. */
+.c4-setup{display:grid;gap:0}
+.c4-setup-head{display:grid;gap:12px;margin:0 0 16px}
+.c4-setup-head .c4-title{margin:0}
+.c4-setup-progress{display:flex;align-items:center;gap:12px}
+.c4-setup-bar{flex:1;max-width:420px;height:8px;border-radius:99px;background:var(--c4-quiet-bg);overflow:hidden}
+.c4-setup-bar>span{display:block;height:100%;border-radius:99px;background:var(--c4-accent);transition:width .4s cubic-bezier(.2,.8,.2,1)}
+.c4-setup-count{color:var(--c4-muted);font-size:13px;font-weight:600;white-space:nowrap}
+.c4-setup-nav{display:flex;gap:8px;margin:0;padding:2px 0 4px;list-style:none;overflow-x:auto;scrollbar-width:none}
+.c4-setup-nav::-webkit-scrollbar{display:none}
+.c4-setup-chip{display:inline-flex;align-items:center;gap:8px;min-height:40px;padding:0 14px 0 6px;border:1px solid var(--c4-line);border-radius:999px;background:var(--c4-surface);color:var(--c4-ink);font-size:13px;font-weight:600;text-decoration:none;white-space:nowrap}
+.c4-setup-chip-n{display:grid;place-items:center;width:28px;height:28px;border-radius:50%;background:var(--c4-quiet-bg);color:var(--c4-muted);font-size:12px;font-weight:700}
+.c4-setup-chip-n svg,.c4-setup-n svg{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:2.6;stroke-linecap:round;stroke-linejoin:round}
+.c4-setup-chip.is-done .c4-setup-chip-n{background:var(--c4-ok-bg);color:var(--c4-ok-ink)}
+.c4-setup-chip.is-next{border-color:var(--c4-accent);box-shadow:0 0 0 1px var(--c4-accent)}
+.c4-setup-chip.is-next .c4-setup-chip-n{background:var(--c4-accent);color:var(--c4-on-accent)}
+.c4-setup-step{scroll-margin-top:84px}
+.c4-setup-step.is-next{border-color:var(--c4-accent);box-shadow:0 0 0 1px var(--c4-accent),0 8px 24px -14px var(--c4-accent)}
+.c4-setup-step-head{display:grid;grid-template-columns:40px minmax(0,1fr) auto;gap:14px;align-items:start}
+.c4-setup-n{display:grid;place-items:center;width:40px;height:40px;border-radius:50%;background:var(--c4-quiet-bg);color:var(--c4-muted);font-size:16px;font-weight:750}
+.c4-setup-step.is-next .c4-setup-n{background:var(--c4-accent);color:var(--c4-on-accent)}
+.c4-setup-step.is-done .c4-setup-n{background:var(--c4-ok-bg);color:var(--c4-ok-ink)}
+.c4-setup-step-title h2{margin:0;font-size:17px;letter-spacing:-.01em}
+.c4-setup-step-title p{margin:4px 0 0;color:var(--c4-muted);font-size:13px;line-height:1.45}
+.c4-setup-body{margin:14px 0 0 54px;display:grid;gap:12px;min-width:0}
+.c4-setup-body .c4-hint{margin:0}
+.c4-setup-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
+.c4-setup-how{margin:0;padding-left:20px;display:grid;gap:10px;font-size:13.5px;line-height:1.5}
+.c4-setup-how a{color:var(--c4-accent)}
+.c4-setup-cmd{display:grid;gap:4px;margin-top:6px;min-width:0}
+.c4-setup-cmd-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--c4-muted)}
+.c4-setup-cmd code{display:block;padding:10px 12px;border-radius:9px;background:var(--c4-ghost);border:1px solid var(--c4-line);font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--c4-ink);overflow-x:auto;white-space:pre;user-select:all;-webkit-user-select:all}
+.c4-setup-cmd code:focus-visible{outline:2px solid var(--c4-accent);outline-offset:2px}
+.c4-setup-agent{scroll-margin-top:84px}
+.c4-setup-agent:target{background:var(--c4-open-bg);border-radius:10px}
+.c4-setup-agent-help{grid-column:1/-1;display:grid;gap:4px;padding:2px 0 4px 20px;min-width:0}
+.c4-setup-more summary{cursor:pointer;color:var(--c4-accent);font-size:13px;min-height:32px;display:flex;align-items:center}
+.c4-setup-ready{border-color:var(--c4-dot-ok);background:linear-gradient(135deg,var(--c4-ok-bg),var(--c4-surface) 60%)}
+.c4-setup-ready h2{margin:0 0 4px;font-size:19px}
+.c4-setup-ready p{margin:0 0 12px;color:var(--c4-muted)}
+.c4-setup-link{margin:0 0 14px}
+@media (max-width:640px){
+ .c4-setup-step-head{grid-template-columns:34px minmax(0,1fr);gap:12px}
+ .c4-setup-step-head>.c4-pill{grid-column:2;justify-self:start}
+ .c4-setup-n{width:34px;height:34px;font-size:14px}
+ .c4-setup-body{margin-left:0}
+ .c4-setup-agent-help{padding-left:0}
+}
+@media (prefers-reduced-motion:reduce){.c4-setup-bar>span{transition:none}}
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+'use strict';
+
+/**
+ * What a picker says about each agent (owner 2026-11-03: the selects "should be aware of what the user has set up"):
+ * from the fleet's rows (fleet.agents), one state, a short badge, a line and, when the agent is not ready, where to make
+ * it ready. Shared's ov-forms.js draws them on an <option> (data-state, data-badge, data-desc, data-href); without it
+ * the badge rides in the option's own words.
+ *
+ * readiness(rows) → Map agent → { state, badge, desc, href, hrefLabel, ready }
+ * optionAttrs(info) → ' data-state="…" data-badge="…" …' (escaped)
+ * SETUP → how each plan agent is installed and signed in, for the guided setup
+ */
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+/**
+ * The plan agents a machine can sign in to, and the two commands that make one ready. Only commands each CLI documents
+ * itself; an agent with none here says to follow its own guide.
+ */
+const SETUP = {
+ 'claude-code': { name: 'Claude Code', plan: 'a Claude Pro or Max plan', install: 'npm install -g @anthropic-ai/claude-code', signIn: 'claude', signInNote: 'and sign in when it asks', docs: 'https://docs.claude.com/en/docs/claude-code/setup' },
+ codex: { name: 'Codex', plan: 'a ChatGPT Plus or Pro plan', install: 'npm install -g @openai/codex', signIn: 'codex login', docs: 'https://github.com/openai/codex' },
+ opencode: { name: 'OpenCode', plan: 'OpenCode Go, or your own keys', install: 'npm install -g opencode-ai', signIn: 'opencode auth login', docs: 'https://opencode.ai/docs' },
+ 'command-code': { name: 'Command Code', plan: 'a Command Code plan', install: null, signIn: null, docs: 'https://commandcode.ai' },
+ 'minimax-code': { name: 'MiniMax Code', plan: 'a MiniMax coding plan', install: null, signIn: null, docs: 'https://www.minimax.io' },
+};
+
+function readiness(rows) {
+ const out = new Map();
+ for (const row of rows || []) {
+ if (!row || !row.agent) continue;
+ const used = Number.isFinite(Number(row.used)) ? Math.round(Number(row.used)) : 0;
+ const where = (row.machines || []).filter((m) => m && m.room).map((m) => m.name || m.machine).filter(Boolean).slice(0, 2).join(', ');
+ const setupHref = `/setup#agent-${row.agent}`;
+ let info;
+ switch (row.state) {
+ case 'ok': info = { state: 'ok', badge: used ? `${used}% used` : 'Ready', desc: where ? `Signed in on ${where}` : 'Signed in on your machine', ready: true }; break;
+ case 'low': info = { state: 'warn', badge: `${used}% used`, desc: 'Nearly at its limit', ready: true }; break;
+ case 'limited': info = { state: 'bad', badge: 'Full', desc: 'Its usage window is spent for now', ready: false }; break;
+ case 'empty': info = { state: 'bad', badge: 'Out of credit', desc: 'Its balance is nearly gone', ready: false }; break;
+ case 'login': info = { state: 'bad', badge: 'Sign in', desc: 'Installed, not signed in', href: setupHref, hrefLabel: 'Sign in', ready: false }; break;
+ case 'absent': info = { state: 'off', badge: 'Not installed', desc: 'Not on your machine yet', href: setupHref, hrefLabel: 'Set up', ready: false }; break;
+ default: info = { state: 'off', badge: 'No reading', desc: 'Your machine has not reported it', ready: false };
+ }
+ out.set(row.agent, info);
+ }
+ return out;
+}
+
+/** The data-* attributes an <option> carries for ov-forms.js. */
+function optionAttrs(info) {
+ if (!info) return '';
+ return [
+ info.state ? ` data-state="${esc(info.state)}"` : '',
+ info.badge ? ` data-badge="${esc(info.badge)}"` : '',
+ info.desc ? ` data-desc="${esc(info.desc)}"` : '',
+ info.href ? ` data-href="${esc(info.href)}" data-href-label="${esc(info.hrefLabel || 'Set up')}"` : '',
+ ].join('');
+}
+
+module.exports = { readiness, optionAttrs, SETUP };
@@ -29,6 +29,7 @@ const MODELS = [['opus', 'Opus 5.5 (default)'], ['sonnet', 'Sonnet 5.5'], ['haik
@@ -29,6 +29,7 @@ const MODELS = [['opus', 'Opus 5.5 (default)'], ['sonnet', 'Sonnet 5.5'], ['haik
const { CONDUCTOR_AGENTS, AGENT_NAMES: failoverNames } = require('../domain/conductor-failover');
const { CONDUCTOR_AGENTS, AGENT_NAMES: failoverNames } = require('../domain/conductor-failover');
/** The agents a conductor may fall back to: every conductor agent but Claude Code, which is its own. */
/** The agents a conductor may fall back to: every conductor agent but Claude Code, which is its own. */
const CONDUCTOR_FALLBACKS = CONDUCTOR_AGENTS.filter((a) => a !== 'claude-code');
const CONDUCTOR_FALLBACKS = CONDUCTOR_AGENTS.filter((a) => a !== 'claude-code');
+const { readiness: agentReadiness, optionAttrs } = require('../domain/readiness');
const modelLabel = (m) => ({ opus: 'Opus 5.5', sonnet: 'Sonnet 5.5', haiku: 'Haiku 5.5' }[m] || m || 'Opus 5.5');
const modelLabel = (m) => ({ opus: 'Opus 5.5', sonnet: 'Sonnet 5.5', haiku: 'Haiku 5.5' }[m] || m || 'Opus 5.5');
const age = (value) => {
const age = (value) => {
const numeric = Number(value);
const numeric = Number(value);
@@ -90,12 +91,14 @@ function createC4Routes(ctx) {
@@ -90,12 +91,14 @@ function createC4Routes(ctx) {
const db = store.db;
const db = store.db;
const form = express.urlencoded({ extended: false, limit: '16kb' });
const form = express.urlencoded({ extended: false, limit: '16kb' });
const page = (req, res, title, body, extra = {}) => {
const page = (req, res, title, body, extra = {}) => {
+ // A page may add its own stylesheet (the guided setup's), so c4.css stays the size every page pays for.
+ const css = ['css/c4.css', ...(Array.isArray(extra.css) ? extra.css : [])];
if (viewer(req) && ctx.chatShell && !extra.index) {
if (viewer(req) && ctx.chatShell && !extra.index) {
const here = '/' + (String(req.path || '/').split('/')[1] || '');
const here = '/' + (String(req.path || '/').split('/')[1] || '');
- return ctx.chatShell(req, res, { title: title || 'Codes', body: String(body), css: ['css/c4.css'], scripts: ['js/c4.js'], here });
+ return ctx.chatShell(req, res, { title: title || 'Codes', body: String(body), css, scripts: ['js/c4.js'], here });
}
}
const wantsScript = req.path === '/' || /^\/tasks\/tsk_/.test(req.path) || /^\/(agents|machines|conductor)/.test(req.path);
const wantsScript = req.path === '/' || /^\/tasks\/tsk_/.test(req.path) || /^\/(agents|machines|conductor)/.test(req.path);
- return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), css: ['css/c4.css'], scripts: wantsScript ? ['js/c4.js'] : [], body: raw(body), ...extra });
+ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), scripts: wantsScript ? ['js/c4.js'] : [], body: raw(body), ...extra, css });
};
};
const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
/**
/**
@@ -756,7 +759,10 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
@@ -756,7 +759,10 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
const settings = viewer(req) ? await userSettings.get(req.viewer.subject) : null;
const settings = viewer(req) ? await userSettings.get(req.viewer.subject) : null;
const mine = viewer(req) ? await machinesOf(req, res) : { machines: [] };
const mine = viewer(req) ? await machinesOf(req, res) : { machines: [] };
const used = viewer(req) ? await lastUsedMachines(req) : [];
const used = viewer(req) ? await lastUsedMachines(req) : [];
- const agentOptions = (harnesses?.list?.() || []).map((a) => `<option value="${esc(a.id)}"${settings && settings.default_agent === a.id ? ' selected' : ''}>${esc(a.name)}</option>`).join('');
+ // Each agent says what it is ready for (domain/readiness.js), drawn by Shared's select as a badge and a line.
+ const fleetRows = viewer(req) && fleet ? await fleet.agents(req.viewer.subject, harnesses).catch(() => []) : [];
+ const aware = agentReadiness(fleetRows);
+ const agentOptions = (harnesses?.list?.() || []).map((a) => `<option value="${esc(a.id)}"${settings && settings.default_agent === a.id ? ' selected' : ''}${optionAttrs(aware.get(a.id))}>${esc(a.name)}</option>`).join('');
// The same order the Home picker shows: the machines their work ran on last come first.
// The same order the Home picker shows: the machines their work ran on last come first.
const machineOptions = orderMachines((mine.machines || []).filter((m) => m.state !== 'revoked'), used).map((m) => machineOption(m, settings)).join('');
const machineOptions = orderMachines((mine.machines || []).filter((m) => m.state !== 'revoked'), used).map((m) => machineOption(m, settings)).join('');
// Settings → Limits (C9a): steps and minutes per task, for every agent at once; No limit means none.
// Settings → Limits (C9a): steps and minutes per task, for every agent at once; No limit means none.
@@ -767,18 +773,10 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
@@ -767,18 +773,10 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
};
};
// Settings → Conductor (the failover): the agents a conductor can fall back to, each said with what the person's
// Settings → Conductor (the failover): the agents a conductor can fall back to, each said with what the person's
// machines report about it (ready, how full, signed out), so a pick that cannot run is visible before it is made.
// machines report about it (ready, how full, signed out), so a pick that cannot run is visible before it is made.
- const fleetRows = viewer(req) && fleet ? await fleet.agents(req.viewer.subject, harnesses).catch(() => []) : [];
- const fleetBy = new Map(fleetRows.map((r) => [r.agent, r]));
- const readiness = (agent) => {
- const r = fleetBy.get(agent);
- if (!r) return 'not reported';
- const [word] = FLEET_STATE[r.state] || FLEET_STATE.unknown;
- return r.used ? `${word.toLowerCase()}, ${Math.round(r.used)}% used` : word.toLowerCase();
- };
const fallbacks = (settings && settings.conductor_fallbacks) || [];
const fallbacks = (settings && settings.conductor_fallbacks) || [];
const fallbackRow = (i) => {
const fallbackRow = (i) => {
const f = fallbacks[i] || { agent: '', model: '' };
const f = fallbacks[i] || { agent: '', model: '' };
- const options = CONDUCTOR_FALLBACKS.map((a) => `<option value="${a}"${f.agent === a ? ' selected' : ''} data-state="${esc((fleetBy.get(a) || {}).state || 'unknown')}">${esc(failoverNames[a])} — ${esc(readiness(a))}</option>`).join('');
+ const options = CONDUCTOR_FALLBACKS.map((a) => `<option value="${a}"${f.agent === a ? ' selected' : ''}${optionAttrs(aware.get(a))}>${esc(failoverNames[a])}</option>`).join('');
return `<div class="c4-fallback"><span class="c4-fallback-n" aria-hidden="true">${i + 1}</span><label>Fallback ${i + 1} <select name="fallback_agent_${i + 1}"><option value="">None</option>${options}</select></label><label>Model <input name="fallback_model_${i + 1}" value="${esc(f.model || '')}" placeholder="its default" autocomplete="off" spellcheck="false" maxlength="100"></label></div>`;
return `<div class="c4-fallback"><span class="c4-fallback-n" aria-hidden="true">${i + 1}</span><label>Fallback ${i + 1} <select name="fallback_agent_${i + 1}"><option value="">None</option>${options}</select></label><label>Model <input name="fallback_model_${i + 1}" value="${esc(f.model || '')}" placeholder="its default" autocomplete="off" spellcheck="false" maxlength="100"></label></div>`;
};
};
const switchPct = (settings && settings.conductor_switch_pct) || 90;
const switchPct = (settings && settings.conductor_switch_pct) || 90;
@@ -787,7 +785,7 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
@@ -787,7 +785,7 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
<div class="c4-fallbacks">${[0, 1, 2].map(fallbackRow).join('')}</div>
<div class="c4-fallbacks">${[0, 1, 2].map(fallbackRow).join('')}</div>
<div class="c4-form-grid"><label>Switch at <select name="conductor_switch_pct">${[70, 80, 85, 90, 95].map((v) => `<option value="${v}"${switchPct === v ? ' selected' : ''}>${v}% of a window</option>`).join('')}</select></label></div>
<div class="c4-form-grid"><label>Switch at <select name="conductor_switch_pct">${[70, 80, 85, 90, 95].map((v) => `<option value="${v}"${switchPct === v ? ' selected' : ''}>${v}% of a window</option>`).join('')}</select></label></div>
<p class="c4-hint">Codex and OpenCode have no approval prompt: on "Ask first" they work with edits in the folder only. Your machine needs OpenVibe Node with Codes 0.7 or newer to switch.</p>` : '';
<p class="c4-hint">Codex and OpenCode have no approval prompt: on "Ask first" they work with edits in the folder only. Your machine needs OpenVibe Node with Codes 0.7 or newer to switch.</p>` : '';
- const content = `${header('Settings', 'Your choices, remembered for your account.')}${viewer(req) ? `${flash(req)}<section class="c4-card"><form class="c4-form" method="post" action="/settings">
+ const content = `${header('Settings', 'Your choices, remembered for your account.')}${viewer(req) ? `${flash(req)}<p class="c4-hint c4-setup-link">New here, or something not working? <a href="/setup">The guided setup</a> checks your machine, agents and keys step by step.</p><section class="c4-card"><form class="c4-form" method="post" action="/settings">
<div class="c4-form-grid">
<div class="c4-form-grid">
<label>Default agent <select name="default_agent"><option value="">Auto</option>${agentOptions}</select></label>
<label>Default agent <select name="default_agent"><option value="">Auto</option>${agentOptions}</select></label>
<label>Default machine <select name="default_machine"><option value="">Auto</option>${machineOptions}</select></label>
<label>Default machine <select name="default_machine"><option value="">Auto</option>${machineOptions}</select></label>
@@ -846,6 +844,15 @@ ${conductorSection}
@@ -846,6 +844,15 @@ ${conductorSection}
// module and borrow this one's kit (the page, the frame, the card, the pill, the flash and the same-origin check).
// module and borrow this one's kit (the page, the frame, the card, the pill, the flash and the same-origin check).
r.use(require('./memory').createMemoryRoutes(ctx, { page, wrap, header, pill, flash, sameOrigin, viewer, form, signIn, note }));
r.use(require('./memory').createMemoryRoutes(ctx, { page, wrap, header, pill, flash, sameOrigin, viewer, form, signIn, note }));
+ // The guided setup (/setup): machine, agents, keys, accounts and choices, each step read live. Its home-page card
+ // reads the database only, through ctx.setupProgress (the chat app's home asks it).
+ const setupRoutes = require('./setup').createSetupRoutes(ctx, {
+ page: (req, res, title, body) => page(req, res, title, body, { css: ['css/setup.css'] }),
+ wrap, header, pill, flash, sameOrigin, viewer, form, signIn, note, machinesOf, usageMeter, SAFETY_OPTIONS, YOUR_AI, PROVIDER_NAMES,
+ });
+ r.use(setupRoutes);
+ ctx.setupProgress = setupRoutes.setupProgress;
+
// The front door a visitor gets at /, as a page for the layout: the Markdown twin of / is this page
// The front door a visitor gets at /, as a page for the layout: the Markdown twin of / is this page
// (server/agent-pages.js), so the twin says what a visitor reads (SEO-1; it was the old marketing home).
// (server/agent-pages.js), so the twin says what a visitor reads (SEO-1; it was the old marketing home).
r.visitorHome = () => ({ css: ['css/c4.css'], scripts: ['js/c4.js'], body: raw(wrap(visitorHome())), index: true, agent: true, styles: [showcase.STYLESHEET], jsonLd: homeJsonLd(config) });
r.visitorHome = () => ({ css: ['css/c4.css'], scripts: ['js/c4.js'], body: raw(wrap(visitorHome())), index: true, agent: true, styles: [showcase.STYLESHEET], jsonLd: homeJsonLd(config) });
@@ -23,6 +23,7 @@ const { sessionLogOf } = require('../domain/run-tasks');
@@ -23,6 +23,7 @@ const { sessionLogOf } = require('../domain/run-tasks');
const models = require('../data/models');
const models = require('../data/models');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const thread = require('openvibe-shared/thread');
const thread = require('openvibe-shared/thread');
+const { readiness, optionAttrs } = require('../domain/readiness');
const start = require('./start');
const start = require('./start');
const LIVE = ['queued', 'placed', 'running'];
const LIVE = ['queued', 'placed', 'running'];
@@ -129,7 +130,9 @@ const repoLabel = (repo) => { try { return new URL(String(repo)).pathname.split(
@@ -129,7 +130,9 @@ const repoLabel = (repo) => { try { return new URL(String(repo)).pathname.split(
function createChatRoutes(ctx) {
function createChatRoutes(ctx) {
const r = asyncRouter();
const r = asyncRouter();
- const { config, store, harnesses, tasks, conductor, approvals, userSettings, github, machines } = ctx;
+ const { config, store, harnesses, tasks, conductor, approvals, userSettings, github, machines, fleet } = ctx;
+ /** What each agent is ready for on the person's machines (the stored fleet: no call out), for the pickers. */
+ const readyFor = async (req) => (fleet && viewer(req) ? readiness(await fleet.agents(req.viewer.subject, harnesses).catch(() => [])) : new Map());
const db = store.db;
const db = store.db;
const form = express.urlencoded({ extended: false, limit: '32kb' });
const form = express.urlencoded({ extended: false, limit: '32kb' });
const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
@@ -341,19 +344,20 @@ function createChatRoutes(ctx) {
@@ -341,19 +344,20 @@ function createChatRoutes(ctx) {
}
}
// ── The composer ─────────────────────────────────────────────────────────────────────────────────────────────
// ── The composer ─────────────────────────────────────────────────────────────────────────────────────────────
- function whoPicker(selected, { label = 'Who works on it', auto = null, cls = '' } = {}) {
+ function whoPicker(selected, { label = 'Who works on it', auto = null, cls = '', ready = null } = {}) {
const all = models.choices(harnesses);
const all = models.choices(harnesses);
const groups = new Map();
const groups = new Map();
for (const c of all.slice(1)) { if (!groups.has(c.group)) groups.set(c.group, []); groups.get(c.group).push(c); }
for (const c of all.slice(1)) { if (!groups.has(c.group)) groups.set(c.group, []); groups.get(c.group).push(c); }
- const opt = (c) => `<option value="${esc(c.value)}"${selected === c.value ? ' selected' : ''}${c.note ? ` title="${esc(c.note)}"` : ''}>${esc(c.label)}</option>`;
+ // Each agent and model says what it is ready for on the person's machines (Shared's select draws it).
+ const opt = (c) => `<option value="${esc(c.value)}"${selected === c.value ? ' selected' : ''}${c.note ? ` title="${esc(c.note)}"` : ''}${ready ? optionAttrs(ready.get(String(c.value).split('|')[0])) : ''}>${esc(c.label)}</option>`;
const first = auto ? `<option value="">${esc(auto)}</option>` : opt(all[0]);
const first = auto ? `<option value="">${esc(auto)}</option>` : opt(all[0]);
- return `<label class="ch-pick${cls ? ` ${cls}` : ''}" title="${esc(label)}"><span class="ch-sr">${esc(label)}</span>${ico('agents')}<select name="who" data-ch-who>${first}${[...groups].map(([g, list]) => list.length > 1 ? `<optgroup label="${esc(g)}">${list.map(opt).join('')}</optgroup>` : opt(list[0])).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
+ return `<label class="ch-pick${cls ? ` ${cls}` : ''}" title="${esc(label)}"><span class="ch-sr">${esc(label)}</span>${ico('agents')}<select name="who" data-ch-who data-ov-select="bare" data-ov-title="${esc(label)}">${first}${[...groups].map(([g, list]) => list.length > 1 ? `<optgroup label="${esc(g)}">${list.map(opt).join('')}</optgroup>` : opt(list[0])).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
}
}
// The effort in plain words (the shared ladder underneath; each agent gets the nearest level it accepts).
// The effort in plain words (the shared ladder underneath; each agent gets the nearest level it accepts).
const EFFORT = [['', 'Effort: auto'], ['low', 'Quick'], ['medium', 'Balanced'], ['high', 'Thorough'], ['max', 'Deepest']];
const EFFORT = [['', 'Effort: auto'], ['low', 'Quick'], ['medium', 'Balanced'], ['high', 'Thorough'], ['max', 'Deepest']];
const effortWord = (v) => (EFFORT.find(([k]) => k === v) || [null, v])[1];
const effortWord = (v) => (EFFORT.find(([k]) => k === v) || [null, v])[1];
function effortPicker(selected = '', cls = '') {
function effortPicker(selected = '', cls = '') {
- return `<label class="ch-pick${cls ? ` ${cls}` : ''}" title="How hard it thinks"><span class="ch-sr">Effort</span>${ico('sub')}<select name="effort" data-ch-effort>${EFFORT.map(([v, l]) => `<option value="${v}"${selected === v ? ' selected' : ''}>${l}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
+ return `<label class="ch-pick${cls ? ` ${cls}` : ''}" title="How hard it thinks"><span class="ch-sr">Effort</span>${ico('sub')}<select name="effort" data-ch-effort data-ov-select="bare" data-ov-title="Effort">${EFFORT.map(([v, l]) => `<option value="${v}"${selected === v ? ' selected' : ''}>${l}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
}
}
/** The kit's composer with Codes' pickers in its tools, where the work runs under the bar, and its JSON route. */
/** The kit's composer with Codes' pickers in its tools, where the work runs under the bar, and its JSON route. */
function composer({ action, api = '', placeholder, pickers = '', live = false, stopAction = '', note = '', id = null, where = '' }) {
function composer({ action, api = '', placeholder, pickers = '', live = false, stopAction = '', note = '', id = null, where = '' }) {
@@ -472,11 +476,12 @@ function createChatRoutes(ctx) {
@@ -472,11 +476,12 @@ function createChatRoutes(ctx) {
const modes = `<fieldset class="ch-modes"><legend class="ch-sr">What are you making?</legend>${MODE_CARDS.map(([v, icon, title, line]) => `<label class="ch-mode"><input type="radio" name="mode" value="${v}" form="ch-start"${mode === v ? ' checked' : ''} data-ch-mode><span class="ch-mode-i">${ico(icon)}</span><b>${esc(title)}</b><small>${esc(line)}</small></label>`).join('')}</fieldset>`;
const modes = `<fieldset class="ch-modes"><legend class="ch-sr">What are you making?</legend>${MODE_CARDS.map(([v, icon, title, line]) => `<label class="ch-mode"><input type="radio" name="mode" value="${v}" form="ch-start"${mode === v ? ' checked' : ''} data-ch-mode><span class="ch-mode-i">${ico(icon)}</span><b>${esc(title)}</b><small>${esc(line)}</small></label>`).join('')}</fieldset>`;
// The pickers: each shows for the way it belongs to (CSS reads the checked card).
// The pickers: each shows for the way it belongs to (CSS reads the checked card).
- const kindPick = `<label class="ch-pick ch-for-new" title="What it is"><span class="ch-sr">What it is</span>${ico('web')}<select name="kind" data-ch-kind>${Object.entries(start.KINDS).map(([v, k]) => `<option value="${v}"${kind === v ? ' selected' : ''}>${esc(k.label)}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
+ const kindPick = `<label class="ch-pick ch-for-new" title="What it is"><span class="ch-sr">What it is</span>${ico('web')}<select name="kind" data-ch-kind data-ov-select="bare" data-ov-title="What it is">${Object.entries(start.KINDS).map(([v, k]) => `<option value="${v}"${kind === v ? ' selected' : ''}>${esc(k.label)}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
const groups = [...new Set(start.PROJECTS.map((p) => p.group))];
const groups = [...new Set(start.PROJECTS.map((p) => p.group))];
- const projectPick = `<label class="ch-pick ch-for-openvibe" title="Which OpenVibe project"><span class="ch-sr">Which OpenVibe project</span>${ico('ov')}<select name="project" data-ch-project><option value="auto"${chosenProject === 'auto' ? ' selected' : ''}>Codes picks for you</option>${groups.map((g) => `<optgroup label="${esc(g)}">${start.PROJECTS.filter((p) => p.group === g).map((p) => `<option value="${esc(p.repository)}"${chosenProject === p.repository ? ' selected' : ''}${p.line ? ` title="${esc(p.line)}"` : ''}>${esc(p.name)}${p.domain ? ` · ${esc(p.domain)}` : ''}</option>`).join('')}</optgroup>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
+ const projectPick = `<label class="ch-pick ch-for-openvibe" title="Which OpenVibe project"><span class="ch-sr">Which OpenVibe project</span>${ico('ov')}<select name="project" data-ch-project data-ov-select="bare" data-ov-title="Which OpenVibe project"><option value="auto"${chosenProject === 'auto' ? ' selected' : ''}>Codes picks for you</option>${groups.map((g) => `<optgroup label="${esc(g)}">${start.PROJECTS.filter((p) => p.group === g).map((p) => `<option value="${esc(p.repository)}"${chosenProject === p.repository ? ' selected' : ''}${p.line ? ` title="${esc(p.line)}"` : ''}>${esc(p.name)}${p.domain ? ` · ${esc(p.domain)}` : ''}</option>`).join('')}</optgroup>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
const repoPick = `<label class="ch-pick ch-pick-repo ch-for-code" title="Repository"><span class="ch-sr">Repository: owner/name or any Git URL</span>${ico('repo')}<input name="repo" type="text" inputmode="url" spellcheck="false" autocapitalize="off" value="${esc(repo)}" placeholder="owner/name or any Git URL" list="ch-repos" autocomplete="off" data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>`;
const repoPick = `<label class="ch-pick ch-pick-repo ch-for-code" title="Repository"><span class="ch-sr">Repository: owner/name or any Git URL</span>${ico('repo')}<input name="repo" type="text" inputmode="url" spellcheck="false" autocapitalize="off" value="${esc(repo)}" placeholder="owner/name or any Git URL" list="ch-repos" autocomplete="off" data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>`;
- const pickers = kindPick + projectPick + repoPick + whoPicker(settings.default_agent ? `${settings.default_agent}|` : '', { cls: 'ch-for-agents' }) + effortPicker('', 'ch-for-agents');
+ const ready = await readyFor(req);
+ const pickers = kindPick + projectPick + repoPick + whoPicker(settings.default_agent ? `${settings.default_agent}|` : '', { cls: 'ch-for-agents', ready }) + effortPicker('', 'ch-for-agents');
// The repositories a person used last are a tap away (the box also searches every one they can reach).
// The repositories a person used last are a tap away (the box also searches every one they can reach).
const newRepo = `<a class="ch-chip ch-chip-new" href="/git">${ico('new')}New on OpenVibe Git</a>`;
const newRepo = `<a class="ch-chip ch-chip-new" href="/git">${ico('new')}New on OpenVibe Git</a>`;
const recent = `<div class="ch-recent ch-for-code" role="group" aria-label="Your repositories">${newRepo}${repos.length > 1 ? repos.map((x) => `<button class="ch-chip" type="button" data-ch-saved-repo="${esc(x.url)}">${ico('repo')}${esc(x.full_name)}</button>`).join('') : ''}</div>`;
const recent = `<div class="ch-recent ch-for-code" role="group" aria-label="Your repositories">${newRepo}${repos.length > 1 ? repos.map((x) => `<button class="ch-chip" type="button" data-ch-saved-repo="${esc(x.url)}">${ico('repo')}${esc(x.full_name)}</button>`).join('') : ''}</div>`;
@@ -491,7 +496,10 @@ function createChatRoutes(ctx) {
@@ -491,7 +496,10 @@ function createChatRoutes(ctx) {
const rail = (list) => `<ul class="ch-spark-list">${list.map((i) => card(i, false)).join('')}</ul><ul class="ch-spark-list" aria-hidden="true">${list.map((i) => card(i, true)).join('')}</ul>`;
const rail = (list) => `<ul class="ch-spark-list">${list.map((i) => card(i, false)).join('')}</ul><ul class="ch-spark-list" aria-hidden="true">${list.map((i) => card(i, true)).join('')}</ul>`;
const ideas = `<section class="ch-spark" aria-label="Ideas to start from" data-ch-ideas data-ch-ideasets="${esc(JSON.stringify(sets))}"><div class="ch-spark-view"><div class="ch-spark-track" data-ch-ideas-track>${rail(sets[mode])}</div></div><template data-ch-idea-icons>${['web', 'game', 'app', 'ov', 'spark'].map((n) => ico(n).replace('<svg ', `<svg data-icon="${n}" `)).join('')}</template><button class="ch-icon-btn ch-spark-pause" type="button" data-ch-ideas-pause aria-pressed="false" aria-label="Pause the ideas" title="Pause the ideas">${ico('pause', 'ch-i-pause')}${ico('play', 'ch-i-play')}</button></section>`;
const ideas = `<section class="ch-spark" aria-label="Ideas to start from" data-ch-ideas data-ch-ideasets="${esc(JSON.stringify(sets))}"><div class="ch-spark-view"><div class="ch-spark-track" data-ch-ideas-track>${rail(sets[mode])}</div></div><template data-ch-idea-icons>${['web', 'game', 'app', 'ov', 'spark'].map((n) => ico(n).replace('<svg ', `<svg data-icon="${n}" `)).join('')}</template><button class="ch-icon-btn ch-spark-pause" type="button" data-ch-ideas-pause aria-pressed="false" aria-label="Pause the ideas" title="Pause the ideas">${ico('pause', 'ch-i-pause')}${ico('play', 'ch-i-play')}</button></section>`;
- const hello = `<div class="ch-hello"><span class="ov-mark" data-size="56" aria-hidden="true"></span><h1>${name ? `What should we build, ${esc(name)}?` : 'What should we build?'}</h1><p>Say it the way you would to a friend. Pick what it is, and Codes sends it to the right place.</p>${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}${modes}</div>`;
+ // Not set up yet: one line to the guided setup, saying how far along the person is (database reads only).
+ const progress = ctx.setupProgress ? await ctx.setupProgress(req).catch(() => null) : null;
+ const nudge = progress ? `<a class="ch-setup-nudge" href="/setup"><span class="ch-setup-nudge-bar" aria-hidden="true"><span style="width:${Math.round((progress.done / progress.of) * 100)}%"></span></span><span><b>Finish setting up</b> · ${progress.done} of ${progress.of} done · next: ${esc(progress.next)}</span>${ico('chevron', 'ch-setup-nudge-go')}</a>` : '';
+ const hello = `<div class="ch-hello"><span class="ov-mark" data-size="56" aria-hidden="true"></span><h1>${name ? `What should we build, ${esc(name)}?` : 'What should we build?'}</h1><p>Say it the way you would to a friend. Pick what it is, and Codes sends it to the right place.</p>${nudge}${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}${modes}</div>`;
return frame(req, res, {
return frame(req, res, {
title: 'New chat', heading: 'New chat', start: true, empty: hello,
title: 'New chat', heading: 'New chat', start: true, empty: hello,
composerHtml: composer({ action: '/chat', id: 'ch-start', placeholder: mode === 'new' ? 'Describe your website, game or app' : 'Describe the change, question or fix', pickers, where }) + ideas,
composerHtml: composer({ action: '/chat', id: 'ch-start', placeholder: mode === 'new' ? 'Describe your website, game or app' : 'Describe the change, question or fix', pickers, where }) + ideas,
@@ -596,7 +604,7 @@ function createChatRoutes(ctx) {
@@ -596,7 +604,7 @@ function createChatRoutes(ctx) {
// The stream starts after the last line this page already shows.
// The stream starts after the last line this page already shows.
const seqRow = live ? await db.maybe('SELECT max(seq) AS seq FROM codes_session_lines WHERE task_id = $1', [row.id]) : null;
const seqRow = live ? await db.maybe('SELECT max(seq) AS seq FROM codes_session_lines WHERE task_id = $1', [row.id]) : null;
const lastSeq = seqRow && seqRow.seq != null ? Number(seqRow.seq) : 0;
const lastSeq = seqRow && seqRow.seq != null ? Number(seqRow.seq) : 0;
- const pickers = whoPicker('', { label: 'Who carries on', auto: `Same agent (${agentOf(row.agent, agentNames()).name})` }) + effortPicker('');
+ const pickers = whoPicker('', { label: 'Who carries on', auto: `Same agent (${agentOf(row.agent, agentNames()).name})`, ready: await readyFor(req) }) + effortPicker('');
return frame(req, res, {
return frame(req, res, {
currentId: row.id, title: titleOf(row), heading: titleOf(row), state, panel: await sideHtml(row),
currentId: row.id, title: titleOf(row), heading: titleOf(row), state, panel: await sideHtml(row),
turns: [...errorTurns, ...await taskThread(row)],
turns: [...errorTurns, ...await taskThread(row)],
new file mode 100644
new file mode 100644
@@ -0,0 +1,229 @@
@@ -0,0 +1,229 @@
+'use strict';
+
+/**
+ * The guided setup (owner 2026-11-03: "a guided setup which is intuitive and very user friendly for the user to configure
+ * their API keys, accounts, harnesses, etc."): /setup, one page of five steps, each read live from where it lives —
+ *
+ * 1 Your machine paired and online (Network, with the person's token)
+ * 2 Your agents installed and signed in on it, with what each has left (the fleet, read from Bot now)
+ * 3 Your AI keys optional: keys on Your AI (OpenVibe.AI) for the agents and models paid by use
+ * 4 Your accounts optional: OpenVibe Git, linked once
+ * 5 Your choices the default agent and machine, the conductor's fallbacks and its switch point, the safety level
+ *
+ * Nothing is stored about the setup itself: a step is done when the thing it sets up is there, so the page is right
+ * however the person got there. Every step is on the page at once (nothing folded away); the first that needs the
+ * person is marked Next. The pickers say what each choice is ready for (domain/readiness.js), and Shared's ov-forms.js
+ * draws them as OpenVibe's own select.
+ *
+ * GET /setup the page
+ * POST /setup/choices step 5's form → codes_user_settings, back to /setup#step-choices
+ * setupProgress(req) what the home page's "Finish setting up" card says (database reads only)
+ */
+
+const { asyncRouter } = require('./router');
+const { esc } = require('../render/html');
+const { readiness, optionAttrs, SETUP } = require('../domain/readiness');
+const { CONDUCTOR_AGENTS, AGENT_NAMES } = require('../domain/conductor-failover');
+
+const CHECK = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>';
+
+function createSetupRoutes(ctx, kit) {
+ const { page, wrap, pill, flash, sameOrigin, viewer, form, signIn, note, machinesOf, usageMeter, SAFETY_OPTIONS = [], YOUR_AI, PROVIDER_NAMES = {} } = kit;
+ const { store, fleet, harnesses, connections, sso, forge, userSettings } = ctx;
+ const db = store.db;
+ const r = asyncRouter();
+
+ const command = (text, label) => `<div class="c4-setup-cmd"><span class="c4-setup-cmd-label">${esc(label)}</span><code tabindex="0">${esc(text)}</code></div>`;
+
+ /** Everything the page says, read once per view. */
+ async function readAll(req, res) {
+ const subject = req.viewer.subject;
+ const machineList = await machinesOf(req, res);
+ let fleetProblem = null;
+ if (fleet) {
+ try {
+ const out = await sso.asViewer(req, res, (token) => fleet.refresh(subject, token));
+ if (out && out.problem) fleetProblem = out.problem;
+ } catch (e) { fleetProblem = { detail: String((e && e.message) || 'the fleet could not be read').slice(0, 200) }; }
+ }
+ const rows = fleet ? await fleet.agents(subject, harnesses).catch(() => []) : [];
+ let keys = null;
+ try { keys = connections ? await sso.asViewer(req, res, (token) => connections.mine(token)) : null; } catch { keys = null; }
+ let git = null;
+ try { git = forge && forge.enabled ? await forge.link(subject) : null; } catch { git = null; }
+ const settings = await userSettings.get(subject);
+ const saved = !!(await db.maybe('SELECT 1 AS one FROM codes_user_settings WHERE subject = $1', [subject]));
+ return { machines: machineList, rows, fleetProblem, keys, git, settings, saved };
+ }
+
+ function stepsOf(d) {
+ const live = (d.machines.machines || []).filter((m) => m.state !== 'revoked');
+ const online = live.filter((m) => m.state === 'online');
+ const ready = d.rows.filter((x) => x.state === 'ok' || x.state === 'low');
+ const credentials = (d.keys && d.keys.credentials) || [];
+ return [
+ { id: 'machine', title: 'Your machine', short: 'Machine', done: online.length > 0, warn: !online.length && live.length > 0, required: true },
+ { id: 'agents', title: 'Your agents', short: 'Agents', done: ready.length > 0, required: true },
+ { id: 'keys', title: 'Your AI keys', short: 'AI keys', done: credentials.some((k) => k.status !== 'failing'), optional: true },
+ { id: 'accounts', title: 'Your accounts', short: 'Accounts', done: !!d.git, optional: true },
+ { id: 'choices', title: 'Your choices', short: 'Choices', done: d.saved, required: true },
+ ];
+ }
+
+ function stepCard(step, n, next, body, sub) {
+ const state = step.done ? pill('Done', 'ok') : step.id === next ? pill('Next', 'open') : step.optional ? pill('Optional', 'skip') : pill('To do', 'skip');
+ return `<section class="c4-card c4-setup-step${step.done ? ' is-done' : ''}${step.id === next ? ' is-next' : ''}" id="step-${step.id}" aria-labelledby="step-${step.id}-h">
+<div class="c4-setup-step-head"><span class="c4-setup-n" aria-hidden="true">${step.done ? CHECK : n}</span><div class="c4-setup-step-title"><h2 id="step-${step.id}-h">${esc(step.title)}</h2><p>${sub}</p></div>${state}</div>
+<div class="c4-setup-body">${body}</div></section>`;
+ }
+
+ function machineBody(d) {
+ const list = (d.machines.machines || []).filter((m) => m.state !== 'revoked');
+ const rows = list.map((m) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-${m.state === 'online' ? 'ok' : 'skip'}" aria-hidden="true"></span><span class="c4-row-label">${esc(m.name || m.node_id || m.id)}</span><span class="c4-row-note">${m.state === 'online' ? 'Online: ready for your work' : 'Offline: start it, and it comes back here'}</span></li>`).join('');
+ const problem = d.machines.ok === false && d.machines.problem ? note(esc(d.machines.problem.detail || 'Your machines could not be read.'), 'bad') : '';
+ const how = `<ol class="c4-setup-how">
+<li><b>Get a one-time code</b> on <a href="https://openvibe.bot/machines" rel="noopener">openvibe.bot/machines</a>. It lasts ten minutes.</li>
+<li><b>Run the installer on that computer</b> and give it the code:${command('curl -fsSL https://openvibe.bot/install | sh -s -- --worker', 'On the machine')}</li>
+<li><b>It pairs itself</b> and shows up here as Online. It runs your work and nobody else's.</li></ol>`;
+ return `${problem}${rows ? `<ul class="c4-rows">${rows}</ul>` : ''}${list.length ? `<details class="c4-setup-more"><summary>Pair another machine</summary>${how}</details>` : how}
+<div class="c4-setup-actions"><a class="c4-button${list.length ? ' c4-button-light' : ''}" href="/machines#pair">${list.length ? 'Manage machines' : 'I have a code'}</a><a class="c4-opill" href="/setup#step-machine">Check again</a></div>`;
+ }
+
+ function agentsBody(d) {
+ const by = new Map(d.rows.map((x) => [x.agent, x]));
+ const ids = [...new Set([...Object.keys(SETUP), ...d.rows.map((x) => x.agent)])];
+ const items = ids.map((id) => {
+ const row = by.get(id);
+ const how = SETUP[id] || null;
+ const name = (row && row.name) || (how && how.name) || id;
+ const st = row ? row.state : 'absent';
+ const word = { ok: ['Ready', 'ok'], low: ['Nearly full', 'skip'], limited: ['Full for now', 'bad'], empty: ['Out of credit', 'bad'], login: ['Sign in', 'bad'], absent: ['Not installed', 'skip'], unknown: ['No reading', 'skip'] }[st] || ['No reading', 'skip'];
+ const meters = row && row.windows && row.windows.length ? `<div class="c4-fleet-windows">${row.windows.map(usageMeter).join('')}</div>` : '';
+ let help = '';
+ if (st === 'absent' || st === 'login') {
+ help = how && how.install
+ ? `<div class="c4-setup-agent-help">${st === 'absent' ? command(how.install, '1 · Install') : ''}${command(how.signIn, `${st === 'absent' ? '2' : '1'} · Sign in`)}${how.signInNote ? `<p class="c4-hint">Run it ${esc(how.signInNote)}, on the machine, as you. It uses ${esc(how.plan)}.</p>` : `<p class="c4-hint">On the machine, as you. It uses ${esc(how.plan)}.</p>`}</div>`
+ : `<div class="c4-setup-agent-help"><p class="c4-hint">Install it and sign in on the machine${how && how.docs ? ` (<a href="${esc(how.docs)}" rel="noopener">its guide</a>)` : ''}; Codes sees it on the machine's next heartbeat.</p></div>`;
+ }
+ return `<li class="c4-row c4-fleet-row c4-setup-agent" id="agent-${esc(id)}"><span class="c4-row-dot${st === 'ok' ? ' c4-dot-ok' : st === 'login' || st === 'limited' || st === 'empty' ? ' c4-dot-bad' : ''}" aria-hidden="true"></span><span class="c4-row-label">${esc(name)}</span><span class="c4-row-note">${esc(how ? how.plan : 'on your machine')}</span>${pill(word[0], word[1])}${meters}${help}</li>`;
+ }).join('');
+ const problem = d.fleetProblem ? note(`Your machines' agents could not be read just now: ${esc(d.fleetProblem.detail || 'Bot did not answer')}. The last reading is below.`, 'bad') : '';
+ return `${problem}<p class="c4-hint">One signed-in agent is enough to start; two let your conductor carry on when one runs out (step 5).</p><ul class="c4-rows c4-fleet">${items}</ul>
+<div class="c4-setup-actions"><a class="c4-opill" href="/setup#step-agents">Check again</a><a class="c4-opill" href="/agents">All agents and their usage</a></div>`;
+ }
+
+ function keysBody(d) {
+ const list = (d.keys && d.keys.credentials) || [];
+ const rows = list.map((k) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot${k.status === 'ok' ? ' c4-dot-ok' : k.status === 'failing' ? ' c4-dot-bad' : ''}" aria-hidden="true"></span><span class="c4-row-label">${esc(PROVIDER_NAMES[k.provider] || k.provider)}</span><span class="c4-row-note">…${esc(k.hint || '????')}</span>${k.status === 'ok' ? pill('Working', 'ok') : k.status === 'failing' ? pill('Not working', 'bad') : pill('Not tested', 'skip')}</li>`).join('');
+ return `<p class="c4-hint">Only for agents and models you pay for by use (DeepSeek, OpenAI, Anthropic, Google and more). An agent signed in with a plan needs no key. Keys live on Your AI, sealed, and every OpenVibe site uses the same ones.</p>
+${rows ? `<ul class="c4-rows">${rows}</ul>` : d.keys ? '<div class="c4-emptyline">No keys yet.</div>' : '<div class="c4-emptyline">Your keys could not be read just now.</div>'}
+<div class="c4-setup-actions"><a class="c4-button${rows ? ' c4-button-light' : ''}" href="${esc(YOUR_AI)}" rel="noopener">${rows ? 'Manage keys' : 'Add a key'} ↗</a></div>`;
+ }
+
+ function accountsBody(d) {
+ if (!forge || !forge.enabled) return '<p class="c4-hint">OpenVibe Git opens here once it trusts Codes. Your tasks can already work on any GitHub or OpenVibe Git repository you can reach.</p>';
+ return d.git
+ ? `<ul class="c4-rows"><li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-ok" aria-hidden="true"></span><span class="c4-row-label">OpenVibe Git</span><span class="c4-row-note">Linked as ${esc(d.git.forge_user || 'you')}</span>${pill('Linked', 'ok')}</li></ul><div class="c4-setup-actions"><a class="c4-opill" href="/git">Your repositories</a></div>`
+ : `<p class="c4-hint">Link OpenVibe Git once, and Codes can make repositories in your name and push your tasks' branches there. It is one consent with your OpenVibe account.</p><div class="c4-setup-actions"><a class="c4-button c4-button-light" href="/git/connect">Link OpenVibe Git</a></div>`;
+ }
+
+ function choicesBody(d, machines) {
+ const s = d.settings;
+ const ready = readiness(d.rows);
+ const offers = (harnesses && harnesses.list ? harnesses.list() : []).filter((a) => a && a.id);
+ const agentOption = (id, name, selected) => `<option value="${esc(id)}"${selected ? ' selected' : ''}${optionAttrs(ready.get(id))}>${esc(name)}</option>`;
+ const agents = offers.map((a) => agentOption(a.id, a.name || a.id, s.default_agent === a.id)).join('');
+ const machineOptions = (machines.machines || []).filter((m) => m.state !== 'revoked').map((m) => `<option value="${esc(m.node_id || m.id)}"${s.default_machine && (s.default_machine === m.node_id || s.default_machine === m.id) ? ' selected' : ''} data-state="${m.state === 'online' ? 'ok' : 'off'}" data-badge="${m.state === 'online' ? 'Online' : 'Offline'}">${esc(m.name || m.node_id || m.id)}</option>`).join('');
+ const fallbacks = s.conductor_fallbacks || [];
+ const fallbackRow = (i) => {
+ const f = fallbacks[i] || { agent: '', model: '' };
+ const options = CONDUCTOR_AGENTS.filter((a) => a !== 'claude-code').map((a) => agentOption(a, AGENT_NAMES[a], f.agent === a)).join('');
+ return `<div class="c4-fallback"><span class="c4-fallback-n" aria-hidden="true">${i + 1}</span><label>Fallback ${i + 1} <select name="fallback_agent_${i + 1}" data-ov-title="Fallback ${i + 1}"><option value="">None</option>${options}</select></label><label>Model <input name="fallback_model_${i + 1}" value="${esc(f.model || '')}" placeholder="its default" autocomplete="off" spellcheck="false" maxlength="100"></label></div>`;
+ };
+ const pct = s.conductor_switch_pct || 90;
+ return `<form class="c4-form" method="post" action="/setup/choices">
+<div class="c4-form-grid">
+<label>Default agent <select name="default_agent" data-ov-title="Default agent"><option value="" data-desc="Codes picks the agent with room for each task">Auto</option>${agents}</select></label>
+<label>Default machine <select name="default_machine" data-ov-title="Default machine"><option value="" data-desc="Your machine with room, else OpenVibe's">Auto</option>${machineOptions}</select></label>
+</div>
+<h3 class="c4-subhead">When your conductor's agent runs low</h3>
+<p class="c4-hint">Your conductor runs Claude Code. When it nears a usage limit, the next turn moves to the first fallback with room, and comes back once the window frees.</p>
+<div class="c4-fallbacks">${[0, 1, 2].map(fallbackRow).join('')}</div>
+<div class="c4-form-grid"><label>Switch at <select name="conductor_switch_pct">${[70, 80, 85, 90, 95].map((v) => `<option value="${v}"${pct === v ? ' selected' : ''}>${v}% of a window</option>`).join('')}</select></label></div>
+<h3 class="c4-subhead">Safety</h3>
+<div class="c4-safety" role="group" aria-label="Safety level">${SAFETY_OPTIONS.map(([value, title, desc]) => `<label class="c4-safety-card"><input type="radio" name="safety" value="${esc(value)}"${s.safety === value ? ' checked' : ''}><span><b>${esc(title)}</b><small>${esc(desc)}</small></span></label>`).join('')}</div>
+<div class="c4-setup-actions"><button class="c4-button" type="submit">Save my choices</button><a class="c4-opill" href="/settings">All settings</a></div>
+</form>`;
+ }
+
+ r.get('/setup', async (req, res) => {
+ if (!viewer(req)) return page(req, res, 'Set up Codes', wrap(`<div class="c4-title"><div><h1>Set up Codes</h1><p>Your machine, your agents, your keys: five short steps.</p></div></div>${signIn(req)}`));
+ const d = await readAll(req, res);
+ const steps = stepsOf(d);
+ const next = (steps.find((s) => !s.done && s.required) || steps.find((s) => !s.done && !s.optional) || {}).id || null;
+ const done = steps.filter((s) => s.done).length;
+ const requiredLeft = steps.filter((s) => s.required && !s.done).length;
+ const pctDone = Math.round((done / steps.length) * 100);
+ const subs = {
+ machine: 'Where your agents run: one of your own computers, paired once.',
+ agents: 'The coding agents on it, signed in with your own plans.',
+ keys: 'Keys for the models you pay for by use.',
+ accounts: 'Where your work is pushed.',
+ choices: 'What Codes uses when you do not say.',
+ };
+ const bodies = { machine: machineBody(d), agents: agentsBody(d), keys: keysBody(d), accounts: accountsBody(d), choices: choicesBody(d, d.machines) };
+ const nav = steps.map((s, i) => `<li><a class="c4-setup-chip${s.done ? ' is-done' : ''}${s.id === next ? ' is-next' : ''}" href="#step-${s.id}"><span class="c4-setup-chip-n" aria-hidden="true">${s.done ? CHECK : i + 1}</span>${esc(s.short)}<span class="c4-sr-only">${s.done ? ', done' : s.optional ? ', optional' : ''}</span></a></li>`).join('');
+ const ready = requiredLeft === 0
+ ? `<section class="c4-card c4-setup-ready"><h2>You are set</h2><p>Your machine, an agent and your choices are in place.${steps.some((s) => s.optional && !s.done) ? ' The optional steps can wait.' : ''}</p><div class="c4-setup-actions"><a class="c4-button" href="/">Start a task</a><a class="c4-opill" href="/conductor">Start your conductor</a></div></section>`
+ : '';
+ const content = `<div class="c4-setup">
+<div class="c4-setup-head"><div class="c4-title"><div><h1>Set up Codes</h1><p>${requiredLeft ? `${requiredLeft} step${requiredLeft === 1 ? '' : 's'} left before your first task.` : 'Everything your tasks need is ready.'} Each step checks itself; come back any time.</p></div></div>
+<div class="c4-setup-progress"><div class="c4-setup-bar" role="progressbar" aria-label="Setup" aria-valuemin="0" aria-valuemax="${steps.length}" aria-valuenow="${done}"><span style="width:${pctDone}%"></span></div><span class="c4-setup-count">${done} of ${steps.length} done</span></div>
+<nav aria-label="Setup steps"><ol class="c4-setup-nav">${nav}</ol></nav></div>
+${flash(req)}${ready}${steps.map((s, i) => stepCard(s, i + 1, next, bodies[s.id], subs[s.id])).join('')}</div>`;
+ page(req, res, 'Set up Codes', wrap(content));
+ });
+
+ r.post('/setup/choices', form, async (req, res) => {
+ if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/setup')}`);
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ const b = req.body || {};
+ try {
+ await userSettings.save(req.viewer.subject, {
+ default_agent: b.default_agent === undefined ? undefined : b.default_agent,
+ default_machine: b.default_machine === undefined ? undefined : b.default_machine,
+ ...(b.safety ? { safety: b.safety } : {}),
+ ...(b.fallback_agent_1 !== undefined ? {
+ conductor_fallbacks: [1, 2, 3].map((n) => ({ agent: b[`fallback_agent_${n}`] || '', model: b[`fallback_model_${n}`] || '' })).filter((f) => f.agent),
+ } : {}),
+ ...(b.conductor_switch_pct !== undefined ? { conductor_switch_pct: b.conductor_switch_pct } : {}),
+ });
+ return res.redirect(303, '/setup?ok=Your%20choices%20are%20saved#step-choices');
+ } catch (e) {
+ return res.redirect(303, `/setup?error=${encodeURIComponent(String((e && e.message) || 'your choices could not be saved').slice(0, 180))}#step-choices`);
+ }
+ });
+
+ /**
+ * The home page's card: how far the person is, from the database alone (the stored fleet and settings; no call to
+ * Network or Bot on the home page). null when the setup needs nothing more.
+ */
+ r.setupProgress = async (req) => {
+ if (!viewer(req)) return null;
+ const subject = req.viewer.subject;
+ const latest = fleet ? await fleet.latest(subject).catch(() => ({ machines: [] })) : { machines: [] };
+ const rows = fleet ? await fleet.agents(subject, harnesses).catch(() => []) : [];
+ const saved = !!(await db.maybe('SELECT 1 AS one FROM codes_user_settings WHERE subject = $1', [subject]));
+ const steps = [
+ { title: 'Your machine', done: (latest.machines || []).length > 0 },
+ { title: 'Your agents', done: rows.some((x) => x.state === 'ok' || x.state === 'low') },
+ { title: 'Your choices', done: saved },
+ ];
+ const left = steps.filter((s) => !s.done);
+ return left.length ? { done: steps.length - left.length, of: steps.length, next: left[0].title } : null;
+ };
+
+ return r;
+}
+
+module.exports = { createSetupRoutes };
@@ -135,7 +135,7 @@ const { stepKind } = require('../server/http/chat');
@@ -135,7 +135,7 @@ const { stepKind } = require('../server/http/chat');
await check('an OpenVibe site picked from the words: one project, several, or a question when none is named', async () => {
await check('an OpenVibe site picked from the words: one project, several, or a question when none is named', async () => {
const home = await t.get('/', { as: ada });
const home = await t.get('/', { as: ada });
- assert.match(home.text, /<select name="project" data-ch-project><option value="auto" selected>Codes picks for you<\/option>/, 'the default is to work it out');
+ assert.match(home.text, /<select name="project" data-ch-project[^>]*><option value="auto" selected>Codes picks for you<\/option>/, 'the default is to work it out');
assert.match(home.text, /data-ch-picked data-ch-match-url="\/chat\/match"/);
assert.match(home.text, /data-ch-picked data-ch-match-url="\/chat\/match"/);
const m = await t.get(`/chat/match?text=${encodeURIComponent('Make the navbar smaller')}`, { as: ada });
const m = await t.get(`/chat/match?text=${encodeURIComponent('Make the navbar smaller')}`, { as: ada });
assert.deepStrictEqual(JSON.parse(m.text).projects.map((p) => p.name), ['Shared']);
assert.deepStrictEqual(JSON.parse(m.text).projects.map((p) => p.name), ['Shared']);
@@ -200,7 +200,7 @@ const HOUR = 3600_000;
@@ -200,7 +200,7 @@ const HOUR = 3600_000;
assert.match(page.text, /class="ovt-divider"><span>.*Claude Code is at 93% of its week/, 'the switch is a line in the thread');
assert.match(page.text, /class="ovt-divider"><span>.*Claude Code is at 93% of its week/, 'the switch is a line in the thread');
const settings = await t.get('/settings', { as: ada });
const settings = await t.get('/settings', { as: ada });
assert.match(settings.text, /id="conductor"/);
assert.match(settings.text, /id="conductor"/);
- assert.match(settings.text, /<option value="codex" selected data-state="ok">Codex — has room, 30% used<\/option>/);
+ assert.match(settings.text, /<option value="codex" selected data-state="ok" data-badge="30% used"[^>]*>Codex<\/option>/, 'the fallback picker says what each agent is ready for');
assert.match(settings.text, /name="fallback_model_1" value="gpt-5\.5"/);
assert.match(settings.text, /name="fallback_model_1" value="gpt-5\.5"/);
});
});
} finally {
} finally {
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
+'use strict';
+
+/**
+ * The guided setup (owner 2026-11-03): /setup's five steps, each read live; the pickers that say what each agent is
+ * ready for; the choices it saves; the home's line to it while it is unfinished.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { readiness, optionAttrs } = require('../server/domain/readiness');
+
+const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2S1';
+const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2S1';
+
+(async () => {
+ await check('readiness: a state, a badge, a line and where to fix it', async () => {
+ const r = readiness([
+ { agent: 'claude-code', state: 'ok', used: 41, machines: [{ name: 'ada-laptop', room: true }] },
+ { agent: 'codex', state: 'absent', used: 0, machines: [] },
+ { agent: 'opencode', state: 'login', used: 0, machines: [] },
+ { agent: 'minimax-code', state: 'low', used: 88, machines: [] },
+ ]);
+ assert.deepStrictEqual(r.get('claude-code'), { state: 'ok', badge: '41% used', desc: 'Signed in on ada-laptop', ready: true });
+ assert.strictEqual(r.get('codex').href, '/setup#agent-codex');
+ assert.strictEqual(r.get('opencode').hrefLabel, 'Sign in');
+ assert.strictEqual(r.get('minimax-code').state, 'warn');
+ assert.strictEqual(optionAttrs({ state: 'ok', badge: 'a "b"', desc: '<x>' }), ' data-state="ok" data-badge="a "b"" data-desc="<x>"');
+ });
+
+ const t = await boot({});
+ const ada = t.network.addUser('ada');
+ try {
+ await check('a new person: every step to do, the machine first, and the home says so', async () => {
+ const res = await t.get('/setup', { as: ada });
+ assert.strictEqual(res.status, 200, res.text);
+ for (const id of ['machine', 'agents', 'keys', 'accounts', 'choices']) assert.match(res.text, new RegExp(`id="step-${id}"`));
+ assert.match(res.text, /0 of 5 done/);
+ assert.match(res.text, /class="c4-card c4-setup-step is-next" id="step-machine"/, 'the machine is next');
+ assert.match(res.text, /curl -fsSL https:\/\/openvibe\.bot\/install \| sh -s -- --worker/);
+ assert.match(res.text, /npm install -g @openai\/codex/, 'an agent that is not there says how to install it');
+ assert.match(res.text, /href="\/css\/setup\.css|css\/setup\.css/, 'the page brings its own stylesheet');
+ const home = await t.get('/', { as: ada });
+ assert.match(home.text, /class="ch-setup-nudge" href="\/setup"/);
+ assert.match(home.text, /0 of 3 done · next: Your machine/);
+ });
+
+ await check('a paired machine with Claude Code signed in: two steps done, the pickers know', async () => {
+ t.network.addNode(ada.subject, { principal: NODE, node_id: 'node_ada', name: 'ada-laptop', status: 'active', last_seen_at: new Date().toISOString() });
+ t.bot.addMachine({ id: DEVICE, node_id: NODE, name: 'ada-laptop', online: true, fleet_at: new Date().toISOString(), fleet: [
+ { agent: 'claude-code', installed: true, signed_in: true, windows: [{ name: 'week', pct: 41, resets_at: new Date(Date.now() + 86400000).toISOString() }] },
+ { agent: 'opencode', installed: true, signed_in: false, windows: [] },
+ ] });
+ const res = await t.get('/setup', { as: ada });
+ assert.match(res.text, /class="c4-card c4-setup-step is-done" id="step-machine"/);
+ assert.match(res.text, /class="c4-card c4-setup-step is-done" id="step-agents"/);
+ assert.match(res.text, /class="c4-card c4-setup-step is-next" id="step-choices"/, 'the choices are next');
+ assert.match(res.text, /opencode auth login/, 'a signed-out agent says how to sign in');
+ assert.match(res.text, /<option value="claude-code"[^>]*data-state="ok" data-badge="41% used"/, 'the default agent picker says what is ready');
+ assert.match(res.text, /<option value="opencode"[^>]*data-state="bad" data-badge="Sign in"[^>]*data-href="\/setup#agent-opencode"/);
+ const home = await t.get('/', { as: ada });
+ assert.match(home.text, /2 of 3 done · next: Your choices/);
+ assert.match(home.text, /<option value="claude-code\|[^"]*"[^>]*data-state="ok"/, 'the composer\'s agent picker knows too');
+ assert.match(home.text, /data-ch-who data-ov-select="bare"/, 'the composer\'s chips use the bare select');
+ });
+
+ await check('saving the choices finishes the setup', async () => {
+ const res = await t.get('/setup/choices', { as: ada, form: { default_agent: 'claude-code', default_machine: NODE, fallback_agent_1: 'codex', fallback_model_1: 'gpt-5.5', fallback_agent_2: '', fallback_model_2: '', fallback_agent_3: '', fallback_model_3: '', conductor_switch_pct: '85', safety: 'project' } });
+ assert.strictEqual(res.status, 303, res.text);
+ assert.match(res.headers.get('location'), /^\/setup\?ok=/);
+ const s = await t.ctx.userSettings.get(ada.subject);
+ assert.strictEqual(s.default_agent, 'claude-code');
+ assert.deepStrictEqual(s.conductor_fallbacks, [{ agent: 'codex', model: 'gpt-5.5' }]);
+ assert.strictEqual(s.conductor_switch_pct, 85);
+ assert.strictEqual(s.safety, 'project');
+ const page = await t.get('/setup', { as: ada });
+ assert.match(page.text, /You are set/);
+ assert.match(page.text, /class="c4-card c4-setup-step is-done" id="step-choices"/);
+ const home = await t.get('/', { as: ada });
+ assert.doesNotMatch(home.text, /ch-setup-nudge/, 'a finished setup leaves the home alone');
+ });
+
+ await check('signed out: the page asks to sign in', async () => {
+ const res = await t.get('/setup');
+ assert.strictEqual(res.status, 200);
+ assert.match(res.text, /Set up Codes/);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });