openvibers/openvibe.codes Commits

Conductor: the shared agent composer, the model as its chip (#428)

OP OpenVibers committed 1 days ago · d36b18bedb · 7 files, +109 −10

* Conductor: the shared agent composer, the model as its chip

* openvibe-shared v4.215.0 (the composer v2 release)

* Merge origin/main

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex ed4aee1..1da5af7 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,11 +9,11 @@
"domain": "openvibe.codes",
"contracts": "openvibe-contracts v0.406.0",
"sdk": "openvibe-sdk v0.150.0",
-- "shared": "openvibe-shared v4.214.0",
++ "shared": "openvibe-shared v4.215.0",
"packages": [
"openvibe-contracts v0.406.0",
"openvibe-sdk v0.150.0",
-- "openvibe-shared v4.214.0"
++ "openvibe-shared v4.215.0"
],
"summary": "Public at https://openvibe.codes (sudo ovhost deploy codes; unit openvibe-codes on 127.0.0.1:4900). Since 2026-10-08 Codes is the open coding-agent harness: openvibe-codes (a command-line tool and Node.js module, v0.5.2) runs a coding task on Claude Code, Claude Code on another provider, Codex, OpenCode, Command Code, MiniMax Code, Aider, the DeepSeek API or a person's own OpenAI-compatible model on their machine, hands it to the next agent when one fails or goes quiet, and keeps every run; the site carries the agent catalog with a public routing API, getting started, Improve OpenVibe and the community documents. The developer console that used to live here moved to OpenVibe.Services (https://openvibe.services) and every one of its addresses answers a permanent redirect there.",
"works": [
ddiff --git a/package-lock.json b/package-lock.json
iindex 785876f..3325f8f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.406.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",
"pg": "^8.23.0",
"yaml": "^2.9.1"
},
@@@ -1275,9 +1275,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.214.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",
-- "integrity": "sha512-HvaqQOZLLFEgwDaCkrhJFfwQfsMIlOmV05PhO796JFmC7A1NScxOqp86djT6sYsK1pARMqeo0kTua5mAdXiQFw==",
++ "version": "4.215.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",
++ "integrity": "sha512-2hXCSsMQieDuYtdcr4KEhBmrM0rzw10T8TT/edGSsoXXQCf4bmqMWp7iPBR3NYaJgiGATcnNFdW931AbtGhfAw==",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex b44b374..c094ca6 100644
---- a/package.json
++++ b/package.json
@@@ -32,7 +32,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.406.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",
"pg": "^8.23.0",
"yaml": "^2.9.1"
},
ddiff --git a/public/js/c4.js b/public/js/c4.js
iindex b2023f5..3e44b71 100644
---- a/public/js/c4.js
++++ b/public/js/c4.js
@@@ -232,7 +232,7 @@
const text = (form.querySelector('textarea[name="text"]') || {}).value || '';
const res = await fetch(form.dataset.conductorApi, {
method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
-- credentials: 'same-origin', body: JSON.stringify({ text, attachments: [] }),
++ credentials: 'same-origin', body: JSON.stringify({ text, attachments: [], who: (form.querySelector('select[name="who"]') || {}).value || undefined }),
});
const doc = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(doc.detail || `the message was refused (${res.status})`);
ddiff --git a/public/js/composer.js b/public/js/composer.js
nnew file mode 100644
iindex 0000000..bb33403
---- /dev/null
++++ b/public/js/composer.js
@@@ -0,0 +1,38 @@
++/* The conductor's message box on the shared composer (openvibe-shared 4.215.0, OpenVibeComposer.mount, preset
++ agent): the session form keeps its action, upload picker, status lines and no-JS post; its model becomes the mode
++ chip (the form's `who`), and c4.js still drives the send. Without ov-composer.js the server's box stays. */
++(function () {
++ 'use strict';
++ function upgrade(form) {
++ if (!form || form.__ovcComposer || !window.OpenVibeComposer || !window.OpenVibeComposer.mount) return;
++ var ta = form.querySelector('#c4-message, textarea[name="text"], textarea');
++ if (!ta) return;
++ var options = [];
++ try { options = JSON.parse(form.getAttribute('data-c4-model-options') || '[]'); } catch (e) { options = []; }
++ var value = form.getAttribute('data-c4-model-value') || '';
++ var c = window.OpenVibeComposer.mount(document.createElement('div'), {
++ preset: 'agent', name: ta.name || 'text', maxLength: 16000,
++ placeholder: ta.placeholder || 'Tell your conductor what to do next', label: 'Message the conductor',
++ submitLabel: 'Send', tools: [],
++ mode: options.length ? { name: 'who', label: 'Model', options: options, value: value } : null,
++ });
++ if (!c) return;
++ c.text.id = 'c4-message';
++ var anchor = ta.closest('label') || ta;
++ while (c.form.firstChild) anchor.parentNode.insertBefore(c.form.firstChild, anchor);
++ anchor.remove();
++ var old = form.querySelector('.c4-conductor-actions button[type="submit"]');
++ if (old) old.remove();
++ form.classList.add('ovt-composer', 'ovc-v2');
++ form.dataset.preset = 'agent';
++ c.text.addEventListener('keydown', function (e) {
++ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.submit(); }
++ });
++ form.__ovcComposer = c;
++ }
++ function scan() {
++ Array.prototype.forEach.call(document.querySelectorAll('form[data-ovc-compose]'), upgrade);
++ }
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', scan, { once: true }); else scan();
++ window.OVCodesComposer = { scan: scan, upgrade: upgrade };
++})();
ddiff --git a/server/http/c4.js b/server/http/c4.js
iindex 357e910..d3ebfd8 100644
---- a/server/http/c4.js
++++ b/server/http/c4.js
@@@ -381,9 +381,15 @@ ${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="
const outcome = row.outcome === 'unfinished' || row.outcome === 'stopped'
? `<div class="c4-outcome"><span class="c4-obadge" aria-hidden="true">${icon('<path d="m18 6-12 12M6 6l12 12"/>')}</span><div class="c4-outcome-copy"><b>${row.outcome === 'stopped' ? 'Stopped' : 'The last turn did not finish'}</b><span>${esc(row.outcome_note || row.error || 'Write a message to carry on.')}</span></div></div>` : '';
const transcript = `<section class="c4-card c4-transcript" data-c4-conductor-stream="${esc(`${api}/events?last_event_id=${lastSeq}`)}" data-task="${esc(row.id)}" data-started-at="${esc(row.started_at || row.created_at || '')}" data-c4-conductor="${live ? 'live' : 'idle'}"><div class="c4-card-head"><h2>${live ? 'This turn, live' : 'The last turn'}</h2><span class="c4-muted" data-live-label>${live ? 'Connecting…' : 'Nothing is running'}</span></div><pre class="c4-log" data-task-log aria-live="polite" tabindex="0">${esc(lastLines)}</pre></section>`;
-- const composer = row.outcome === 'stopped' ? '' : `<section class="c4-card c4-conductor-composer"><form method="post" action="/tasks/${encodeURIComponent(row.id)}/messages" enctype="multipart/form-data" data-c4-conductor-form data-conductor-api="/api/v1/conductor/${encodeURIComponent(row.id)}/messages"><label for="c4-message">${live ? 'Say something: it is the next turn' : 'What next?'}</label><textarea id="c4-message" name="text" rows="3" placeholder="Tell your conductor what to do next, or ask what it is doing"></textarea><div class="c4-conductor-actions"><div data-c4-upload></div><noscript><label class="c4-file">Attach files<input type="file" name="files" multiple></label></noscript><button class="c4-button" type="submit">Send</button></div><p class="c4-hint">Up to 10 files, 20 MB each, 50 MB together. Programs cannot be attached.</p><div data-c4-upload-progress></div><p class="c4-hint" data-c4-message-status role="status">${live ? 'A message waits until the current turn ends; it is not lost.' : ''}</p></form></section>`;
++ // The session's message box is the shared composer (openvibe-shared 4.215.0, preset agent): its model is the
++ // mode chip, so the picker's who goes with the form and the JSON send, and the turn runs on the model chosen.
++ const modelOptions = row.agent === 'claude-code'
++ ? MODELS.map(([id, label]) => [`${row.agent}|${id}`, label])
++ : [[`${row.agent}|${row.model || ''}`, row.model || 'its default model']];
++ const whoValue = `${row.agent}|${row.model || ''}`;
++ const composer = row.outcome === 'stopped' ? '' : `<section class="c4-card c4-conductor-composer"><form method="post" action="/tasks/${encodeURIComponent(row.id)}/messages" enctype="multipart/form-data" data-c4-conductor-form data-ovc-compose data-ovc-preset="agent" data-c4-model-options="${esc(JSON.stringify(modelOptions))}" data-c4-model-value="${esc(whoValue)}" data-conductor-api="/api/v1/conductor/${encodeURIComponent(row.id)}/messages"><label for="c4-message">${live ? 'Say something: it is the next turn' : 'What next?'}</label><textarea id="c4-message" name="text" rows="3" maxlength="16000" placeholder="Tell your conductor what to do next, or ask what it is doing"></textarea><div class="c4-conductor-actions"><div data-c4-upload></div><noscript><label class="c4-file">Attach files<input type="file" name="files" multiple></label></noscript><button class="c4-button" type="submit">Send</button></div><p class="c4-hint">Up to 10 files, 20 MB each, 50 MB together. Programs cannot be attached.</p><div data-c4-upload-progress></div><p class="c4-hint" data-c4-message-status role="status">${live ? 'A message waits until the current turn ends; it is not lost.' : ''}</p></form></section>`;
const content = `${outcome}<section class="c4-card c4-conductor-head"><div class="c4-task-heading"><div class="c4-task-title-wrap"><h1>Conductor</h1></div><span class="c4-status c4-${esc(row.state)}">${esc(conductorState(row))}</span></div><div class="c4-chips" aria-label="Conductor details"><span class="c4-chip">${esc(row.folder || 'a folder')}</span><span class="c4-chip">${esc(row.agent === 'claude-code' ? modelLabel(row.model) : (row.model || 'its default model'))}</span><span class="c4-chip">safety: ${esc(row.safety || 'ask')}</span><span class="c4-chip">${esc(machineLabel || 'your machine')}</span></div><p class="c4-hint">One long session on your own machine, on the agent you chose and your own login. Each message is the next turn of the same conversation; it hands the work to your other agents through the Codes tools.</p></section>${approvals}${usageCard}<section class="c4-card c4-turns"><div class="c4-card-head"><h2>Turns</h2><span class="c4-muted">${(view.messages || []).length} so far</span></div>${turns ? `<ol class="c4-turn-list">${turns}</ol>` : '<div class="c4-emptyline">Its first turn is starting.</div>'}</section>${transcript}${composer}`;
-- page(req, res, 'Conductor', wrap(content), { sharedScripts: ['agent-session.js'] });
++ page(req, res, 'Conductor', wrap(content), { sharedScripts: ['agent-session.js', 'ov-composer.js'], scripts: ['js/composer.js'] });
}
// ── The front door (a visitor): what Codes is, shown rather than told ──
ddiff --git a/test/composer.test.js b/test/composer.test.js
nnew file mode 100644
iindex 0000000..5941a9c
---- /dev/null
++++ b/test/composer.test.js
@@@ -0,0 +1,55 @@
++'use strict';
++// The conductor's message box on the shared composer (openvibe-shared 4.215.0, OpenVibeComposer.mount, preset
++// agent): the session form keeps its action, uploader and status line; the model is the mode chip; the box sends.
++// Real Chrome at 390 px; skipped without Chrome.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const h = require('openvibe-shared/browser-harness');
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('composer: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
++const SHARED = process.env.OV_SHARED_DIR || path.join(__dirname, '..', 'node_modules', 'openvibe-shared');
++const SITE = path.join(__dirname, '..');
++const form = `<section class="c4-card c4-conductor-composer"><form method="post" action="/tasks/t1/messages" enctype="multipart/form-data" data-c4-conductor-form data-ovc-compose data-ovc-preset="agent" data-c4-model-options='[["claude-code|opus","Opus 5.5"],["claude-code|sonnet","Sonnet 5.5"]]' data-c4-model-value="claude-code|opus"><label for="c4-message">What next?</label><textarea id="c4-message" name="text" rows="3" maxlength="16000" placeholder="Tell your conductor what to do next"></textarea><div class="c4-conductor-actions"><div data-c4-upload></div><button class="c4-button" type="submit">Send</button></div><p class="c4-hint" data-c4-message-status role="status"></p></form></section>`;
++let posted = '';
++const server = http.createServer((req, res) => {
++ if (req.method === 'POST') {
++ let body = '';
++ req.on('data', (d) => { body += d; });
++ req.on('end', () => { posted = body; res.writeHead(200, { 'content-type': 'text/html' }); res.end('<!doctype html><p id="posted">posted</p>'); });
++ return;
++ }
++ if (req.url === '/') {
++ res.writeHead(200, { 'content-type': 'text/html' });
++ return res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Conductor</title><link rel="icon" href="data:,"></head><body style="margin:0;padding:12px;background:#0b1220;color:#e6edf7;font-family:sans-serif">${form}<script src="/ov-composer.js"></script><script src="/composer.js"></script></body></html>`);
++ }
++ if (req.url === '/ov-composer.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(SHARED, 'ov-composer.js'))); }
++ if (req.url === '/composer.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(SITE, 'public/js/composer.js'))); }
++ res.writeHead(404); res.end();
++});
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-composer-'));
++ const browser = await h.launch({ tmpDir });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ const page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 900 });
++ try {
++ await page.goto(base + '/', { maxMs: 6000 });
++ for (let i = 0; i < 100 && !(await page.evaluate(`!!document.querySelector('form.ovc-v2')`)); i++) await wait(40);
++ const shape = await page.evaluate(`(() => { const f = document.querySelector('form[data-c4-conductor-form]'); return { preset: f.dataset.preset, action: f.getAttribute('action'), text: f.querySelector('#c4-message[data-ovt-text]') ? f.querySelector('#c4-message').value : null, chip: f.querySelector('.ovc-chip-v') ? f.querySelector('.ovc-chip-v').textContent : null, who: f.querySelector('select[name=who]').value, upload: !!f.querySelector('[data-c4-upload]'), status: !!f.querySelector('[data-c4-message-status]'), old: !!f.querySelector('.c4-conductor-actions button[type=submit]') }; })()`);
++ assert.deepEqual(shape, { preset: 'agent', action: '/tasks/t1/messages', text: '', chip: 'Opus 5.5', who: 'claude-code|opus', upload: true, status: true, old: false });
++ assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390, 'nothing is wider than a phone');
++ await page.evaluate(`(() => { const f = document.querySelector('form.ovc-v2'); const t = f.querySelector('textarea'); t.value = 'Carry on'; t.dispatchEvent(new Event('input', { bubbles: true })); f.querySelector('select[name=who]').value = 'claude-code|sonnet'; f.querySelector('[data-ovt-send]').click(); })()`);
++ for (let i = 0; i < 100 && !posted; i++) await wait(40);
++ assert.match(posted, /Carry on/);
++ assert.match(posted, /claude-code\|sonnet/);
++ console.log('composer: the conductor is the shared agent composer, its model is the chip, sends, 390 px');
++ } finally {
++ await page.close().catch(() => {});
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });