openvibers/openvibe.actor Commits
Actor's chat: steer it while it works, watch its screen, and examples people would really use (#225)
Changes
@@@ -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; } }@@@ -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'));@@@ -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.moreturn 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.',@@@ -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>`;}@@@ -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@@@ -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 };@@@ -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"/);@@@ -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'
@@ -150,7 +150,6 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
@@ -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 button:hover { border-color: var(--accent, #f97316); }
.ct-starters b { font-size: 14px; }
.ct-starters b { font-size: 14px; }
.ct-starters span { font-size: 13.5px; line-height: 1.45; color: var(--text-secondary, #a8b3c4); }
.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. */
/* 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));
.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; }
@@ -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 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:active { transform: scale(.92); }
.cc-send:disabled { opacity: .45; cursor: default; }
.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-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); }
.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; } }
@media (max-width: 560px) { .cc-fine { display: none; } }
@@ -187,3 +189,28 @@ html.ov-has-appbar .cc { padding-bottom: 8px; }
@@ -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; }
.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; }
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; } }
@@ -2,7 +2,9 @@
@@ -2,7 +2,9 @@
* - a message sends without a reload, its turn appears at once, and the thread stays at the bottom;
* - 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
* - 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;
* (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;
* - the chats open as a drawer on phones, and the search box filters them;
* - Copy on each answer and on each code block.
* - 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).
* Booted on load and after an in-place page swap (openvibe-shared/boost's ov:boost:load).
@@ -77,15 +79,15 @@
@@ -77,15 +79,15 @@
b.addEventListener('click', function () { text.value = b.getAttribute('data-starter'); grow(); syncSend(); text.focus(); });
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; }
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() {
function syncSend() {
if (!send) return;
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();
syncSend();
@@ -102,16 +104,37 @@
@@ -102,16 +104,37 @@
if (text) text.placeholder = 'Reply to Actor';
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) {
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;
if (!window.fetch || !text || !text.value.trim()) return;
+ var w = working();
+ if (w) { e.preventDefault(); problem(null); steer(w); return; }
e.preventDefault();
e.preventDefault();
problem(null);
problem(null);
send.disabled = true;
send.disabled = true;
@@ -183,10 +206,18 @@
@@ -183,10 +206,18 @@
if (!finished) {
if (!finished) {
// Only the work list changes while it works: the fold keeps its open or closed state.
// 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]');
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);
liveLine(turn);
} else {
} else {
+ stopScreen(turn);
turn.replaceWith(next);
turn.replaceWith(next);
var ans = next.querySelector('[data-answer]');
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'; }); }
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 @@
@@ -201,9 +232,43 @@
})
})
.catch(function () {});
.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) {
function follow(turn) {
if (!window.EventSource || !turn || !turn.hasAttribute('data-open') || turn.dataset.following) return;
if (!window.EventSource || !turn || !turn.hasAttribute('data-open') || turn.dataset.following) return;
turn.dataset.following = '1';
turn.dataset.following = '1';
+ screen(turn);
liveLine(turn);
liveLine(turn);
var id = turn.getAttribute('data-task');
var id = turn.getAttribute('data-task');
var src = new EventSource('/api/v1/tasks/' + encodeURIComponent(id) + '/events?after=' + encodeURIComponent(turn.getAttribute('data-last') || '0'));
var src = new EventSource('/api/v1/tasks/' + encodeURIComponent(id) + '/events?after=' + encodeURIComponent(turn.getAttribute('data-last') || '0'));
@@ -28,6 +28,8 @@ const { MODES } = require('../agents/router');
@@ -28,6 +28,8 @@ const { MODES } = require('../agents/router');
const catalog = require('../agents/catalog');
const catalog = require('../agents/catalog');
const taskEvents = require('../../public/js/task-events');
const taskEvents = require('../../public/js/task-events');
const { sameOrigin } = require('./principal');
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 TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const OPEN = ['queued', 'running', 'verifying'];
const OPEN = ['queued', 'running', 'verifying'];
@@ -128,8 +130,26 @@ function createChatPages(ctx) {
@@ -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>',
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>',
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>',
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. */
/** 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) {
function workRows(lines) {
const { toolLabel, msText } = pageKit();
const { toolLabel, msText } = pageKit();
@@ -137,6 +157,7 @@ function createChatPages(ctx) {
@@ -137,6 +157,7 @@ function createChatPages(ctx) {
const rows = [];
const rows = [];
for (const l of lines) {
for (const l of lines) {
if (l.t === 'step') rows.push(html`<li class="cw-step">${raw(ICON.step)}<span>${l.text}</span></li>`);
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 === '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 === '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>`);
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=
@@ -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 evs = events || await store.eventsAfter(s, row.id, 0);
const open = OPEN.includes(row.state);
const open = OPEN.includes(row.state);
let reply;
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 === '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 === '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 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') : ''}>
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-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>
<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=
@@ -241,27 +262,36 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
</aside>`;
</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>` : ''}
${problem ? html`<p class="cc-problem" role="alert">${problem}</p>` : ''}
${parent ? html`<input type="hidden" name="parent" value="${parent}">` : ''}
${parent ? html`<input type="hidden" name="parent" value="${parent}">` : ''}
<div class="cc-box">
<div class="cc-box">
<label class="sr-only" for="cc-text">Message Actor</label>
<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">
<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>
<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>
<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>
<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>
</div></div>
<p class="cc-fine">Actor checks each answer with a second model. ${config.allowance ? `Free: ${config.allowance.tasksPerDay} tasks a day.` : ''}</p>
<p class="cc-fine">Actor checks each answer with a second model. ${config.allowance ? `Free: ${config.allowance.tasksPerDay} tasks a day.` : ''}</p>
</form>`;
</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 = [
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 } = {}) {
async function render(req, res, { row = null, problem = null, text = '', status = 200 } = {}) {
@@ -283,10 +313,10 @@ ${sidebar(chats, first ? first.id : null)}
@@ -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>
<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>
<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>
<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>
</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>
</section>
</div>`,
</div>`,
});
});
@@ -317,6 +347,20 @@ ${composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mo
@@ -317,6 +347,20 @@ ${composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mo
return render(req, res, { row });
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 = {
const PROBLEM = {
'actor.allowance.exhausted': 'You have used today\'s free tasks. They come back at midnight UTC.',
'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.',
'actor.budget.day_spent': 'Today\'s budget is spent. It resets at midnight UTC.',
@@ -51,31 +51,38 @@ const MODE_TEXT = {
@@ -51,31 +51,38 @@ const MODE_TEXT = {
};
};
const MODE_LABEL = { cheapest: 'Cheapest', balanced: 'Balanced', best: 'Best', fastest: 'Fastest', private: 'Private' };
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: '' };
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 = [
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 = [
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: [
{ 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: [
{ 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.' },
{ 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 = [
@@ -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.' },
{ 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: [
{ 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.' },
{ 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: [
{ group: 'Private', badge: 'Private', type: 'person', needs: 'Answered on OpenVibe\'s own model; nothing leaves OpenVibe.', items: [
@@ -571,8 +577,8 @@ ${raw(foldBlock({
@@ -571,8 +577,8 @@ ${raw(foldBlock({
function routerCard(agents) {
function routerCard(agents) {
const shown = agents.filter((a) => a.id !== 'codes');
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">
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>`;
<p class="rc-foot">Checked by a second model before you see it.</p></figure>`;
}
}
@@ -536,6 +536,15 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -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
* 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.
* 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 } = {}) {
async function drive(state, ctx, { reflections = 0, maxModelCalls = Infinity } = {}) {
const signal = state.signal;
const signal = state.signal;
// Every break in this loop is a final answer: mark it before the checkpoint so a restart delivers it without
// 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.
@@ -548,6 +557,10 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
};
};
for (;;) {
for (;;) {
if (signal && signal.aborted) throw Object.assign(new Error('cancelled'), { code: 'actor.task.cancelled' });
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) {
if (ctx.budgetLeft && ctx.budgetLeft() <= 0) {
await answered('the task reached its budget');
await answered('the task reached its budget');
break;
break;
@@ -937,6 +950,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -937,6 +950,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
};
};
control.pending = [];
control.pending = [];
control.assume = applyAssumptions;
control.assume = applyAssumptions;
+ control.steer = steerOf(state, ctx);
control.plan = () => state.plan;
control.plan = () => state.plan;
for (const text of control.pending.splice(0)) await applyAssumptions(text);
for (const text of control.pending.splice(0)) await applyAssumptions(text);
}
}
@@ -1027,6 +1041,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -1027,6 +1041,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
if (control && typeof control === 'object') {
if (control && typeof control === 'object') {
control.pending = [];
control.pending = [];
control.assume = async (text) => { control.pending.push(String(text == null ? '' : text)); return { ok: true, queued: true }; };
control.assume = async (text) => { control.pending.push(String(text == null ? '' : text)); return { ok: true, queued: true }; };
+ control.steer = steerOf(state, ctx);
}
}
try {
try {
// Understand first (stage V2-8): one cheap call, before any tool, that sets the deliverable, the risk and
// Understand first (stage V2-8): one cheap call, before any tool, that sets the deliverable, the risk and
@@ -146,6 +146,13 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -146,6 +146,13 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
return await control.assume(text);
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) {
function submit(id) {
if (stopped) return;
if (stopped) return;
waiting.push(id);
waiting.push(id);
@@ -707,7 +714,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -707,7 +714,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
const status = () => ({ running: running.size, waiting: waiting.length });
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 };
module.exports = { createEngine, MAX_ATTEMPTS };
@@ -30,11 +30,13 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
@@ -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/);
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 });
const r = await t.get('/chat', { as: kim });
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.text, /<h2>What should Actor do\?<\/h2>/);
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, /<form class="cc" method="post" action="\/chat" data-composer>/);
assert.match(r.text, /Your chats show here\./);
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');
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$/;
@@ -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');
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, /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, /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, /\(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.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');
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 () => {
await check('the app bar and the menu lead to Chat', async () => {
const r = await t.get('/chat', { as: kim });
const r = await t.get('/chat', { as: kim });
assert.match(r.text, /"label":"Chat","href":"\/chat"/);
assert.match(r.text, /"label":"Chat","href":"\/chat"/);
@@ -201,6 +201,33 @@ const runArgs = (m, extra = {}) => ({
@@ -201,6 +201,33 @@ const runArgs = (m, extra = {}) => ({
assert.ok(!/tell the person what you could not verify/i.test(prompt));
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 () => {
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'
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'
+ '**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'