openvibers/openvibe.codes Commits
A conversation's next turn starts from the work before it, on any agent; the workspace is one app (L31 CH4, CH7) (#205)
Changes
@@@ -169,6 +169,8 @@ function parseArgs(raw, env = process.env) {agent: typeof a.agent === 'string' && /^[a-z0-9-]{1,40}$/.test(a.agent) ? a.agent : null,model: typeof a.model === 'string' && a.model.length <= 100 ? a.model : null,...(effort ? { effort } : {}),++ // The conversation's work so far (Codes L31 CH4): applied to the clone before the agent starts.++ ...(typeof a.base_patch === 'string' && a.base_patch.trim() && a.base_patch.length <= MAX_PATCH ? { base_patch: a.base_patch } : {}),};}@@@ -465,6 +467,20 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tmbase = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: work })).trim();result.base = base;say({ t: 'step', text: `Working on ${base}` });++ // A turn of a conversation starts from the work of the turns before it. A diff that no longer applies++ // (the branch moved under it) is said, and the turn starts from the branch as it is.++ if (job.base_patch) {++ const file = path.join(dir, 'conversation.patch');++ fs.writeFileSync(file, job.base_patch.endsWith('\n') ? job.base_patch : `${job.base_patch}\n`);++ try {++ await git(['apply', '--3way', '--whitespace=nowarn', file], { cwd: work });++ result.base_patch = 'applied';++ say({ t: 'step', text: 'Picked up the changes from the earlier turns' });++ } catch (e) {++ result.base_patch = 'failed';++ say({ t: 'note', level: 'warning', text: 'The earlier turns\' changes no longer apply to the branch; this turn starts from the branch as it is.' });++ }++ }} else if (job.kind === 'conductor') {// The owner's own folder: never a clone, never touched by git. Claude Code opens it, so it loads that// project's memory and AGENTS.md.@@@ -33,6 +33,7 @@ main.page:has(> .ch){max-width:none;margin:0;padding:0}.ch-rail-foot{display:grid;grid-template-columns:repeat(5,1fr);gap:2px;padding:8px;border-top:1px solid var(--ch-line)}.ch-rail-foot a{display:grid;justify-items:center;gap:3px;padding:7px 2px;border-radius:10px;color:var(--ch-muted);text-decoration:none;font-size:10.5px;font-weight:600}.ch-rail-foot a:hover{background:var(--ch-hover);color:var(--ch-ink)}++.ch-rail-foot a.is-here{background:var(--ch-hover);color:var(--ch-accent)}/* Faces: an agent's initials in its own colour; the conductor's is its sign. */.ch-av{display:inline-grid;place-items:center;width:24px;height:24px;flex:none;border-radius:50%;font-size:10.5px;font-weight:800;letter-spacing:-.02em;color:var(--av,#64748b);background:color-mix(in srgb,var(--av,#64748b) 18%,transparent)}@@@ -185,6 +186,13 @@ details[open]>summary .ch-caret{rotate:180deg}.ch-recent{margin-top:2px}.ch-recent .ch-chip{font-size:12.5px;padding:4px 10px;color:var(--ch-muted)}++/* The workspace's other pages (Agents, Machines, Usage, Settings…) in the conversation's place: they scroll there and++ keep their own heading, so the bar above them is only the phone's way to the rail. */++.ch-page{flex:1;min-height:0;overflow-y:auto}++.ch-page .c4{max-width:none;padding:0 max(20px,calc((100% - 980px) / 2)) 44px}++.ch.is-page .ch-head{display:none}++@media (max-width:760px){.ch.is-page .ch-head{display:flex;border-bottom:0;min-height:44px}.ch-page .c4{padding:0 14px 32px}.ch-page .c4-main{padding-top:6px}}++/* ── The side: what it did ── */.ch-side{min-height:0;overflow-y:auto;padding:10px 14px 18px;border-left:1px solid var(--ch-line);background:var(--ch-rail)}.ch-side-head{display:flex;align-items:center;justify-content:space-between;min-height:40px;font-size:14px}@@@ -804,15 +804,18 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouconst live = attempts.slice().reverse().find((a) => !ENDED.has(a.state));if (live) return { attempt: live, live: true, row };const text = note ? String(note).trim().slice(0, 4000) : '';++ const lastResult = asObject(row.result) || {};++ const basePatch = typeof lastResult.patch === 'string' && lastResult.patch && !lastResult.patch_truncated ? lastResult.patch : null;++ const soFar = await conversationSoFar(row);const plan = agent? {-- kind: 'switch', agent, model: model || null, effort: effort || null, session: null,-- prompt: `${row.prompt}\n\n---\n## Hand-over (written by openvibe.codes)\n\n${attemptStore.nameOf(agent)} takes over from ${attemptStore.nameOf(ending.agent)}, which ${attemptStore.wordsOf(ending.reason || 'error')}. Keep the work that was done, do not redo it, and finish the task.\n`,-- why: `${attemptStore.nameOf(agent)} took over at the owner's request`,++ kind: 'switch', agent, model: model || null, effort: effort || null, session: null, basePatch,++ prompt: `${row.prompt}${soFar}\n\n---\n## Hand-over (written by openvibe.codes)\n\n${attemptStore.nameOf(agent)} takes over from ${attemptStore.nameOf(ending.agent)}, which ${attemptStore.wordsOf(ending.reason || 'error')}. Keep the work that was done, do not redo it, and finish the task.\n${text ? `\n---\n## A note from the owner\n\n${text}\n` : ''}`,++ why: text ? `carried on by the owner: ${text.slice(0, 200)}` : `${attemptStore.nameOf(agent)} took over at the owner's request`,}: {-- kind: 'manual', agent: ending.agent, model: model || ending.model, effort: effort || ending.effort || null, session: null,-- prompt: `${row.prompt}${text ? `\n\n---\n## A note from the owner\n\n${text}\n` : ''}`,++ kind: 'manual', agent: ending.agent, model: model || ending.model, effort: effort || ending.effort || null, session: null, basePatch,++ prompt: `${row.prompt}${soFar}${text ? `\n\n---\n## A note from the owner\n\n${text}\n` : ''}`,why: text ? `carried on by the owner: ${text.slice(0, 200)}` : 'carried on by the owner',};let attempt = null;@@@ -865,12 +868,42 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou}/** The job's arguments for a follow-up: the task's own repository, the plan's prompt and the session to resume. */++ /**++ * The conversation so far (Codes L31 CH4), for the next turn's brief: each earlier turn's agent and the words it++ * ended with, and what the person said between turns. Compact (each answer cut to 1 200 characters, the whole to++ * about 6 000), so any agent can pick the conversation up without its predecessor's session; the work itself++ * travels as the diff (base_patch).++ */++ async function conversationSoFar(row) {++ const attempts = await attemptStore.list(row.id);++ if (!attempts.length) return '';++ const ends = await db.many(++ `SELECT DISTINCT ON (attempt_n) attempt_n, body FROM codes_session_lines++ WHERE task_id = $1 AND kind IN ('done', 'text') ORDER BY attempt_n, (kind = 'done') DESC, seq DESC`, [row.id],++ ).catch(() => []);++ const said = new Map(ends.map((l) => [Number(l.attempt_n), String((l.body && l.body.text) || '').trim()]));++ const parts = [];++ for (const a of attempts) {++ const note = /^carried on by the owner: /.test(a.why || '') ? String(a.why).replace(/^carried on by the owner: /, '') : null;++ if (note && Number(a.n) > 1) parts.push(`**The owner:** ${note}`);++ const text = said.get(Number(a.n));++ const who = attemptStore.nameOf(a.agent);++ parts.push(`**Turn ${a.n} · ${who}${a.state && a.state !== 'done' ? ` (${attemptStore.wordsOf(a.reason || a.state)})` : ''}:** ${text ? (text.length > 1200 ? `${text.slice(0, 1200)}…` : text) : 'it ended without a word.'}`);++ }++ let out = parts.join('\n\n');++ if (out.length > 6000) out = `…${out.slice(-6000)}`;++ return `\n\n---\n## The conversation so far (written by openvibe.codes)\n\n${out}\n\nThe changes from these turns are already in your working tree.\n`;++ }++function followUpArgs(row, plan) {return parseArgs(JSON.stringify({repo: row.repo, ref: row.ref || null, prompt: plan.prompt || row.prompt,agent: plan.agent || null, permission: 'edit',...(plan.model ? { model: plan.model } : {}),...(plan.effort ? { effort: plan.effort } : {}),++ // The work so far: the last turn's diff (against the branch the task started from, so it holds every++ // earlier turn's changes too). A diff cut to fit the result channel would not apply, so it is not sent.++ ...(plan.basePatch ? { base_patch: plan.basePatch } : {}),...(row.push ? { push: { pull_request: !!row.pull_request } } : {}),...(plan.session ? { resume: plan.session } : {}),}));@@@ -72,6 +72,10 @@ function createC4Routes(ctx) {const db = store.db;const form = express.urlencoded({ extended: false, limit: '16kb' });const page = (req, res, title, body, extra = {}) => {++ if (viewer(req) && ctx.chatShell && !extra.index) {++ const here = '/' + (String(req.path || '/').split('/')[1] || '');++ return ctx.chatShell(req, res, { title: title || 'Codes', body: String(body), css: ['css/c4.css'], scripts: ['js/c4.js'], here });++ }const wantsScript = req.path === '/' || /^\/tasks\/tsk_/.test(req.path) || /^\/(agents|machines|conductor)/.test(req.path);return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), css: ['css/c4.css'], scripts: wantsScript ? ['js/c4.js'] : [], body: raw(body), ...extra });};@@@ -136,7 +136,7 @@ function createChatRoutes(ctx) {FROM run_tasks WHERE subject = $1 ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 120`, [subject],);}-- function railHtml(rows, currentId, waitingIds) {++ function railHtml(rows, currentId, waitingIds, here = '') {const now = new Date();const day0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();const bucket = (row) => {@@@ -160,7 +160,7 @@ function createChatRoutes(ctx) {groups[groups.length - 1][1].push(row);}const foot = [['Agents', '/agents', 'agents'], ['Machines', '/machines', 'machines'], ['Memory', '/memory', 'memory'], ['Usage', '/usage', 'usage'], ['Settings', '/settings', 'settings']]-- .map(([label, href, i]) => `<a href="${href}" title="${label}">${ico(i)}<span>${label}</span></a>`).join('');++ .map(([label, href, i]) => `<a href="${href}" title="${label}"${here === href ? ' class="is-here" aria-current="page"' : ''}>${ico(i)}<span>${label}</span></a>`).join('');return `<aside class="ch-rail" id="ch-rail" aria-label="Your conversations"><div class="ch-rail-top"><a class="ch-new" href="/">${ico('new')}<span>New chat</span></a><button class="ch-icon-btn ch-rail-close" type="button" data-ch-rail-close aria-label="Close the list">${ico('close')}</button></div><form class="ch-find" role="search" method="get" action="/tasks"><label class="ch-sr" for="ch-find">Search your conversations</label>${ico('search')}<input id="ch-find" name="q" placeholder="Search" autocomplete="off" data-ch-find></form>@@@ -222,9 +222,9 @@ ${rows.length ? '' : '<p class="ch-empty-rail">Your conversations appear here.</const n = Number(a.n);if (prev) {const note = /^carried on by the owner: /.exec(a.why || '') ? String(a.why).replace(/^carried on by the owner: /, '') : null;-- if (a.kind === 'manual' && note) out.push(you(note));-- else if (a.agent && prev.agent && a.agent !== prev.agent) out.push(divider(`${agentOf(a.agent, names).name} took over${prev.end_why ? `: ${agentOf(prev.agent, names).name} ${prev.end_why}` : ''}`));-- else if (a.why) out.push(divider(String(a.why), 'handover'));++ if (note) out.push(you(note));++ if (a.agent && prev.agent && a.agent !== prev.agent) out.push(divider(`${agentOf(a.agent, names).name} took over${prev.end_why ? `: ${agentOf(prev.agent, names).name} ${prev.end_why}` : ''}`));++ else if (a.why && !note) out.push(divider(String(a.why), 'handover'));}const isLive = live && n === Number(attempts[attempts.length - 1].n);out.push(agentTurn(agentOf(a.agent || row.agent, names), byAttempt.get(n) || [], { live: isLive, attempt: a }));@@@ -312,21 +312,21 @@ ${note ? `<p class="ch-note" data-ch-status role="status">${note}</p>` : '<p cla}// ── Pages ────────────────────────────────────────────────────────────────────────────────────────────────────-- async function frame(req, res, { currentId = null, title, head = '', thread = '', composerHtml = '', side = '', streamUrl = '', empty = false }) {++ async function frame(req, res, { currentId = null, title, head = '', thread = '', composerHtml = '', side = '', streamUrl = '', empty = false, page = null, here = '', css = [], scripts = [] }) {const rows = await conversations(req.viewer.subject);const waiting = approvals ? await approvals.inbox(req.viewer.subject).catch(() => []) : [];const waitingIds = new Set(waiting.map((a) => a.task_id));-- const body = `<div class="ch${side ? ' has-side' : ''}${empty ? ' is-empty' : ''}" data-chat>--${railHtml(rows, currentId, waitingIds)}++ const body = `<div class="ch${side ? ' has-side' : ''}${empty ? ' is-empty' : ''}${page != null ? ' is-page' : ''}" data-chat>++${railHtml(rows, currentId, waitingIds, here)}<div class="ch-scrim" data-ch-scrim hidden></div><section class="ch-main" aria-label="Conversation"><header class="ch-head"><button class="ch-icon-btn ch-rail-open" type="button" data-ch-rail-open aria-label="Your conversations" aria-controls="ch-rail">${ico('rail')}</button>${head}${side ? `<button class="ch-icon-btn ch-side-open" type="button" data-ch-side-open aria-label="What it did" aria-controls="ch-side">${ico('side')}</button>` : ''}</header>--<div class="ch-thread" id="ch-thread"${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${thread}</div>--${composerHtml}++${page != null ? `<div class="ch-page">${page}</div>` : `<div class="ch-thread" id="ch-thread"${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${thread}</div>++${composerHtml}`}</section>${side}</div>`;-- return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: ['css/chat.css'], scripts: ['js/chat.js'], body: raw(body) });++ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], scripts: [...scripts, 'js/chat.js'], body: raw(body) });}/** The side: who worked on it, and where the work is. */@@@ -347,6 +347,10 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f</aside>`;}++ ctx.chatShell = (req, res, { title, body, css = [], scripts = [], here = '' }) => frame(req, res, {++ title, page: body, here, css, scripts, head: '',++ });++// A new conversation: the greeting, the repository and the agent, and the composer.r.get('/', async (req, res, next) => {if (!viewer(req)) return next();@@@ -79,16 +79,12 @@ function renderPage(o) {// the current one active, and the product's own places fold into More; a visitor who is not signed in keeps the// product's links, because the sections hold the visitor's own work and a visitor has none yet.const here = (href) => pathOnly === href || (href !== '/' && pathOnly.startsWith(`${href}/`));-- // A conversation (a signed-in /, /tasks/:id) is the chat app: its rail is the navigation, so the bar carries none.-- const chatPage = signedIn && (pathOnly === '/' || /^\/tasks\/tsk_[0-9A-Z]{26}$/.test(pathOnly));-- const links = chatPage ? []-- : (signedIn && c4Workspace)-- ? [-- ...WORKSPACE_NAV.map((l) => ({ ...l, active: here(l.href) })),-- // The catalogue of harnesses is the product's Agents page; the workspace's own Agents page is above, so the-- // product's one is named for what it is.-- ...NAV.map((l) => ({ ...l, more: true, label: l.href === '/harnesses' ? 'Agent catalogue' : l.label })),-- ]++ // A signed-in person's workspace is one app (Codes L31): its rail is the navigation (the conversations, and the++ // workspace's own pages at its foot), so the navbar keeps only the product's places, in More. The catalogue of++ // harnesses is named for what it is, beside the workspace's own Agents page in the rail.++ const inApp = signedIn && c4Workspace;++ const links = inApp++ ? NAV.map((l) => ({ ...l, more: true, label: l.href === '/harnesses' ? 'Agent catalogue' : l.label })): NAV.map((l) => ({ ...l, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) }));const nav = {service: 'codes',@@@ -98,7 +94,8 @@ function renderPage(o) {// Phones: one tab bar, the Frame's own (Shared 4.20). On a workspace page a signed-in person gets the workspace's// sections there — the first four (Home, Tasks, Conductor, Agents) and More, which opens the navbar's drawer with// the rest; the section the person is in is marked. Everyone else keeps the product's four.-- appBar: c4Workspace && signedIn ? true : [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Start', href: '/start', icon: 'fa-play' }, { label: 'Run', href: '/run', icon: 'fa-microchip' }, { label: 'Agents', href: '/harnesses', icon: 'fa-robot' }],++ // Phones: in the app the rail is a drawer and the conversation owns the screen, so no tab bar (L31).++ appBar: inApp ? false : [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Start', href: '/start', icon: 'fa-play' }, { label: 'Run', href: '/run', icon: 'fa-microchip' }, { label: 'Agents', href: '/harnesses', icon: 'fa-robot' }],history: { type: 'page', title: o.title || SITE_NAME },silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,sessionUrl: '/auth/me',@@@ -294,6 +294,19 @@ const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;assert.strictEqual((await attemptsOf(id)).length, 3, 'no third continue');});++ await check('the next turn carries the work and the conversation so far, to the same agent or another (L31 CH4)', async () => {++ const id = await startTask();++ const patch = 'diff --git a/cache.js b/cache.js\nnew file mode 100644\n--- /dev/null\n+++ b/cache.js\n@@ -0,0 +1 @@\n+module.exports = new Map();\n';++ await endAttempt(id, { state: 'succeeded', result: { ok: true, summary: 'I added the cache.', files: [{ path: 'cache.js', added: 1, removed: 0 }], patch, log: [{ t: 'done', ok: true, text: 'I added the cache.' }] } });++ const continued = await call('POST', `/tasks/${id}/continue`, { body: { note: 'Now the tests.' } });++ assert.strictEqual(continued.status, 200, JSON.stringify(continued.json));++ const submits = calls.filter((c) => c[0] === 'submit');++ const args = submits[submits.length - 1][1].args;++ assert.strictEqual(args.base_patch, patch, 'the work so far travels as the diff');++ assert.ok(args.prompt.includes('## The conversation so far') && args.prompt.includes('Now the tests.'), 'and the conversation as words');++ assert.ok(args.prompt.indexOf('## The conversation so far') < args.prompt.indexOf('Now the tests.'), 'the history before the new note');++ });++await check('the owner needs no token: their own task reads, continues and stops', async () => {const id = await startTask();const mine = await t.get(`/api/v1/tasks/${id}`, { as: ada });@@@ -133,9 +133,9 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';assert.ok(page.text.includes('Online'), 'with its state');assert.ok(page.text.includes(`/machines/${NODE}/revoke`), 'and a way to revoke it');assert.ok(page.text.includes('Pair a machine') && page.text.includes('openvibe.bot/machines'), 'the pairing flow is Nodes');-- assert.ok(page.text.includes('"appBar":true') && !page.text.includes('c4-mobile'), 'the workspace gives the Frame app bar its own sections: no second bar');++ assert.ok(page.text.includes('"appBar":false') && !page.text.includes('c4-mobile') && page.text.includes('data-ch-rail-open'), 'the app has no tab bar: the rail is its navigation (L31)');const coded = await t.get('/machines?code=abcd-2345', { as: ada });-- assert.ok(coded.text.includes('"appBar":true'), 'a query string does not bring a second bar back');++ assert.ok(coded.text.includes('"appBar":false') && coded.text.includes('data-ch-rail-open'), 'a query string keeps the page in the app');assert.ok(coded.text.includes('sudo openvibe-node pair ABCD-2345 --network https://openvibe.network'), 'the code makes the command');const wrong = await t.get('/machines?code=nope', { as: ada });assert.ok(wrong.text.includes('a pairing code is 8 letters and digits, like ABCD-1234'), 'a wrong code gets Node’s own words');@@@ -30,20 +30,15 @@ const split = (links) => ({const t = await boot();const ada = t.network.addUser('c4c-ada');try {-- await check('a signed-in person’s workspace page has one navigation: the Frame navbar’s sections', async () => {-- const page = await t.get('/tasks', { as: ada });++ await check('a signed-in person’s workspace is one app: the rail is its navigation (L31)', async () => {++ const page = await t.get('/agents', { as: ada });assert.strictEqual(page.status, 200);-- const links = navbarOf(page.text).links;-- const { top, more } = split(links);-- assert.deepStrictEqual(top, ['Home', 'Tasks', 'Conductor', 'Agents', 'Machines', 'Memory', 'Usage', 'Settings'], 'the sections are the navbar');-- assert.ok(more.includes('Get started') && more.includes('Run on your machine'), 'the product’s places fold into More');-- assert.ok(!top.includes('Agents') || top.filter((l) => l === 'Agents').length === 1, 'Agents is in one place only');-- const tasks = links.find((l) => l.href === '/tasks');-- assert.ok(tasks && tasks.active, 'the section the person is in is active');-- assert.ok(!links.find((l) => l.href === '/agents').active, 'and no other one is');-- // The page carries no sidebar of its own: the Frame owns the navigation.-- assert.ok(!page.text.includes('c4-side'), 'no second navigation on the page');-- assert.ok(!page.text.includes('c4-layout'), 'and no sidebar column');++ const { top, more } = split(navbarOf(page.text).links);++ assert.deepStrictEqual(top, [], 'the navbar carries no sections');++ assert.ok(more.includes('Get started') && more.includes('Run on your machine'), 'only the product’s places, in More');++ assert.ok(page.text.includes('aria-label="Your conversations"'), 'the rail is on the page');++ assert.ok(/<a href="\/agents" title="Agents" class="is-here" aria-current="page">/.test(page.text), 'and marks the page the person is on');++ assert.ok(!page.text.includes('c4-side') && !page.text.includes('c4-layout'), 'no other sidebar');});await check('the section that is not the person’s own — the product’s catalogue — is named for what it is', async () => {@@@ -70,19 +65,18 @@ const split = (links) => ({assert.ok(!top.includes('Machines'), 'and the workspace’s sections are not');});-- await check('the phone page reserves the Frame app bar’s height, and has no bar of its own', async () => {++ await check('on a phone the app has no tab bar: the rail is a drawer, and the page reserves no bar’s height', async () => {const page = await t.get('/settings', { as: ada });-- assert.ok(page.text.includes('<body class="ov-workspace">'), 'the workspace page says it carries a tab bar');-- assert.ok(!page.text.includes('c4-mobile'), 'and it draws no second bar of its own');-- const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'c4.css'), 'utf8');-- assert.ok(/body\.ov-workspace\{[^}]*padding-bottom:var\(--ovnav-appbar-h,calc\(58px \+ env\(safe-area-inset-bottom,0px\)\)\)/.test(css),-- 'the page reserves the Frame bar’s height, its safe area included');++ assert.strictEqual(navbarOf(page.text).appBar, false, 'no Frame tab bar in the app');++ assert.ok(!page.text.includes('c4-mobile'), 'and no bar of its own');++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'chat.css'), 'utf8');++ assert.ok(css.includes('body.ov-workspace:has(.ch){padding-bottom:0}'), 'so nothing is reserved for one');++ assert.ok(page.text.includes('data-ch-rail-open'), 'the rail opens from the page’s own header');});-- await check('the Frame’s app bar is the workspace’s sections for a signed-in person, and the product’s for a visitor', async () => {++ await check('the Frame’s app bar: none in a signed-in person’s app, the product’s for a visitor', async () => {const signedIn = await t.get('/tasks', { as: ada });-- assert.strictEqual(navbarOf(signedIn.text).appBar, true,-- 'the bar takes the navbar’s own sections: the first four and More (Shared appbar.js)');++ assert.strictEqual(navbarOf(signedIn.text).appBar, false, 'in the app the rail is the navigation, not a tab bar');const visitor = await t.get('/tasks');const bar = navbarOf(visitor.text).appBar;assert.ok(Array.isArray(bar) && bar.length <= 5, 'a visitor’s bar is at most five places');@@@ -114,6 +114,22 @@ function fakeHarness({ fail = false } = {}) {assert.strictEqual(fs.readFileSync(path.join(src.dir, 'README.md'), 'utf8'), '# Demo\n');});++ await check('a conversation\'s next turn starts from the work of the turns before it (L31 CH4)', async () => {++ const src = repository();++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-tmp-'));++ const turn = (extra) => runJob({ input: JSON.stringify({ repo: src.url, prompt: 'Add a line', ...extra }), harness: fakeHarness(), emit: () => {}, writeResult: () => {}, env, tmp });++ const first = await turn({});++ assert.ok(first.patch && first.patch.includes('+Added by the agent.'), 'the first turn\'s diff');++ const lines = [];++ const second = await runJob({ input: JSON.stringify({ repo: src.url, prompt: 'Add another', base_patch: first.patch }), harness: fakeHarness(), emit: (l) => lines.push(JSON.parse(l)), writeResult: () => {}, env, tmp });++ assert.strictEqual(second.base_patch, 'applied', 'the earlier work was picked up');++ assert.strictEqual((second.patch.match(/\+Added by the agent\./g) || []).length, 2, 'and the new diff holds both turns');++ assert.ok(lines.some((l) => /Picked up the changes from the earlier turns/.test(JSON.stringify(l))), 'and says so');++ const stale = await turn({ base_patch: 'diff --git a/NOPE.md b/NOPE.md\n--- a/NOPE.md\n+++ b/NOPE.md\n@@ -1 +1 @@\n-x\n+y\n' });++ assert.strictEqual(stale.base_patch, 'failed', 'a diff that no longer applies is said');++ assert.ok(stale.patch && stale.patch.includes('+Added by the agent.'), 'and the turn still runs from the branch as it is');++ });++await check('a push is only ever a new branch: never the base, a protected name, an existing branch, or unchecked', () => {const { pushTarget } = require('../harness/job');assert.deepStrictEqual(pushTarget('fix/typo', 'main'), { branch: 'fix/typo' });
@@ -169,6 +169,8 @@ function parseArgs(raw, env = process.env) {
@@ -169,6 +169,8 @@ function parseArgs(raw, env = process.env) {
agent: typeof a.agent === 'string' && /^[a-z0-9-]{1,40}$/.test(a.agent) ? a.agent : null,
agent: typeof a.agent === 'string' && /^[a-z0-9-]{1,40}$/.test(a.agent) ? a.agent : null,
model: typeof a.model === 'string' && a.model.length <= 100 ? a.model : null,
model: typeof a.model === 'string' && a.model.length <= 100 ? a.model : null,
...(effort ? { effort } : {}),
...(effort ? { effort } : {}),
+ // The conversation's work so far (Codes L31 CH4): applied to the clone before the agent starts.
+ ...(typeof a.base_patch === 'string' && a.base_patch.trim() && a.base_patch.length <= MAX_PATCH ? { base_patch: a.base_patch } : {}),
};
};
}
}
@@ -465,6 +467,20 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
@@ -465,6 +467,20 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
base = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: work })).trim();
base = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: work })).trim();
result.base = base;
result.base = base;
say({ t: 'step', text: `Working on ${base}` });
say({ t: 'step', text: `Working on ${base}` });
+ // A turn of a conversation starts from the work of the turns before it. A diff that no longer applies
+ // (the branch moved under it) is said, and the turn starts from the branch as it is.
+ if (job.base_patch) {
+ const file = path.join(dir, 'conversation.patch');
+ fs.writeFileSync(file, job.base_patch.endsWith('\n') ? job.base_patch : `${job.base_patch}\n`);
+ try {
+ await git(['apply', '--3way', '--whitespace=nowarn', file], { cwd: work });
+ result.base_patch = 'applied';
+ say({ t: 'step', text: 'Picked up the changes from the earlier turns' });
+ } catch (e) {
+ result.base_patch = 'failed';
+ say({ t: 'note', level: 'warning', text: 'The earlier turns\' changes no longer apply to the branch; this turn starts from the branch as it is.' });
+ }
+ }
} else if (job.kind === 'conductor') {
} else if (job.kind === 'conductor') {
// The owner's own folder: never a clone, never touched by git. Claude Code opens it, so it loads that
// The owner's own folder: never a clone, never touched by git. Claude Code opens it, so it loads that
// project's memory and AGENTS.md.
// project's memory and AGENTS.md.
@@ -33,6 +33,7 @@ main.page:has(> .ch){max-width:none;margin:0;padding:0}
@@ -33,6 +33,7 @@ main.page:has(> .ch){max-width:none;margin:0;padding:0}
.ch-rail-foot{display:grid;grid-template-columns:repeat(5,1fr);gap:2px;padding:8px;border-top:1px solid var(--ch-line)}
.ch-rail-foot{display:grid;grid-template-columns:repeat(5,1fr);gap:2px;padding:8px;border-top:1px solid var(--ch-line)}
.ch-rail-foot a{display:grid;justify-items:center;gap:3px;padding:7px 2px;border-radius:10px;color:var(--ch-muted);text-decoration:none;font-size:10.5px;font-weight:600}
.ch-rail-foot a{display:grid;justify-items:center;gap:3px;padding:7px 2px;border-radius:10px;color:var(--ch-muted);text-decoration:none;font-size:10.5px;font-weight:600}
.ch-rail-foot a:hover{background:var(--ch-hover);color:var(--ch-ink)}
.ch-rail-foot a:hover{background:var(--ch-hover);color:var(--ch-ink)}
+.ch-rail-foot a.is-here{background:var(--ch-hover);color:var(--ch-accent)}
/* Faces: an agent's initials in its own colour; the conductor's is its sign. */
/* Faces: an agent's initials in its own colour; the conductor's is its sign. */
.ch-av{display:inline-grid;place-items:center;width:24px;height:24px;flex:none;border-radius:50%;font-size:10.5px;font-weight:800;letter-spacing:-.02em;color:var(--av,#64748b);background:color-mix(in srgb,var(--av,#64748b) 18%,transparent)}
.ch-av{display:inline-grid;place-items:center;width:24px;height:24px;flex:none;border-radius:50%;font-size:10.5px;font-weight:800;letter-spacing:-.02em;color:var(--av,#64748b);background:color-mix(in srgb,var(--av,#64748b) 18%,transparent)}
@@ -185,6 +186,13 @@ details[open]>summary .ch-caret{rotate:180deg}
@@ -185,6 +186,13 @@ details[open]>summary .ch-caret{rotate:180deg}
.ch-recent{margin-top:2px}
.ch-recent{margin-top:2px}
.ch-recent .ch-chip{font-size:12.5px;padding:4px 10px;color:var(--ch-muted)}
.ch-recent .ch-chip{font-size:12.5px;padding:4px 10px;color:var(--ch-muted)}
+/* The workspace's other pages (Agents, Machines, Usage, Settings…) in the conversation's place: they scroll there and
+ keep their own heading, so the bar above them is only the phone's way to the rail. */
+.ch-page{flex:1;min-height:0;overflow-y:auto}
+.ch-page .c4{max-width:none;padding:0 max(20px,calc((100% - 980px) / 2)) 44px}
+.ch.is-page .ch-head{display:none}
+@media (max-width:760px){.ch.is-page .ch-head{display:flex;border-bottom:0;min-height:44px}.ch-page .c4{padding:0 14px 32px}.ch-page .c4-main{padding-top:6px}}
+
/* ── The side: what it did ── */
/* ── The side: what it did ── */
.ch-side{min-height:0;overflow-y:auto;padding:10px 14px 18px;border-left:1px solid var(--ch-line);background:var(--ch-rail)}
.ch-side{min-height:0;overflow-y:auto;padding:10px 14px 18px;border-left:1px solid var(--ch-line);background:var(--ch-rail)}
.ch-side-head{display:flex;align-items:center;justify-content:space-between;min-height:40px;font-size:14px}
.ch-side-head{display:flex;align-items:center;justify-content:space-between;min-height:40px;font-size:14px}
@@ -804,15 +804,18 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -804,15 +804,18 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const live = attempts.slice().reverse().find((a) => !ENDED.has(a.state));
const live = attempts.slice().reverse().find((a) => !ENDED.has(a.state));
if (live) return { attempt: live, live: true, row };
if (live) return { attempt: live, live: true, row };
const text = note ? String(note).trim().slice(0, 4000) : '';
const text = note ? String(note).trim().slice(0, 4000) : '';
+ const lastResult = asObject(row.result) || {};
+ const basePatch = typeof lastResult.patch === 'string' && lastResult.patch && !lastResult.patch_truncated ? lastResult.patch : null;
+ const soFar = await conversationSoFar(row);
const plan = agent
const plan = agent
? {
? {
- kind: 'switch', agent, model: model || null, effort: effort || null, session: null,
- prompt: `${row.prompt}\n\n---\n## Hand-over (written by openvibe.codes)\n\n${attemptStore.nameOf(agent)} takes over from ${attemptStore.nameOf(ending.agent)}, which ${attemptStore.wordsOf(ending.reason || 'error')}. Keep the work that was done, do not redo it, and finish the task.\n`,
- why: `${attemptStore.nameOf(agent)} took over at the owner's request`,
+ kind: 'switch', agent, model: model || null, effort: effort || null, session: null, basePatch,
+ prompt: `${row.prompt}${soFar}\n\n---\n## Hand-over (written by openvibe.codes)\n\n${attemptStore.nameOf(agent)} takes over from ${attemptStore.nameOf(ending.agent)}, which ${attemptStore.wordsOf(ending.reason || 'error')}. Keep the work that was done, do not redo it, and finish the task.\n${text ? `\n---\n## A note from the owner\n\n${text}\n` : ''}`,
+ why: text ? `carried on by the owner: ${text.slice(0, 200)}` : `${attemptStore.nameOf(agent)} took over at the owner's request`,
}
}
: {
: {
- kind: 'manual', agent: ending.agent, model: model || ending.model, effort: effort || ending.effort || null, session: null,
- prompt: `${row.prompt}${text ? `\n\n---\n## A note from the owner\n\n${text}\n` : ''}`,
+ kind: 'manual', agent: ending.agent, model: model || ending.model, effort: effort || ending.effort || null, session: null, basePatch,
+ prompt: `${row.prompt}${soFar}${text ? `\n\n---\n## A note from the owner\n\n${text}\n` : ''}`,
why: text ? `carried on by the owner: ${text.slice(0, 200)}` : 'carried on by the owner',
why: text ? `carried on by the owner: ${text.slice(0, 200)}` : 'carried on by the owner',
};
};
let attempt = null;
let attempt = null;
@@ -865,12 +868,42 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -865,12 +868,42 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
}
}
/** The job's arguments for a follow-up: the task's own repository, the plan's prompt and the session to resume. */
/** The job's arguments for a follow-up: the task's own repository, the plan's prompt and the session to resume. */
+ /**
+ * The conversation so far (Codes L31 CH4), for the next turn's brief: each earlier turn's agent and the words it
+ * ended with, and what the person said between turns. Compact (each answer cut to 1 200 characters, the whole to
+ * about 6 000), so any agent can pick the conversation up without its predecessor's session; the work itself
+ * travels as the diff (base_patch).
+ */
+ async function conversationSoFar(row) {
+ const attempts = await attemptStore.list(row.id);
+ if (!attempts.length) return '';
+ const ends = await db.many(
+ `SELECT DISTINCT ON (attempt_n) attempt_n, body FROM codes_session_lines
+ WHERE task_id = $1 AND kind IN ('done', 'text') ORDER BY attempt_n, (kind = 'done') DESC, seq DESC`, [row.id],
+ ).catch(() => []);
+ const said = new Map(ends.map((l) => [Number(l.attempt_n), String((l.body && l.body.text) || '').trim()]));
+ const parts = [];
+ for (const a of attempts) {
+ const note = /^carried on by the owner: /.test(a.why || '') ? String(a.why).replace(/^carried on by the owner: /, '') : null;
+ if (note && Number(a.n) > 1) parts.push(`**The owner:** ${note}`);
+ const text = said.get(Number(a.n));
+ const who = attemptStore.nameOf(a.agent);
+ parts.push(`**Turn ${a.n} · ${who}${a.state && a.state !== 'done' ? ` (${attemptStore.wordsOf(a.reason || a.state)})` : ''}:** ${text ? (text.length > 1200 ? `${text.slice(0, 1200)}…` : text) : 'it ended without a word.'}`);
+ }
+ let out = parts.join('\n\n');
+ if (out.length > 6000) out = `…${out.slice(-6000)}`;
+ return `\n\n---\n## The conversation so far (written by openvibe.codes)\n\n${out}\n\nThe changes from these turns are already in your working tree.\n`;
+ }
+
function followUpArgs(row, plan) {
function followUpArgs(row, plan) {
return parseArgs(JSON.stringify({
return parseArgs(JSON.stringify({
repo: row.repo, ref: row.ref || null, prompt: plan.prompt || row.prompt,
repo: row.repo, ref: row.ref || null, prompt: plan.prompt || row.prompt,
agent: plan.agent || null, permission: 'edit',
agent: plan.agent || null, permission: 'edit',
...(plan.model ? { model: plan.model } : {}),
...(plan.model ? { model: plan.model } : {}),
...(plan.effort ? { effort: plan.effort } : {}),
...(plan.effort ? { effort: plan.effort } : {}),
+ // The work so far: the last turn's diff (against the branch the task started from, so it holds every
+ // earlier turn's changes too). A diff cut to fit the result channel would not apply, so it is not sent.
+ ...(plan.basePatch ? { base_patch: plan.basePatch } : {}),
...(row.push ? { push: { pull_request: !!row.pull_request } } : {}),
...(row.push ? { push: { pull_request: !!row.pull_request } } : {}),
...(plan.session ? { resume: plan.session } : {}),
...(plan.session ? { resume: plan.session } : {}),
}));
}));
@@ -72,6 +72,10 @@ function createC4Routes(ctx) {
@@ -72,6 +72,10 @@ function createC4Routes(ctx) {
const db = store.db;
const db = store.db;
const form = express.urlencoded({ extended: false, limit: '16kb' });
const form = express.urlencoded({ extended: false, limit: '16kb' });
const page = (req, res, title, body, extra = {}) => {
const page = (req, res, title, body, extra = {}) => {
+ if (viewer(req) && ctx.chatShell && !extra.index) {
+ const here = '/' + (String(req.path || '/').split('/')[1] || '');
+ return ctx.chatShell(req, res, { title: title || 'Codes', body: String(body), css: ['css/c4.css'], scripts: ['js/c4.js'], here });
+ }
const wantsScript = req.path === '/' || /^\/tasks\/tsk_/.test(req.path) || /^\/(agents|machines|conductor)/.test(req.path);
const wantsScript = req.path === '/' || /^\/tasks\/tsk_/.test(req.path) || /^\/(agents|machines|conductor)/.test(req.path);
return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), css: ['css/c4.css'], scripts: wantsScript ? ['js/c4.js'] : [], body: raw(body), ...extra });
return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), css: ['css/c4.css'], scripts: wantsScript ? ['js/c4.js'] : [], body: raw(body), ...extra });
};
};
@@ -136,7 +136,7 @@ function createChatRoutes(ctx) {
@@ -136,7 +136,7 @@ function createChatRoutes(ctx) {
FROM run_tasks WHERE subject = $1 ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 120`, [subject],
FROM run_tasks WHERE subject = $1 ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 120`, [subject],
);
);
}
}
- function railHtml(rows, currentId, waitingIds) {
+ function railHtml(rows, currentId, waitingIds, here = '') {
const now = new Date();
const now = new Date();
const day0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const day0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const bucket = (row) => {
const bucket = (row) => {
@@ -160,7 +160,7 @@ function createChatRoutes(ctx) {
@@ -160,7 +160,7 @@ function createChatRoutes(ctx) {
groups[groups.length - 1][1].push(row);
groups[groups.length - 1][1].push(row);
}
}
const foot = [['Agents', '/agents', 'agents'], ['Machines', '/machines', 'machines'], ['Memory', '/memory', 'memory'], ['Usage', '/usage', 'usage'], ['Settings', '/settings', 'settings']]
const foot = [['Agents', '/agents', 'agents'], ['Machines', '/machines', 'machines'], ['Memory', '/memory', 'memory'], ['Usage', '/usage', 'usage'], ['Settings', '/settings', 'settings']]
- .map(([label, href, i]) => `<a href="${href}" title="${label}">${ico(i)}<span>${label}</span></a>`).join('');
+ .map(([label, href, i]) => `<a href="${href}" title="${label}"${here === href ? ' class="is-here" aria-current="page"' : ''}>${ico(i)}<span>${label}</span></a>`).join('');
return `<aside class="ch-rail" id="ch-rail" aria-label="Your conversations">
return `<aside class="ch-rail" id="ch-rail" aria-label="Your conversations">
<div class="ch-rail-top"><a class="ch-new" href="/">${ico('new')}<span>New chat</span></a><button class="ch-icon-btn ch-rail-close" type="button" data-ch-rail-close aria-label="Close the list">${ico('close')}</button></div>
<div class="ch-rail-top"><a class="ch-new" href="/">${ico('new')}<span>New chat</span></a><button class="ch-icon-btn ch-rail-close" type="button" data-ch-rail-close aria-label="Close the list">${ico('close')}</button></div>
<form class="ch-find" role="search" method="get" action="/tasks"><label class="ch-sr" for="ch-find">Search your conversations</label>${ico('search')}<input id="ch-find" name="q" placeholder="Search" autocomplete="off" data-ch-find></form>
<form class="ch-find" role="search" method="get" action="/tasks"><label class="ch-sr" for="ch-find">Search your conversations</label>${ico('search')}<input id="ch-find" name="q" placeholder="Search" autocomplete="off" data-ch-find></form>
@@ -222,9 +222,9 @@ ${rows.length ? '' : '<p class="ch-empty-rail">Your conversations appear here.</
@@ -222,9 +222,9 @@ ${rows.length ? '' : '<p class="ch-empty-rail">Your conversations appear here.</
const n = Number(a.n);
const n = Number(a.n);
if (prev) {
if (prev) {
const note = /^carried on by the owner: /.exec(a.why || '') ? String(a.why).replace(/^carried on by the owner: /, '') : null;
const note = /^carried on by the owner: /.exec(a.why || '') ? String(a.why).replace(/^carried on by the owner: /, '') : null;
- if (a.kind === 'manual' && note) out.push(you(note));
- else if (a.agent && prev.agent && a.agent !== prev.agent) out.push(divider(`${agentOf(a.agent, names).name} took over${prev.end_why ? `: ${agentOf(prev.agent, names).name} ${prev.end_why}` : ''}`));
- else if (a.why) out.push(divider(String(a.why), 'handover'));
+ if (note) out.push(you(note));
+ if (a.agent && prev.agent && a.agent !== prev.agent) out.push(divider(`${agentOf(a.agent, names).name} took over${prev.end_why ? `: ${agentOf(prev.agent, names).name} ${prev.end_why}` : ''}`));
+ else if (a.why && !note) out.push(divider(String(a.why), 'handover'));
}
}
const isLive = live && n === Number(attempts[attempts.length - 1].n);
const isLive = live && n === Number(attempts[attempts.length - 1].n);
out.push(agentTurn(agentOf(a.agent || row.agent, names), byAttempt.get(n) || [], { live: isLive, attempt: a }));
out.push(agentTurn(agentOf(a.agent || row.agent, names), byAttempt.get(n) || [], { live: isLive, attempt: a }));
@@ -312,21 +312,21 @@ ${note ? `<p class="ch-note" data-ch-status role="status">${note}</p>` : '<p cla
@@ -312,21 +312,21 @@ ${note ? `<p class="ch-note" data-ch-status role="status">${note}</p>` : '<p cla
}
}
// ── Pages ────────────────────────────────────────────────────────────────────────────────────────────────────
// ── Pages ────────────────────────────────────────────────────────────────────────────────────────────────────
- async function frame(req, res, { currentId = null, title, head = '', thread = '', composerHtml = '', side = '', streamUrl = '', empty = false }) {
+ async function frame(req, res, { currentId = null, title, head = '', thread = '', composerHtml = '', side = '', streamUrl = '', empty = false, page = null, here = '', css = [], scripts = [] }) {
const rows = await conversations(req.viewer.subject);
const rows = await conversations(req.viewer.subject);
const waiting = approvals ? await approvals.inbox(req.viewer.subject).catch(() => []) : [];
const waiting = approvals ? await approvals.inbox(req.viewer.subject).catch(() => []) : [];
const waitingIds = new Set(waiting.map((a) => a.task_id));
const waitingIds = new Set(waiting.map((a) => a.task_id));
- const body = `<div class="ch${side ? ' has-side' : ''}${empty ? ' is-empty' : ''}" data-chat>
-${railHtml(rows, currentId, waitingIds)}
+ const body = `<div class="ch${side ? ' has-side' : ''}${empty ? ' is-empty' : ''}${page != null ? ' is-page' : ''}" data-chat>
+${railHtml(rows, currentId, waitingIds, here)}
<div class="ch-scrim" data-ch-scrim hidden></div>
<div class="ch-scrim" data-ch-scrim hidden></div>
<section class="ch-main" aria-label="Conversation">
<section class="ch-main" aria-label="Conversation">
<header class="ch-head"><button class="ch-icon-btn ch-rail-open" type="button" data-ch-rail-open aria-label="Your conversations" aria-controls="ch-rail">${ico('rail')}</button>${head}${side ? `<button class="ch-icon-btn ch-side-open" type="button" data-ch-side-open aria-label="What it did" aria-controls="ch-side">${ico('side')}</button>` : ''}</header>
<header class="ch-head"><button class="ch-icon-btn ch-rail-open" type="button" data-ch-rail-open aria-label="Your conversations" aria-controls="ch-rail">${ico('rail')}</button>${head}${side ? `<button class="ch-icon-btn ch-side-open" type="button" data-ch-side-open aria-label="What it did" aria-controls="ch-side">${ico('side')}</button>` : ''}</header>
-<div class="ch-thread" id="ch-thread"${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${thread}</div>
-${composerHtml}
+${page != null ? `<div class="ch-page">${page}</div>` : `<div class="ch-thread" id="ch-thread"${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${thread}</div>
+${composerHtml}`}
</section>
</section>
${side}
${side}
</div>`;
</div>`;
- return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: ['css/chat.css'], scripts: ['js/chat.js'], body: raw(body) });
+ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], scripts: [...scripts, 'js/chat.js'], body: raw(body) });
}
}
/** The side: who worked on it, and where the work is. */
/** The side: who worked on it, and where the work is. */
@@ -347,6 +347,10 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
@@ -347,6 +347,10 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
</aside>`;
</aside>`;
}
}
+ ctx.chatShell = (req, res, { title, body, css = [], scripts = [], here = '' }) => frame(req, res, {
+ title, page: body, here, css, scripts, head: '',
+ });
+
// A new conversation: the greeting, the repository and the agent, and the composer.
// A new conversation: the greeting, the repository and the agent, and the composer.
r.get('/', async (req, res, next) => {
r.get('/', async (req, res, next) => {
if (!viewer(req)) return next();
if (!viewer(req)) return next();
@@ -79,16 +79,12 @@ function renderPage(o) {
@@ -79,16 +79,12 @@ function renderPage(o) {
// the current one active, and the product's own places fold into More; a visitor who is not signed in keeps the
// the current one active, and the product's own places fold into More; a visitor who is not signed in keeps the
// product's links, because the sections hold the visitor's own work and a visitor has none yet.
// product's links, because the sections hold the visitor's own work and a visitor has none yet.
const here = (href) => pathOnly === href || (href !== '/' && pathOnly.startsWith(`${href}/`));
const here = (href) => pathOnly === href || (href !== '/' && pathOnly.startsWith(`${href}/`));
- // A conversation (a signed-in /, /tasks/:id) is the chat app: its rail is the navigation, so the bar carries none.
- const chatPage = signedIn && (pathOnly === '/' || /^\/tasks\/tsk_[0-9A-Z]{26}$/.test(pathOnly));
- const links = chatPage ? []
- : (signedIn && c4Workspace)
- ? [
- ...WORKSPACE_NAV.map((l) => ({ ...l, active: here(l.href) })),
- // The catalogue of harnesses is the product's Agents page; the workspace's own Agents page is above, so the
- // product's one is named for what it is.
- ...NAV.map((l) => ({ ...l, more: true, label: l.href === '/harnesses' ? 'Agent catalogue' : l.label })),
- ]
+ // A signed-in person's workspace is one app (Codes L31): its rail is the navigation (the conversations, and the
+ // workspace's own pages at its foot), so the navbar keeps only the product's places, in More. The catalogue of
+ // harnesses is named for what it is, beside the workspace's own Agents page in the rail.
+ const inApp = signedIn && c4Workspace;
+ const links = inApp
+ ? NAV.map((l) => ({ ...l, more: true, label: l.href === '/harnesses' ? 'Agent catalogue' : l.label }))
: NAV.map((l) => ({ ...l, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) }));
: NAV.map((l) => ({ ...l, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) }));
const nav = {
const nav = {
service: 'codes',
service: 'codes',
@@ -98,7 +94,8 @@ function renderPage(o) {
@@ -98,7 +94,8 @@ function renderPage(o) {
// Phones: one tab bar, the Frame's own (Shared 4.20). On a workspace page a signed-in person gets the workspace's
// Phones: one tab bar, the Frame's own (Shared 4.20). On a workspace page a signed-in person gets the workspace's
// sections there — the first four (Home, Tasks, Conductor, Agents) and More, which opens the navbar's drawer with
// sections there — the first four (Home, Tasks, Conductor, Agents) and More, which opens the navbar's drawer with
// the rest; the section the person is in is marked. Everyone else keeps the product's four.
// the rest; the section the person is in is marked. Everyone else keeps the product's four.
- appBar: c4Workspace && signedIn ? true : [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Start', href: '/start', icon: 'fa-play' }, { label: 'Run', href: '/run', icon: 'fa-microchip' }, { label: 'Agents', href: '/harnesses', icon: 'fa-robot' }],
+ // Phones: in the app the rail is a drawer and the conversation owns the screen, so no tab bar (L31).
+ appBar: inApp ? false : [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Start', href: '/start', icon: 'fa-play' }, { label: 'Run', href: '/run', icon: 'fa-microchip' }, { label: 'Agents', href: '/harnesses', icon: 'fa-robot' }],
history: { type: 'page', title: o.title || SITE_NAME },
history: { type: 'page', title: o.title || SITE_NAME },
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
@@ -294,6 +294,19 @@ const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -294,6 +294,19 @@ const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
assert.strictEqual((await attemptsOf(id)).length, 3, 'no third continue');
assert.strictEqual((await attemptsOf(id)).length, 3, 'no third continue');
});
});
+ await check('the next turn carries the work and the conversation so far, to the same agent or another (L31 CH4)', async () => {
+ const id = await startTask();
+ const patch = 'diff --git a/cache.js b/cache.js\nnew file mode 100644\n--- /dev/null\n+++ b/cache.js\n@@ -0,0 +1 @@\n+module.exports = new Map();\n';
+ await endAttempt(id, { state: 'succeeded', result: { ok: true, summary: 'I added the cache.', files: [{ path: 'cache.js', added: 1, removed: 0 }], patch, log: [{ t: 'done', ok: true, text: 'I added the cache.' }] } });
+ const continued = await call('POST', `/tasks/${id}/continue`, { body: { note: 'Now the tests.' } });
+ assert.strictEqual(continued.status, 200, JSON.stringify(continued.json));
+ const submits = calls.filter((c) => c[0] === 'submit');
+ const args = submits[submits.length - 1][1].args;
+ assert.strictEqual(args.base_patch, patch, 'the work so far travels as the diff');
+ assert.ok(args.prompt.includes('## The conversation so far') && args.prompt.includes('Now the tests.'), 'and the conversation as words');
+ assert.ok(args.prompt.indexOf('## The conversation so far') < args.prompt.indexOf('Now the tests.'), 'the history before the new note');
+ });
+
await check('the owner needs no token: their own task reads, continues and stops', async () => {
await check('the owner needs no token: their own task reads, continues and stops', async () => {
const id = await startTask();
const id = await startTask();
const mine = await t.get(`/api/v1/tasks/${id}`, { as: ada });
const mine = await t.get(`/api/v1/tasks/${id}`, { as: ada });
@@ -133,9 +133,9 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
@@ -133,9 +133,9 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
assert.ok(page.text.includes('Online'), 'with its state');
assert.ok(page.text.includes('Online'), 'with its state');
assert.ok(page.text.includes(`/machines/${NODE}/revoke`), 'and a way to revoke it');
assert.ok(page.text.includes(`/machines/${NODE}/revoke`), 'and a way to revoke it');
assert.ok(page.text.includes('Pair a machine') && page.text.includes('openvibe.bot/machines'), 'the pairing flow is Nodes');
assert.ok(page.text.includes('Pair a machine') && page.text.includes('openvibe.bot/machines'), 'the pairing flow is Nodes');
- assert.ok(page.text.includes('"appBar":true') && !page.text.includes('c4-mobile'), 'the workspace gives the Frame app bar its own sections: no second bar');
+ assert.ok(page.text.includes('"appBar":false') && !page.text.includes('c4-mobile') && page.text.includes('data-ch-rail-open'), 'the app has no tab bar: the rail is its navigation (L31)');
const coded = await t.get('/machines?code=abcd-2345', { as: ada });
const coded = await t.get('/machines?code=abcd-2345', { as: ada });
- assert.ok(coded.text.includes('"appBar":true'), 'a query string does not bring a second bar back');
+ assert.ok(coded.text.includes('"appBar":false') && coded.text.includes('data-ch-rail-open'), 'a query string keeps the page in the app');
assert.ok(coded.text.includes('sudo openvibe-node pair ABCD-2345 --network https://openvibe.network'), 'the code makes the command');
assert.ok(coded.text.includes('sudo openvibe-node pair ABCD-2345 --network https://openvibe.network'), 'the code makes the command');
const wrong = await t.get('/machines?code=nope', { as: ada });
const wrong = await t.get('/machines?code=nope', { as: ada });
assert.ok(wrong.text.includes('a pairing code is 8 letters and digits, like ABCD-1234'), 'a wrong code gets Node’s own words');
assert.ok(wrong.text.includes('a pairing code is 8 letters and digits, like ABCD-1234'), 'a wrong code gets Node’s own words');
@@ -30,20 +30,15 @@ const split = (links) => ({
@@ -30,20 +30,15 @@ const split = (links) => ({
const t = await boot();
const t = await boot();
const ada = t.network.addUser('c4c-ada');
const ada = t.network.addUser('c4c-ada');
try {
try {
- await check('a signed-in person’s workspace page has one navigation: the Frame navbar’s sections', async () => {
- const page = await t.get('/tasks', { as: ada });
+ await check('a signed-in person’s workspace is one app: the rail is its navigation (L31)', async () => {
+ const page = await t.get('/agents', { as: ada });
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
- const links = navbarOf(page.text).links;
- const { top, more } = split(links);
- assert.deepStrictEqual(top, ['Home', 'Tasks', 'Conductor', 'Agents', 'Machines', 'Memory', 'Usage', 'Settings'], 'the sections are the navbar');
- assert.ok(more.includes('Get started') && more.includes('Run on your machine'), 'the product’s places fold into More');
- assert.ok(!top.includes('Agents') || top.filter((l) => l === 'Agents').length === 1, 'Agents is in one place only');
- const tasks = links.find((l) => l.href === '/tasks');
- assert.ok(tasks && tasks.active, 'the section the person is in is active');
- assert.ok(!links.find((l) => l.href === '/agents').active, 'and no other one is');
- // The page carries no sidebar of its own: the Frame owns the navigation.
- assert.ok(!page.text.includes('c4-side'), 'no second navigation on the page');
- assert.ok(!page.text.includes('c4-layout'), 'and no sidebar column');
+ const { top, more } = split(navbarOf(page.text).links);
+ assert.deepStrictEqual(top, [], 'the navbar carries no sections');
+ assert.ok(more.includes('Get started') && more.includes('Run on your machine'), 'only the product’s places, in More');
+ assert.ok(page.text.includes('aria-label="Your conversations"'), 'the rail is on the page');
+ assert.ok(/<a href="\/agents" title="Agents" class="is-here" aria-current="page">/.test(page.text), 'and marks the page the person is on');
+ assert.ok(!page.text.includes('c4-side') && !page.text.includes('c4-layout'), 'no other sidebar');
});
});
await check('the section that is not the person’s own — the product’s catalogue — is named for what it is', async () => {
await check('the section that is not the person’s own — the product’s catalogue — is named for what it is', async () => {
@@ -70,19 +65,18 @@ const split = (links) => ({
@@ -70,19 +65,18 @@ const split = (links) => ({
assert.ok(!top.includes('Machines'), 'and the workspace’s sections are not');
assert.ok(!top.includes('Machines'), 'and the workspace’s sections are not');
});
});
- await check('the phone page reserves the Frame app bar’s height, and has no bar of its own', async () => {
+ await check('on a phone the app has no tab bar: the rail is a drawer, and the page reserves no bar’s height', async () => {
const page = await t.get('/settings', { as: ada });
const page = await t.get('/settings', { as: ada });
- assert.ok(page.text.includes('<body class="ov-workspace">'), 'the workspace page says it carries a tab bar');
- assert.ok(!page.text.includes('c4-mobile'), 'and it draws no second bar of its own');
- const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'c4.css'), 'utf8');
- assert.ok(/body\.ov-workspace\{[^}]*padding-bottom:var\(--ovnav-appbar-h,calc\(58px \+ env\(safe-area-inset-bottom,0px\)\)\)/.test(css),
- 'the page reserves the Frame bar’s height, its safe area included');
+ assert.strictEqual(navbarOf(page.text).appBar, false, 'no Frame tab bar in the app');
+ assert.ok(!page.text.includes('c4-mobile'), 'and no bar of its own');
+ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'chat.css'), 'utf8');
+ assert.ok(css.includes('body.ov-workspace:has(.ch){padding-bottom:0}'), 'so nothing is reserved for one');
+ assert.ok(page.text.includes('data-ch-rail-open'), 'the rail opens from the page’s own header');
});
});
- await check('the Frame’s app bar is the workspace’s sections for a signed-in person, and the product’s for a visitor', async () => {
+ await check('the Frame’s app bar: none in a signed-in person’s app, the product’s for a visitor', async () => {
const signedIn = await t.get('/tasks', { as: ada });
const signedIn = await t.get('/tasks', { as: ada });
- assert.strictEqual(navbarOf(signedIn.text).appBar, true,
- 'the bar takes the navbar’s own sections: the first four and More (Shared appbar.js)');
+ assert.strictEqual(navbarOf(signedIn.text).appBar, false, 'in the app the rail is the navigation, not a tab bar');
const visitor = await t.get('/tasks');
const visitor = await t.get('/tasks');
const bar = navbarOf(visitor.text).appBar;
const bar = navbarOf(visitor.text).appBar;
assert.ok(Array.isArray(bar) && bar.length <= 5, 'a visitor’s bar is at most five places');
assert.ok(Array.isArray(bar) && bar.length <= 5, 'a visitor’s bar is at most five places');
@@ -114,6 +114,22 @@ function fakeHarness({ fail = false } = {}) {
@@ -114,6 +114,22 @@ function fakeHarness({ fail = false } = {}) {
assert.strictEqual(fs.readFileSync(path.join(src.dir, 'README.md'), 'utf8'), '# Demo\n');
assert.strictEqual(fs.readFileSync(path.join(src.dir, 'README.md'), 'utf8'), '# Demo\n');
});
});
+ await check('a conversation\'s next turn starts from the work of the turns before it (L31 CH4)', async () => {
+ const src = repository();
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-tmp-'));
+ const turn = (extra) => runJob({ input: JSON.stringify({ repo: src.url, prompt: 'Add a line', ...extra }), harness: fakeHarness(), emit: () => {}, writeResult: () => {}, env, tmp });
+ const first = await turn({});
+ assert.ok(first.patch && first.patch.includes('+Added by the agent.'), 'the first turn\'s diff');
+ const lines = [];
+ const second = await runJob({ input: JSON.stringify({ repo: src.url, prompt: 'Add another', base_patch: first.patch }), harness: fakeHarness(), emit: (l) => lines.push(JSON.parse(l)), writeResult: () => {}, env, tmp });
+ assert.strictEqual(second.base_patch, 'applied', 'the earlier work was picked up');
+ assert.strictEqual((second.patch.match(/\+Added by the agent\./g) || []).length, 2, 'and the new diff holds both turns');
+ assert.ok(lines.some((l) => /Picked up the changes from the earlier turns/.test(JSON.stringify(l))), 'and says so');
+ const stale = await turn({ base_patch: 'diff --git a/NOPE.md b/NOPE.md\n--- a/NOPE.md\n+++ b/NOPE.md\n@@ -1 +1 @@\n-x\n+y\n' });
+ assert.strictEqual(stale.base_patch, 'failed', 'a diff that no longer applies is said');
+ assert.ok(stale.patch && stale.patch.includes('+Added by the agent.'), 'and the turn still runs from the branch as it is');
+ });
+
await check('a push is only ever a new branch: never the base, a protected name, an existing branch, or unchecked', () => {
await check('a push is only ever a new branch: never the base, a protected name, an existing branch, or unchecked', () => {
const { pushTarget } = require('../harness/job');
const { pushTarget } = require('../harness/job');
assert.deepStrictEqual(pushTarget('fix/typo', 'main'), { branch: 'fix/typo' });
assert.deepStrictEqual(pushTarget('fix/typo', 'main'), { branch: 'fix/typo' });