openvibers/openvibe.actor Commits

Your agents as contacts: the roster, presets in one press, and the level picker (#319)

OP OpenVibers committed 6 days ago · a53679057c · 4 files, +257 −10

Changes

ddiff --git a/public/css/agents.css b/public/css/agents.css
iindex cea04a9..8c8d4e0 100644
---- a/public/css/agents.css
++++ b/public/css/agents.css
@@@ -47,3 +47,59 @@
.pa-template.is-chosen { border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, var(--bg-card, #131c2e)); }
.pa-template:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
@media (hover: hover) { .pa-template:hover { border-color: var(--accent, #3b82f6); } }
++
++/* Agents as contacts (plan §12 A-1): the roster, the faces, the level ladder and its floor. */
++.ac-links { display: flex; flex-wrap: wrap; gap: .4rem 1rem; margin: .6rem 0 0; font-size: 14px; }
++.ac-face { --s: 44px; --h: 220; display: inline-grid; place-items: center; flex: none; width: var(--s); height: var(--s); border-radius: calc(var(--s) * .3); object-fit: cover;
++ color: hsl(var(--h) 90% 88%); background: linear-gradient(145deg, hsl(var(--h) 62% 46%), hsl(calc(var(--h) + 28) 58% 30%)); box-shadow: inset 0 0 0 1px rgba(255,255,255,.12);
++ font-weight: 700; font-size: calc(var(--s) * .42); line-height: 1; }
++.ac-face svg { width: 56%; height: 56%; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
++img.ac-face { background: var(--bg-card, #131c2e); }
++.ac-roster { list-style: none; margin: 0 0 1.25rem; padding: 0; border: 1px solid var(--border-color, rgba(255,255,255,.12)); border-radius: 14px; background: var(--bg-card, #131c2e); overflow: hidden; }
++.ac-roster li + li { border-top: 1px solid var(--border-color, rgba(255,255,255,.08)); }
++.ac-row { display: flex; align-items: center; gap: .85rem; padding: .8rem 1rem; min-height: 64px; color: inherit; text-decoration: none; }
++@media (hover: hover) { .ac-row:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); } }
++.ac-row:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
++.ac-row-main { display: grid; gap: .15rem; flex: 1; min-width: 0; }
++.ac-row-top { display: flex; flex-wrap: wrap; align-items: center; gap: .35rem .5rem; min-width: 0; }
++.ac-row-top strong { font-size: 1.02rem; color: var(--text-primary, #e6edf7); }
++.ac-row-uses, .ac-row-when { font-size: 13px; color: var(--text-secondary, #96a7c2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ac-row-when { color: var(--text-muted, #7d8ba3); }
++.ac-first { font-size: 12px; color: var(--accent-light, #60a5fa); }
++.ac-paused { font-size: 12px; color: var(--warning-text, #fbbf24); }
++.ac-chev { width: 18px; height: 18px; flex: none; fill: none; stroke: var(--text-muted, #7d8ba3); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
++/* The level, one chip: the more an agent may do alone, the warmer its colour. */
++.ac-level { display: inline-flex; align-items: center; padding: .1rem .5rem; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap;
++ color: var(--text-secondary, #96a7c2); border: 1px solid var(--border-color, rgba(255,255,255,.16)); }
++.ac-level[data-level="auto-edits"] { color: #7dd3fc; border-color: color-mix(in srgb, #7dd3fc 40%, transparent); }
++.ac-level[data-level="approve-for-me"] { color: #c4b5fd; border-color: color-mix(in srgb, #c4b5fd 40%, transparent); }
++.ac-level[data-level="full"] { color: var(--warning-text, #fbbf24); border-color: color-mix(in srgb, #fbbf24 45%, transparent); }
++:root[data-theme="light"] .ac-level[data-level="auto-edits"] { color: #0369a1; }
++:root[data-theme="light"] .ac-level[data-level="approve-for-me"] { color: #6d28d9; }
++:root[data-theme="light"] .ac-level[data-level="full"] { color: #b45309; }
++.ac-presets { list-style: none; margin: .75rem 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(15rem, 100%), 1fr)); gap: .6rem; }
++.ac-presets form { margin: 0; }
++.ac-preset { display: flex; align-items: center; gap: .75rem; width: 100%; min-height: 64px; padding: .7rem .85rem; text-align: left; font: inherit; cursor: pointer;
++ color: inherit; background: transparent; border: 1px solid var(--border-color, rgba(255,255,255,.14)); border-radius: 12px; }
++@media (hover: hover) { .ac-preset:hover { border-color: var(--accent, #3b82f6); } }
++.ac-preset > span:last-child { display: grid; gap: .1rem; min-width: 0; }
++.ac-preset small { font-size: 12.5px; color: var(--text-secondary, #96a7c2); }
++.ac-sheet-head { display: flex; align-items: flex-start; gap: 1rem; }
++.ac-sheet-head h1 { margin: 0; }
++.ac-sheet-head .pa-lede { display: flex; flex-wrap: wrap; align-items: center; gap: .35rem .5rem; }
++.ac-sheet-uses { margin: .15rem 0 .35rem; color: var(--text-secondary, #96a7c2); font-size: 14px; }
++.ac-levels { display: grid; gap: .5rem; margin: 0; padding: 0; border: 0; }
++.pa-panel label.ac-lv { display: grid; font-weight: 400; font-size: 14px; gap: .1rem .65rem; }
++.ac-lv { position: relative; display: grid; grid-template-columns: auto 1fr; column-gap: .65rem; row-gap: .1rem; align-items: start; padding: .7rem .85rem; cursor: pointer;
++ border: 1px solid var(--border-color, rgba(255,255,255,.14)); border-radius: 12px; }
++.ac-lv input { grid-row: span 2; margin: .2rem 0 0; accent-color: var(--accent, #3b82f6); width: 18px; height: 18px; }
++.ac-lv-title { font-weight: 650; color: var(--text-primary, #e6edf7); }
++.ac-lv-line { grid-column: 2; font-size: 13px; color: var(--text-secondary, #96a7c2); }
++.ac-lv:has(input:checked) { border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent); }
++.ac-lv:has(input[value="full"]:checked) { border-color: color-mix(in srgb, #fbbf24 60%, transparent); background: color-mix(in srgb, #fbbf24 7%, transparent); }
++.ac-lv:has(input:focus-visible) { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.ac-floor { display: flex; gap: .6rem; align-items: flex-start; margin: .75rem 0; padding: .65rem .8rem; border-radius: 10px; font-size: 13.5px;
++ color: var(--text-secondary, #96a7c2); background: color-mix(in srgb, var(--text-primary, #fff) 5%, transparent); }
++.ac-floor svg { width: 18px; height: 18px; flex: none; margin-top: 1px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
++.ac-floor b { color: var(--text-primary, #e6edf7); font-weight: 600; }
++@media (max-width: 30rem) { .ac-sheet-head .ac-face { --s: 52px; } .ac-row { padding: .75rem .8rem; } }
ddiff --git a/server/http/agent-contacts-ui.js b/server/http/agent-contacts-ui.js
nnew file mode 100644
iindex 0000000..35b0209
---- /dev/null
++++ b/server/http/agent-contacts-ui.js
@@@ -0,0 +1,112 @@
++'use strict';
++
++/**
++ * Your agents as contacts (plan §12 A-1, Opus's design): the roster, the face, the one line saying what an agent
++ * uses, and the level picker with the always-ask floor. Pure renderers over contacts.wire(row); the routes are in
++ * agent-pages.js.
++ *
++ * face(agent, size) the agent's face: a picture, or its preset's mark on its own colour
++ * usesLine(agent) "OpenVibe AI · a browser that remembers · Email, Slack"
++ * levelChip(level) the level in two words
++ * roster(rows) the contact rows, Vibe first
++ * presetCards(presets) "Add an agent" from a preset, one form each
++ * levelPicker(agent) four cards and the floor, posting to /me/agents/:id/level
++ */
++
++const { html, raw } = require('../render/html');
++
++// The level ladder in words a person can act on. The floor is the same at every level (agents/levels.js ALWAYS_ASK).
++const LEVEL_WORDS = {
++ ask: ['Asks first', 'It asks you before it does anything outside Actor.'],
++ 'auto-edits': ['Edits on its own', 'It changes your files and drafts without asking. Anything else asks.'],
++ 'approve-for-me': ['A second model checks', 'A model from another family reviews each step. When it says no or is not sure, you decide.'],
++ full: ['Full trust', 'It acts without asking.'],
++};
++const FLOOR_WORDS = { money: 'spending money', password: 'a password or key', delete: 'deleting something', new_recipient: 'messaging someone new' };
++
++// Each preset's mark (24-unit paths) and hue. An unknown preset or a bare "preset:agent" gets its initial.
++const MARKS = {
++ vibe: ['M12 3l1.9 5.6L19.5 10l-5.6 1.9L12 17.5l-1.9-5.6L4.5 10l5.6-1.4z', 258],
++ researcher: ['M10.5 4a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13zM15.5 15.5 20 20', 205],
++ inbox: ['M4 13l2.2-7h11.6L20 13v5H4zM4 13h4.5l1.2 2h4.6l1.2-2H20', 152],
++ studio: ['M4 7h16v12H4zM4 7l3-3M10 7l3-3M16 7l3-3M10 11v5l4-2.5z', 24],
++ party: ['M5 20l4-11 6 6zM14 4l1 2M19 6l-2 1M18 11h2M12 7c1-1 2-1 3 0', 320],
++};
++const HUE = (s) => { let h = 0; for (const c of String(s)) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };
++
++function face(agent, size = 44) {
++ const f = String((agent && agent.face) || 'preset:agent');
++ const name = String((agent && agent.name) || 'Agent');
++ if (/^https:\/\/[^\s"'<>]+$/.test(f)) return html`<img class="ac-face" src="${f}" alt="" width="${String(size)}" height="${String(size)}" loading="lazy" decoding="async">`;
++ const key = f.startsWith('preset:') ? f.slice(7) : '';
++ const mark = MARKS[key];
++ const hue = mark ? mark[1] : HUE(name);
++ const inner = mark
++ ? html`<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${mark[0]}"/></svg>`
++ : html`<span aria-hidden="true">${(name.trim()[0] || 'A').toUpperCase()}</span>`;
++ return html`<span class="ac-face" style="--h:${String(hue)};--s:${String(size)}px">${inner}</span>`;
++}
++
++const MODEL_WORDS = (m) => {
++ const model = String((m && m.model) || '');
++ switch (m && m.engine) {
++ case 'own-keys': {
++ const [provider, id] = model.includes(':') ? model.split(/:(.+)/) : ['', model];
++ const who = { openai: 'OpenAI', anthropic: 'Anthropic', deepseek: 'DeepSeek', google: 'Google', mistral: 'Mistral' }[provider] || provider || 'your';
++ return `Your ${who} key${id ? `, ${id}` : ''}`;
++ }
++ case 'open-gpu': return 'A free open model on OpenVibe';
++ case 'link': return 'Your own agent on Link';
++ default: return model === 'fast' ? 'OpenVibe AI, fast' : model === 'best' ? 'OpenVibe AI, best' : 'OpenVibe AI';
++ }
++};
++const COMPUTER_WORDS = { browser: 'a fresh browser', 'kept-browser': 'a browser that remembers', desktop: 'a desktop', 'gpu-desktop': 'a desktop with a GPU',
++ 'always-on': 'an always-on computer', 'your-computer': 'your computer', throwaway: 'a throwaway computer' };
++const PLACE_WORDS = { link: ' on your machine', home: ' at home', ovh: ' in OpenVibe’s cloud', auto: '' };
++const APP_WORDS = (id) => {
++ const bare = String(id).replace(/^connect\./, '');
++ return { email: 'Email', slack: 'Slack', media: 'Media', chat: 'Chat', github: 'GitHub', calendar: 'Calendar', drive: 'Drive' }[bare]
++ || bare.replace(/[._-]+/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
++};
++
++/** What the agent uses, in one line: its model, its computer (if any), its apps (if any). */
++function usesLine(agent) {
++ const parts = [MODEL_WORDS(agent.model)];
++ if (agent.computer && COMPUTER_WORDS[agent.computer.kind]) parts.push(`${COMPUTER_WORDS[agent.computer.kind]}${PLACE_WORDS[agent.computer.placement] || ''}`);
++ if (Array.isArray(agent.apps) && agent.apps.length) parts.push(agent.apps.slice(0, 4).map(APP_WORDS).join(', ') + (agent.apps.length > 4 ? ` +${agent.apps.length - 4}` : ''));
++ return parts.join(' · ');
++}
++
++const levelChip = (level) => html`<span class="ac-level" data-level="${LEVEL_WORDS[level] ? level : 'ask'}">${(LEVEL_WORDS[level] || LEVEL_WORDS.ask)[0]}</span>`;
++
++/** One contact row per agent: the face, the name and its level, what it uses, and its schedule when it has one. */
++function roster(rows, { when = () => '' } = {}) {
++ return html`<ul class="ac-roster">${rows.map(({ agent, row }) => {
++ const sched = row && row.schedule && row.schedule !== 'off' ? when(row) : '';
++ const paused = row && row.status === 'paused';
++ return html`<li><a class="ac-row" href="/me/agents/${agent.id}">${face(agent)}
++<span class="ac-row-main"><span class="ac-row-top"><strong>${agent.name}</strong>${row && row.is_vibe ? html`<span class="ac-first">Your first agent</span>` : ''}${levelChip(agent.level)}${paused ? html`<span class="ac-paused">Paused</span>` : ''}</span>
++<span class="ac-row-uses">${usesLine(agent)}</span>${sched ? html`<span class="ac-row-when">${sched}</span>` : ''}</span>
++<svg class="ac-chev" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></a></li>`;
++ })}</ul>`;
++}
++
++/** "Add an agent": each preset as a card that makes it in one press (its instructions come with the preset). */
++function presetCards(presets) {
++ return html`<ul class="ac-presets">${presets.map((p) => html`<li><form method="post" action="/me/agents/preset"><input type="hidden" name="preset" value="${p.id}">
++<button type="submit" class="ac-preset">${face(p, 40)}<span><strong>${p.name}</strong><small>${usesLine(p)}</small></span></button></form></li>`)}</ul>`;
++}
++
++/** The level picker: four cards (no JavaScript needed), and the floor that holds at every level. */
++function levelPicker(agent) {
++ const current = LEVEL_WORDS[agent.level] ? agent.level : 'ask';
++ return html`<form method="post" action="/me/agents/${agent.id}/level" class="ac-levels-form">
++<fieldset class="ac-levels"><legend class="sr-only">How much it may do on its own</legend>
++${Object.entries(LEVEL_WORDS).map(([id, [title, line]]) => html`<label class="ac-lv"><input type="radio" name="level" value="${id}"${id === current ? raw(' checked') : ''}>
++<span class="ac-lv-title">${title}</span><span class="ac-lv-line">${line}</span></label>`)}
++</fieldset>
++<p class="ac-floor"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 11V8a5 5 0 0 1 10 0v3M5 11h14v9H5z"/></svg><span><b>Always asks, at every level:</b> ${Object.values(FLOOR_WORDS).join(' · ')}.</span></p>
++<button type="submit" class="pa-btn pa-primary">Save</button></form>`;
++}
++
++module.exports = { face, usesLine, levelChip, roster, presetCards, levelPicker, LEVEL_WORDS, FLOOR_WORDS };
ddiff --git a/server/http/agent-pages.js b/server/http/agent-pages.js
iindex b7979c9..4874030 100644
---- a/server/http/agent-pages.js
++++ b/server/http/agent-pages.js
@@@ -6,6 +6,8 @@ const { sameOrigin } = require('./principal');
const { html, raw, time } = require('../render/html');
const { send } = require('../render/layout');
const personal = require('../agents/personal');
++const contacts = require('../agents/contacts');
++const ui = require('./agent-contacts-ui');
const approvals = require('../agents/approvals');
const memoryStore = require('../memory');
@@@ -77,17 +79,16 @@ function createAgentPages(ctx) {
r.get('/me/agents', async (req, res) => {
if (!signed(req)) return login(req, res);
-- const agents = await personal.list(s, owner(req));
++ // Vibe is made on the first visit (contacts.ensureVibe), so the roster is never empty.
++ const rows = await contacts.list(s, config, owner(req));
const waiting = await approvals.pendingCount(s, owner(req));
const chosen = TEMPLATES.find((x) => x.id === req.query.template) || null;
-- page(req, res, 'Your agents', html`<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · personal agents · alpha</p><h1>Your agents</h1>
--<p class="pa-lede">An agent is a task you set up once: standing instructions, a memory you can read and edit, and a schedule. Every run is an ordinary Actor task, with the same routing, checks, costs and limits.</p>
--<p><a href="/me/approvals">Approvals${waiting ? html` <span class="pill pill-running">${String(waiting)} waiting</span>` : ''}</a> · what your agents asked to do while you were away · <a href="/memory">Memory</a></p></header>
--${agents.length ? html`<ul class="pa-cards">${agents.map((a) => html`<li class="pa-card"><a href="/me/agents/${a.id}"><strong>${a.name}</strong></a>
--<span class="pill ${a.status === 'paused' ? 'pill-failed' : 'pill-succeeded'}">${a.status === 'paused' ? 'Paused' : 'Active'}</span>
--<span class="pa-when">${when(a)}</span>${a.last_run_at ? html`<span class="pa-when">Last run ${time(a.last_run_at)}</span>` : ''}</li>`)}</ul>`
-- : html`<p class="pa-empty">No agent yet. Start from a template, or create your own below.</p>`}
--<section class="pa-panel" aria-labelledby="pa-templates-h"><h2 id="pa-templates-h">Start from a template</h2>
++ page(req, res, 'Your agents', html`<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · your agents · alpha</p><h1>Your agents</h1>
++<p class="pa-lede">Each agent is a contact: a face, a model, a computer to work on, the apps it may use, its memory, and how much it may do without asking. Every run is an ordinary Actor task, with the same checks, costs and limits.</p>
++<p class="ac-links"><a href="/me/approvals">Approvals${waiting ? html` <span class="pill pill-running">${String(waiting)} waiting</span>` : ''}</a> <a href="/memory">Memory</a></p></header>
++${ui.roster(rows.map((row) => ({ row, agent: contacts.wire(row) })), { when })}
++<section class="pa-panel" aria-labelledby="ac-add-h"><h2 id="ac-add-h">Add an agent</h2><p class="pa-hint">Each comes ready with its instructions; change anything after.</p>${ui.presetCards(contacts.presets().presets)}</section>
++<section class="pa-panel" aria-labelledby="pa-templates-h"><h2 id="pa-templates-h">Or set up a standing job</h2><p class="pa-hint">An agent with a schedule: it runs on its own and keeps notes between runs.</p>
<ul class="pa-templates">${TEMPLATES.map((x) => html`<li><a class="pa-template${chosen && chosen.id === x.id ? ' is-chosen' : ''}" href="/me/agents?template=${x.id}#create"><strong>${x.name}</strong><span>${x.line}</span><small>${when({ schedule: x.schedule, schedule_at: x.schedule_at, schedule_dow: x.schedule_dow })}</small></a></li>`)}</ul></section>
<section class="pa-panel" id="create"><h2>${chosen ? `Create: ${chosen.name}` : 'Create an agent'}</h2>${chosen ? html`<p class="pa-hint">Fill in the parts in &lt;angle brackets&gt;, then create it. You can change anything later.</p>` : ''}<form method="post" action="/me/agents">${form(chosen ? { ...chosen, id: undefined } : {})}<button type="submit" class="pa-btn pa-primary">Create agent</button></form></section>`);
});
@@@ -100,6 +101,19 @@ ${agents.length ? html`<ul class="pa-cards">${agents.map((a) => html`<li class="
if (!out.row) return page(req, res, 'Create agent', html`<h1>Create agent</h1><p>${out.error || 'Agent limit reached.'}</p><form method="post" action="/me/agents">${form(req.body)}<button type="submit">Create</button></form>`, out.limit ? 429 : 422);
res.redirect(303, `/me/agents/${out.row.id}`);
});
++ r.post('/me/agents/preset', async (req, res) => {
++ if (!write(req, res)) return;
++ const out = await contacts.create(s, config, owner(req), { preset: String((req.body || {}).preset || '') });
++ if (!out.row) return page(req, res, 'Add an agent', html`<h1>Add an agent</h1><p>${out.error || 'Agent limit reached.'}</p><p><a href="/me/agents">Your agents</a></p>`, out.limit ? 429 : 422);
++ res.redirect(303, `/me/agents/${out.row.id}`);
++ });
++ r.post('/me/agents/:id/level', async (req, res) => {
++ if (!write(req, res)) return;
++ const out = await contacts.setLevel(s, config, owner(req), req.params.id, String((req.body || {}).level || ''));
++ if (!out) return page(req, res, 'Agent not found', html`<h1>Agent not found</h1>`, 404);
++ if (out.error) return page(req, res, 'Not saved', html`<h1>Not saved</h1><p>${out.error}</p><p><a href="/me/agents/${req.params.id}">Back</a></p>`, 422);
++ res.redirect(303, `/me/agents/${req.params.id}#level`);
++ });
// The approval inbox (agents/approvals.js): what an agent asked to do while its owner was away.
const APPROVAL_STATE = { pending: ['Waiting for you', 'pill-running'], approved: ['Approved', 'pill-succeeded'], declined: ['Declined', 'pill-failed'], expired: ['Expired', 'pill-failed'] };
const approvalPill = (a) => html`<span class="pill ${APPROVAL_STATE[a.state][1]}">${APPROVAL_STATE[a.state][0]}</span>`;
@@@ -151,11 +165,14 @@ ${a.state === 'pending' ? html`<form method="post" action="/me/approvals/${a.id}
};
const usd = (v) => (v == null || !Number.isFinite(Number(v)) ? '' : ` · $${Number(v) < 0.01 ? Number(v).toFixed(4) : Number(v).toFixed(2)}`);
const pill = (state) => state === 'succeeded' ? 'pill-succeeded' : state === 'failed' || state === 'cancelled' ? 'pill-failed' : 'pill-running';
++ const contact = contacts.wire(a);
page(req, res, a.name, html`<p class="crumbs"><a href="/me/agents">Your agents</a></p>
--<header class="pa-head"><h1>${a.name}</h1><p class="pa-lede"><span class="pill ${a.status === 'paused' ? 'pill-failed' : 'pill-succeeded'}">${a.status === 'paused' ? 'Paused' : 'Active'}</span> ${when(a)}${a.next_run_at && a.status !== 'paused' && a.schedule !== 'off' ? html` · next ${time(a.next_run_at)}` : ''}</p></header>
++<header class="pa-head ac-sheet-head">${ui.face(contact, 64)}<div><h1>${a.name}</h1><p class="ac-sheet-uses">${ui.usesLine(contact)}</p>
++<p class="pa-lede">${ui.levelChip(contact.level)} <span class="pill ${a.status === 'paused' ? 'pill-failed' : 'pill-succeeded'}">${a.status === 'paused' ? 'Paused' : 'Active'}</span> ${when(a)}${a.next_run_at && a.status !== 'paused' && a.schedule !== 'off' ? html` · next ${time(a.next_run_at)}` : ''}</p></div></header>
${a.last_skip_reason ? html`<p class="notice" role="status">The last scheduled run was skipped: ${a.last_skip_reason}</p>` : ''}
<div class="pa-grid">
<section class="pa-panel"><h2>Run now</h2><form method="post" action="/me/agents/${a.id}/run"><label>Input (optional) <span class="pa-hint">Anything this run should also consider.</span><textarea name="input" maxlength="8000" rows="3"></textarea></label><button type="submit" class="pa-btn pa-primary">Run now</button></form></section>
++<section class="pa-panel" id="level" aria-labelledby="ac-level-h"><h2 id="ac-level-h">How much it may do on its own</h2>${ui.levelPicker(contact)}</section>
<section class="pa-panel"><h2>Wake it when something happens</h2><p>Besides its schedule, this agent can run the moment something happens: a channel goes live, a new blog post, a page changes, a price drops, a webhook arrives. <a href="https://openvibe.watch">OpenVibe.Watch</a> watches for it and runs this agent with what it saw.</p>
<p class="pa-actions"><a class="pa-btn" href="https://openvibe.watch/watches/new?template=event&amp;agent_id=${a.id}">When something happens on OpenVibe</a> <a class="pa-btn" href="https://openvibe.watch/watches/new?template=page&amp;agent_id=${a.id}">When a page changes</a></p></section>
<section class="pa-panel"><h2>Recent runs</h2>${vibeChat && /^https:\/\/openvibe\.chat\/vibe\/v-[0-9a-f]{12}$/.test(vibeChat.chat_url) ? html`<p class="pa-hint">Each run also lands in <a href="${vibeChat.chat_url}">its Vibe chat</a>, where you can ask Vibe about it.</p>` : ''}${runs.length ? html`<ul class="pa-runs">${runs.map((t) => html`<li><a href="/tasks/${t.id}">${time(t.created_at)}</a> <span class="pill ${pill(t.state)}">${t.state}</span>${usd(t.cost_usd)}${gist(t) ? html`<span class="pa-run-gist">${gist(t)}</span>` : ''}</li>`)}</ul>` : html`<p class="muted">No run yet.</p>`}</section>
ddiff --git a/test/agents-roster-pages.test.js b/test/agents-roster-pages.test.js
nnew file mode 100644
iindex 0000000..d7ecf2b
---- /dev/null
++++ b/test/agents-roster-pages.test.js
@@@ -0,0 +1,62 @@
++'use strict';
++/**
++ * Your agents as contacts, on the pages (plan §12 A-1): the roster puts Vibe first, a preset makes an agent in one
++ * press, and the agent's sheet sets its level with the always-ask floor in view. Another person's agent is a 404.
++ *
++ * node test/agents-roster-pages.test.js
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const SAME = { 'sec-fetch-site': 'same-origin' };
++
++(async () => {
++ const t = await boot();
++ try {
++ const a = t.network.addUser('roster-a');
++ const b = t.network.addUser('roster-b');
++ let inbox = null;
++
++ await check('the roster: Vibe first, with what each agent uses and its level', async () => {
++ const page = await t.get('/me/agents', { as: a });
++ assert.strictEqual(page.status, 200);
++ const rows = [...page.text.matchAll(/<a class="ac-row" href="\/me\/agents\/(agt_[0-9A-Z]{26})">[\s\S]*?<strong>([^<]+)<\/strong>/g)];
++ assert.strictEqual(rows[0][2], 'Vibe', 'Vibe is first');
++ assert.ok(page.text.includes('Your first agent'));
++ assert.ok(page.text.includes('<span class="ac-level" data-level="ask">Asks first</span>'), 'its level in two words');
++ assert.ok(page.text.includes('<span class="ac-row-uses">OpenVibe AI</span>'), 'what it uses');
++ for (const id of ['researcher', 'inbox', 'studio-editor', 'party-host']) assert.ok(page.text.includes(`name="preset" value="${id}"`), `the ${id} preset`);
++ });
++
++ await check('a preset makes an agent in one press', async () => {
++ const made = await t.get('/me/agents/preset', { as: a, form: { preset: 'inbox' }, headers: SAME });
++ assert.strictEqual(made.status, 303, made.text);
++ inbox = String(made.headers.get('location')).split('/').pop();
++ const sheet = await t.get(`/me/agents/${inbox}`, { as: a });
++ assert.match(sheet.text, /<h1>Inbox<\/h1>/);
++ assert.ok(sheet.text.includes('OpenVibe AI, fast · Email, Slack'), 'what it uses, in words');
++ assert.strictEqual((await t.get('/me/agents/preset', { as: a, form: { preset: 'nope' }, headers: SAME })).status, 422, 'an unknown preset');
++ assert.strictEqual((await t.get('/me/agents/preset', { as: a, form: { preset: 'inbox' }, headers: { 'sec-fetch-site': 'cross-site' } })).status, 403, 'only from Actor itself');
++ });
++
++ await check('the level picker saves, and the floor is always in view', async () => {
++ const sheet = await t.get(`/me/agents/${inbox}`, { as: a });
++ assert.ok(sheet.text.includes('value="ask" checked'), 'asks first by default');
++ assert.match(sheet.text, /Always asks, at every level:<\/b> spending money · a password or key · deleting something · messaging someone new\./);
++ const saved = await t.get(`/me/agents/${inbox}/level`, { as: a, form: { level: 'approve-for-me' }, headers: SAME });
++ assert.strictEqual(saved.status, 303);
++ assert.strictEqual(saved.headers.get('location'), `/me/agents/${inbox}#level`);
++ const after = await t.get(`/me/agents/${inbox}`, { as: a });
++ assert.ok(after.text.includes('value="approve-for-me" checked'));
++ assert.ok(after.text.includes('<span class="ac-level" data-level="approve-for-me">A second model checks</span>'));
++ assert.strictEqual((await t.get(`/me/agents/${inbox}/level`, { as: a, form: { level: 'everything' }, headers: SAME })).status, 422, 'an unknown level');
++ });
++
++ await check('another person cannot see or change it', async () => {
++ assert.strictEqual((await t.get(`/me/agents/${inbox}`, { as: b })).status, 404);
++ assert.strictEqual((await t.get(`/me/agents/${inbox}/level`, { as: b, form: { level: 'full' }, headers: SAME })).status, 404);
++ assert.ok((await t.get(`/me/agents/${inbox}`, { as: a })).text.includes('value="approve-for-me" checked'), 'unchanged');
++ });
++ } finally {
++ await done(t);
++ }
++})();