openvibers/openvibe.actor Commits

Actor's chat: steer it while it works, watch its screen, and examples people would really use (#225)

OP OpenVibers committed 12 days ago · 6f758c9b34 · 8 files, +271 −56

The owner, 2026-11-01: Actor is 'a Grokbot… it browses the web, it has a display… you should be able to watch it and
just chill while you do stuff, and steer it'; and 'I hate all the current examples… search what people use it for'.

- Steering: while a turn works, the composer sends what you write to it (POST /chat/:id/steer, engine.steer); the run
  hears it at its next model turn, after the tool round it arrived in, never inside one; the steps show it as yours.
  Stop is its own button.
- The screen: a working turn shows what Actor sees (the last picture it looked at, refreshed every two seconds) with a
  Live badge and Bot's page to watch it live and take over; a finished turn keeps the last picture.
- Examples: the chat's starters and the home's templates are what people hand agents like this (research: OpenAI's
  agent launch, Manus reviews, Perplexity's Comet usage study): a flight, the best deal, a fact-check, watching eBay
  for a card, a weekend plan, a table of streamers, the buzz about a phone, a live channel on OpenVibe.

Changes

ddiff --git a/public/css/chat.css b/public/css/chat.css
iindex cb2fdbd..1a27d21 100644
---- a/public/css/chat.css
++++ b/public/css/chat.css
@@@ -150,7 +150,6 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
.ct-starters button:hover { border-color: var(--accent, #f97316); }
.ct-starters b { font-size: 14px; }
.ct-starters span { font-size: 13.5px; line-height: 1.45; color: var(--text-secondary, #a8b3c4); }
--@media (max-width: 560px) { .ct-starters { grid-template-columns: minmax(0, 1fr); } }
/* The composer: at the bottom of the screen, above the app bar. */
.cc { position: sticky; bottom: var(--ovnav-appbar-h, 0px); z-index: 15; padding: 10px max(12px, env(safe-area-inset-right, 0px)) calc(8px + env(safe-area-inset-bottom, 0px)) max(12px, env(safe-area-inset-left, 0px));
@@@ -172,7 +171,10 @@ html.ov-has-appbar .cc { padding-bottom: 8px; }
.cc-send svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.cc-send:active { transform: scale(.92); }
.cc-send:disabled { opacity: .45; cursor: default; }
--.cc-send.is-stop { background: var(--text-primary, #e6edf7); color: var(--bg-primary, #0a0f1c); }
++.cc-stop { flex: none; display: grid; place-items: center; width: 38px; height: 38px; border-radius: 50%; border: 1px solid var(--line); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); }
++.cc-stop[hidden] { display: none; }
++.cc-stop svg { width: 16px; height: 16px; fill: currentColor; }
++.cc-stop:hover { border-color: var(--error, #ef4444); color: var(--error-text, var(--error, #ef4444)); }
.cc-problem { max-width: 780px; margin: 0 auto 8px; padding: 8px 12px; border-radius: 12px; background: color-mix(in srgb, var(--warning, #f59e0b) 14%, var(--bg-card, #131c2e)); font-size: 14px; }
.cc-fine { max-width: 780px; margin: 6px auto 0; text-align: center; font-size: 12px; color: var(--text-muted, #7386a3); }
@media (max-width: 560px) { .cc-fine { display: none; } }
@@@ -187,3 +189,28 @@ html.ov-has-appbar .cc { padding-bottom: 8px; }
.cw-live, .cw-tool.is-run b { color: var(--text-primary, #e6edf7); background: none; }
}
html[data-ov-motion=reduced] .ct-answer.is-reveal > * { animation: none !important; }
++
++/* What the person said while it worked, in its list. */
++.cw-you p { margin: 0; padding: 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--accent, #f97316) 14%, var(--bg-card, #131c2e)); color: var(--text-primary, #e6edf7); overflow-wrap: anywhere; justify-self: start; }
++.cw-you { color: var(--accent, #f97316); }
++.cw-you.is-sending p { opacity: .6; }
++
++/* The screen: what Actor sees while it works, with Bot's live view one tap away. */
++.ct-screen { margin: 0; display: grid; gap: 6px; }
++.ct-screen[hidden] { display: none; }
++.ct-screen-frame { position: relative; overflow: hidden; border-radius: 14px; border: 1px solid var(--line); background: #05070d; aspect-ratio: 16 / 10; display: grid; place-items: center; }
++.ct-screen-frame img { display: block; width: 100%; height: 100%; object-fit: contain; }
++.ct-screen-wait { padding: 16px; font-size: 14px; text-align: center; color: var(--text-muted, #7386a3); }
++.ct-live { position: absolute; top: 8px; left: 8px; display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: 999px; background: rgba(0, 0, 0, .6); color: #fff; font-size: 11.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
++.ct-live::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: #ef4444; animation: ct-breathe 1.4s ease-in-out infinite; }
++.ct-screen figcaption { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 12px; font-size: 13px; color: var(--text-muted, #7386a3); }
++.ct-watch { display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 0 12px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--accent, #f97316) 55%, var(--line)); color: var(--text-primary, #e6edf7); font-weight: 600; text-decoration: none; }
++.ct-watch:hover { background: color-mix(in srgb, var(--accent, #f97316) 14%, transparent); }
++.ct-watch svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
++.ct-screen:not(.is-live) .ct-screen-frame { max-width: 420px; }
++
++/* The starters carry the set's own glyphs. */
++.ct-starters button { grid-template-columns: 22px minmax(0, 1fr); column-gap: 10px; align-items: start; }
++.ct-starters button svg { grid-row: span 2; margin-top: 1px; color: var(--accent, #f97316); }
++.ct-starters span { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
++@media (max-width: 560px) { .ct-starters { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .ct-starters button { padding: 10px 11px; grid-template-columns: minmax(0, 1fr); } .ct-starters button svg { grid-row: auto; } .ct-starters span { font-size: 12.5px; } }
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 1f4c8d3..566d5fa 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -2,7 +2,9 @@
* - a message sends without a reload, its turn appears at once, and the thread stays at the bottom;
* - a turn that is working follows its event stream: the live line says what it is doing now, the work list fills in
* (the server renders it: GET /chat/turn/:id), and the answer fades in when it is done;
-- * - Send turns into Stop while Actor works; Enter sends on a keyboard, Shift+Enter is a new line;
++ * - while Actor works, what you write steers that turn (it hears it at its next step) and Stop is its own button;
++ * Enter sends on a keyboard, Shift+Enter is a new line;
++ * - a turn working on a screen shows what Actor sees, refreshed every two seconds, with Bot's live view one tap away;
* - the chats open as a drawer on phones, and the search box filters them;
* - Copy on each answer and on each code block.
* Booted on load and after an in-place page swap (openvibe-shared/boost's ov:boost:load).
@@@ -77,15 +79,15 @@
b.addEventListener('click', function () { text.value = b.getAttribute('data-starter'); grow(); syncSend(); text.focus(); });
});
function openTurn() { var t = chat.querySelectorAll('.ct-turn'); var last = t[t.length - 1]; return last && (last.hasAttribute('data-open') || last.getAttribute('data-state') === 'needs_you') ? last : null; }
-- var STOP = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor" stroke="none"/></svg>';
-- var SEND = send ? send.innerHTML : '';
++ var stopBtn = chat.querySelector('[data-cc-stop]');
++ function working() { var t = openTurn(); return t && t.hasAttribute('data-open') ? t : null; }
function syncSend() {
if (!send) return;
-- var busy = openTurn();
-- send.classList.toggle('is-stop', !!busy && busy.hasAttribute('data-open'));
-- send.innerHTML = busy && busy.hasAttribute('data-open') ? STOP : SEND;
-- send.setAttribute('aria-label', busy && busy.hasAttribute('data-open') ? 'Stop' : 'Send');
-- send.disabled = !(busy && busy.hasAttribute('data-open')) && !(text && text.value.trim());
++ var w = working();
++ if (stopBtn) { stopBtn.hidden = !w; stopBtn.type = 'button'; stopBtn.removeAttribute('formaction'); }
++ form.setAttribute('action', w ? '/chat/' + w.getAttribute('data-task') + '/steer' : '/chat');
++ if (text) text.placeholder = w ? 'Tell Actor something while it works' : (form.querySelector('input[name=parent]') ? 'Reply to Actor' : 'Ask Actor to do something');
++ send.disabled = !(text && text.value.trim());
}
syncSend();
@@@ -102,16 +104,37 @@
if (text) text.placeholder = 'Reply to Actor';
}
++ if (stopBtn) stopBtn.addEventListener('click', function () {
++ var w = working();
++ if (!w) return;
++ stopBtn.disabled = true;
++ if (note) note.textContent = 'Stopping…';
++ fetch('/tasks/' + encodeURIComponent(w.getAttribute('data-task')) + '/cancel', { method: 'POST', credentials: 'same-origin', redirect: 'manual' })
++ .catch(function () {}).then(function () { stopBtn.disabled = false; });
++ });
++ // Steering: the words join the working turn's list at once, and the run hears them at its next step.
++ function steer(w) {
++ var said = text.value.trim();
++ var row = document.createElement('li'); row.className = 'cw-you is-sending';
++ row.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 5.5h14v10H10l-5 4Z"/></svg><p></p>';
++ row.querySelector('p').textContent = said;
++ var list = w.querySelector('[data-work]');
++ if (list) { var fold = list.closest('details'); if (fold) fold.open = true; list.appendChild(row); }
++ text.value = ''; grow(); syncSend();
++ var body = new URLSearchParams({ task: said });
++ fetch('/chat/' + encodeURIComponent(w.getAttribute('data-task')) + '/steer', { method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body: body.toString() })
++ .then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { ok: r.ok, j: j }; }); })
++ .then(function (res) {
++ row.classList.remove('is-sending');
++ if (!res.ok) { row.remove(); text.value = said; grow(); syncSend(); problem(res.j.error || 'Actor could not take that just now.'); }
++ })
++ .catch(function () { row.remove(); text.value = said; grow(); syncSend(); problem('No connection. Your message is still in the box.'); });
++ }
++
if (form) form.addEventListener('submit', function (e) {
-- var busy = openTurn();
-- if (busy && busy.hasAttribute('data-open')) { // Stop
-- e.preventDefault();
-- send.disabled = true;
-- if (note) note.textContent = 'Stopping…';
-- fetch('/tasks/' + encodeURIComponent(busy.getAttribute('data-task')) + '/cancel', { method: 'POST', credentials: 'same-origin', redirect: 'manual' }).catch(function () {});
-- return;
-- }
if (!window.fetch || !text || !text.value.trim()) return;
++ var w = working();
++ if (w) { e.preventDefault(); problem(null); steer(w); return; }
e.preventDefault();
problem(null);
send.disabled = true;
@@@ -183,10 +206,18 @@
if (!finished) {
// Only the work list changes while it works: the fold keeps its open or closed state.
var list = turn.querySelector('[data-work]'), fresh = next.querySelector('[data-work]');
-- if (list && fresh) list.innerHTML = fresh.innerHTML;
-- else if (fresh) turn.querySelector('.ct-reply').innerHTML = next.querySelector('.ct-reply').innerHTML;
++ if (list && fresh) {
++ // Keep a line the person just sent until the server's list has it too.
++ var sending = Array.prototype.slice.call(list.querySelectorAll('.cw-you.is-sending'));
++ list.innerHTML = fresh.innerHTML;
++ sending.forEach(function (r) { list.appendChild(r); });
++ } else if (fresh) turn.querySelector('.ct-reply').innerHTML = next.querySelector('.ct-reply').innerHTML;
++ // The screen appears (or its live link changes) when the run opens a desk.
++ var scr = turn.querySelector('[data-screen]'), scr2 = next.querySelector('[data-screen]');
++ if (scr && scr2 && (scr.hidden !== scr2.hidden || scr.getAttribute('data-desk') !== scr2.getAttribute('data-desk'))) { scr.replaceWith(scr2); screen(turn); }
liveLine(turn);
} else {
++ stopScreen(turn);
turn.replaceWith(next);
var ans = next.querySelector('[data-answer]');
if (ans && !reduce) { ans.classList.add('is-reveal'); Array.prototype.forEach.call(ans.children, function (c, i) { c.style.animationDelay = Math.min(i, 14) * 55 + 'ms'; }); }
@@@ -201,9 +232,43 @@
})
.catch(function () {});
}
++ // What Actor sees: the last picture, fetched every two seconds while the turn works (only when it changed).
++ var screens = {};
++ function screen(turn) {
++ var id = turn.getAttribute('data-task');
++ if (screens[id] || !turn.hasAttribute('data-open')) return;
++ var fig = turn.querySelector('[data-screen]');
++ if (!fig) return;
++ var busyLoad = false;
++ screens[id] = setInterval(function () {
++ if (busyLoad || document.hidden) return;
++ busyLoad = true;
++ var img = new Image();
++ img.onload = function () {
++ busyLoad = false;
++ var f = turn.querySelector('[data-screen]');
++ if (!f) return;
++ var shown = f.querySelector('img[data-look]');
++ if (!shown) {
++ var frame = f.querySelector('.ct-screen-frame');
++ if (!frame) { frame = document.createElement('div'); frame.className = 'ct-screen-frame'; f.insertBefore(frame, f.firstChild); }
++ var wait = frame.querySelector('.ct-screen-wait'); if (wait) wait.remove();
++ shown = document.createElement('img'); shown.setAttribute('data-look', ''); shown.alt = 'What Actor sees now';
++ frame.insertBefore(shown, frame.firstChild);
++ }
++ shown.src = img.src;
++ f.hidden = false;
++ };
++ img.onerror = function () { busyLoad = false; };
++ img.src = '/tasks/' + encodeURIComponent(id) + '/look?at=' + Date.now();
++ }, 2000);
++ }
++ function stopScreen(turn) { var id = turn.getAttribute('data-task'); if (screens[id]) { clearInterval(screens[id]); delete screens[id]; } }
++
function follow(turn) {
if (!window.EventSource || !turn || !turn.hasAttribute('data-open') || turn.dataset.following) return;
turn.dataset.following = '1';
++ screen(turn);
liveLine(turn);
var id = turn.getAttribute('data-task');
var src = new EventSource('/api/v1/tasks/' + encodeURIComponent(id) + '/events?after=' + encodeURIComponent(turn.getAttribute('data-last') || '0'));
ddiff --git a/server/http/chat-pages.js b/server/http/chat-pages.js
iindex 1842c98..ead3b21 100644
---- a/server/http/chat-pages.js
++++ b/server/http/chat-pages.js
@@@ -28,6 +28,8 @@ const { MODES } = require('../agents/router');
const catalog = require('../agents/catalog');
const taskEvents = require('../../public/js/task-events');
const { sameOrigin } = require('./principal');
++const looks = require('../agents/looks');
++const glyphs = require('openvibe-shared/glyphs');
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const OPEN = ['queued', 'running', 'verifying'];
@@@ -128,8 +130,26 @@ function createChatPages(ctx) {
copy: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg>',
more: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h10"/></svg>',
file: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 3h7l5 5v13H7Z"/><path d="M14 3v5h5"/></svg>',
++ you: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 5.5h14v10H10l-5 4Z"/></svg>',
++ open: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 5h5v5M19 5l-8 8M10 6H6a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-4"/></svg>',
};
++ /**
++ * The screen (the owner, 2026-11-01: "it has a display… you should be able to watch it and just chill while you do
++ * stuff, and steer it"): while a turn works on a desk or reads a screen, what the agent sees now, refreshed by
++ * js/chat.js, and Bot's own page to watch it live and take it over. A finished turn keeps the last picture (for an
++ * hour, in memory: agents/looks.js).
++ */
++ function screenBlock(row, events, open) {
++ const desk = open ? store.deskFrom(events) : null;
++ const look = looks.get(row.id);
++ if (!desk && !look) return open ? html`<figure class="ct-screen" data-screen hidden></figure>` : '';
++ return html`<figure class="ct-screen${open ? ' is-live' : ''}" data-screen${desk ? html` data-desk="${desk.url}"` : ''}>
++<div class="ct-screen-frame">${look ? html`<img data-look src="/tasks/${row.id}/look?at=${look.at}" alt="What Actor ${open ? 'sees now' : 'last saw'}"${look.width && look.height ? html` width="${look.width}" height="${look.height}"` : ''}>` : html`<span class="ct-screen-wait">The screen shows here as soon as Actor looks at it.</span>`}${open ? html`<span class="ct-live">Live</span>` : ''}</div>
++<figcaption><span>${open ? `What Actor sees${desk && desk.kind === 'browser' ? ' in its browser' : ''}` : 'What Actor last saw'}</span>${desk ? html`<a class="ct-watch" href="${desk.url}" target="_blank" rel="noopener">${raw(ICON.open)}Watch live and take over</a>` : ''}</figcaption>
++</figure>`;
++ }
++
/** The work fold's rows, in order: a plan step, a note the agent wrote, or a tool call with what it returned. */
function workRows(lines) {
const { toolLabel, msText } = pageKit();
@@@ -137,6 +157,7 @@ function createChatPages(ctx) {
const rows = [];
for (const l of lines) {
if (l.t === 'step') rows.push(html`<li class="cw-step">${raw(ICON.step)}<span>${l.text}</span></li>`);
++ else if (l.t === 'text' && /^You: /.test(l.text)) rows.push(html`<li class="cw-you">${raw(ICON.you)}<p>${clip(l.text.slice(5), 300)}</p></li>`);
else if (l.t === 'text') rows.push(html`<li class="cw-note">${raw(ICON.note)}<p>${clip(l.text, 700)}</p></li>`);
else if (l.t === 'note') rows.push(html`<li class="cw-note is-${l.level || 'info'}">${raw(ICON.note)}<p>${clip(l.text, 400)}</p></li>`);
else if (l.t === 'handoff') rows.push(html`<li class="cw-step">${raw(ICON.step)}<span>Handed to ${l.to || 'another agent'}${l.reason ? `: ${clip(l.reason, 160)}` : ''}</span></li>`);
@@@ -210,11 +231,11 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
const evs = events || await store.eventsAfter(s, row.id, 0);
const open = OPEN.includes(row.state);
let reply;
-- if (row.state === 'succeeded') reply = html`${workBlock(row, evs, false)}${answerBlock(row, t)}`;
++ if (row.state === 'succeeded') reply = html`${workBlock(row, evs, false)}${screenBlock(row, evs, false)}${answerBlock(row, t)}`;
else if (row.state === 'needs_you') reply = html`${workBlock(row, evs, false)}${questionBlock(row, evs)}`;
else if (row.state === 'failed') reply = html`${workBlock(row, evs, false)}${failedBlock(row, t)}`;
else if (row.state === 'cancelled') reply = html`${workBlock(row, evs, false)}<p class="ct-stopped">Stopped. What it spent before stopping stays spent.</p>`;
-- else reply = workBlock(row, evs, true);
++ else reply = html`${workBlock(row, evs, true)}${screenBlock(row, evs, true)}`;
return html`<li class="ct-turn state-${row.state}" id="t-${row.id}" data-task="${row.id}" data-state="${row.state}" data-last="${row.last_seq}"${open ? raw(' data-open') : ''}>
<div class="ct-you"><p class="ct-bubble">${row.task}</p></div>
<div class="ct-bot"><span class="ct-avatar" aria-hidden="true"></span><div class="ct-reply">${reply}</div></div>
@@@ -241,27 +262,36 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
</aside>`;
}
-- function composer({ parent = null, mode = 'balanced', busy = false, problem = null, text = '' } = {}) {
-- return html`<form class="cc" method="post" action="/chat" data-composer${busy ? raw(' data-busy') : ''}>
++ function composer({ parent = null, mode = 'balanced', busy = false, working = null, problem = null, text = '' } = {}) {
++ return html`<form class="cc" method="post" action="${working ? `/chat/${working}/steer` : '/chat'}" data-composer${busy ? raw(' data-busy') : ''}>
${problem ? html`<p class="cc-problem" role="alert">${problem}</p>` : ''}
${parent ? html`<input type="hidden" name="parent" value="${parent}">` : ''}
<div class="cc-box">
<label class="sr-only" for="cc-text">Message Actor</label>
--<textarea id="cc-text" name="task" rows="1" maxlength="20000" required placeholder="${parent ? 'Reply to Actor' : 'Ask Actor to do something'}" data-cc-text>${text}</textarea>
++<textarea id="cc-text" name="task" rows="1" maxlength="20000" required placeholder="${working ? 'Tell Actor something while it works' : parent ? 'Reply to Actor' : 'Ask Actor to do something'}" data-cc-text>${text}</textarea>
<div class="cc-row">
<label class="cc-mode" title="How Actor picks the agent"><span class="sr-only">Mode</span><select name="mode" data-cc-mode>${MODES.map((m) => html`<option value="${m}" title="${MODE_HINT[m] || ''}"${m === mode ? raw(' selected') : ''}>${MODE_LABEL[m] || m}</option>`)}</select></label>
<span class="cc-note" data-cc-note></span>
++${working ? html`<button class="cc-stop" type="submit" formaction="/tasks/${working}/cancel" formnovalidate aria-label="Stop" data-cc-stop>${raw('<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>')}</button>` : html`<button class="cc-stop" type="button" aria-label="Stop" data-cc-stop hidden>${raw('<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>')}</button>`}
<button class="cc-send" type="submit" aria-label="Send" data-cc-send>${raw('<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5M6 11l6-6 6 6"/></svg>')}</button>
</div></div>
<p class="cc-fine">Actor checks each answer with a second model. ${config.allowance ? `Free: ${config.allowance.tasksPerDay} tasks a day.` : ''}</p>
</form>`;
}
++ // What people really hand agents like this (the owner, 2026-11-01: "I hate the current examples… search what
++ // people use Grok-style agents for"; research the same day: OpenAI's agent launch, Manus reviews and Perplexity's
++ // Comet usage study put travel, shopping and deals, fact-checks and research, watching for something, and lists
++ // first). Each one only fills the box: nothing runs until the person sends it.
const STARTERS = [
-- ['Check a domain', 'Who handles the mail for openvibe.network, and is DMARC set up?'],
-- ['Read a page', 'Read https://openvibe.live and tell me in three lines what it is.'],
-- ['Work something out', 'What is 18% of 2,450, and what is that per month over a year?'],
-- ['Plan', 'Plan a 20-minute stream: an opening, three segments and a close.'],
++ ['airplane', 'Find a flight', 'Find the cheapest round trip from New York to Tokyo in March. Show me the best three and stop before paying.'],
++ ['check-circle', 'Is this true?', 'Fact-check this with real sources, and tell me what is true, what is false and what is unclear: '],
++ ['cart', 'Best deal', 'Compare the five best standing desks under $500: price, reviews, and where each is cheapest today.'],
++ ['eye', 'Watch for it', 'Watch eBay for a used RTX 4090 under $900 and tell me the moment one is listed.'],
++ ['map', 'Plan a trip', 'Plan a weekend in Lisbon for two: a hotel under $150 a night, where to eat, and a plan for each day.'],
++ ['table', 'Make a list', 'Make a table of 20 small gaming streamers (under 10k followers) with their channel links and when they stream.'],
++ ['news', 'What\'s the buzz', 'What are people saying about the newest iPhone today on Reddit, X and the news? Give me the short version.'],
++ ['globe', 'Use a website', 'Open openvibe.live, find a channel that is live right now, and tell me what is happening on it.'],
];
async function render(req, res, { row = null, problem = null, text = '', status = 200 } = {}) {
@@@ -283,10 +313,10 @@ ${sidebar(chats, first ? first.id : null)}
<header class="ct-head"><button type="button" class="ct-menu" data-cs-open aria-controls="chats" aria-expanded="false">${raw('<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>')}<span class="sr-only">Your chats</span></button>
<h1 class="ct-title">${title}</h1>${first ? html`<a class="ct-new" href="/chat" aria-label="New chat">${raw('<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>')}</a>` : ''}</header>
<div class="ct-scroll" data-scroll>
--${turns.length ? html`<ol class="ct-thread" data-thread>${turnHtmls}</ol>` : html`<div class="ct-hello"><span class="ct-hello-mark" aria-hidden="true"></span><h2>What should Actor do?</h2><p>Ask in plain words. Actor picks the agent and the tools, checks the answer, and shows what it cost.</p>
--<ul class="ct-starters">${STARTERS.map(([k, v]) => html`<li><button type="button" data-starter="${v}"><b>${k}</b><span>${v}</span></button></li>`)}</ul></div>`}
++${turns.length ? html`<ol class="ct-thread" data-thread>${turnHtmls}</ol>` : html`<div class="ct-hello"><span class="ct-hello-mark" aria-hidden="true"></span><h2>What should Actor do?</h2><p>It browses, clicks and types for you. Watch it work, steer it, or go do something else.</p>
++<ul class="ct-starters">${STARTERS.map(([icon, k, v]) => html`<li><button type="button" data-starter="${v}">${raw(glyphs.svg(icon, { size: 20 }))}<b>${k}</b><span>${v}</span></button></li>`)}</ul></div>`}
</div>
--${composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mode : 'balanced', busy, problem, text })}
++${composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mode : 'balanced', busy, working: lastTurn && OPEN.includes(lastTurn.state) ? lastTurn.id : null, problem, text })}
</section>
</div>`,
});
@@@ -317,6 +347,20 @@ ${composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mo
return render(req, res, { row });
});
++ // Words for the turn that is working (the composer while Actor works): the run hears them at its next step.
++ r.post('/chat/:id/steer', express.urlencoded({ extended: false, limit: '16kb' }), async (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ const wantsJson = String(req.get('accept') || '').includes('application/json');
++ if (!signedIn(req) || !TASK_ID.test(req.params.id)) return wantsJson ? res.status(401).json({ error: 'Sign in first.' }) : res.redirect(303, '/auth/login?next=%2Fchat');
++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('A message must be sent from openvibe.actor itself.');
++ const row = await store.getTask(s, req.params.id);
++ if (!row || row.requester !== requesterOf(req)) return wantsJson ? res.status(404).json({ error: 'That chat is not here.' }) : res.redirect(303, '/chat');
++ const text = String((req.body && req.body.task) || '').trim().slice(0, 1000);
++ const out = text && OPEN.includes(row.state) ? await engine.steer(row.id, text) : { ok: false, reason: text ? 'it has finished' : 'nothing was said' };
++ if (wantsJson) return res.status(out.ok ? 202 : 409).json(out.ok ? { ok: true } : { error: out.reason === 'it has finished' ? 'Actor already finished that one. Send it as a new message.' : 'Actor could not take that just now.' });
++ return res.redirect(303, `/chat/${row.id}#t-${row.id}`);
++ });
++
const PROBLEM = {
'actor.allowance.exhausted': 'You have used today\'s free tasks. They come back at midnight UTC.',
'actor.budget.day_spent': 'Today\'s budget is spent. It resets at midnight UTC.',
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 4179e94..57e4347 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -51,31 +51,38 @@ const MODE_TEXT = {
};
const MODE_LABEL = { cheapest: 'Cheapest', balanced: 'Balanced', best: 'Best', fastest: 'Fastest', private: 'Private' };
const STATE_KIND = { queued: '', running: 'info', verifying: 'info', succeeded: 'ok', failed: 'bad', cancelled: '' };
++// What people really hand agents like this (the owner, 2026-11-01: "I hate all the current examples… people aren't
++// gonna use it for that… search what people use Grok-style agents for"; research the same day: OpenAI's agent launch,
++// Manus reviews and Perplexity's Comet usage study put travel and bookings, shopping and deals, fact-checks and
++// research, watching for something, and lists and tables first). The chat's starters are the same set.
const EXAMPLES = [
-- 'Who handles the mail for openvibe.network, and are its SPF and DMARC records set up properly?',
-- 'What changed in the latest Node.js LTS release? Give me the three changes that matter most, with sources.',
-- 'Explain the difference between a mutex and a semaphore with a short example.',
-- 'Is the TLS certificate of example.com valid, and when does it expire?',
++ 'Find the cheapest round trip from New York to Tokyo in March. Show me the best three and stop before paying.',
++ 'Compare the five best standing desks under $500: price, reviews, and where each is cheapest today.',
++ 'Fact-check this with real sources, and tell me what is true, what is false and what is unclear: ',
++ 'Watch eBay for a used RTX 4090 under $900 and tell me the moment one is listed.',
];
--// Tasks to start from, by what they need. Each one works today. On the home they are a rail of cards (kit.card): a short
--// title, the kind as the badge and the card type's icon; the card's link puts the whole task in the task box, ready to run
--// or change. What a robot, computer, desk or private task needs is said in the box once that task is chosen (NEEDS).
++// Tasks to start from, by what they need. On the home they are a rail of cards (kit.card): a short title, the kind as
++// the badge and the card type's icon; the card's link puts the whole task in the task box, ready to run or change. What a
++// robot, computer, desk or private task needs is said in the box once that task is chosen (NEEDS).
const TEMPLATES = [
-- { group: 'Look it up', badge: 'Lookup', type: 'domain', items: [
-- { title: 'Check a domain\'s mail setup', text: EXAMPLES[0] },
-- { title: 'Is a site\'s certificate valid?', text: EXAMPLES[3] },
++ { group: 'Book and buy', badge: 'Errand', type: 'site', items: [
++ { title: 'The cheapest flight to Tokyo in March', text: EXAMPLES[0] },
++ { title: 'The best standing desk under $500', text: EXAMPLES[1] },
] },
-- { group: 'Research with sources', badge: 'Research', type: 'report', items: [
-- { title: 'What changed in the latest Node.js LTS', text: EXAMPLES[1] },
-- { title: 'Which licences let you sell hosted copies', text: 'Which open-source licences let me sell hosted copies of the software? Compare MIT, Apache-2.0, GPL-3.0 and AGPL-3.0, with sources.' },
++ { group: 'Check and research', badge: 'Research', type: 'report', items: [
++ { title: 'Is this true? Check it with sources', text: EXAMPLES[2] },
++ { title: 'What people say about the new iPhone today', text: 'What are people saying about the newest iPhone today on Reddit, X and the news? Give me the short version.' },
] },
-- { group: 'Write and explain', badge: 'Write', type: 'post', items: [
-- { title: 'A mutex and a semaphore, explained', text: EXAMPLES[2] },
-- { title: 'A note telling viewers the stream moves', text: 'Write a short, friendly note telling my viewers the stream starts an hour later on Friday.', mode: 'cheapest' },
++ { group: 'Watch for it', badge: 'Watch', type: 'cam', items: [
++ { title: 'A used RTX 4090 under $900 on eBay', text: EXAMPLES[3] },
++ ] },
++ { group: 'Plan and list', badge: 'Plan', type: 'post', items: [
++ { title: 'A weekend in Lisbon for two', text: 'Plan a weekend in Lisbon for two: a hotel under $150 a night, where to eat, and a plan for each day.' },
++ { title: '20 small gaming streamers, in a table', text: 'Make a table of 20 small gaming streamers (under 10k followers) with their channel links and when they stream.' },
] },
{ group: 'Across OpenVibe', badge: 'OpenVibe', type: 'stream', items: [
-- { title: 'The best posts and clips on Pi robots', text: 'Find recent posts and clips on OpenVibe about Raspberry Pi robots, and link the best three.' },
++ { title: 'Who is live on OpenVibe, and about what', text: 'Open openvibe.live, find the channels that are live right now, and tell me what is happening on each.' },
] },
{ group: 'Your robot', badge: 'Robot', type: 'toy', needs: 'Turn on Your agent on the robot\'s panel first.', items: [
{ title: 'Drive your rover and say hello', text: 'Drive my rover forward for two seconds, turn on its headlights, then say hello.' },
@@@ -84,7 +91,6 @@ const TEMPLATES = [
{ title: 'The ten largest files in your Downloads', text: 'On my computer, list the ten largest files in my Downloads folder.' },
] },
{ group: 'On a desk of its own', badge: 'Desk', type: 'cam', needs: 'Needs a computer or desk host with the OpenVibe Node\'s computer plugin. Watch the desk and take it over from the task.', items: [
-- { title: 'Who is live on OpenVibe, and about what', text: 'On my computer, open openvibe.live in your own browser on a desk of your own, and tell me who is live right now and what each stream is about.' },
{ title: 'Sign in on its desk, then read your account', text: 'On my computer, open the sign-in page of <a site I use> on your desk and hand it to me to sign in; then tell me what my account page shows.' },
] },
{ group: 'Private', badge: 'Private', type: 'person', needs: 'Answered on OpenVibe\'s own model; nothing leaves OpenVibe.', items: [
@@@ -571,8 +577,8 @@ ${raw(foldBlock({
function routerCard(agents) {
const shown = agents.filter((a) => a.id !== 'codes');
return html`<figure class="router-card" aria-label="How Actor picked an agent for a sample task">
--<figcaption><span class="muted small">Sample task · Balanced</span><b>“Who handles the mail for openvibe.network, and is DMARC set up?”</b></figcaption>
--<ol>${shown.map((a, i) => html`<li class="${i === 0 ? 'pick' : ''}"><span class="rc-name">${a.name}</span><span class="rc-cost">~${usd(catalog.typicalCost(a.id, config))}</span><span class="rc-why">${i === 0 ? 'can run the DNS tools; cheapest that can' : a.can.includes('task:lookup') ? 'can do it, costs more' : 'cannot run lookups'}</span></li>`)}</ol>
++<figcaption><span class="muted small">Sample task · Balanced</span><b>“Find the best standing desk under $500, and where it is cheapest today.”</b></figcaption>
++<ol>${shown.map((a, i) => html`<li class="${i === 0 ? 'pick' : ''}"><span class="rc-name">${a.name}</span><span class="rc-cost">~${usd(catalog.typicalCost(a.id, config))}</span><span class="rc-why">${i === 0 ? 'searches and reads the shops; cheapest that can' : a.can.includes('task:lookup') ? 'can do it, costs more' : 'cannot run lookups'}</span></li>`)}</ol>
<p class="rc-foot">Checked by a second model before you see it.</p></figure>`;
}
ddiff --git a/server/runtime/loop.js b/server/runtime/loop.js
iindex 0d343f5..d8c515b 100644
---- a/server/runtime/loop.js
++++ b/server/runtime/loop.js
@@@ -536,6 +536,15 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
* a loop or (for a sub-agent) its cap on model calls. After every step the progress ledger decides whether to
* replan (two stalls) or stop (three) — in code, never as the model's mood.
*/
++ /** What the person says to a running task (the chat's composer while it works): queued, said at the next turn. */
++ const steerOf = (state, ctx) => async (text) => {
++ const said = clip(String(text == null ? '' : text).trim(), 1000);
++ if (!said) return { ok: false, reason: 'nothing was said' };
++ (state.steers = state.steers || []).push(said);
++ await ctx.emit({ step: 'text', text: `You: ${clip(said, 300)}` });
++ return { ok: true };
++ };
++
async function drive(state, ctx, { reflections = 0, maxModelCalls = Infinity } = {}) {
const signal = state.signal;
// Every break in this loop is a final answer: mark it before the checkpoint so a restart delivers it without
@@@ -548,6 +557,10 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
};
for (;;) {
if (signal && signal.aborted) throw Object.assign(new Error('cancelled'), { code: 'actor.task.cancelled' });
++ if (state.steers && state.steers.length) {
++ const said = state.steers.splice(0);
++ state.messages.push({ role: 'user', content: `While you work, the person says: ${said.join(' / ')}\nTake this into account from your next step. If it changes what they want, change course and say so in one short line.` });
++ }
if (ctx.budgetLeft && ctx.budgetLeft() <= 0) {
await answered('the task reached its budget');
break;
@@@ -937,6 +950,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
};
control.pending = [];
control.assume = applyAssumptions;
++ control.steer = steerOf(state, ctx);
control.plan = () => state.plan;
for (const text of control.pending.splice(0)) await applyAssumptions(text);
}
@@@ -1027,6 +1041,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
if (control && typeof control === 'object') {
control.pending = [];
control.assume = async (text) => { control.pending.push(String(text == null ? '' : text)); return { ok: true, queued: true }; };
++ control.steer = steerOf(state, ctx);
}
try {
// Understand first (stage V2-8): one cheap call, before any tool, that sets the deliverable, the risk and
ddiff --git a/server/tasks/engine.js b/server/tasks/engine.js
iindex d6bcb59..3b8d7ba 100644
---- a/server/tasks/engine.js
++++ b/server/tasks/engine.js
@@@ -146,6 +146,13 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
return await control.assume(text);
}
++ /** Words for a running task (the chat's composer while it works): the run hears them at its next turn. */
++ async function steer(id, text) {
++ const control = controls.get(id);
++ if (!control || typeof control.steer !== 'function') return { ok: false, reason: 'this task is not running' };
++ return await control.steer(text);
++ }
++
function submit(id) {
if (stopped) return;
waiting.push(id);
@@@ -707,7 +714,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
const status = () => ({ running: running.size, waiting: waiting.length });
-- return { submit, cancel, stop, status, answer, assume, resumeInterrupted, resumeWithEvent, resumeWithNotice, failTask, notifyWaitTimeout, setWaits, MAX_ATTEMPTS };
++ return { submit, cancel, stop, status, answer, assume, steer, resumeInterrupted, resumeWithEvent, resumeWithNotice, failTask, notifyWaitTimeout, setWaits, MAX_ATTEMPTS };
}
module.exports = { createEngine, MAX_ATTEMPTS };
ddiff --git a/test/chat.test.js b/test/chat.test.js
iindex df2caa2..ee32cbf 100644
---- a/test/chat.test.js
++++ b/test/chat.test.js
@@@ -30,11 +30,13 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
assert.match(r.headers.get('location'), /^\/auth\/login\?next=%2Fchat/);
});
-- await check('a new chat: one question, four starters, the composer, and an empty list of chats', async () => {
++ await check('a new chat: one question, eight starters from what people use agents for, the composer, an empty list', async () => {
const r = await t.get('/chat', { as: kim });
assert.strictEqual(r.status, 200);
assert.match(r.text, /<h2>What should Actor do\?<\/h2>/);
-- assert.strictEqual((r.text.match(/data-starter="/g) || []).length, 4);
++ assert.strictEqual((r.text.match(/data-starter="/g) || []).length, 8);
++ ['Find a flight', 'Is this true?', 'Best deal', 'Watch for it', 'Plan a trip', 'Make a list'].forEach((k) => assert.ok(r.text.includes(`<b>${k}</b>`), k));
++ assert.ok(!/Who handles the mail/.test(r.text), 'not the old DNS example (the owner: "people aren\'t gonna use it for that")');
assert.match(r.text, /<form class="cc" method="post" action="\/chat" data-composer>/);
assert.match(r.text, /Your chats show here\./);
assert.match(r.text, /<main id="main" class="page page-app">/, 'the app layout: the whole width, no crumbs');
@@@ -142,12 +144,34 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
const js = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'js', 'chat.js'), 'utf8');
assert.match(js, /new EventSource\('\/api\/v1\/tasks\/' \+ encodeURIComponent\(id\) \+ '\/events\?after='/, 'follows the task\'s own event stream');
assert.match(js, /fetch\('\/chat\/turn\/' \+ encodeURIComponent\(id\)/, 'the server draws each turn; the page swaps it in');
-- assert.match(js, /'\/tasks\/' \+ encodeURIComponent\(busy\.getAttribute\('data-task'\)\) \+ '\/cancel'/, 'Stop cancels the working turn');
++ assert.match(js, /'\/tasks\/' \+ encodeURIComponent\(w\.getAttribute\('data-task'\)\) \+ '\/cancel'/, 'Stop cancels the working turn');
++ assert.match(js, /'\/chat\/' \+ encodeURIComponent\(w\.getAttribute\('data-task'\)\) \+ '\/steer'/, 'what you write while it works steers it');
++ assert.match(js, /'\/tasks\/' \+ encodeURIComponent\(id\) \+ '\/look\?at='/, 'the screen refreshes from the last picture');
assert.match(js, /\(hover: hover\) and \(pointer: fine\)/, 'Enter sends only with a keyboard and mouse; a phone\'s Enter is a new line');
assert.match(js, /prefers-reduced-motion: reduce/);
assert.ok(!/innerHTML = [^;]*(text\.value|e\.text|title)/.test(js), 'what a person typed is never set as HTML');
});
++ await check('steering: words for a working turn go to it; a finished turn, another person, another site: no', async () => {
++ const done = await t.get(`/chat/${first}/steer`, { as: kim, form: { task: 'only Hetzner' }, headers: { ...SAME, accept: 'application/json' } });
++ assert.strictEqual(done.status, 409);
++ assert.match(done.json().error, /already finished/);
++ assert.strictEqual((await t.get(`/chat/${first}/steer`, { as: bo, form: { task: 'x' }, headers: { ...SAME, accept: 'application/json' } })).status, 404);
++ assert.strictEqual((await t.get(`/chat/${first}/steer`, { as: kim, form: { task: 'x' }, headers: { 'sec-fetch-site': 'cross-site' } })).status, 403);
++ assert.deepStrictEqual(await t.ctx.engine.steer('tsk_00000000000000000000000000', 'hi'), { ok: false, reason: 'this task is not running' });
++ });
++
++ await check('a working turn: the composer steers it and Stop is its own button; the screen waits for a picture', async () => {
++ const last = (await s.db.maybe('SELECT id FROM tasks WHERE requester = $1 ORDER BY id DESC LIMIT 1', [`user:${kim.subject}`])).id;
++ await s.db.query("UPDATE tasks SET state = 'running' WHERE id = $1", [last]);
++ const page = await t.get(`/chat/${last}`, { as: kim });
++ await s.db.query("UPDATE tasks SET state = 'succeeded' WHERE id = $1", [last]);
++ assert.match(page.text, new RegExp(`<form class="cc" method="post" action="/chat/${last}/steer" data-composer data-busy>`));
++ assert.match(page.text, /placeholder="Tell Actor something while it works"/);
++ assert.match(page.text, new RegExp(`<button class="cc-stop" type="submit" formaction="/tasks/${last}/cancel" formnovalidate aria-label="Stop" data-cc-stop>`));
++ assert.match(page.text, /<figure class="ct-screen" data-screen hidden><\/figure>/, 'no picture yet: the screen is there, hidden, for the script to fill');
++ });
++
await check('the app bar and the menu lead to Chat', async () => {
const r = await t.get('/chat', { as: kim });
assert.match(r.text, /"label":"Chat","href":"\/chat"/);
ddiff --git a/test/runtime-loop.test.js b/test/runtime-loop.test.js
iindex 58eac00..caea290 100644
---- a/test/runtime-loop.test.js
++++ b/test/runtime-loop.test.js
@@@ -201,6 +201,33 @@ const runArgs = (m, extra = {}) => ({
assert.ok(!/tell the person what you could not verify/i.test(prompt));
});
++ await ok('steering: what the person says while it works reaches the model at its next turn, never inside a tool round', async () => {
++ const control = {};
++ const m = make({
++ smart: [
++ { text: 'Looking.', tools: [call('lookup', { q: 'vps' }, 's1')] },
++ { text: 'Hetzner CX22 [E1].' },
++ ],
++ run: async (name, input) => {
++ // The person types while the tool runs.
++ const out = await control.steer('only look at Hetzner');
++ assert.deepStrictEqual(out, { ok: true });
++ return { text: JSON.stringify({ name, input }), data: { name, input }, sources: [] };
++ },
++ config: { ACTOR_LOOP_SELF_REFLECTIONS: '0' },
++ });
++ const out = await m.loop.run(runArgs(m, { control }));
++ assert.strictEqual(out.text, 'Hetzner CX22 [E1].');
++ const work = m.models.chats.filter((c) => c.tier !== 'fast');
++ const last = work[work.length - 1].messages;
++ const said = last.findIndex((x) => x.role === 'user' && /While you work, the person says: only look at Hetzner/.test(String(x.content)));
++ assert.ok(said > 0, 'the next model turn hears it');
++ assert.ok(last.slice(0, said).some((x) => x.role === 'tool'), 'after the tool round it arrived in, not inside it');
++ assert.ok(m.events.some((e) => e.step === 'text' && e.text === 'You: only look at Hetzner'), 'the steps show it as the person\'s');
++ assert.deepStrictEqual(await control.steer(' '), { ok: false, reason: 'nothing was said' });
++ await m.loop.close(out.state);
++ });
++
await ok('a reflection written out before a "Final answer" heading is the check, not the answer (2026-11-01)', async () => {
const leaked = 'None of the claims in the existing draft are live facts, so no evidence is required, no tools need to be called.\n\n'
+ '**Claim-by-claim reflection**\n\nYour rule applies to:\n\n- Prices\n- Scores or standings\n\nThe draft I gave earlier only contains:\n\n- Descriptions of my capabilities\n\n'