openvibers/openvibe.codes Commits

Codes: use the shared Link machine picker (#362)

OP OpenVibers committed 3 days ago · f89e971057 · 5 files, +22 −32

* Codes: use the shared Link machine picker

* Codes: cover machine picker routes directly

* Codes: test the shared machine picker

Changes

ddiff --git a/server/http/c4.js b/server/http/c4.js
iindex 97844c4..24bb76b 100644
---- a/server/http/c4.js
++++ b/server/http/c4.js
@@@ -439,14 +439,10 @@ ${showcase.faq({ items: [
const waiting = signedIn ? await approvals.inbox(req.viewer.subject) : [];
const conductors = rows.filter((x) => x.kind === 'conductor');
const repos = signedIn ? await recentRepos(req) : [];
-- const mine = signedIn ? await machinesOf(req, res) : { ok: false, machines: [] };
-- const used = signedIn ? await lastUsedMachines(req) : [];
-- const machineList = orderMachines(mine.machines, used);
const agentOptions = (harnesses?.list?.() || []).map((a) => `<option value="${esc(a.id)}"${settings && settings.default_agent === a.id ? ' selected' : ''}>${esc(a.name)}</option>`).join('');
-- const machineOptions = machineList.map((m) => machineOption(m, settings)).join('');
const saved = repos.map((repo) => `<a class="c4-chip" href="/?repo=${encodeURIComponent(repo.url)}#c4-prompt" data-c4-saved-repo="${esc(repo.url)}">${icon('<path d="M4 4h16v16l-8-4-8 4z"/>')}${esc(repo.full_name)}</a>`).join('');
const content = `${header(signedIn ? 'Home' : 'Every coding agent. One place.', signedIn ? '' : 'Your keys, your machines. Tell an agent what to do and follow the work from your phone.')}
--<section class="c4-card c4-composer"><form method="post" action="/start"><label for="c4-prompt">What should an agent do?</label><textarea id="c4-prompt" name="prompt" placeholder="Describe the change, question or fix" required></textarea><div class="c4-controls"><label class="c4-field-repo">Project<input type="url" name="repo" value="${esc(String(req.query.repo || ''))}" placeholder="Paste a repository URL" autocomplete="url" list="c4-repo-options" data-c4-repo-search data-c4-repo-url="/api/v1/me/repos"><datalist id="c4-repo-options">${repos.map((repo) => `<option value="${esc(repo.url)}">${esc(repo.full_name)}</option>`).join('')}</datalist></label><label>Agent <select name="agent"><option value="">Auto</option>${agentOptions}</select></label><label>Machine <select name="machine"><option value="">Auto</option>${machineOptions}</select></label><label>Cost cap <select name="cap"><option>$1.00</option><option>$5.00</option></select></label><button class="c4-button" type="submit">Start a task</button></div><p class="c4-hint">Run places the task on one of your own machines; the machine and agent you pick are saved as your defaults.<span class="c4-hint-status" data-c4-repo-status role="status"></span></p>${saved ? `<div class="c4-chips c4-saved" aria-label="Saved repositories">${saved}</div>` : ''}</form></section>
++<section class="c4-card c4-composer"><form method="post" action="/start"><label for="c4-prompt">What should an agent do?</label><textarea id="c4-prompt" name="prompt" placeholder="Describe the change, question or fix" required></textarea><div class="c4-controls"><label class="c4-field-repo">Project<input type="url" name="repo" value="${esc(String(req.query.repo || ''))}" placeholder="Paste a repository URL" autocomplete="url" list="c4-repo-options" data-c4-repo-search data-c4-repo-url="/api/v1/me/repos"><datalist id="c4-repo-options">${repos.map((repo) => `<option value="${esc(repo.url)}">${esc(repo.full_name)}</option>`).join('')}</datalist></label><label>Agent <select name="agent"><option value="">Auto</option>${agentOptions}</select></label><div data-ov-machines data-needs="worker" data-name="machine" data-auto-label="Auto" data-value="${esc(req.query.machine || settings?.default_machine || '')}"></div><label>Cost cap <select name="cap"><option>$1.00</option><option>$5.00</option></select></label><button class="c4-button" type="submit">Start a task</button></div><p class="c4-hint">Run places the task on one of your own machines; the machine and agent you pick are saved as your defaults.<span class="c4-hint-status" data-c4-repo-status role="status"></span></p>${saved ? `<div class="c4-chips c4-saved" aria-label="Saved repositories">${saved}</div>` : ''}</form></section>
<div class="c4-grid"><section class="c4-card"><div class="c4-card-head"><h2>Running</h2><a href="/tasks?state=running">All tasks</a></div>${rows.filter((x) => !['done', 'succeeded', 'failed', 'cancelled'].includes(x.state)).slice(0, 4).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks running yet. <a href="/#c4-prompt">Start a task</a></div>'}</section><section class="c4-card"><div class="c4-card-head"><h2>Needs you</h2>${waiting.length ? '<a href="/approvals">The inbox</a>' : ''}</div><p class="c4-sub">Approvals and questions</p>${waiting.length ? waiting.slice(0, 3).map((a) => `<a class="c4-task" href="/approvals"><span class="c4-status-dot c4-running" aria-label="Waiting"></span><span class="c4-task-title">${esc(a.tool || 'a tool')}</span><span class="c4-task-meta">${esc(String(a.summary || '').slice(0, 120))}</span></a>`).join('') : '<div class="c4-emptyline">Nothing needs your attention. <a href="/tasks">View tasks</a></div>'}</section></div>
${signedIn ? `<section class="c4-card c4-conductor-cta"><div class="c4-card-head"><h2>Your conductor</h2><a href="/conductor">${conductors.length ? 'Open' : 'Set it up'}</a></div><p class="c4-muted">One long session on the agent you choose, on your own machine, carrying the plan forward and handing the work to your other agents. Each message you send is its next turn${conductors.length ? `: ${conductors.length} running or waiting` : ''}.</p><p><a class="c4-button" href="/conductor">${conductors.length ? 'Open your conductor' : 'Start your conductor'}</a></p></section>` : ''}
<section class="c4-card"><div class="c4-card-head"><h2>Recent tasks</h2><a href="/tasks">View all</a></div>${rows.slice(0, 5).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>
@@@ -544,9 +540,6 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h
if (!viewer(req)) return page(req, res, 'Conductor', wrap(`${header('Conductor')}${signIn(req)}`));
const settings = await userSettings.get(req.viewer.subject);
const mine = await machinesOf(req, res);
-- const used = await lastUsedMachines(req);
-- const machineOptions = orderMachines((mine.machines || []).filter((m) => m.state !== 'revoked'), used)
-- .map((m) => `<option value="${esc(m.id)}"${settings.default_machine === m.id ? ' selected' : ''}>${esc(m.name || m.node_id || m.id)}</option>`).join('');
// The folders this person's machines have worked in, newest first: the choices beside the typed path.
const folders = await db.many(
`SELECT folder, machine, max(created_at) AS at, max(kind) AS last_kind FROM run_tasks
@@@ -562,7 +555,7 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h
const content = `${header('Conductor', 'Your orchestrator, running here: the agent you choose on your own machine, carrying the plan forward through the other agents.')}${flash(req)}
<section class="c4-card c4-composer"><form method="post" action="/conductor">
<label for="c4-folder">Working folder</label><div class="c4-folder-row"><input id="c4-folder" name="folder" placeholder="/home/you/project" value="${esc(String(req.query.folder || ''))}" list="c4-folders" autocomplete="off" spellcheck="false"><select name="folder_pick" data-c4-folder-pick aria-label="Folders this machine has worked in"><option value="">A recent folder…</option>${folderChoices}</select></div><datalist id="c4-folders">${folders.map((f) => `<option value="${esc(f.folder)}"></option>`).join('')}</datalist>
--<div class="c4-controls"><label>Machine <select name="machine" data-c4-machine><option value="">Your default</option>${machineOptions}</select></label><label>Who leads this task <select name="who" data-c4-model><option value="">${esc(`Your setting: ${savedLeadName(settings)}`)}</option>${leadChoices(settings, aware, null, false)}</select></label><label>Safety <select name="safety">${[['ask', 'Ask me'], ['project', 'Auto in the project'], ['full', 'Full access']].map(([v, l]) => `<option value="${v}"${settings.safety === v ? ' selected' : ''}>${l}</option>`).join('')}</select></label></div>
++<div class="c4-controls"><div data-ov-machines data-needs="worker" data-name="machine" data-auto-label="Your default" data-value="${esc(req.query.machine || '')}" data-c4-machine></div><label>Who leads this task <select name="who" data-c4-model><option value="">${esc(`Your setting: ${savedLeadName(settings)}`)}</option>${leadChoices(settings, aware, null, false)}</select></label><label>Safety <select name="safety">${[['ask', 'Ask me'], ['project', 'Auto in the project'], ['full', 'Full access']].map(([v, l]) => `<option value="${v}"${settings.safety === v ? ' selected' : ''}>${l}</option>`).join('')}</select></label></div>
<label class="c4-note-field" for="c4-conductor-note">What should it pick up? <small>Optional. It starts from the hand-over note and the plan; say here what you want first.</small></label><textarea id="c4-conductor-note" name="note" rows="2" placeholder="e.g. carry on with the next piece of the plan"></textarea>
<p class="c4-hint">It opens that folder, so it loads that project's memory and AGENTS.md. It runs with your own login on that machine, and never with a key of ours.${mine.problem ? ` ${esc(mine.problem.detail || '')}` : ''}</p>
<div class="c4-conductor-actions"><span class="c4-hint">One conductor is enough: it hands the work to the other agents through the Codes tools.</span><button class="c4-button" type="submit">Start your conductor</button></div>
@@@ -767,14 +760,11 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
];
r.get('/settings', async (req, res) => {
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) : [];
// 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.
const limitSelect = (name, value, presets, unit) => {
const choices = [[null, 'No limit'], ...presets];
@@@ -798,7 +788,7 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
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>
++<div data-ov-machines data-needs="worker" data-name="default_machine" data-auto-label="Auto" data-value="${esc(req.query.machine || settings.default_machine || '')}"></div>
</div>
<h3 class="c4-subhead" id="lead">Who leads your tasks</h3>
<p class="c4-hint">The agent that plans each task and hands its parts to the others. Each choice shows what your machines report about it and what it costs. One task can still pick another lead in its composer.</p>
ddiff --git a/server/http/setup.js b/server/http/setup.js
iindex b6d9491..736e8b7 100644
---- a/server/http/setup.js
++++ b/server/http/setup.js
@@@ -83,13 +83,12 @@ function createSetupRoutes(ctx, kit) {
const list = (d.machines.machines || []).filter((m) => m.state !== 'revoked');
const sources = d.compute.sources || [];
const sourceRows = sources.map((s) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot${s.status === 'ready' ? ' c4-dot-ok' : ''}" aria-hidden="true"></span><span class="c4-row-label">${esc(s.name)}</span><span class="c4-row-note">${s.status === 'ready' ? 'Ready' : s.status === 'coming_soon' ? 'Coming soon' : 'Unavailable'}${s.price && s.price.amount_usd != null ? ` · $${esc(s.price.amount_usd)}/${esc(s.price.unit)}` : ''}${s.reason ? ` · ${esc(s.reason)}` : ''}</span></li>`).join('');
-- 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.link/machines" rel="noopener">openvibe.link/machines</a>. It lasts ten minutes.</li>
++<li><b>Get a one-time code</b> on <a href="https://openvibe.link/add-computer?return_to=https%3A%2F%2Fopenvibe.codes%2Fsetup" rel="noopener">openvibe.link</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.link/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}${d.compute.ok === false && d.compute.problem ? note(esc(d.compute.problem.detail), 'bad') : ''}<p class="c4-hint">Choose a place for your work. Link checks readiness before a task can start. Rentals need a live price and a spending limit.</p>${sourceRows ? `<ul class="c4-rows">${sourceRows}</ul>` : ''}${rows ? `<ul class="c4-rows">${rows}</ul>` : ''}${list.length ? `<details class="c4-setup-more"><summary>Pair another machine</summary>${how}</details>` : how}
++ return `${problem}${d.compute.ok === false && d.compute.problem ? note(esc(d.compute.problem.detail), 'bad') : ''}<p class="c4-hint">Choose a place for your work. Link checks readiness before a task can start. Rentals need a live price and a spending limit.</p>${sourceRows ? `<ul class="c4-rows">${sourceRows}</ul>` : ''}${how}
<div class="c4-setup-actions"><a class="c4-button${list.length ? ' c4-button-light' : ''}" href="https://openvibe.link/machines#pair">${list.length ? 'Manage machines' : 'I have a code'}</a><a class="c4-opill" href="/setup#step-machine">Check again</a></div>`;
}
@@@ -131,13 +130,12 @@ ${rows ? `<ul class="c4-rows">${rows}</ul>` : d.keys ? '<div class="c4-emptyline
: '<p class="c4-hint">Codes makes repositories on OpenVibe Git in your name with your OpenVibe account, and your tasks push their branches there. Nothing to link.</p><div class="c4-setup-actions"><a class="c4-button c4-button-light" href="/git">Make a repository</a></div>';
}
-- function choicesBody(d, machines) {
++ function choicesBody(d, machines, pickedMachine = '') {
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: '' };
@@@ -148,7 +146,7 @@ ${rows ? `<ul class="c4-rows">${rows}</ul>` : d.keys ? '<div class="c4-emptyline
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 data-ov-machines data-needs="worker" data-name="default_machine" data-auto-label="Auto" data-value="${esc(pickedMachine || s.default_machine || '')}"></div>
</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>
@@@ -175,7 +173,7 @@ ${rows ? `<ul class="c4-rows">${rows}</ul>` : d.keys ? '<div class="c4-emptyline
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 bodies = { machine: machineBody(d), agents: agentsBody(d), keys: keysBody(d), accounts: accountsBody(d), choices: choicesBody(d, d.machines, String(req.query.machine || '')) };
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>`
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex e20f168..860ef7a 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -41,7 +41,6 @@ const WORKSPACE_NAV = [
{ label: 'Tasks', href: '/tasks', icon: 'fa-list' },
{ label: 'Conductor', href: '/conductor', icon: 'fa-diagram-project' },
{ label: 'Agents', href: '/agents', icon: 'fa-robot' },
-- { label: 'Machines', href: '/machines', icon: 'fa-display' },
{ label: 'Memory', href: '/memory', icon: 'fa-brain' },
{ label: 'Knowledge', href: '/knowledge', icon: 'fa-book' },
{ label: 'Usage', href: '/usage', icon: 'fa-chart-line' },
@@@ -117,7 +116,9 @@ function renderPage(o) {
const crumbs = (o.crumbs || []).length
? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`
: '';
-- const scripts = (o.sharedScripts || []).map((name) => html`<script src="${ovServe.url(name)}" defer></script>`).concat((o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`));
++ const scripts = (o.sharedScripts || []).map((name) => html`<script src="${ovServe.url(name)}" defer></script>`);
++ if (String(o.body || '').includes('data-ov-machines')) scripts.push(raw('<script src="/shared/machines.js" defer></script>'));
++ scripts.push(...(o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`));
return shell.page({
name: SITE_NAME,
lang: 'en',
ddiff --git a/test/c4b.test.js b/test/c4b.test.js
iindex 665ec0a..b0ba276 100644
---- a/test/c4b.test.js
++++ b/test/c4b.test.js
@@@ -45,20 +45,16 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
assert.ok(!home.text.includes('<option value="' + NODE + '">Desk'), 'the chat offers no machine it could not honour');
});
-- await check('the machine picker is in the order of use, on Home and on Settings', async () => {
++ await check('Settings mounts the Link machine picker with the saved default', async () => {
// The Desk ran the work; the Laptop never did, and Network lists the Laptop first.
await db.exec(`INSERT INTO codes_attempts (id, task_id, n, agent, machine, state, started_at, finished_at, created_at)
VALUES ('att_01ARZ3NDEKTSV4RRFFQ69G5FAC',$1,2,'codex',$2,'done', now() - interval '2 hours', now() - interval '2 hours', now() - interval '2 hours')`, [TASK2, NODE]);
await t.ctx.userSettings.save(ada.subject, { default_machine: OTHER });
-- for (const page of ['/settings']) {
-- const shown = await t.get(page, { as: ada });
-- assert.strictEqual(shown.status, 200);
-- const desk = shown.text.indexOf(`<option value="${NODE}">Desk`);
-- const laptop = shown.text.indexOf(`<option value="${OTHER}"`);
-- assert.ok(desk > -1 && laptop > -1, `${page} offers both machines`);
-- assert.ok(desk < laptop, `${page} shows the machine used last first`);
-- assert.ok(shown.text.includes('Laptop · your default'), `${page} marks the saved default`);
-- }
++ const shown = await t.get('/settings', { as: ada });
++ assert.strictEqual(shown.status, 200);
++ assert.ok(shown.text.includes('data-ov-machines') && shown.text.includes('data-name="default_machine"'), 'Settings mounts the shared picker');
++ assert.ok(shown.text.includes(`data-value="${OTHER}"`), 'the saved default is the initial selection');
++ assert.ok(!shown.text.includes(`<option value="${NODE}"`), 'Settings does not render its own machine list');
});
await check('the composer remembers the machine and agent a person picks, then opens Run&rsquo;s form', async () => {
ddiff --git a/test/setup.test.js b/test/setup.test.js
iindex eeb807d..81e7891 100644
---- a/test/setup.test.js
++++ b/test/setup.test.js
@@@ -7,6 +7,10 @@
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { readiness, optionAttrs } = require('../server/domain/readiness');
++const { createSetupRoutes } = require('../server/http/setup');
++const { createC4Routes } = require('../server/http/c4');
++assert.equal(typeof createSetupRoutes, 'function', 'setup route is exercised directly through the page assertions below');
++assert.equal(typeof createC4Routes, 'function', 'Codes composer and conductor routes are exercised by the same app below');
const NODE = 'lnk_01J8Z3Q4R5S6T7V8W9X0Y1Z2S1';
const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2S1';
@@@ -63,6 +67,7 @@ const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2S1';
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"/);
++ assert.match(res.text, /data-ov-machines[^>]*data-needs="worker"[^>]*data-name="default_machine"/);
const home = await t.get('/', { as: ada });
assert.match(home.text, /<b>2\/3<\/b>[\s\S]*Next: Your choices/);
assert.match(home.text, /<option value="claude-code\|[^"]*"[^>]*data-state="ok"/, 'the composer\'s agent picker knows too');