openvibers/openvibe.codes Commits

Any file in the composer, and who leads your tasks (#308)

OP OpenVibers committed 6 days ago · cee22b8cff · 22 files, +1427 −167

* Any file in the composer, and who leads your tasks

Every composer takes any non-executable file: at most 10, 20 MB each, 50 MB a message. The page uploads first with a chip, thumbnail, progress and a remove; without JavaScript the same form posts multipart. Files are owner-only, listed under their message, and written for the agent under .openvibe/attachments/<message>/<name>, named in the turn's prompt.

Who leads your tasks: the conductor's agent and model are a saved setting (GET/PUT /api/v1/me/settings, the Settings page) defaulting to Claude Code on Opus, with every harness and model, its availability and price. A single conductor can pick another leader in its composer. A conductor turn on an API harness is funded like a coding task.

* Merge origin/main into feat/composer-files-conductor

Changes

ddiff --git a/harness/job.js b/harness/job.js
iindex 26b1668..f0c9544 100644
---- a/harness/job.js
++++ b/harness/job.js
@@@ -20,8 +20,11 @@ const { execFile } = require('child_process');
const { collectProof } = require('../engine/proof');
const JOB_PROTOCOL = '1.0.0';
--/** The agents a conductor's turn may run on: the CLIs that resume a session and start the Codes MCP server. */
--const CONDUCTOR_AGENTS = ['claude-code', 'codex', 'opencode']; // the job's contract with the Node (stdin, stdout lines, fd 3): unchanged by 1.1's lines
++/** The agents a conductor's turn may run on: every harness Codes offers. */
++// Every harness can lead a conductor; the three that resume a session and take the Codes MCP tools are
++// the failover chain's own (server/domain/conductor-failover.js). The job's contract with the Node (stdin, stdout
++// lines, fd 3) is unchanged by 1.1's lines.
++const CONDUCTOR_AGENTS = ['claude-code', 'claude-other', 'codex', 'opencode', 'command-code', 'minimax-code', 'aider', 'deepseek', 'openvibe-ai', 'openai-compatible'];
const LINES = '1.1'; // 1.1: lines carry step ids, inputs, results, progress, helpers; the result keeps `log`
const MAX_PROMPT = 16_000;
const MAX_PATCH = 180_000;
@@@ -148,6 +151,29 @@ function jobEnv(input) {
return out;
}
++/**
++ * The owner's files, as the job may fetch them: a name that is one file, an https URL on Codes, a MIME hint, and the
++ * message folder they belong under. Ten at most, as a message is; the bytes themselves stay in the server's database.
++ */
++function jobAttachments(list) {
++ if (!Array.isArray(list)) return [];
++ const out = [];
++ for (const f of list.slice(0, 10)) {
++ if (!f || typeof f !== 'object') continue;
++ if (typeof f.url !== 'string' || !/^https?:\/\//.test(f.url) || f.url.length > 2000) continue;
++ const dir = String(f.dir || 'message');
++ out.push({
++ // The server already made the name one safe file; a space is part of a name, not a path separator.
++ name: String(f.name || 'file').replace(/[^A-Za-z0-9._ -]/g, '_').replace(/\s+/g, ' ').trim().replace(/^\.+/, '').slice(0, 120) || 'file',
++ mime: typeof f.mime === 'string' ? f.mime.slice(0, 100) : 'application/octet-stream',
++ ...(Number.isFinite(Number(f.size)) && Number(f.size) > 0 ? { size: Number(f.size) } : {}),
++ url: f.url,
++ dir: /^[A-Za-z0-9._-]{1,80}$/.test(dir) ? dir : 'message',
++ });
++ }
++ return out;
++}
++
/** The job's input checked: a public https repository (file:// only when OPENVIBE_CODES_ALLOW_FILE_REPO=1, for tests). */
function parseArgs(raw, env = process.env) {
let a;
@@@ -165,12 +191,7 @@ function parseArgs(raw, env = process.env) {
const folder = String(a.folder || '').trim();
if (!folder.startsWith('/') || folder.length > 4096 || /[\0-\x1f\x7f]/.test(folder)) throw new Error('folder must be an absolute path');
const safety = ['ask', 'project', 'full'].includes(a.safety) ? a.safety : 'ask';
-- const files = Array.isArray(a.attachments) ? a.attachments.slice(0, 4).filter((f) => f && typeof f === 'object'
-- && typeof f.url === 'string' && /^https?:\/\//.test(f.url) && f.url.length <= 2000).map((f) => ({
-- name: String(f.name || 'image').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120) || 'image',
-- mime: typeof f.mime === 'string' ? f.mime.slice(0, 100) : 'application/octet-stream',
-- url: f.url,
-- })) : [];
++ const files = jobAttachments(a.attachments);
const codesUrl = typeof a.codes_url === 'string' && /^https?:\/\//.test(a.codes_url) && a.codes_url.length <= 300 ? a.codes_url.replace(/\/+$/, '') : null;
const resumeIn = a.resume == null || a.resume === '' ? env.RESUME : a.resume;
const resume = typeof resumeIn === 'string' && /^[A-Za-z0-9._:-]{1,200}$/.test(resumeIn) ? resumeIn : null;
@@@ -191,6 +212,7 @@ function parseArgs(raw, env = process.env) {
...(Object.keys(wanted).length ? { env: wanted } : {}),
};
}
++ const attached = jobAttachments(a.attachments);
let repo = null;
if (kind === 'code') {
try { repo = new URL(String(a.repo || '')); } catch { throw new Error('repo must be a repository URL'); }
@@@ -221,6 +243,7 @@ function parseArgs(raw, env = process.env) {
} : null;
return {
kind, repo: repo ? repo.toString() : null, ref, prompt, permission, push,
++ ...(attached.length ? { attachments: attached } : {}),
...(limits ? { limits } : {}),
...(resume ? { resume } : {}),
...(Object.keys(wanted).length ? { env: wanted } : {}),
@@@ -464,32 +487,52 @@ function opencodeConfig(file, server) {
return file;
}
--/** The owner's attached images, written as files the turn can read: [{ path, name }] (the ones that arrived). */
--async function writeAttachments(job, { dir, env, fetchImpl, say }) {
++/**
++ * The owner's attached files, written where the agent can read them: `<root>/.openvibe/attachments/<dir>/<name>`. For a
++ * coding task root is the clone (and .openvibe/ is kept out of the diff); for a conductor it is the person's folder.
++ * Returns [{ path, rel, name, mime, size }] for the ones that arrived.
++ */
++async function writeAttachments(job, { dir, env, fetchImpl, say, root = null }) {
const out = [];
if (!Array.isArray(job.attachments) || !job.attachments.length) return out;
const token = codesToken(env);
-- const at = path.join(dir, 'attachments');
-- fs.mkdirSync(at, { recursive: true });
++ const base = root || dir;
++ fs.mkdirSync(path.join(base, '.openvibe', 'attachments'), { recursive: true });
for (const file of job.attachments) {
try {
const response = await fetchImpl(file.url, {
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), Accept: file.mime },
-- signal: AbortSignal.timeout(30_000),
++ signal: AbortSignal.timeout(60_000),
});
if (!response.ok) throw new Error(`the server answered ${response.status}`);
const bytes = Buffer.from(await response.arrayBuffer());
-- if (!bytes.length || bytes.length > 8 * 1024 * 1024) throw new Error('the image is empty or too large');
-- const target = path.join(at, file.name);
++ if (!bytes.length || bytes.length > 20 * 1024 * 1024) throw new Error('the file is empty or larger than 20 MB');
++ const folder = /^[A-Za-z0-9._-]{1,80}$/.test(String(file.dir || '')) ? String(file.dir) : 'message';
++ const at = path.join(base, '.openvibe', 'attachments', folder);
++ fs.mkdirSync(at, { recursive: true });
++ // Two uploads with one name stay two files: the second says which copy it is, rather than overwriting.
++ let name = file.name || 'file';
++ let target = path.join(at, name);
++ for (let n = 2; fs.existsSync(target) && n < 100; n++) {
++ const dot = name.lastIndexOf('.');
++ target = path.join(at, dot > 0 ? `${name.slice(0, dot)}-${n}${name.slice(dot)}` : `${name}-${n}`);
++ }
fs.writeFileSync(target, bytes);
-- out.push({ path: target, name: file.name });
++ out.push({ path: target, rel: `.openvibe/attachments/${folder}/${path.basename(target)}`, name: file.name, mime: file.mime, size: bytes.length });
} catch (e) {
-- say({ t: 'note', level: 'warning', text: `An attached image could not be read (${file.name}): ${scrub(e.message)}` });
++ say({ t: 'note', level: 'warning', text: `An attached file could not be read (${file.name}): ${scrub(e.message)}` });
}
}
return out;
}
++/** The lines a turn's prompt gets for its files: exactly where they are in the workspace. */
++function attachmentPrompt(files) {
++ const list = Array.isArray(files) ? files.filter((f) => f && f.rel) : [];
++ if (!list.length) return '';
++ return `\n\n---\n## Files the owner attached\n\n${list.map((f) => `- \`${f.rel}\`${f.mime ? ` (${f.mime}${f.size ? `, ${f.size} bytes` : ''})` : ''}`).join('\n')}\n\nThey are files in this workspace; read them.`;
++}
++
/**
* Commit on a new branch and push it with GITHUB_TOKEN; a pull request when asked. Only ever a branch that does not
* exist yet: never the base, never a protected name, never someone's existing branch (GitHub is asked first, and a push
@@@ -640,6 +683,13 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
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.' });
}
}
++ // The owner's files are part of the workspace, not of the change: .openvibe/ stays out of git.
++ const files = await writeAttachments(job, { dir, env, fetchImpl, say, root: work });
++ if (files.length) {
++ try { fs.appendFileSync(path.join(work, '.git', 'info', 'exclude'), '\n.openvibe/\n'); } catch { /* not a git checkout: nothing to keep out */ }
++ job = { ...job, prompt: job.prompt + attachmentPrompt(files) };
++ say({ t: 'step', text: `${files.length} file${files.length === 1 ? '' : 's'} attached` });
++ }
} 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.
@@@ -650,21 +700,12 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
fs.chmodSync(dir, 0o700);
result.folder = work;
say({ t: 'step', text: `Opening ${work}` });
-- const images = await writeAttachments(job, { dir, env, fetchImpl, say });
-- let prompt = job.prompt;
-- if (images.length) {
-- prompt += `\n\n---\n## Images the owner attached\n\n${images.map((i) => `- \`${i.path}\``).join('\n')}\n\nThey are files on this machine; read them.`;
-- say({ t: 'step', text: `${images.length} image${images.length === 1 ? '' : 's'} attached` });
++ const files = await writeAttachments(job, { dir, env, fetchImpl, say, root: work });
++ if (files.length) {
++ job = { ...job, prompt: job.prompt + attachmentPrompt(files) };
++ say({ t: 'step', text: `${files.length} file${files.length === 1 ? '' : 's'} attached` });
}
-- job = { ...job, prompt };
-- if (job.agent === 'codex' || job.agent === 'opencode') {
-- // A fallback agent (the server's failover): the same Codes tools, started by a launcher script.
-- const server = job.mcp ? conductorMcpLauncher(path.join(dir, 'codes-mcp.sh'), job, env) : null;
-- adapterOptions = server
-- ? (job.agent === 'codex' ? { mcpServer: server } : { configFile: opencodeConfig(path.join(dir, 'opencode.json'), server) })
-- : {};
-- say({ t: 'step', text: `This turn runs on ${job.agent === 'codex' ? 'Codex' : 'OpenCode'}` });
-- } else {
++ if (job.agent === 'claude-code' || job.agent === 'claude-other') {
adapterOptions = {
// "ask" is Claude Code's own default mode, where every risky call goes to the permission prompt tool
// (the MCP tool that asks the owner through Codes' approvals inbox).
@@@ -672,6 +713,18 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
...(job.mcp ? { mcpConfig: conductorMcpConfig(path.join(dir, 'mcp.json'), job, env), allowedTools: ['mcp__codes'] } : {}),
...(job.mcp && job.safety === 'ask' ? { permissionPromptTool: 'mcp__codes__approve' } : {}),
};
++ } else if (job.agent === 'codex' || job.agent === 'opencode') {
++ // The same Codes tools, started by a launcher script for an agent that takes no MCP config argument.
++ const server = job.mcp ? conductorMcpLauncher(path.join(dir, 'codes-mcp.sh'), job, env) : null;
++ adapterOptions = server
++ ? (job.agent === 'codex' ? { mcpServer: server } : { configFile: opencodeConfig(path.join(dir, 'opencode.json'), server) })
++ : {};
++ say({ t: 'step', text: `This turn runs on ${job.agent === 'codex' ? 'Codex' : 'OpenCode'}` });
++ } else {
++ // Another harness leading the conversation (the setting or a per-task pick): its own CLI/API takes the
++ // turn. It has no Codes MCP tools, so it plans and works in the folder itself.
++ adapterOptions = {};
++ say({ t: 'step', text: `This turn runs on ${job.agent}` });
}
} else {
fs.mkdirSync(work, { recursive: true });
@@@ -766,4 +819,4 @@ function resultWriter(emit) {
};
}
--module.exports = { CONDUCTOR_AGENTS, conductorMcpLauncher, opencodeConfig, gitAuthFor, credentialFor, giteaHosts, pushForge, cloneProblem, runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, jobEnv, ENV_NAMES, JOB_PROTOCOL, codesToken, conductorMcpConfig, writeAttachments, agentPrompt };
++module.exports = { CONDUCTOR_AGENTS, conductorMcpLauncher, opencodeConfig, gitAuthFor, credentialFor, giteaHosts, pushForge, cloneProblem, runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, jobEnv, ENV_NAMES, JOB_PROTOCOL, codesToken, conductorMcpConfig, writeAttachments, attachmentPrompt, agentPrompt, jobAttachments };
ddiff --git a/migrations/0030_attachments_and_conductor_lead.sql b/migrations/0030_attachments_and_conductor_lead.sql
nnew file mode 100644
iindex 0000000..bb4ed79
---- /dev/null
++++ b/migrations/0030_attachments_and_conductor_lead.sql
@@@ -0,0 +1,31 @@
++-- phase: expand
++-- Full file support in every composer (owner 2026-11-07): any file a person attaches to a message, stored in Codes'
++-- own database, owner-only, and handed to the agent as a file in its workspace. The bytes live in `data` (bytea), never
++-- in the message's JSON, so a 20 MB file is one row and not a 27 MB jsonb string.
++--
++-- subject the person who uploaded it (every read and link is scoped to it)
++-- task_id set when the upload is attached to a task; NULL while it waits in the composer
++-- attempt_n the task's turn the file belongs to (1 for the brief, 2 for the first "carry on", …)
++-- message_id the message folder the agent reads it under: .openvibe/attachments/<message_id>/<name>
++-- linked_at when it was attached; an upload never moves to a second message
++CREATE TABLE IF NOT EXISTS codes_attachments (
++ id text COLLATE "C" PRIMARY KEY, -- fil_<ULID>
++ subject text COLLATE "C" NOT NULL,
++ task_id text COLLATE "C",
++ attempt_n integer,
++ message_id text COLLATE "C",
++ name text COLLATE "C" NOT NULL,
++ mime text COLLATE "C" NOT NULL,
++ size bigint NOT NULL,
++ data bytea NOT NULL,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ linked_at timestamptz
++);
++CREATE INDEX IF NOT EXISTS codes_attachments_subject ON codes_attachments (subject, created_at DESC);
++CREATE INDEX IF NOT EXISTS codes_attachments_task ON codes_attachments (task_id, attempt_n);
++CREATE INDEX IF NOT EXISTS codes_attachments_message ON codes_attachments (message_id) WHERE message_id IS NOT NULL;
++
++-- "Who leads your tasks" (owner 2026-11-07): the agent and model a new conductor starts on, and the one every
++-- conductor without its own pick keeps. NULL means today's choice: Claude Code on Opus.
++ALTER TABLE codes_user_settings ADD COLUMN IF NOT EXISTS conductor_agent text COLLATE "C";
++ALTER TABLE codes_user_settings ADD COLUMN IF NOT EXISTS conductor_model text COLLATE "C";
ddiff --git a/public/js/c4.js b/public/js/c4.js
iindex 7bad149..33b5779 100644
---- a/public/js/c4.js
++++ b/public/js/c4.js
@@@ -197,12 +197,12 @@
reader.readAsDataURL(file);
});
form.addEventListener('submit', async (event) => {
-- const files = Array.from(input.files || []).slice(0, 4);
-- if (!files.length) return; // words alone: the plain form post works without this script
++ const files = Array.from(input.files || []).slice(0, 10);
++ if (files.length) return; // the form itself posts the files as multipart
event.preventDefault();
const button = form.querySelector('button[type="submit"]');
if (button) button.disabled = true;
-- say(files.length === 1 ? 'Sending the image…' : `Sending ${files.length} images…`);
++ say('Sending…');
try {
const attachments = (await Promise.all(files.map(asData))).filter(Boolean);
const text = (form.querySelector('textarea[name="text"]') || {}).value || '';
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 9e2df17..38adebf 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -3,7 +3,9 @@
// L55 C4): ov-thread.js grows the composer, sends on Enter, filters the rail, opens the panel on a narrow screen, adds
// Copy and keeps the thread at the bottom; kit-web.js opens the rail on a phone. The page works without either. Here:
// - a new chat's three ways to start, the ideas rail, the projects picked from the words and the repository search;
--// - the conductor's images go up with the message through the JSON API;
++// - a composer's files go up through the attachments API first (chips with a thumbnail, progress and a ×), then the
++// message names their ids; without JavaScript the same form posts the files as multipart;
++// - paste and drop add files, as choosing them does;
// - a running turn streams into the thread (OVThread.tool / toolDone / live), and the page renders the finished turn
// when the attempt ends.
//
@@@ -163,33 +165,150 @@
if ($('.ovt.is-start', app)) text.focus({ preventScroll: true });
}
if (form) {
-- const images = $('[data-ch-images]', form);
-- if (images) images.addEventListener('change', () => {
-- const n = (images.files || []).length;
-- images.closest('.ch-pick-file').classList.toggle('has-files', n > 0);
-- say(n ? `${n} image${n === 1 ? '' : 's'} will go with your message` : '');
++ // ── The composer's files: upload before send, with a chip, a thumbnail, a progress bar and a × ──
++ const picker = $('[data-ch-files]', form);
++ const chips = $('[data-ch-files-list]', form);
++ const BLOCKED = /\.(exe|dll|com|bat|cmd|msi|scr|ps1|sh|so|dylib|app|apk|deb|rpm|jar|bin|elf|vbs|wsf|hta)$/i;
++ const MAX_FILES = 10, MAX_FILE = 20 * 1024 * 1024, MAX_TOTAL = 50 * 1024 * 1024;
++ const entries = [];
++ const sizeWords = (n) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / (1024 * 1024)).toFixed(n < 10 * 1024 * 1024 ? 1 : 0)} MB`);
++ const attachIcon = picker && picker.closest('label') && picker.closest('label').querySelector('svg');
++ const chipFor = (entry) => {
++ const li = document.createElement('span');
++ li.className = 'ch-chip ch-file';
++ li.setAttribute('role', 'listitem');
++ li.dataset.chFile = entry.file.name;
++ if (/^image\//i.test(entry.file.type)) {
++ const img = document.createElement('img');
++ img.width = 24; img.height = 24; img.alt = ''; img.src = entry.thumb;
++ li.appendChild(img);
++ } else if (attachIcon) {
++ li.appendChild(attachIcon.cloneNode(true));
++ }
++ const name = document.createElement('span'); name.textContent = entry.file.name; li.appendChild(name);
++ const size = document.createElement('small'); size.textContent = sizeWords(entry.file.size); li.appendChild(size);
++ const bar = document.createElement('progress'); bar.max = 100; bar.value = 0; bar.setAttribute('aria-label', `Uploading ${entry.file.name}`); li.appendChild(bar);
++ const remove = document.createElement('button');
++ remove.type = 'button'; remove.className = 'ch-icon-btn'; remove.title = `Remove ${entry.file.name}`;
++ remove.setAttribute('aria-label', `Remove ${entry.file.name}`); remove.textContent = '×';
++ remove.addEventListener('click', () => {
++ const i = entries.indexOf(entry);
++ if (i >= 0) entries.splice(i, 1);
++ if (entry.xhr) entry.xhr.abort();
++ if (entry.thumb) URL.revokeObjectURL(entry.thumb);
++ li.remove();
++ if (entry.id) fetch(`/api/v1/attachments/${encodeURIComponent(entry.id)}`, { method: 'DELETE', credentials: 'same-origin' }).catch(() => {});
++ say(entries.length ? `${entries.length} file${entries.length === 1 ? '' : 's'} ready to send` : '');
++ });
++ entry.bar = bar; entry.li = li;
++ li.appendChild(remove);
++ if (chips) { chips.hidden = false; chips.appendChild(li); }
++ return li;
++ };
++ const upload = (entry) => {
++ entry.uploading = true;
++ const xhr = entry.xhr = new XMLHttpRequest();
++ xhr.open('POST', '/api/v1/attachments');
++ xhr.withCredentials = true;
++ xhr.upload.onprogress = (e) => { if (e.lengthComputable && entry.bar) entry.bar.value = Math.round((e.loaded / e.total) * 100); };
++ xhr.onload = () => {
++ entry.uploading = false;
++ let doc = {};
++ try { doc = JSON.parse(xhr.responseText || '{}'); } catch { doc = {}; }
++ if (xhr.status === 201 && doc.attachment && doc.attachment.id) {
++ entry.id = doc.attachment.id;
++ if (entry.bar) { entry.bar.value = 100; entry.bar.hidden = true; }
++ if (entry.li) entry.li.dataset.uploaded = '1';
++ } else {
++ if (entry.li) entry.li.classList.add('is-bad');
++ say(doc.detail || `“${entry.file.name}” could not be attached`, true);
++ }
++ };
++ xhr.onerror = () => { entry.uploading = false; if (entry.li) entry.li.classList.add('is-bad'); say(`“${entry.file.name}” could not be attached`, true); };
++ xhr.onabort = () => { entry.uploading = false; };
++ const data = new FormData();
++ data.append('file', entry.file, entry.file.name);
++ xhr.send(data);
++ };
++ const addFiles = (list) => {
++ const incoming = Array.from(list || []);
++ if (!incoming.length) return;
++ if (entries.length + incoming.length > MAX_FILES) { say(`A message carries at most ${MAX_FILES} files`, true); return; }
++ let total = entries.reduce((n, e) => n + e.file.size, 0);
++ for (const file of incoming) {
++ if (BLOCKED.test(file.name)) { say(`“${file.name}” is a program or script, and cannot be attached`, true); continue; }
++ if (!file.size) { say(`“${file.name}” is empty`, true); continue; }
++ if (file.size > MAX_FILE) { say(`“${file.name}” is larger than 20 MB`, true); continue; }
++ if (total + file.size > MAX_TOTAL) { say('The files of one message are larger than 50 MB together', true); break; }
++ total += file.size;
++ const entry = { file, id: null, uploading: false, thumb: /^image\//i.test(file.type) ? URL.createObjectURL(file) : null, xhr: null };
++ entries.push(entry);
++ chipFor(entry);
++ upload(entry);
++ }
++ if (entries.length) say(`${entries.length} file${entries.length === 1 ? '' : 's'} ready to send`);
++ };
++ if (picker) {
++ picker.addEventListener('change', () => { addFiles(picker.files); picker.value = ''; });
++ }
++ // Paste an image from the clipboard, or drop files on the composer.
++ if (text) text.addEventListener('paste', (e) => {
++ const files = e.clipboardData && e.clipboardData.files;
++ if (files && files.length) { e.preventDefault(); addFiles(files); }
++ });
++ form.addEventListener('dragover', (e) => { if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files')) { e.preventDefault(); form.classList.add('is-dragging'); } });
++ form.addEventListener('dragleave', () => form.classList.remove('is-dragging'));
++ form.addEventListener('drop', (e) => {
++ if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) return;
++ e.preventDefault(); form.classList.remove('is-dragging'); addFiles(e.dataTransfer.files);
});
form.addEventListener('submit', async (e) => {
const submitter = e.submitter;
if (submitter && submitter.formAction && submitter.formAction !== form.action) return; // Stop: a plain post
-- const files = images ? Array.from(images.files || []).slice(0, 4) : [];
form.classList.add('is-sending');
form.querySelectorAll('button').forEach((b) => { b.disabled = b.matches('[data-ovt-send]'); });
-- if (!files.length) { say('Sending…'); return; } // words alone: the plain form post
++ if (!entries.length) { say('Sending…'); return; } // words alone: the plain form post
e.preventDefault();
-- say(files.length === 1 ? 'Sending the image…' : `Sending ${files.length} images…`);
-- const asData = (file) => new Promise((resolve) => { const r = new FileReader(); r.onload = () => resolve({ name: file.name, mime: file.type, data: String(r.result || '') }); r.onerror = () => resolve(null); r.readAsDataURL(file); });
-- try {
-- const attachments = (await Promise.all(files.map(asData))).filter(Boolean);
-- const res = await fetch(form.dataset.chApi, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ text: text.value, attachments }) });
-- const doc = await res.json().catch(() => ({}));
-- if (!res.ok) throw new Error(doc.detail || `the message was refused (${res.status})`);
-- location.reload();
-- } catch (err) {
-- say(String((err && err.message) || 'the message could not be sent'), true);
++ // A file still going up is waited for, not dropped: the message names ids and the server checks them.
++ if (entries.some((x) => x.uploading)) say('Uploading the files…');
++ while (entries.some((x) => x.uploading)) await new Promise((done) => setTimeout(done, 120));
++ const failed = entries.filter((x) => !x.id);
++ if (failed.length) {
++ say(`${failed.length} file${failed.length === 1 ? '' : 's'} could not be attached`, true);
form.classList.remove('is-sending');
form.querySelectorAll('button').forEach((b) => { b.disabled = false; });
++ return;
++ }
++ const ids = entries.map((x) => x.id);
++ if (form.dataset.chApi) {
++ say(ids.length === 1 ? 'Sending the file…' : `Sending ${ids.length} files…`);
++ try {
++ const who = $('[name="who"]', form);
++ const res = await fetch(form.dataset.chApi, {
++ method: 'POST', credentials: 'same-origin',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
++ body: JSON.stringify({ text: text ? text.value : '', attachment_ids: ids, ...(who && who.value ? { who: who.value } : {}) }),
++ });
++ const doc = await res.json().catch(() => ({}));
++ if (!res.ok) throw new Error(doc.detail || `the message was refused (${res.status})`);
++ location.reload();
++ } catch (err) {
++ say(String((err && err.message) || 'the message could not be sent'), true);
++ form.classList.remove('is-sending');
++ form.querySelectorAll('button').forEach((b) => { b.disabled = false; });
++ }
++ return;
++ }
++ // The files are up: a plain form post names their ids (and sends the words and the pickers as usual).
++ form.enctype = 'application/x-www-form-urlencoded';
++ if (picker) picker.value = '';
++ for (const id of ids) {
++ const hidden = document.createElement('input');
++ hidden.type = 'hidden'; hidden.name = 'attachment_ids'; hidden.value = id;
++ form.appendChild(hidden);
}
++ say(ids.length === 1 ? 'Sending the file…' : `Sending ${ids.length} files…`);
++ form.submit();
});
}
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 8376870..6106f8d 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -8,6 +8,7 @@
* about it (codes_events_inbox);
* - the run tokens minted for their attempts (codes_run_tokens, C3: ids and expiry, never the token);
* - their workspace choices (codes_user_settings, C4b) and their placement rules;
++ * - the files they attached to a message (codes_attachments, owner 2026-11-07): names, types and sizes, never bytes;
* - the placement counts learned about their tasks (placement_outcomes, A1): option keys and weights, never text;
* - their conductor's turns (codes_conductor_messages, C9b) and the approvals it asked them (codes_approvals);
* - the events Codes sent about them (codes_outbox);
@@@ -35,6 +36,10 @@ const TABLES = [
erase: { keep: 'erased with its task-keyed rows by the service extraErase' },
},
{ table: 'codes_user_settings', subject: 'subject', file: 'settings.json', order: 'updated_at' },
++ // The files a person attached to a message (owner 2026-11-07): what they are and which turn they belong to. The
++ // bytes are theirs too, but the export carries the record, not a second copy of every 20 MB file; deletion takes
++ // the rows (and their bytes) with the person.
++ { table: 'codes_attachments', subject: 'subject', file: 'attachments.json', order: 'created_at', columns: ['id', 'task_id', 'attempt_n', 'message_id', 'name', 'mime', 'size', 'created_at', 'linked_at'] },
// Placement A1's learned counts: the person's rows leave and are erased with them. No text, no payload.
{ table: 'placement_outcomes', subject: 'person_id', file: 'placement-outcomes.json', order: 'updated_at', columns: ['person_id', 'option_key', 'step_kind', 'ok_w', 'n_w', 'updated_at'] },
// What each of the person's machines reported with its heartbeat (Codes C9c): labels, numbers and times only, no
ddiff --git a/server/app.js b/server/app.js
iindex e96d276..feb1e8e 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -36,6 +36,7 @@ const { createTasksApi } = require('./http/tasks-api');
const { createConnectionsApi } = require('./http/connections-api');
const { createActionsApi } = require('./http/actions-api');
const { createConductorApi } = require('./http/conductor-api');
++const { createAttachmentsApi } = require('./http/attachments-api');
const { createRunClient } = require('./domain/run-client');
const { createRunTasks } = require('./domain/run-tasks');
const { createConnections } = require('./domain/connections');
@@@ -66,6 +67,7 @@ const { createKnowledge } = require('./domain/knowledge');
const { createFleet } = require('./domain/fleet');
const { createNotices } = require('./domain/notices');
const { createUserSettings } = require('./domain/user-settings');
++const { createAttachments } = require('./domain/attachments');
const { createPlacementOutcomes } = require('./domain/placement-outcomes');
const { createBenchPriors } = require('./domain/bench-priors');
const { createPlacementContext } = require('./domain/placement-context');
@@@ -123,7 +125,9 @@ async function createApp(opts = {}) {
// The fleet (C9c): each machine's agents and limit windows, reported with its heartbeat and read from Bot.
const fleet = createFleet({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log, now: store.now, balance: opts.balance || null });
// A person's choices (their notification switches) and the notices a task sends once (C9c).
-- const userSettings = createUserSettings({ store });
++ const userSettings = createUserSettings({ store, harnesses });
++ // The files a person attaches to a message (owner 2026-11-07): their own store, linked to a turn when it is sent.
++ const attachments = createAttachments({ store, log });
const notify = opts.notify || createNotify({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
const notices = createNotices({ store, config, notify, userSettings, log });
const benchPublisher = createBenchPublisher({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });
@@@ -137,8 +141,8 @@ async function createApp(opts = {}) {
const sharing = opts.sharing || createSharing({ db: store.db, config, fetchImpl: fetchImpl || globalThis.fetch, log });
// The skills hub (Compute plan §5, E6): versioned skills, rules and MCP servers, shared through the same SDK.
const skills = opts.skills || createSkills({ store, sharing, log });
-- const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, knowledge, sharing, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });
-- const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, benchPublisher, placementOutcomes, benchPriors, placementContext, knowledge, sharing, skills };
++ const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, knowledge, sharing, attachments, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });
++ const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, attachments, benchPublisher, placementOutcomes, benchPriors, placementContext, knowledge, sharing, skills };
ctx.forgejo = createForgejoClient({ config, log });
// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.
ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
@@@ -155,7 +159,7 @@ async function createApp(opts = {}) {
ctx.approvals = createApprovals({ store, config, log, notify: ctx.notify, userSettings: ctx.userSettings });
ctx.conductor = createConductor({
store, config, log, run, attempts, tasks, sessions, approvals: ctx.approvals, notify: ctx.notify,
-- fetchImpl: fetchImpl || globalThis.fetch, fleet, userSettings,
++ fetchImpl: fetchImpl || globalThis.fetch, fleet, userSettings, harnesses, attachments,
});
tasks.setConductor(ctx.conductor);
@@@ -304,7 +308,9 @@ async function createApp(opts = {}) {
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createSkillsApi({ config, skills: ctx.skills, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, actorLimits: ctx.actorLimits, sharing, log }));
-- app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, github: ctx.github, actorLimits: ctx.actorLimits, log }));
++ // The composer's files: upload before send (with progress), or as the multipart form without JavaScript.
++ app.use('/api/v1', createAttachmentsApi({ attachments: ctx.attachments, actorLimits: ctx.actorLimits, config, keys, log }));
++ app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, userSettings: ctx.userSettings, github: ctx.github, actorLimits: ctx.actorLimits, log }));
// The Dock's agent view (Dock K-D3, Agent Streams S1): the person's own desk stream with a view ticket minted here.
app.use('/api/v1', createAgentView({ config, sso, actorLimits: ctx.actorLimits, log }));
// Know K5: the person's own agents read and add workspace knowledge with their token (the ov-know MCP server).
@@@ -315,7 +321,8 @@ async function createApp(opts = {}) {
// The conductor's own routes (Codes C9b): its turns, sub-tasks, and the approvals inbox the MCP tools speak to.
app.use('/api/v1', createConductorApi({
config, store, keys, harnesses, tasks, conductor: ctx.conductor, approvals: ctx.approvals,
-- machines: ctx.machines, fleet: ctx.fleet, actorLimits: ctx.actorLimits, log,
++ machines: ctx.machines, fleet: ctx.fleet, userSettings: ctx.userSettings, attachments: ctx.attachments,
++ actorLimits: ctx.actorLimits, log,
}));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
ddiff --git a/server/domain/attachments.js b/server/domain/attachments.js
nnew file mode 100644
iindex 0000000..9a24cfa
---- /dev/null
++++ b/server/domain/attachments.js
@@@ -0,0 +1,296 @@
++'use strict';
++
++/**
++ * The files a person attaches to a message (owner 2026-11-07: "full file support in the composer"). Any file that is not
++ * an executable: images, PDFs, text and code, zips, documents. Codes keeps the bytes in its own database, owner-only,
++ * and a message carries only their names and a route to read them (server/http/attachments-api.js).
++ *
++ * createAttachments({ store })
++ * save(subject, file) one upload → its row ({ id, name, mime, size, image })
++ * saveMany(subject, files) a whole message's uploads, refused as one when a limit is passed
++ * own(subject, id) the row, without bytes; null for another person
++ * get(subject, id) the row with its bytes (Buffer in `data`)
++ * remove(subject, id) an upload that is not attached yet
++ * pending(subject, ids) the uploads a JSON submit names, checked and unclaimed
++ * link(subject, rows, { taskId, attemptN, messageId }) claim them for one message
++ * forTask(taskId, attemptN) one turn's files, in upload order
++ * forMessage(messageId) a conductor message's files
++ * present(row) { id, name, mime, size, image } (never the bytes)
++ * promptSection(files, folder) the lines an agent gets: where each file is
++ *
++ * The limits are the owner's: at most 10 files, at most 20 MB each and 50 MB for one message. An executable is refused
++ * by its extension AND by its magic bytes (a renamed .exe is still a PE), and the type stored is the one the bytes say,
++ * never the one the client claimed.
++ */
++const path = require('path');
++const { isUtf8 } = require('buffer');
++
++const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const MAX_FILES = 10;
++const MAX_FILE_BYTES = 20 * 1024 * 1024;
++const MAX_MESSAGE_BYTES = 50 * 1024 * 1024;
++const MAX_NAME = 120;
++const SAFE_DIR = /^[A-Za-z0-9._-]{1,80}$/;
++
++/** Tools and programs, never something an agent should open as an attachment. */
++const BLOCKED_EXT = new Set([
++ '.app', '.apk', '.bat', '.bin', '.cmd', '.com', '.deb', '.dll', '.dmg', '.elf', '.exe', '.jar', '.msi', '.pkg',
++ '.ps1', '.rpm', '.scr', '.sh', '.so', '.dylib', '.vb', '.vbs', '.wsf', '.cpl', '.msc', '.gadget', '.hta',
++]);
++
++const fail = (status, code, message) => Object.assign(new Error(message), { status, code });
++
++/** A filename that is one file in one folder: no path, no control characters, still its own extension. */
++function safeName(raw) {
++ const base = path.basename(String(raw || '').replace(/\\/g, '/')).slice(0, 200);
++ const cleaned = base.replace(/[^A-Za-z0-9._ -]/g, '_').replace(/\s+/g, ' ').trim().replace(/^\.+/, '');
++ const name = cleaned.slice(0, MAX_NAME) || 'file';
++ return name === '.' || name === '..' ? 'file' : name;
++}
++
++/** The extension a name declares, lower-case ('' when none). */
++const extOf = (name) => {
++ const base = path.basename(String(name || ''));
++ const i = base.lastIndexOf('.');
++ return i > 0 ? base.slice(i).toLowerCase() : '';
++};
++
++const starts = (b, bytes) => b.length >= bytes.length && b.subarray(0, bytes.length).equals(Buffer.from(bytes));
++const PE = (b) => b.length >= 2 && b[0] === 0x4d && b[1] === 0x5a; // MZ
++const ELF = (b) => starts(b, [0x7f, 0x45, 0x4c, 0x46]); // \x7fELF
++const MACH = (b) => starts(b, [0xfe, 0xed, 0xfa, 0xce]) || starts(b, [0xfe, 0xed, 0xfa, 0xcf])
++ || starts(b, [0xce, 0xfa, 0xed, 0xfe]) || starts(b, [0xcf, 0xfa, 0xed, 0xfe])
++ || starts(b, [0xca, 0xfe, 0xba, 0xbe]); // Mach-O / universal / Java class
++const SHEBANG = (b) => starts(b, [0x23, 0x21]); // #!
++const DOS_SCRIPT = (b) => starts(b, [0x40, 0x65, 0x63, 0x68, 0x6f]); // @echo
++
++/** What the bytes themselves say. Nothing here trusts the MIME the browser sent. */
++function magicMime(buffer, name) {
++ if (!buffer.length) return 'application/octet-stream';
++ if (starts(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return 'image/png';
++ if (starts(buffer, [0xff, 0xd8, 0xff])) return 'image/jpeg';
++ if (starts(buffer, [0x47, 0x49, 0x46, 0x38])) return 'image/gif';
++ if (buffer.length >= 12 && starts(buffer, [0x52, 0x49, 0x46, 0x46]) && buffer.subarray(8, 12).toString() === 'WEBP') return 'image/webp';
++ if (starts(buffer, [0x00, 0x00, 0x00]) && buffer.subarray(4, 8).toString() === 'ftyp' && /avif|avis|heic|heix|mif1|msf1/.test(buffer.subarray(8, 16).toString())) return buffer.subarray(8, 12).toString().startsWith('avi') ? 'image/avif' : 'image/heic';
++ if (starts(buffer, [0x25, 0x50, 0x44, 0x46])) return 'application/pdf'; // %PDF
++ if (starts(buffer, [0x50, 0x4b, 0x03, 0x04]) || starts(buffer, [0x50, 0x4b, 0x05, 0x06]) || starts(buffer, [0x50, 0x4b, 0x07, 0x08])) {
++ const ext = extOf(name);
++ if (ext === '.docx') return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
++ if (ext === '.xlsx') return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
++ if (ext === '.pptx') return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
++ if (ext === '.epub') return 'application/epub+zip';
++ return 'application/zip';
++ }
++ if (starts(buffer, [0x1f, 0x8b])) return 'application/gzip';
++ if (starts(buffer, [0x42, 0x5a, 0x68])) return 'application/x-bzip2';
++ if (starts(buffer, [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c])) return 'application/x-7z-compressed';
++ if (starts(buffer, [0x52, 0x61, 0x72, 0x21])) return 'application/vnd.rar';
++ if (starts(buffer, [0x7f, 0x45, 0x4c, 0x46])) return 'application/x-elf';
++ if (PE(buffer)) return 'application/x-msdownload';
++ if (MACH(buffer)) return 'application/x-mach-binary';
++ if (SHEBANG(buffer) || DOS_SCRIPT(buffer)) return 'application/x-executable';
++ // Text and code: valid UTF-8 with no NUL in what we read is text (a 20 MB code file is still text).
++ const sample = buffer.length > 8192 ? buffer.subarray(0, 8192) : buffer;
++ if (!sample.includes(0) && isUtf8(buffer)) {
++ const ext = extOf(name);
++ return {
++ '.md': 'text/markdown', '.json': 'application/json', '.csv': 'text/csv', '.html': 'text/html', '.htm': 'text/html',
++ '.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript', '.cjs': 'text/javascript',
++ '.ts': 'text/typescript', '.tsx': 'text/typescript', '.jsx': 'text/javascript', '.py': 'text/x-python',
++ '.rb': 'text/x-ruby', '.go': 'text/x-go', '.rs': 'text/x-rust', '.java': 'text/x-java', '.c': 'text/x-c',
++ '.h': 'text/x-c', '.cpp': 'text/x-c++', '.hpp': 'text/x-c++', '.sql': 'text/plain', '.yml': 'text/yaml',
++ '.yaml': 'text/yaml', '.toml': 'text/plain', '.xml': 'application/xml', '.svg': 'image/svg+xml',
++ '.txt': 'text/plain', '.log': 'text/plain',
++ }[ext] || 'text/plain';
++ }
++ return 'application/octet-stream';
++}
++
++/**
++ * One upload checked: a name in one folder, a size under the cap, no executable by extension or bytes, and the MIME
++ * the bytes say. Throws a 413/422 with a code the composer can show.
++ */
++function inspect(file) {
++ const rawName = String((file && file.name) || 'file');
++ const name = safeName(rawName);
++ const ext = extOf(rawName);
++ if (BLOCKED_EXT.has(ext)) throw fail(422, 'attachment.executable', `${name}: programs and scripts cannot be attached`);
++ const buffer = Buffer.isBuffer(file && file.buffer) ? file.buffer : Buffer.alloc(0);
++ if (!buffer.length) throw fail(422, 'attachment.empty', `${name} is empty`);
++ if (buffer.length > MAX_FILE_BYTES) throw fail(413, 'attachment.too_large', `${name} is larger than 20 MB`);
++ if (PE(buffer) || ELF(buffer) || MACH(buffer) || SHEBANG(buffer) || DOS_SCRIPT(buffer)) {
++ throw fail(422, 'attachment.executable', `${name} is a program or script, and cannot be attached`);
++ }
++ const mime = magicMime(buffer, name);
++ if (mime === 'application/x-elf' || mime === 'application/x-msdownload' || mime === 'application/x-mach-binary' || mime === 'application/x-executable') {
++ throw fail(422, 'attachment.executable', `${name} is a program or script, and cannot be attached`);
++ }
++ return { name, mime, size: buffer.length, buffer };
++}
++
++/** The files a whole message carries, checked together: at most 10 and at most 50 MB. */
++function inspectMany(files) {
++ const list = Array.isArray(files) ? files.filter(Boolean) : [];
++ if (list.length > MAX_FILES) throw fail(422, 'attachment.too_many', `a message carries at most ${MAX_FILES} files`);
++ const out = list.map(inspect);
++ const total = out.reduce((n, f) => n + f.size, 0);
++ if (total > MAX_MESSAGE_BYTES) throw fail(413, 'attachment.message_too_large', 'the files of one message are larger than 50 MB together');
++ return out;
++}
++
++const imageMime = (mime) => /^image\/(png|jpeg|gif|webp|avif|heic)$/.test(String(mime || ''));
++
++function createAttachments({ store, log = console }) {
++ const db = store.db;
++
++ const requireUser = (subject) => {
++ if (!USER.test(String(subject || ''))) throw fail(400, 'subject.required', 'attachments belong to a signed-in person');
++ return subject;
++ };
++
++ /** The SQL row as the API and the page see it: never the bytes. */
++ const present = (row) => row && {
++ id: row.id, name: row.name, mime: row.mime, size: row.size == null ? null : Number(row.size), image: imageMime(row.mime),
++ };
++
++ async function insert(subject, one) {
++ const f = inspect(one);
++ const row = await db.one(
++ `INSERT INTO codes_attachments (id, subject, name, mime, size, data, created_at)
++ VALUES ($1, $2, $3, $4, $5, $6, now()) RETURNING id, subject, name, mime, size, created_at`,
++ [store.newId('fil'), subject, f.name, f.mime, f.size, f.buffer],
++ );
++ return row;
++ }
++
++ /** One upload: checked and stored, owned by the person, not attached to anything yet. */
++ async function save(subject, file) {
++ requireUser(subject);
++ return present(await insert(subject, file));
++ }
++
++ /** A message's uploads (a no-JS multipart form or a batch): all refused or all stored. */
++ async function saveMany(subject, files) {
++ requireUser(subject);
++ const list = inspectMany(files);
++ const rows = [];
++ await store.tx(async () => {
++ for (const f of list) rows.push(await insert(subject, f));
++ });
++ return rows.map(present);
++ }
++
++ async function own(subject, id) {
++ if (!USER.test(String(subject || '')) || !/^fil_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || ''))) return null;
++ return await db.maybe('SELECT id, subject, task_id, attempt_n, message_id, name, mime, size, created_at, linked_at FROM codes_attachments WHERE id = $1 AND subject = $2', [id, subject]);
++ }
++
++ async function get(subject, id) {
++ if (!USER.test(String(subject || '')) || !/^fil_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || ''))) return null;
++ return await db.maybe('SELECT * FROM codes_attachments WHERE id = $1 AND subject = $2', [id, subject]);
++ }
++
++ /** An upload the person changed their mind about: only while it is not attached to a message. */
++ async function remove(subject, id) {
++ const row = await own(subject, id);
++ if (!row) throw fail(404, 'attachment.not_found', 'no such attachment of yours');
++ if (row.task_id || row.message_id) throw fail(409, 'attachment.in_use', 'this file is already on a message');
++ await db.exec('DELETE FROM codes_attachments WHERE id = $1 AND subject = $2', [id, subject]);
++ return { removed: true };
++ }
++
++ /** The uploads a JSON submit names: theirs, still unclaimed, 10 at most and 50 MB together. */
++ async function pending(subject, ids) {
++ requireUser(subject);
++ const wanted = [...new Set((Array.isArray(ids) ? ids : []).map(String).filter(Boolean))];
++ if (wanted.length > MAX_FILES) throw fail(422, 'attachment.too_many', `a message carries at most ${MAX_FILES} files`);
++ if (!wanted.length) return [];
++ const rows = [];
++ for (const id of wanted) rows.push(await own(subject, id));
++ if (rows.some((r) => !r)) throw fail(404, 'attachment.not_found', 'an attached file is not yours, or no longer here');
++ if (rows.some((r) => r.task_id || r.message_id)) throw fail(409, 'attachment.in_use', 'an attached file is already on another message');
++ const total = rows.reduce((n, r) => n + Number(r.size || 0), 0);
++ if (total > MAX_MESSAGE_BYTES) throw fail(413, 'attachment.message_too_large', 'the files of one message are larger than 50 MB together');
++ return rows;
++ }
++
++ /**
++ * Claim uploads for one message. The WHERE keeps a file from being claimed twice even when two sends race; a row
++ * that lost the race throws, and the caller's transaction rolls back.
++ */
++ async function link(subject, rows, { taskId = null, attemptN = null, messageId = null } = {}) {
++ requireUser(subject);
++ const list = Array.isArray(rows) ? rows : [];
++ if (!list.length) return [];
++ if (list.length > MAX_FILES) throw fail(422, 'attachment.too_many', `a message carries at most ${MAX_FILES} files`);
++ if (messageId != null && !SAFE_DIR.test(String(messageId))) throw fail(500, 'attachment.folder_invalid', 'the attachment folder is not a safe name');
++ const out = [];
++ for (const row of list) {
++ const claimed = await db.maybe(
++ `UPDATE codes_attachments SET task_id = $3, attempt_n = $4, message_id = $5, linked_at = now()
++ WHERE id = $1 AND subject = $2 AND task_id IS NULL AND message_id IS NULL
++ RETURNING id, subject, task_id, attempt_n, message_id, name, mime, size, created_at, linked_at`,
++ [row.id, subject, taskId, attemptN, messageId],
++ );
++ if (!claimed) throw fail(409, 'attachment.in_use', 'an attached file is already on another message');
++ out.push(claimed);
++ }
++ return out;
++ }
++
++ /** One turn's files, oldest first: what the page lists under the message. */
++ async function forTask(taskId, attemptN) {
++ const rows = await db.many(
++ 'SELECT id, name, mime, size, message_id FROM codes_attachments WHERE task_id = $1 AND attempt_n = $2 ORDER BY created_at ASC, id ASC',
++ [taskId, attemptN],
++ );
++ return rows.map(present);
++ }
++
++ async function forMessage(messageId) {
++ const rows = await db.many(
++ 'SELECT id, name, mime, size, message_id FROM codes_attachments WHERE message_id = $1 ORDER BY created_at ASC, id ASC',
++ [messageId],
++ );
++ return rows.map(present);
++ }
++
++ /** The bytes of one stored file by its id, for a route that has already checked who owns the task. */
++ async function byId(id) {
++ if (!/^fil_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || ''))) return null;
++ const row = await db.maybe('SELECT * FROM codes_attachments WHERE id = $1', [id]);
++ if (!row) return null;
++ return { id: row.id, name: row.name, mime: row.mime, buffer: Buffer.isBuffer(row.data) ? row.data : Buffer.from(row.data || '') };
++ }
++
++ /** The bytes of one message's file, owner-checked, for the download routes. */
++ async function bytesForMessage(taskId, messageId, index, subject = null) {
++ if (!/^msg_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(messageId || ''))) return null;
++ const rows = await db.many(
++ `SELECT * FROM codes_attachments WHERE task_id = $1 AND message_id = $2 ${subject ? 'AND subject = $3' : ''} ORDER BY created_at ASC, id ASC`,
++ subject ? [taskId, messageId, subject] : [taskId, messageId],
++ );
++ const row = rows[Number(index)];
++ if (!row) return null;
++ return { name: row.name, mime: row.mime, buffer: Buffer.isBuffer(row.data) ? row.data : Buffer.from(row.data || '') };
++ }
++
++ return {
++ save, saveMany, own, get, byId, remove, pending, link, forTask, forMessage, bytesForMessage, present,
++ MAX_FILES, MAX_FILE_BYTES, MAX_MESSAGE_BYTES,
++ };
++}
++
++/** The section of a turn's prompt that names the attachments and where they are. */
++function promptSection(files, folder) {
++ const list = Array.isArray(files) ? files.filter((f) => f && f.name) : [];
++ if (!list.length) return '';
++ const dir = SAFE_DIR.test(String(folder || '')) ? String(folder) : 'message';
++ const lines = list.slice(0, MAX_FILES).map((f) => `- \`.openvibe/attachments/${dir}/${f.name}\`${f.mime ? ` (${f.mime}${f.size ? `, ${f.size} bytes` : ''})` : ''}`);
++ return `\n\n---\n## Files the owner attached\n\n${lines.join('\n')}\n\nThey are files in this workspace; read them.`;
++}
++
++module.exports = {
++ createAttachments, inspect, inspectMany, safeName, magicMime, promptSection,
++ MAX_FILES, MAX_FILE_BYTES, MAX_MESSAGE_BYTES, BLOCKED_EXT, SAFE_DIR, imageMime,
++};
ddiff --git a/server/domain/conductor-failover.js b/server/domain/conductor-failover.js
iindex 53b2f2d..5d3a4f6 100644
---- a/server/domain/conductor-failover.js
++++ b/server/domain/conductor-failover.js
@@@ -25,7 +25,11 @@ const { windowFull, FULL_PCT } = require('../../engine/router');
/** The agents a conductor's turn can run on: the CLIs that resume a session and take the Codes MCP tools. */
const CONDUCTOR_AGENTS = ['claude-code', 'codex', 'opencode'];
--const AGENT_NAMES = { 'claude-code': 'Claude Code', codex: 'Codex', opencode: 'OpenCode' };
++const AGENT_NAMES = {
++ 'claude-code': 'Claude Code', 'claude-other': 'Claude Code (another provider)', codex: 'Codex', opencode: 'OpenCode',
++ 'command-code': 'Command Code', 'minimax-code': 'MiniMax Code', aider: 'Aider', deepseek: 'DeepSeek',
++ 'openvibe-ai': 'OpenVibe.AI', 'openai-compatible': 'Your own model',
++};
const MODEL = /^[A-Za-z0-9._:/-]{1,100}$/;
const MAX_FALLBACKS = 3;
const DEFAULT_SWITCH_PCT = 90;
ddiff --git a/server/domain/conductor.js b/server/domain/conductor.js
iindex 9870333..2c50ce0 100644
---- a/server/domain/conductor.js
++++ b/server/domain/conductor.js
@@@ -1,8 +1,9 @@
'use strict';
/**
-- * The conductor (Codes C9b): a long-lived task whose single attempt is one Claude Code session on a machine the person
-- * chooses. It is the orchestrator that used to be a Claude Code session on the workstation, running in openvibe.codes.
++ * The conductor (Codes C9b): a long-lived task whose single attempt is one agent session on a machine the person
++ * chooses. The agent is their "Who leads your tasks" choice (Claude Code on Opus by default, migration 0030). It is the
++ * orchestrator that used to be a Claude Code session on the workstation, running in openvibe.codes.
*
* createConductor({ store, config, log, run, attempts, tasks, sessions, approvals, notify, fetchImpl })
* create({ subject, project, folder, machine, model, safety, note }) the task, its one attempt and the hand-over turn
@@@ -18,8 +19,8 @@
* while a turn runs; when the turn ends the next queued message starts. A conductor is never "done": when a turn ends
* and nothing waits, it is idle (state succeeded, outcome NULL) until the person writes again.
*
-- * The conductor uses the person's own Claude Code login on the machine (Node's plan_logins, opt-in and read-only, for
-- * one job) and never a key of ours. It sets no steps or minutes budget; a turn's wall time is Run's job cap.
++ * The conductor uses the person's own login on the machine (Node's plan_logins, opt-in and read-only, for one job) and
++ * never a key of ours. It sets no steps or minutes budget; a turn's wall time is Run's job cap.
*
* Failover (owner 2026-11-03, conductor-failover.js): before each turn the conductor's agent and the fallbacks the
* person chose in Settings are read against their machine's usage windows; the turn runs on the first with room under
@@@ -40,8 +41,12 @@ const MODEL = /^[A-Za-z0-9._:-]{1,100}$/;
const NODE = /^(?:nod_)?[0-9A-HJKMNP-TV-Z]{26}$/;
const FRAGMENT = path.join(__dirname, '..', '..', 'engine', 'fragments', 'conductor.md');
const MAX_MESSAGE = 16000;
--const MAX_ATTACHMENTS = 4;
--const MAX_ATTACHMENT_BYTES = 2 * 1024 * 1024;
++const MAX_ATTACHMENTS = 10;
++const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
++const MAX_MESSAGE_BYTES = 50 * 1024 * 1024;
++/** The legacy inline path (an image as base64 in the JSON body): four small images, as before the upload API. */
++const MAX_INLINE_ATTACHMENTS = 4;
++const MAX_INLINE_ATTACHMENT_BYTES = 2 * 1024 * 1024;
const MAX_NOTE = 4000;
const MAX_FOLDER = 4096;
/** The most of the conversation an agent taking over is told, so the turn stays inside a job's 16,000 characters. */
@@@ -71,11 +76,14 @@ function validFolder(folder) {
return f;
}
--/** One attachment as it is stored: an image, its type and its bytes (base64). */
++/**
++ * The legacy attachments (an image as base64 in the JSON body), still accepted from an old client: four small images.
++ * A new client uploads first and sends `attachment_ids`, which attachments.js stores and checks.
++ */
function cleanAttachments(list) {
if (!Array.isArray(list)) return [];
const out = [];
-- for (const a of list.slice(0, MAX_ATTACHMENTS)) {
++ for (const a of list.slice(0, MAX_INLINE_ATTACHMENTS)) {
if (!a || typeof a !== 'object') continue;
const name = String(a.name || '').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120);
const mime = String(a.mime || '').toLowerCase();
@@@ -83,7 +91,7 @@ function cleanAttachments(list) {
if (!name || !/^image\//.test(mime) || !data) continue;
let bytes = 0;
try { bytes = Buffer.from(data, 'base64').length; } catch { bytes = 0; }
-- if (!bytes || bytes > MAX_ATTACHMENT_BYTES) continue;
++ if (!bytes || bytes > MAX_INLINE_ATTACHMENT_BYTES) continue;
out.push({ name, mime, data, size: bytes });
}
return out;
@@@ -106,7 +114,7 @@ function ranOf(result) {
const agentOf = (m) => (m && m.agent) || PRIMARY_AGENT;
--function createConductor({ store, config = {}, log = console, run, attempts, tasks, sessions, approvals = null, notify = null, fetchImpl = null, fleet = null, userSettings = null }) {
++function createConductor({ store, config = {}, log = console, run, attempts, tasks, sessions, approvals = null, notify = null, fetchImpl = null, fleet = null, userSettings = null, harnesses = null, attachments: filesStore = null }) {
const db = store.db;
const fetch = fetchImpl || globalThis.fetch;
const baseUrl = String(config.baseUrl || '').replace(/\/+$/, '');
@@@ -144,6 +152,39 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
return await tasks.latestProject(subject);
}
++ /**
++ * The agent and model a conductor leads with: the person's pick when they made one, else the "Who leads your tasks"
++ * setting, else today's choice (Claude Code on Opus). A model is only ever paired with an agent that takes it:
++ * Claude Code keeps Opus unless another model is named, and every other agent uses its own default.
++ */
++ async function leadFor(subject, agent = null, model = undefined) {
++ let settings = null;
++ if (userSettings && subject) { try { settings = await userSettings.get(subject); } catch { settings = null; } }
++ const chosen = agent || (settings && settings.conductor_agent) || PRIMARY_AGENT;
++ let chosenModel;
++ if (model !== undefined) chosenModel = model === '' ? null : model;
++ else if (settings && settings.conductor_agent && !agent) chosenModel = settings.conductor_model;
++ else chosenModel = chosen === PRIMARY_AGENT ? 'opus' : null;
++ if (chosenModel == null && chosen === PRIMARY_AGENT) chosenModel = 'opus';
++ return { agent: chosen, model: chosenModel == null ? null : String(chosenModel).slice(0, 100) };
++ }
++
++ /** A task's own pick wins over the setting: the conductor page may retarget one conversation, not every one. */
++ async function retarget(task, { agent = null, model = undefined } = {}) {
++ if (!task || !task.id) throw Object.assign(new Error('no such conductor'), { status: 404, code: 'conductor.not_found' });
++ if (agent == null && model === undefined) return task;
++ const lead = await leadFor(task.subject, agent || task.agent, model === undefined ? (agent ? undefined : task.model) : model);
++ if (!lead.agent || (harnesses && !harnesses.list().some((h) => h.id === lead.agent))) {
++ throw Object.assign(new Error('the agent that leads this conductor is one Codes offers'), { status: 422, code: 'conductor.agent_invalid' });
++ }
++ if (lead.model != null && !MODEL.test(String(lead.model))) throw Object.assign(new Error('the model is not a model name'), { status: 422, code: 'conductor.model_invalid' });
++ const row = await db.one('UPDATE run_tasks SET agent = $2, model = $3, updated_at = $4 WHERE id = $1 RETURNING *',
++ [task.id, lead.agent, lead.model == null ? null : String(lead.model).slice(0, 100), store.now()]);
++ const attempt = await attemptOf(task.id);
++ if (attempt) await db.exec('UPDATE codes_attempts SET agent = $2, model = $3 WHERE id = $1', [attempt.id, lead.agent, lead.model == null ? null : String(lead.model).slice(0, 100)]);
++ return row;
++ }
++
/** The one attempt of a conductor task. */
async function attemptOf(taskId) {
return await db.maybe('SELECT * FROM codes_attempts WHERE task_id = $1 ORDER BY n ASC LIMIT 1', [taskId]);
@@@ -155,10 +196,14 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
* `pin`, its OpenVibe.Bot device id (`dev_…`, Codes C9c): every turn is pinned to it on Run, so the session runs
* where its folder and its memory are.
*/
-- async function create({ subject, project, folder, machine = null, pin = null, model = 'opus', safety = 'ask', note = null, key = null } = {}) {
++ async function create({ subject, project, folder, machine = null, pin = null, agent = null, model = undefined, safety = 'ask', note = null, key = null, attachmentIds = [] } = {}) {
const work = validFolder(folder);
if (!work) throw Object.assign(new Error('the working folder must be an absolute path'), { status: 422, code: 'conductor.folder_invalid' });
-- if (!MODEL.test(String(model || ''))) throw Object.assign(new Error('the model is not a model name'), { status: 422, code: 'conductor.model_invalid' });
++ const lead = await leadFor(subject, agent, model);
++ if (!lead.agent || (harnesses && !harnesses.list().some((h) => h.id === lead.agent))) {
++ throw Object.assign(new Error('the agent that leads this conductor is one Codes offers'), { status: 422, code: 'conductor.agent_invalid' });
++ }
++ if (lead.model != null && !MODEL.test(String(lead.model))) throw Object.assign(new Error('the model is not a model name'), { status: 422, code: 'conductor.model_invalid' });
if (!SAFETY.includes(safety)) throw Object.assign(new Error('safety is ask, project or full'), { status: 422, code: 'conductor.safety_invalid' });
if (machine && !NODE.test(String(machine))) throw Object.assign(new Error('the machine is not one of the person\'s machines'), { status: 422, code: 'conductor.machine_invalid' });
if (pin != null && !/^dev_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(pin))) throw Object.assign(new Error('the pin is not a machine id'), { status: 422, code: 'conductor.machine_invalid' });
@@@ -172,35 +217,44 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
await store.tx(async () => {
row = await db.one(
`INSERT INTO run_tasks (id, subject, project_id, repo, prompt, agent, model, kind, folder, machine, pin, safety, state, idempotency_key, created_at, updated_at)
-- VALUES ($1, $2, $3, $4, $5, 'claude-code', $6, 'conductor', $7, $8, $9, $10, 'queued', $11, $12, $12) RETURNING *`,
-- [id, subject, project, repo, first.slice(0, 16000), String(model).slice(0, 100), work, machine || null, pin || null, safety, key || null, at],
++ VALUES ($1, $2, $3, $4, $5, $6, $7, 'conductor', $8, $9, $10, $11, 'queued', $12, $13, $13) RETURNING *`,
++ [id, subject, project, repo, first.slice(0, 16000), lead.agent, lead.model == null ? null : String(lead.model).slice(0, 100), work, machine || null, pin || null, safety, key || null, at],
);
});
await attempts.create({
-- task: row, agent: 'claude-code', model: String(model).slice(0, 100), machine: machine || null,
++ task: row, agent: lead.agent, model: lead.model == null ? null : String(lead.model).slice(0, 100), machine: machine || null,
why: 'the conductor session starts here', kind: 'first', state: 'queued',
});
-- const message = await enqueue(row, { text: first, attachments: [], first: true });
++ const message = await enqueue(row, { text: first, attachments: [], attachmentIds, first: true });
const started = await startNext(id);
return { task: await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [id]), message, started };
}
/** Write one message: the next turn. It waits if a turn is running. */
-- async function enqueue(task, { text, attachments = [], first = false } = {}) {
++ async function enqueue(task, { text, attachments = [], attachmentIds = [], first = false } = {}) {
const prompt = String(text == null ? '' : text).trim();
-- const files = cleanAttachments(attachments);
-- if (!prompt && !files.length) throw Object.assign(new Error('a message needs words or an image'), { status: 422, code: 'conductor.message_empty' });
++ const legacy = cleanAttachments(attachments);
++ const claimed = filesStore ? await filesStore.pending(task.subject, attachmentIds) : [];
++ const count = claimed.length + legacy.length;
++ const total = claimed.reduce((n, a) => n + Number(a.size || 0), 0) + legacy.reduce((n, a) => n + Number(a.size || 0), 0);
++ if (count > MAX_ATTACHMENTS) throw Object.assign(new Error(`a message carries at most ${MAX_ATTACHMENTS} files`), { status: 422, code: 'conductor.attachments_too_many' });
++ if (total > MAX_MESSAGE_BYTES) throw Object.assign(new Error('the files of one message are larger than 50 MB together'), { status: 413, code: 'conductor.attachments_too_large' });
++ if (!prompt && !count) throw Object.assign(new Error('a message needs words or a file'), { status: 422, code: 'conductor.message_empty' });
if (prompt.length > MAX_MESSAGE) throw Object.assign(new Error(`a message is at most ${MAX_MESSAGE} characters`), { status: 422, code: 'conductor.message_too_long' });
++ const files = [...claimed.map((a) => ({ id: a.id, name: a.name, mime: a.mime, size: Number(a.size) })), ...legacy];
++ const id = store.newId('msg');
const made = await store.tx(async () => {
const current = await db.maybe('SELECT * FROM run_tasks WHERE id = $1 FOR UPDATE', [task.id]);
if (!current || current.kind !== 'conductor') throw Object.assign(new Error('no such conductor'), { status: 404, code: 'conductor.not_found' });
if (current.outcome === 'stopped') throw Object.assign(new Error('this conductor was stopped: start a new one'), { status: 409, code: 'conductor.stopped' });
const n = await db.one('SELECT COALESCE(MAX(n), 0) + 1 AS n FROM codes_conductor_messages WHERE task_id = $1', [task.id]);
-- return await db.one(
++ const row = await db.one(
`INSERT INTO codes_conductor_messages (id, task_id, n, prompt, attachments, state, created_at)
VALUES ($1, $2, $3, $4, $5::jsonb, 'queued', $6::timestamptz) RETURNING *`,
-- [store.newId('msg'), task.id, n.n, prompt || (first ? 'Begin.' : 'Look at the attached image.'), files.length ? JSON.stringify(files) : null, store.iso()],
++ [id, task.id, n.n, prompt || (first ? 'Begin.' : 'Look at the attached files.'), files.length ? JSON.stringify(files) : null, store.iso()],
);
++ if (claimed.length) await filesStore.link(task.subject, claimed, { taskId: task.id, attemptN: 1, messageId: id });
++ return row;
});
return made;
}
@@@ -210,10 +264,11 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
* token at hand, a fleet reading older than five minutes is read again first, so the turn's agent is chosen on
* today's usage.
*/
-- async function message(task, { text, attachments = [], token = null } = {}) {
-- const made = await enqueue(task, { text, attachments });
-- await refreshFleet(task.subject, token);
-- const started = await startNext(task.id);
++ async function message(task, { text, attachments = [], attachmentIds = [], agent = null, model = undefined, token = null } = {}) {
++ const current = (agent != null || model !== undefined) ? await retarget(task, { agent, model }) : task;
++ const made = await enqueue(current, { text, attachments, attachmentIds });
++ await refreshFleet(current.subject, token);
++ const started = await startNext(current.id);
return { message: made, started };
}
@@@ -222,9 +277,9 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
* from startNext), or the conductor's own agent and the session the attempt carries.
*/
function turnArgs(task, message, attempt, over = null) {
-- const files = Array.isArray(message.attachments) ? message.attachments : [];
-- const agent = (over && over.agent) || PRIMARY_AGENT;
-- const model = over && 'model' in over ? over.model : (task.model || 'opus');
++ const files = Array.isArray(message.attachments) ? message.attachments : asArray(message.attachments);
++ const agent = (over && over.agent) || task.agent || PRIMARY_AGENT;
++ const model = over && 'model' in over ? over.model : (task.model == null ? null : task.model);
const resume = over && 'resume' in over ? over.resume : (attempt && attempt.session) || null;
return {
kind: 'conductor',
@@@ -238,10 +293,10 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
mcp: true,
codes_url: baseUrl,
...(resume ? { resume } : {}),
-- // The image's bytes stay in the database: the job fetches it with the person's token and writes it as a
-- // file on the machine, so a turn's input stays far inside Run's limit.
++ // The file's bytes stay in the database: the job fetches it with the person's token and writes it under
++ // .openvibe/attachments/<message>/<name> on the machine, so a turn's input stays far inside Run's limit.
...(files.length
-- ? { attachments: files.map((a, i) => ({ name: a.name, mime: a.mime, size: a.size, url: `${baseUrl}/api/v1/tasks/${encodeURIComponent(task.id)}/messages/${encodeURIComponent(message.id)}/attachments/${i}` })) }
++ ? { attachments: files.map((a, i) => ({ name: a.name, mime: a.mime, size: a.size, dir: message.id, url: `${baseUrl}/api/v1/tasks/${encodeURIComponent(task.id)}/messages/${encodeURIComponent(message.id)}/attachments/${i}` })) }
: {}),
};
}
@@@ -261,9 +316,11 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
/** The conductor's chain: its own agent and model, then the person's fallbacks (none twice). */
async function chainFor(task) {
-- const primary = { agent: task.agent || PRIMARY_AGENT, model: task.model || 'opus' };
let settings = null;
if (userSettings) { try { settings = await userSettings.get(task.subject); } catch { settings = null; } }
++ const agent = task.agent || (settings && settings.conductor_agent) || PRIMARY_AGENT;
++ const model = task.model != null ? task.model : (agent === PRIMARY_AGENT ? ((settings && settings.conductor_agent === PRIMARY_AGENT && settings.conductor_model) || 'opus') : null);
++ const primary = { agent, model };
const fallbacks = (settings && settings.conductor_fallbacks) || [];
const chain = [primary, ...fallbacks.filter((f) => !(f.agent === primary.agent && (f.model || null) === primary.model))];
return { chain, switchPct: (settings && settings.conductor_switch_pct) || failover.DEFAULT_SWITCH_PCT };
@@@ -368,13 +425,13 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
let over = null;
try {
const choice = await choiceFor(task);
-- const pick = choice.pick || { agent: PRIMARY_AGENT, model: task.model || 'opus' };
++ const pick = choice.pick || { agent: task.agent || PRIMARY_AGENT, model: task.model == null ? null : task.model };
const earlier = await db.many('SELECT * FROM codes_conductor_messages WHERE task_id = $1 AND n < $2 ORDER BY n ASC', [task.id, turn.n]);
const ran = earlier.filter((m) => m.started_at);
const previous = ran.length ? agentOf(ran[ran.length - 1]) : null;
const own = [...ran].reverse().find((m) => agentOf(m) === pick.agent && m.session);
let resume = own ? own.session : null;
-- if (!resume && pick.agent === PRIMARY_AGENT && !earlier.some((m) => m.agent)) resume = (attempt && attempt.session) || null;
++ if (!resume && pick.agent === (task.agent || PRIMARY_AGENT) && !earlier.some((m) => m.agent)) resume = (attempt && attempt.session) || null;
let prompt = turn.prompt;
if (previous && previous !== pick.agent) {
const intro = catchUp(ran, pick.agent, previous, choice.note);
@@@ -390,8 +447,17 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
}
let job;
try {
++ // Who leads can be an API harness too (DeepSeek, OpenVibe.AI, a local model): that turn is funded like a
++ // coding task, with the person's own key or a run token. A plan CLI needs nothing here.
++ const args = turnArgs(task, turn, attempt, over);
++ let funding = null;
++ try { funding = await tasks.fundTurn({ subject: task.subject, taskId: task.id, attemptId: attempt ? attempt.id : null, agent: args.agent }); } catch (e) {
++ log.warn(`[Codes] conductor ${task.id}: the turn could not be funded (${String(e && e.message).slice(0, 160)})`);
++ }
++ if (funding && funding.env && Object.keys(funding.env).length) args.env = funding.env;
++ if (funding && attempt) await attempts.bind(attempt.id, { run_mode: funding.mode });
job = await run.submit({
-- subject: task.subject, project: task.project_id, args: turnArgs(task, turn, attempt, over), key: turn.id,
++ subject: task.subject, project: task.project_id, args, key: turn.id,
limits: { wall_ms: wallMs, cpu_ms: wallMs, mem_bytes: 4 * 1024 ** 3 }, ttlMs: Math.min(86_400_000, wallMs + 10 * 60_000),
// Pinned to the machine the person chose (Codes C9c): the turn resumes a session whose folder and
// memory are there, so Run must not place it anywhere else.
@@@ -476,9 +542,9 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
// resumes it. A machine whose Codes runner is older runs Claude Code whatever was asked; the row then
// says so, and keeps the session under the agent that really ran.
const ran = ranOf(task.result);
-- const asked = turn.agent || PRIMARY_AGENT;
++ const asked = turn.agent || task.agent || PRIMARY_AGENT;
const agent = ran.agent && failover.CONDUCTOR_AGENTS.includes(ran.agent) ? ran.agent : asked;
-- const session = ran.session || (agent === PRIMARY_AGENT && !turn.agent ? (attempt && attempt.session) : null) || null;
++ const session = ran.session || (agent === (task.agent || PRIMARY_AGENT) && !turn.agent ? (attempt && attempt.session) : null) || null;
let note = turn.switch_note || null;
if (agent !== asked) {
note = `${note ? `${note} ` : ''}Your machine ran ${failover.nameOf(agent)} instead of ${failover.nameOf(asked)}: its Codes runner is older than the switch. Update OpenVibe Node on it to let the conductor switch agents.`.slice(0, 1000);
@@@ -572,15 +638,22 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
async function attachment(taskId, messageId, index) {
const row = await db.maybe('SELECT * FROM codes_conductor_messages WHERE id = $1 AND task_id = $2', [messageId, taskId]);
if (!row) return null;
-- const files = Array.isArray(row.attachments) ? row.attachments : [];
++ const files = Array.isArray(row.attachments) ? row.attachments : asArray(row.attachments);
const file = files[Number(index)];
-- if (!file || !file.data) return null;
-- return { name: String(file.name || 'image'), mime: String(file.mime || 'application/octet-stream'), buffer: Buffer.from(String(file.data), 'base64') };
++ if (!file) return null;
++ // A file uploaded through the composer lives in codes_attachments, by its own id (two uploads can finish in
++ // either order); an older message kept its image inline.
++ if (file.id && filesStore) {
++ const got = await filesStore.byId(file.id);
++ if (got) return got;
++ }
++ if (!file.data) return null;
++ return { name: String(file.name || 'file'), mime: String(file.mime || 'application/octet-stream'), buffer: Buffer.from(String(file.data), 'base64') };
}
return {
-- create, enqueue, message, startNext, onTurnEnd, stop, view, presentMessage, attachment, fragment, turnArgs, projectFor, choiceFor, refreshFleet,
-- SAFETY, MESSAGE_STATES, MAX_MESSAGE, MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES,
++ create, enqueue, message, startNext, onTurnEnd, stop, view, presentMessage, attachment, fragment, turnArgs, projectFor, choiceFor, refreshFleet, leadFor, retarget,
++ SAFETY, MESSAGE_STATES, MAX_MESSAGE, MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES, MAX_MESSAGE_BYTES,
};
}
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex 769438f..90a3459 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -147,7 +147,7 @@ function usageOf(result) {
};
}
--function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = null, fleet = null, notices = null, placementContext = null, harnesses = null, knowledge = null, report = null, sharing = null }) {
++function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = null, fleet = null, notices = null, placementContext = null, harnesses = null, knowledge = null, report = null, sharing = null, attachments: filesStore = null }) {
const db = store.db;
const who = (row) => ({ subject: row.subject, project: row.project_id });
// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).
@@@ -528,9 +528,35 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
return budget.evaluate({ steps: usage.steps || 0, minutes, usd: usage.cost_usd }, limits).hit;
}
-- async function start({ subject, project, args, via = null, key = null, actorRef = null }) {
++ /**
++ * The files a message names (ids from the upload API), checked and still unclaimed. They become the turn's files:
++ * job input names their routes, and the prompt names the path each one will have in the workspace.
++ */
++ async function claimFiles(subject, ids) {
++ return filesStore && Array.isArray(ids) && ids.length ? await filesStore.pending(subject, ids) : [];
++ }
++
++ /** The job's side of a turn's files: an absolute route to fetch, and the message folder to write them under. */
++ function fileArgs(rows, messageId) {
++ const base = String(config.baseUrl || '').replace(/\/+$/, '');
++ return rows.map((r) => ({
++ name: r.name, mime: r.mime, size: Number(r.size), dir: messageId,
++ url: `${base}/api/v1/attachments/${encodeURIComponent(r.id)}`,
++ }));
++ }
++
++ function withFiles(prompt, rows, messageId) {
++ if (!rows.length) return prompt;
++ const section = require('./attachments').promptSection(rows, messageId);
++ const base = String(prompt || '');
++ return `${base.slice(0, Math.max(0, 16000 - section.length))}${section}`;
++ }
++
++ async function start({ subject, project, args, via = null, key = null, actorRef = null, attachmentIds = [], messageId = null }) {
const id = store.newId('tsk');
const at = store.now();
++ const claims = await claimFiles(subject, attachmentIds);
++ const turnId = claims.length ? (messageId || store.newId('msg')) : null;
// A task with no agent named is `auto` (Codes C9c): the router reads the fleet — who is signed in, whose window
// still has room — and the task is started on the agent it picks. Never a fixed agent, and never an Anthropic one.
let agent = args.agent || null;
@@@ -557,6 +583,11 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
log.warn(`[Codes] task ${id}: knowledge could not be recalled: ${String((e && e.message) || e).slice(0, 200)}`);
}
}
++ // The job gets the files and the lines naming them, after the knowledge recall so it is in the same prompt;
++ // the task row keeps the person's own words, so the page's "The brief" does not repeat the paths.
++ const jobArgs = claims.length
++ ? { ...runArgs, attachments: [...(Array.isArray(runArgs.attachments) ? runArgs.attachments : []), ...fileArgs(claims, turnId)], prompt: withFiles(runArgs.prompt, claims, turnId) }
++ : runArgs;
const actorRefValue = actorRef && typeof actorRef === 'object' && !Array.isArray(actorRef) ? JSON.stringify(actorRef) : null;
// Placement A1: the task's kind, its harness, and why the placer put it there. The person's rules, Bench priors
// and learned counts are read once here; the placement itself is pure. A task whose task kind is review is
@@@ -579,6 +610,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
inserted = await db.one(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, via, idempotency_key, actor_ref, placement_line, placement_option_key, placement_step_kind, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12::jsonb, $13, $14, $15, $16, $16) RETURNING *`,
[id, subject, project, runArgs.repo || 'bench://', runArgs.ref, runArgs.prompt, agent, !!runArgs.push, !!(runArgs.push && runArgs.push.pull_request), via, key, actorRefValue, placementLine, placementKey, placementKind, at]);
++ if (claims.length) await filesStore.link(subject, claims, { taskId: id, attemptN: 1, messageId: turnId });
await taskOutbox.emitTask(inserted, null);
return inserted;
});
@@@ -607,7 +639,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
// travels with the job's input and the machine's worker hands it to the harness.
const funding = await attemptEnv({ subject, taskId: id, attemptId: first.attempt.id, agent: agent || null, budget });
await attemptStore.bind(first.attempt.id, { run_mode: funding.mode });
-- job = await run.submit({ subject, project, args: await withEnv(subject, runArgs, funding.env), key: id });
++ job = await run.submit({ subject, project, args: await withEnv(subject, jobArgs, funding.env), key: id });
} catch (e) {
const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;
await attemptStore.end(first.attempt.id, { state: 'failed', reason: refusal ? 'refused' : 'error', finished_at: store.iso() });
@@@ -862,7 +894,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
* continue after a crash, a wrap-up, another manual press — that attempt is returned, and no second one is started
* beside it. Two sessions on one worktree would fight over the same files.
*/
-- async function continueTask(row, { note = null, agent = null, model = null, effort = null } = {}) {
++ async function continueTask(row, { note = null, agent = null, model = null, effort = null, attachmentIds = [], messageId = null } = {}) {
if (!row || !row.id) return { error: 'no such task' };
const attempts = await attemptStore.list(row.id);
const ending = attempts[attempts.length - 1];
@@@ -884,6 +916,12 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
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',
};
++ const claims = await claimFiles(row.subject, attachmentIds);
++ const turnId = claims.length ? (messageId || store.newId('msg')) : null;
++ if (claims.length) {
++ plan.attachments = fileArgs(claims, turnId);
++ plan.prompt = withFiles(plan.prompt, claims, turnId);
++ }
let attempt = null;
const before = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [row.id]);
if (!before) return { error: 'no such task' };
@@@ -907,6 +945,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
task: current, agent: plan.agent, model: plan.model, effort: plan.effort || null, session: null, why: plan.why, kind: plan.kind, state: 'queued', budget: left.left,
});
attempt = made.attempt;
++ if (claims.length) await filesStore.link(row.subject, claims, { taskId: row.id, attemptN: Number(attempt.n), messageId: turnId });
await db.one(`UPDATE run_tasks SET state = 'queued', outcome = NULL, outcome_note = NULL, error = NULL, job_id = NULL, finished_at = NULL, updated_at = $2 WHERE id = $1 RETURNING *`,
[row.id, store.now()]);
return { row: current };
@@@ -972,6 +1011,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
...(plan.basePatch ? { base_patch: plan.basePatch } : {}),
...(row.push ? { push: { pull_request: !!row.pull_request } } : {}),
...(plan.session ? { resume: plan.session } : {}),
++ ...(plan.attachments && plan.attachments.length ? { attachments: plan.attachments } : {}),
}));
}
@@@ -1016,9 +1056,17 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
});
}
++ /**
++ * The environment one conductor turn runs with: the person's own plan login when the harness has one, else a
++ * short-lived run token for its provider (the same funding a coding task gets). `mode` is kept on the attempt.
++ */
++ async function fundTurn({ subject, taskId, attemptId = null, agent = null }) {
++ return await attemptEnv({ subject, taskId, attemptId, agent, budget: null });
++ }
++
return {
start, refresh, applyJob, applyEvent, recordState, afterEnd, continueTask, stopTask, proof: taskProof,
-- latestProject, present, who, attempts: attemptStore, setConductor,
++ latestProject, present, who, attempts: attemptStore, setConductor, fundTurn,
setForge(f) { forge = f || null; },
};
}
ddiff --git a/server/domain/user-settings.js b/server/domain/user-settings.js
iindex 2bf3d52..51dc284 100644
---- a/server/domain/user-settings.js
++++ b/server/domain/user-settings.js
@@@ -8,12 +8,13 @@
*
* createUserSettings({ store })
* get(subject) → { default_agent, default_machine, notify_done, notify_needs, safety,
-- * steps, minutes, no_cloud, only_free, conductor_fallbacks,
-- * conductor_switch_pct }
++ * steps, minutes, no_cloud, only_free, conductor_agent, conductor_model,
++ * conductor_fallbacks, conductor_switch_pct }
* save(subject, patch) → the saved row, in the same shape
*
-- * The conductor's failover (migration 0023): the agents and models it falls back to, in order, and the percentage of a
-- * usage window at which it switches (server/domain/conductor-failover.js).
++ * "Who leads your tasks" (owner 2026-11-07, migration 0030): the agent and model a conductor starts on. NULL means
++ * today's choice, Claude Code on Opus. The failover (migration 0023) is the agents and models it falls back to, in
++ * order, and the percentage of a usage window at which it switches (server/domain/conductor-failover.js).
*
* Only a usr_ subject is accepted: a service token has no preferences of its own to keep.
*/
@@@ -31,6 +32,8 @@ const DEFAULTS = Object.freeze({
steps: null, minutes: null,
// Placement A1: the person's rules, both off until they choose them.
no_cloud: false, only_free: false,
++ // Who leads your tasks: today's choice until the person picks another agent and model.
++ conductor_agent: 'claude-code', conductor_model: 'opus',
// The conductor's failover: no fallback until the person picks one; it switches at 90% of a window.
conductor_fallbacks: [], conductor_switch_pct: failover.DEFAULT_SWITCH_PCT,
});
@@@ -58,12 +61,17 @@ function present(row) {
minutes: row.minutes == null ? null : Number(row.minutes),
no_cloud: row.no_cloud === true,
only_free: row.only_free === true,
++ // A row with no agent is today's choice. A row that names another agent with no model uses that agent's own
++ // default (NULL), which is not Opus: only the Claude Code default is Opus.
++ conductor_agent: row.conductor_agent || DEFAULTS.conductor_agent,
++ conductor_model: row.conductor_model != null ? row.conductor_model : (row.conductor_agent && row.conductor_agent !== 'claude-code' ? null : DEFAULTS.conductor_model),
conductor_fallbacks: failover.storedFallbacks(row.conductor_fallbacks),
conductor_switch_pct: row.conductor_switch_pct == null ? failover.DEFAULT_SWITCH_PCT : Number(row.conductor_switch_pct),
};
}
--function createUserSettings({ store }) {
++function createUserSettings({ store, harnesses = null }) {
++ const knownAgent = (id) => !harnesses || (harnesses.list ? harnesses.list().some((a) => a.id === id) : true);
const db = store.db;
async function get(subject) {
@@@ -91,6 +99,19 @@ function createUserSettings({ store }) {
if (v !== null && !MACHINE.test(v)) throw Object.assign(new Error('the default machine is one of your machines, or empty for Auto'), { status: 422, code: 'settings.machine_unknown' });
next.default_machine = v;
}
++ // Who leads your tasks: an agent id and its model; empty means today's choice for the agent, or Claude Opus.
++ if (patch.conductor_agent !== undefined) {
++ const v = patch.conductor_agent === '' || patch.conductor_agent == null ? null : String(patch.conductor_agent);
++ if (v !== null && (!AGENT.test(v) || !knownAgent(v))) throw Object.assign(new Error('the agent that leads your tasks is a harness id Codes offers, or empty for Claude Code'), { status: 422, code: 'settings.conductor_agent_unknown' });
++ next.conductor_agent = v;
++ // Changing only the agent keeps its own default model unless one was sent; never Opus under Codex.
++ if (patch.conductor_model === undefined) next.conductor_model = v === 'claude-code' || v === null ? 'opus' : null;
++ }
++ if (patch.conductor_model !== undefined) {
++ const v = patch.conductor_model === '' || patch.conductor_model == null ? null : String(patch.conductor_model).trim();
++ if (v !== null && !/^[A-Za-z0-9._:/-]{1,100}$/.test(v)) throw Object.assign(new Error('the model that leads your tasks is a model name, or empty for its default'), { status: 422, code: 'settings.conductor_model_invalid' });
++ next.conductor_model = v;
++ }
if (patch.notify_done !== undefined) next.notify_done = patch.notify_done !== false && patch.notify_done !== '0';
if (patch.notify_needs !== undefined) next.notify_needs = patch.notify_needs !== false && patch.notify_needs !== '0';
// Placement A1's rules: a checkbox sends '1' when set, nothing when not.
@@@ -114,15 +135,16 @@ function createUserSettings({ store }) {
if (switchPct !== undefined) next.conductor_switch_pct = switchPct == null ? failover.DEFAULT_SWITCH_PCT : switchPct;
await db.exec(
`INSERT INTO codes_user_settings (subject, default_agent, default_machine, notify_done, notify_needs, safety,
-- steps, minutes, no_cloud, only_free, conductor_fallbacks, conductor_switch_pct, updated_at)
-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, now())
++ steps, minutes, no_cloud, only_free, conductor_agent, conductor_model, conductor_fallbacks, conductor_switch_pct, updated_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, now())
ON CONFLICT (subject) DO UPDATE SET default_agent = EXCLUDED.default_agent, default_machine = EXCLUDED.default_machine,
notify_done = EXCLUDED.notify_done, notify_needs = EXCLUDED.notify_needs, safety = EXCLUDED.safety,
steps = EXCLUDED.steps, minutes = EXCLUDED.minutes, no_cloud = EXCLUDED.no_cloud,
-- only_free = EXCLUDED.only_free, conductor_fallbacks = EXCLUDED.conductor_fallbacks,
++ only_free = EXCLUDED.only_free, conductor_agent = EXCLUDED.conductor_agent,
++ conductor_model = EXCLUDED.conductor_model, conductor_fallbacks = EXCLUDED.conductor_fallbacks,
conductor_switch_pct = EXCLUDED.conductor_switch_pct, updated_at = now()`,
[subject, next.default_agent, next.default_machine, next.notify_done, next.notify_needs, next.safety, next.steps, next.minutes, next.no_cloud, next.only_free,
-- JSON.stringify(next.conductor_fallbacks || []), next.conductor_switch_pct],
++ next.conductor_agent || null, next.conductor_model || null, JSON.stringify(next.conductor_fallbacks || []), next.conductor_switch_pct],
);
return next;
}
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
iindex 3005521..3335034 100644
---- a/server/http/actor-limits.js
++++ b/server/http/actor-limits.js
@@@ -35,6 +35,8 @@ const BUDGETS = {
'codes.harness.route': { minute: 30, hour: 600 },
// A service starting (or cancelling) a coding task for a person counts against that person (tasks-api.js).
'codes.task.start': { minute: 6, hour: 60 },
++ // A file attached to a message (10 at most per message): a handful a minute, not a flood of 20 MB bodies.
++ 'codes.attachment.upload': { minute: 60, hour: 1000 },
// The Home picker's repository search asks GitHub, which allows an address few searches a minute. The person's
// own repositories come first and cost nothing; this bounds what they may spend on the rest.
'codes.repo.search': { minute: 6, hour: 60 },
ddiff --git a/server/http/attachments-api.js b/server/http/attachments-api.js
nnew file mode 100644
iindex 0000000..ecc02a8
---- /dev/null
++++ b/server/http/attachments-api.js
@@@ -0,0 +1,100 @@
++'use strict';
++
++/**
++ * A person's attachments (owner 2026-11-07). The composer uploads each file before the message is sent (so the page can
++ * show progress and a × before submit), then names the ids in the message; a form without JavaScript sends the same
++ * files as multipart instead and the page's route saves them.
++ *
++ * POST /attachments one file: multipart (`file`) or a raw body with X-File-Name; → { attachment }
++ * GET /attachments/:id its bytes, owner-only; images and PDFs inline, everything else a download
++ * DELETE /attachments/:id an upload that is not attached to a message yet
++ *
++ * A chat message's own copy is listed at /api/v1/tasks/:id/messages/:mid/attachments/:i (conductor), and every
++ * attachment is listed under the turn it belongs to on the task page. Nothing here is public: every query is scoped to
++ * the signed-in person, and another person's attachment answers 404, never 403 (which would say it exists).
++ */
++const { http } = require('openvibe-contracts');
++const { asyncRouter } = require('./router');
++const { readMultipart, readBody, isMultipart } = require('./upload');
++
++const FILE = /^fil_[0-9A-HJKMNP-TV-Z]{26}$/;
++const INLINE = /^(image\/(png|jpeg|gif|webp|avif|heic)|application\/pdf)$/;
++
++function createAttachmentsApi({ attachments, actorLimits, config, keys = null, log = console }) {
++ const r = asyncRouter();
++ const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
++ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
++ /** The person: the session cookie, or their own Network access token as a Bearer (what a job on their machine has). */
++ async function subjectOf(req) {
++ if (signedIn(req)) return req.viewer.subject;
++ const header = String(req.headers.authorization || '');
++ if (!header.startsWith('Bearer ') || !keys) return null;
++ try {
++ if (!keys.loaded()) await keys.ensure();
++ const v = await keys.verifyUser(header.slice(7).trim(), { issuer: config.networkIssuer, audience: [config.oauth.sessionAudience, config.serviceAudience] });
++ if (!v.ok) return null;
++ const subject = typeof v.claims.subject_id === 'string' ? v.claims.subject_id : null;
++ return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject || '') ? subject : null;
++ } catch { return null; }
++ }
++ const sameOrigin = (req) => {
++ const origin = req.get('origin');
++ if (!origin) return true;
++ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
++ };
++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
++
++ r.post('/attachments', actorLimits.budget('codes.attachment.upload'), async (req, res) => {
++ const subject = await subjectOf(req);
++ if (!subject) return problem(req, res, 401, 'auth.required', 'sign in to attach a file');
++ if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site uploads are refused');
++ try {
++ let file;
++ if (isMultipart(req)) {
++ const { files } = await readMultipart(req, { maxBytes: attachments.MAX_FILE_BYTES });
++ if (!files.length) return problem(req, res, 422, 'attachment.empty', 'choose a file to attach');
++ if (files.length > 1) return problem(req, res, 422, 'attachment.too_many', 'upload one file at a time');
++ file = files[0];
++ } else {
++ const name = String(req.get('x-file-name') || '').trim();
++ if (!name) return problem(req, res, 422, 'attachment.name_required', 'send the file as multipart, or with X-File-Name');
++ file = { name, type: String(req.get('content-type') || ''), buffer: await readBody(req, { maxBytes: attachments.MAX_FILE_BYTES }) };
++ }
++ const saved = await attachments.save(subject, file);
++ log.info(`[Codes] attachment ${saved.id} (${saved.size} bytes) uploaded by ${subject}`);
++ return res.status(201).json({ attachment: saved });
++ } catch (e) {
++ return problem(req, res, e.status || 500, e.code || 'attachment.failed', String((e && e.message) || 'the file could not be attached').slice(0, 300));
++ }
++ });
++
++ r.get('/attachments/:id', actorLimits.reads('codes.read'), async (req, res) => {
++ const subject = await subjectOf(req);
++ if (!subject) return problem(req, res, 401, 'auth.required', 'sign in to read your attachment');
++ if (!FILE.test(String(req.params.id))) return problem(req, res, 404, 'attachment.not_found', 'no such attachment');
++ const row = await attachments.get(subject, req.params.id);
++ if (!row) return problem(req, res, 404, 'attachment.not_found', 'no such attachment of yours');
++ const name = String(row.name || 'file').replace(/[^A-Za-z0-9._-]/g, '_');
++ res.set('Content-Type', row.mime || 'application/octet-stream');
++ res.set('X-Content-Type-Options', 'nosniff');
++ res.set('Content-Security-Policy', "default-src 'none'; sandbox");
++ res.set('Content-Disposition', `${INLINE.test(String(row.mime || '')) ? 'inline' : 'attachment'}; filename="${name}"`);
++ return res.send(Buffer.isBuffer(row.data) ? row.data : Buffer.from(row.data || ''));
++ });
++
++ r.delete('/attachments/:id', actorLimits.budget('codes.attachment.upload'), async (req, res) => {
++ const subject = await subjectOf(req);
++ if (!subject) return problem(req, res, 401, 'auth.required', 'sign in to remove an attachment');
++ if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site changes are refused');
++ if (!FILE.test(String(req.params.id))) return problem(req, res, 404, 'attachment.not_found', 'no such attachment');
++ try {
++ return res.json(await attachments.remove(subject, req.params.id));
++ } catch (e) {
++ return problem(req, res, e.status || 500, e.code || 'attachment.failed', String((e && e.message) || 'the attachment could not be removed'));
++ }
++ });
++
++ return r;
++}
++
++module.exports = { createAttachmentsApi, FILE };
ddiff --git a/server/http/c4.js b/server/http/c4.js
iindex a321742..89f2008 100644
---- a/server/http/c4.js
++++ b/server/http/c4.js
@@@ -20,6 +20,8 @@ const { raw, esc } = require('../render/html');
const { asyncRouter } = require('./router');
const showcase = require('openvibe-shared/showcase');
const { homeJsonLd } = require('./discovery');
++const models = require('../data/models');
++const { formBody } = require('./upload');
const stateLabel = (s) => ({ running: 'Running', queued: 'Queued', placed: 'On its way', done: 'Done', succeeded: 'Done', failed: "Didn't finish", cancelled: 'Stopped by you', expired: 'Expired' }[s] || s || 'Unknown');
const safeId = (s) => /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(s || ''));
@@@ -87,9 +89,9 @@ const YOUR_AI = 'https://ai.openvibe.services/you';
function createC4Routes(ctx) {
const r = asyncRouter();
-- const { config, store, harnesses, tasks, sso, connections, machines, userSettings, github, usage, conductor, approvals, fleet } = ctx;
++ const { config, store, harnesses, tasks, sso, connections, machines, userSettings, github, usage, conductor, approvals, fleet, attachments } = ctx;
const db = store.db;
-- const form = express.urlencoded({ extended: false, limit: '16kb' });
++ const form = formBody({ maxBytes: attachments ? attachments.MAX_MESSAGE_BYTES : 50 * 1024 * 1024 });
const page = (req, res, title, body, extra = {}) => {
// A page may add its own stylesheet (the guided setup's), so c4.css stays the size every page pays for.
const css = ['css/c4.css', ...(Array.isArray(extra.css) ? extra.css : [])];
@@@ -276,6 +278,27 @@ ${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="
};
// ── The conductor (C9b) ─────────────────────────────────────────────────────────────────────────────────────
++ /** The price line a "who leads" choice carries: known plans and keys say what they use, never an invented number. */
++ function priceLine(agentId, modelId) {
++ const offer = (harnesses?.list?.() || []).find((a) => a.id === agentId);
++ if (!offer) return 'uses your own account';
++ const agent = (offer.agents || []).find((a) => a.model === modelId) || (offer.agents || [])[0];
++ const out = agent && agent.price_per_1k_tokens ? Number(agent.price_per_1k_tokens.output_usd) : 0;
++ if (out > 0) return `$${out.toFixed(out < 0.01 ? 4 : 2)} per 1k output tokens`;
++ const amount = offer.price ? Number(offer.price.amount_usd) : 0;
++ if (amount > 0) return `$${amount} per ${offer.price.unit || 'run'}`;
++ return 'included in your plan';
++ }
++ /** Every harness and model a conductor may lead with, with what the person's machines say and its price. */
++ function leadChoices(settings, aware, catalog = null) {
++ const chosen = settings && settings.conductor_agent ? `${settings.conductor_agent}|${settings.conductor_model || ''}` : '';
++ return models.choices(harnesses, catalog).slice(1).map((c) => {
++ const info = aware.get(String(c.agent));
++ const price = c.detail || priceLine(c.agent, c.model);
++ const attrs = optionAttrs({ ...(info || {}), desc: [info && info.desc, price].filter(Boolean).join(' · ') });
++ return `<option value="${esc(c.value)}"${chosen === c.value || (!chosen && c.value === 'claude-code|opus') ? ' selected' : ''}${attrs}>${esc(`${c.label} — ${price}`)}</option>`;
++ }).join('');
++ }
const conductorState = (row) => (['queued', 'placed', 'running'].includes(row.state)
? (row.state === 'queued' ? 'Getting ready' : 'Working')
: row.outcome === 'stopped' ? 'Stopped' : row.outcome === 'unfinished' ? 'Needs you' : 'Waiting for you');
@@@ -337,8 +360,8 @@ ${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" 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" required></textarea><div class="c4-conductor-actions"><label class="c4-file">Attach an image<input type="file" accept="image/*" multiple data-c4-images></label><button class="c4-button" type="submit">Send</button></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(modelLabel(row.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 Claude Code session on your own machine, with your own login. Each message is the next turn of the same conversation; its work is carried out through the Codes tools, on your other agents.</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}`;
++ 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"><label class="c4-file">Attach files<input type="file" name="files" multiple data-c4-images></label><button class="c4-button" type="submit">Send</button></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));
}
@@@ -378,7 +401,7 @@ ${showcase.features({ id: 'yours', title: 'Your plans, your keys, your machine',
{ title: 'Your computer', text: 'Pair it once from openvibe.network. Tasks run there with your own tools, and your logins never leave it.', href: '/run' },
{ title: 'Costs you can follow', text: 'Every turn shows its tokens, what came from cache and who paid, on openvibe.report.' },
] })}
--${showcase.features({ id: 'conductor', title: 'A conductor for the big jobs', lede: 'One Claude Code session plans the work, hands the parts to other agents in parallel, reads what they did, and asks you before anything risky.', items: [
++${showcase.features({ id: 'conductor', title: 'A conductor for the big jobs', lede: 'One long session on the agent you choose plans the work, hands the parts to other agents in parallel, reads what they did, and asks you before anything risky.', items: [
{ title: 'Plans, then delegates', text: 'Backend to one agent, tests to another, docs to a third, at the same time.' },
{ title: 'Asks before it acts', text: 'Pushing, deleting or spending waits for your Allow in the app.' },
{ title: 'Remembers the thread', text: 'One long conversation you can pick up from any device.' },
@@@ -408,7 +431,7 @@ ${showcase.faq({ items: [
const content = `${header(signedIn ? 'Home' : 'Every coding agent. One place.', signedIn ? '' : 'Your keys, your machines. Tell an agent what to do and follow the work from your phone.')}
<section class="c4-card c4-composer"><form method="post" action="/start"><label for="c4-prompt">What should an agent do?</label><textarea id="c4-prompt" name="prompt" placeholder="Describe the change, question or fix" required></textarea><div class="c4-controls"><label class="c4-field-repo">Project<input type="url" name="repo" value="${esc(String(req.query.repo || ''))}" placeholder="Paste a repository URL" autocomplete="url" list="c4-repo-options" data-c4-repo-search data-c4-repo-url="/api/v1/me/repos"><datalist id="c4-repo-options">${repos.map((repo) => `<option value="${esc(repo.url)}">${esc(repo.full_name)}</option>`).join('')}</datalist></label><label>Agent <select name="agent"><option value="">Auto</option>${agentOptions}</select></label><label>Machine <select name="machine"><option value="">Auto</option>${machineOptions}</select></label><label>Cost cap <select name="cap"><option>$1.00</option><option>$5.00</option></select></label><button class="c4-button" type="submit">Start a task</button></div><p class="c4-hint">Run places the task on one of your own machines; the machine and agent you pick are saved as your defaults.<span class="c4-hint-status" data-c4-repo-status role="status"></span></p>${saved ? `<div class="c4-chips c4-saved" aria-label="Saved repositories">${saved}</div>` : ''}</form></section>
<div class="c4-grid"><section class="c4-card"><div class="c4-card-head"><h2>Running</h2><a href="/tasks?state=running">All tasks</a></div>${rows.filter((x) => !['done', 'succeeded', 'failed', 'cancelled'].includes(x.state)).slice(0, 4).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks running yet. <a href="/#c4-prompt">Start a task</a></div>'}</section><section class="c4-card"><div class="c4-card-head"><h2>Needs you</h2>${waiting.length ? '<a href="/approvals">The inbox</a>' : ''}</div><p class="c4-sub">Approvals and questions</p>${waiting.length ? waiting.slice(0, 3).map((a) => `<a class="c4-task" href="/approvals"><span class="c4-status-dot c4-running" aria-label="Waiting"></span><span class="c4-task-title">${esc(a.tool || 'a tool')}</span><span class="c4-task-meta">${esc(String(a.summary || '').slice(0, 120))}</span></a>`).join('') : '<div class="c4-emptyline">Nothing needs your attention. <a href="/tasks">View tasks</a></div>'}</section></div>
--${signedIn ? `<section class="c4-card c4-conductor-cta"><div class="c4-card-head"><h2>Your conductor</h2><a href="/conductor">${conductors.length ? 'Open' : 'Set it up'}</a></div><p class="c4-muted">One Claude Code session on your own machine, carrying the plan forward and handing the work to your other agents. Each message you send is its next turn${conductors.length ? `: ${conductors.length} running or waiting` : ''}.</p><p><a class="c4-button" href="/conductor">${conductors.length ? 'Open your conductor' : 'Start your conductor'}</a></p></section>` : ''}
++${signedIn ? `<section class="c4-card c4-conductor-cta"><div class="c4-card-head"><h2>Your conductor</h2><a href="/conductor">${conductors.length ? 'Open' : 'Set it up'}</a></div><p class="c4-muted">One long session on the agent you choose, on your own machine, carrying the plan forward and handing the work to your other agents. Each message you send is its next turn${conductors.length ? `: ${conductors.length} running or waiting` : ''}.</p><p><a class="c4-button" href="/conductor">${conductors.length ? 'Open your conductor' : 'Start your conductor'}</a></p></section>` : ''}
<section class="c4-card"><div class="c4-card-head"><h2>Recent tasks</h2><a href="/tasks">View all</a></div>${rows.slice(0, 5).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>
${signedIn && !mine.ok && mine.problem ? note(`Your machines could not be read, so the machine picker has only Auto. ${esc(mine.problem.detail || '')}`, 'bad') : ''}
${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h2><div class="c4-grid">${[['Any agent', 'Bring a key or plan. Auto picks a good fit and can hand over when one gets stuck.'], ['Your computer or ours', 'Pair your computer with one command. A hosted sandbox is coming soon.'], ['Proof you can inspect', 'See the changes, tests, screenshots and review before you approve.'], ['A cost you can follow', 'Track task costs and plan limits. Set caps that stop with a wrap-up.']].map(([a, b]) => `<article><h3>${a}</h3><p>${b}</p></article>`).join('')}</div></section>`}`;
@@@ -516,13 +539,15 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h
const machineName = new Map((mine.machines || []).map((m) => [m.id, m.name || m.node_id || m.id]));
const folderChoices = folders.map((f) => `<option value="${esc(f.folder)}" data-machine="${esc(f.machine || '')}">${esc(f.folder)}${f.machine ? ` · ${esc(machineName.get(f.machine) || f.machine)}` : ''}</option>`).join('');
const existing = await db.many("SELECT * FROM run_tasks WHERE subject = $1 AND kind = 'conductor' ORDER BY created_at DESC LIMIT 4", [req.viewer.subject]);
-- const list = existing.map((row) => `<a class="c4-task" href="${taskHref(row)}"><span class="c4-status-dot c4-${esc(row.state)}" aria-label="${esc(conductorState(row))}"></span><span class="c4-task-title">${esc(row.folder || 'Conductor')}</span><span class="c4-task-meta">${esc([modelLabel(row.model), conductorState(row), age(row.created_at)].join(' · '))}</span></a>`).join('');
-- const content = `${header('Conductor', 'Your orchestrator, running here: one Claude Code session on your machine, on your own login, carrying the plan forward through the other agents.')}${flash(req)}
++ const list = existing.map((row) => `<a class="c4-task" href="${taskHref(row)}"><span class="c4-status-dot c4-${esc(row.state)}" aria-label="${esc(conductorState(row))}"></span><span class="c4-task-title">${esc(row.folder || 'Conductor')}</span><span class="c4-task-meta">${esc([failoverNames[row.agent] || row.agent, modelLabel(row.model), conductorState(row), age(row.created_at)].join(' · '))}</span></a>`).join('');
++ const fleetRows = fleet ? await fleet.agents(req.viewer.subject, harnesses).catch(() => []) : [];
++ const aware = agentReadiness(fleetRows);
++ const content = `${header('Conductor', 'Your orchestrator, running here: the agent you choose on your own machine, carrying the plan forward through the other agents.')}${flash(req)}
<section class="c4-card c4-composer"><form method="post" action="/conductor">
<label for="c4-folder">Working folder</label><div class="c4-folder-row"><input id="c4-folder" name="folder" placeholder="/home/you/project" value="${esc(String(req.query.folder || ''))}" list="c4-folders" autocomplete="off" spellcheck="false"><select name="folder_pick" data-c4-folder-pick aria-label="Folders this machine has worked in"><option value="">A recent folder…</option>${folderChoices}</select></div><datalist id="c4-folders">${folders.map((f) => `<option value="${esc(f.folder)}"></option>`).join('')}</datalist>
--<div class="c4-controls"><label>Machine <select name="machine" data-c4-machine><option value="">Your default</option>${machineOptions}</select></label><label>Model <select name="model" data-c4-model>${MODELS.map(([v, l]) => `<option value="${v}"${(settings.model || 'opus') === v ? ' selected' : ''}>${l}</option>`).join('')}<option value="other">Other…</option></select></label><label class="c4-model-other" data-c4-model-other hidden>Other model <input name="model_other" placeholder="a model name or id" autocomplete="off" spellcheck="false"></label><label>Safety <select name="safety">${[['ask', 'Ask me'], ['project', 'Auto in the project'], ['full', 'Full access']].map(([v, l]) => `<option value="${v}"${settings.safety === v ? ' selected' : ''}>${l}</option>`).join('')}</select></label></div>
++<div class="c4-controls"><label>Machine <select name="machine" data-c4-machine><option value="">Your default</option>${machineOptions}</select></label><label>Who leads this task <select name="who" data-c4-model><option value="">Today's choice: Claude Code on Opus</option>${leadChoices(settings, aware)}</select></label><label>Safety <select name="safety">${[['ask', 'Ask me'], ['project', 'Auto in the project'], ['full', 'Full access']].map(([v, l]) => `<option value="${v}"${settings.safety === v ? ' selected' : ''}>${l}</option>`).join('')}</select></label></div>
<label class="c4-note-field" for="c4-conductor-note">What should it pick up? <small>Optional. It starts from the hand-over note and the plan; say here what you want first.</small></label><textarea id="c4-conductor-note" name="note" rows="2" placeholder="e.g. carry on with the next piece of the plan"></textarea>
--<p class="c4-hint">It opens that folder, so it loads that project's memory and AGENTS.md. It runs with your own Claude Code login on that machine, and never with a key of ours.${mine.problem ? ` ${esc(mine.problem.detail || '')}` : ''}</p>
++<p class="c4-hint">It opens that folder, so it loads that project's memory and AGENTS.md. It runs with your own login on that machine, and never with a key of ours.${mine.problem ? ` ${esc(mine.problem.detail || '')}` : ''}</p>
<div class="c4-conductor-actions"><span class="c4-hint">One conductor is enough: it hands the work to the other agents through the Codes tools.</span><button class="c4-button" type="submit">Start your conductor</button></div>
</form></section>
${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors</h2><a href="/tasks">All tasks</a></div>${list}</section>` : ''}`;
@@@ -535,16 +560,15 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors
const b = req.body || {};
try {
const folder = String(b.folder || b.folder_pick || '').trim();
-- const picked = String(b.model || 'opus');
-- const model = (picked === 'other' ? String(b.model_other || '') : picked).trim();
-- if (!model) throw Object.assign(new Error('name the model for Other: an alias such as opus, or a model id'), { status: 400 });
-- if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(model)) throw Object.assign(new Error('that is not a model name'), { status: 400 });
++ const [agent, model = ''] = String(b.who || '').split('|');
++ if (agent && !(harnesses?.list?.() || []).some((a) => a.id === agent)) throw Object.assign(new Error('choose who leads this task from the list'), { status: 400 });
const project = await conductor.projectFor(req.viewer.token || null, req.viewer.subject);
if (!project) throw Object.assign(new Error(`start one task at ${config.baseUrl}/run first: a conductor runs in your project, on your machine`), { status: 409 });
const made = await conductor.create({
subject: req.viewer.subject, project,
folder, machine: b.machine || null,
-- model, safety: b.safety || 'ask', note: b.note || null,
++ ...(agent ? { agent, model } : {}),
++ safety: b.safety || 'ask', note: b.note || null,
});
await userSettings.save(req.viewer.subject, { safety: b.safety || 'ask', ...(b.machine ? { default_machine: b.machine } : {}) }).catch(() => {});
return res.redirect(303, `/tasks/${encodeURIComponent(made.task.id)}`);
@@@ -561,7 +585,12 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors
const row = await db.maybe("SELECT * FROM run_tasks WHERE id = $1 AND subject = $2 AND kind = 'conductor'", [req.params.id, req.viewer.subject]);
if (!row) return res.status(404).send('Not found');
try {
-- await conductor.message(row, { text: (req.body || {}).text, token: req.viewer.token || null });
++ const body = req.body || {};
++ const [agent, model = ''] = String(body.who || '').split('|');
++ const named = Array.isArray(body.attachment_ids) ? body.attachment_ids : (body.attachment_ids ? [body.attachment_ids] : []);
++ const saved = attachments && Array.isArray(req.files) && req.files.length ? await attachments.saveMany(req.viewer.subject, req.files) : [];
++ const attachmentIds = [...saved.map((f) => f.id), ...named.map(String)].filter(Boolean);
++ await conductor.message(row, { text: body.text, token: req.viewer.token || null, attachmentIds, ...(agent ? { agent, model } : {}) });
return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}`);
} catch (e) {
return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}?error=${encodeURIComponent(String((e && e.message) || 'the message could not be sent').slice(0, 180))}`);
@@@ -791,6 +820,9 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
<label>Default agent <select name="default_agent"><option value="">Auto</option>${agentOptions}</select></label>
<label>Default machine <select name="default_machine"><option value="">Auto</option>${machineOptions}</select></label>
</div>
++<h3 class="c4-subhead" id="lead">Who leads your tasks</h3>
++<p class="c4-hint">The agent that plans the work and hands the parts to the others. Every harness and model you can run is listed with what your machines say about it and what it costs. A single task can still pick another leader in its composer.</p>
++<div class="c4-form-grid"><label>Who leads your tasks <select name="conductor_who"><option value="">Today's choice: Claude Code on Opus</option>${leadChoices(settings, aware)}</select></label></div>
<h3 class="c4-subhead">Limits</h3>
<div class="c4-form-grid">
<label>Steps per task ${limitSelect('steps', settings.steps, [[200, '200'], [350, '350'], [500, '500'], [1000, '1,000']], 'steps')}</label>
@@@ -827,6 +859,7 @@ ${conductorSection}
no_cloud: b.no_cloud === '1',
only_free: b.only_free === '1',
...(b.safety ? { safety: b.safety } : {}),
++ ...(b.conductor_who !== undefined ? (() => { const [agent, model = ''] = String(b.conductor_who || '').split('|'); return { conductor_agent: agent, conductor_model: model }; })() : {}),
steps: b.steps === undefined ? undefined : b.steps,
minutes: b.minutes === undefined ? undefined : b.minutes,
// The conductor's fallbacks, in the order of the form's rows; an empty row is none.
ddiff --git a/server/http/chat.js b/server/http/chat.js
iindex cfea585..0b6d33e 100644
---- a/server/http/chat.js
++++ b/server/http/chat.js
@@@ -11,7 +11,7 @@
* thread, grows the composer and opens the rail and the side on a phone.
*
* A conversation is a run_tasks row: a coding task (its first message is the brief, each "carry on" is a turn, a
-- * hand-over to another agent is a divider) or the conductor (each message is a turn of one Claude Code session).
++ * hand-over to another agent is a divider) or the conductor (each message is a turn of one long agent session).
* A visitor who is not signed in falls through to the product's own home (server/http/c4.js).
*/
const express = require('express');
@@@ -25,6 +25,7 @@ const kit = require('openvibe-shared/kit');
const thread = require('openvibe-shared/thread');
const { readiness, optionAttrs } = require('../domain/readiness');
const start = require('./start');
++const { formBody } = require('./upload');
const LIVE = ['queued', 'placed', 'running'];
const safeId = (s) => /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(s || ''));
@@@ -74,7 +75,9 @@ const ico = (name, cls = '') => `<svg class="ch-i${cls ? ` ${cls}` : ''}" aria-h
const AGENTS = {
'claude-code': ['Claude Code', 'C', '#d97757'], codex: ['Codex', 'Cx', '#10a37f'], opencode: ['OpenCode', 'O', '#7c6cff'],
'command-code': ['Command Code', 'Cc', '#f59e0b'], aider: ['Aider', 'A', '#14b8a6'], deepseek: ['DeepSeek', 'D', '#4f7cff'],
-- minimax: ['MiniMax', 'M', '#ec4899'], openai: ['Your model', 'Y', '#64748b'],
++ 'minimax-code': ['MiniMax Code', 'M', '#ec4899'], minimax: ['MiniMax', 'M', '#ec4899'],
++ 'openvibe-ai': ['OpenVibe.AI', 'AI', '#64748b'], 'openai-compatible': ['Your model', 'Y', '#64748b'],
++ 'claude-other': ['Claude Code (another provider)', 'C', '#d97757'],
};
const agentOf = (id, names) => {
const known = AGENTS[String(id || '').split(':')[0]];
@@@ -130,7 +133,7 @@ const repoLabel = (repo) => { try { return new URL(String(repo)).pathname.split(
function createChatRoutes(ctx) {
const r = asyncRouter();
-- const { config, store, harnesses, tasks, conductor, approvals, userSettings, github, machines, fleet, aiCatalog } = ctx;
++ const { config, store, harnesses, tasks, conductor, approvals, userSettings, attachments, github, machines, fleet, aiCatalog } = ctx;
/** What each agent is ready for on the person's machines (the stored fleet: no call out), for the pickers. */
const readyFor = async (req) => (fleet && viewer(req) ? readiness(await fleet.agents(req.viewer.subject, harnesses).catch(() => [])) : new Map());
/** OpenVibe.AI's catalog for the pickers, or [] (flag off, AI down, or the legacy single model): never an error,
@@@ -142,7 +145,7 @@ function createChatRoutes(ctx) {
try { return await Promise.race([aiCatalog.choices().catch(() => []), late]); } finally { clearTimeout(timer); }
};
const db = store.db;
-- const form = express.urlencoded({ extended: false, limit: '32kb' });
++ const form = formBody({ maxBytes: attachments ? attachments.MAX_MESSAGE_BYTES : 50 * 1024 * 1024 });
const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const sameOrigin = (req) => {
const origin = req.get('origin');
@@@ -150,6 +153,22 @@ function createChatRoutes(ctx) {
try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
};
const agentNames = () => new Map((harnesses?.list?.() || []).map((a) => [a.id, a.name]));
++ /** The ids a message names: what the script uploaded before submit, or what a multipart form sent now. */
++ async function attachmentIds(req) {
++ if (!attachments) return [];
++ const body = (req && req.body) || {};
++ const named = Array.isArray(body.attachment_ids) ? body.attachment_ids : (body.attachment_ids ? [body.attachment_ids] : []);
++ const files = Array.isArray(req && req.files) ? req.files : [];
++ if (!named.length && !files.length) return [];
++ const saved = files.length ? await attachments.saveMany(req.viewer.subject, files) : [];
++ return [...saved.map((f) => f.id), ...named.map(String)].filter(Boolean);
++ }
++ /** One turn's files, as the kit's turn lists them (a link each, an image one drawn as a picture). */
++ async function filesFor(taskId, n) {
++ if (!attachments) return [];
++ const rows = await attachments.forTask(taskId, n).catch(() => []);
++ return rows.map((f) => ({ href: `/api/v1/attachments/${encodeURIComponent(f.id)}`, name: f.name, size: f.size, image: f.image }));
++ }
// ── The rail ─────────────────────────────────────────────────────────────────────────────────────────────────
async function conversations(subject) {
@@@ -255,13 +274,14 @@ function createChatRoutes(ctx) {
const live = LIVE.includes(row.state);
// Each attempt is a turn; the brief (or a note to carry on) is its person's side, a hand-over a line before it.
const parts = [];
-- let mine = { text: row.prompt || '', caption: 'The brief' };
++ let mine = { text: row.prompt || '', caption: 'The brief', files: await filesFor(row.id, 1) };
let prev = null;
for (const a of attempts) {
const n = Number(a.n);
++ const files = n === 1 ? mine.files : await filesFor(row.id, n);
if (prev) {
const note = /^carried on by the owner: /.exec(a.why || '') ? String(a.why).replace(/^carried on by the owner: /, '') : null;
-- if (note) mine = { text: note };
++ if (note) mine = { text: note, files };
else if (a.agent && prev.agent && a.agent !== prev.agent) parts.push({ divider: `${agentOf(a.agent, names).name} took over${prev.end_why ? `: ${agentOf(prev.agent, names).name} ${prev.end_why}` : ''}` });
else if (a.why) parts.push({ divider: String(a.why) });
}
@@@ -290,7 +310,7 @@ function createChatRoutes(ctx) {
if (m.switch_note) out.push(thread.divider({ text: m.switch_note }));
else if (previous && m.agent && m.agent !== previous) out.push(thread.divider({ text: `${a.name} carries on` }));
if (m.agent) previous = m.agent;
-- const files = (m.attachments || []).map((f, i) => ({ href: `/api/v1/tasks/${encodeURIComponent(row.id)}/messages/${encodeURIComponent(m.id)}/attachments/${i}`, name: f.name, image: /^image\//.test(String(f.mime || '')) }));
++ const files = (m.attachments || []).map((f, i) => ({ href: `/api/v1/tasks/${encodeURIComponent(row.id)}/messages/${encodeURIComponent(m.id)}/attachments/${i}`, name: f.name, size: f.size, image: /^image\//.test(String(f.mime || '')) }));
const mine = { text: m.n === 1 ? (m.prompt || 'Pick up the plan') : m.prompt, caption: m.n === 1 ? 'Hand-over brief' : '', files };
const id = `m${Number(m.n) || out.length + 1}`;
if (m.state === 'running' || (live && m === msgs[msgs.length - 1] && m.state !== 'done')) {
@@@ -382,11 +402,16 @@ function createChatRoutes(ctx) {
function effortPicker(selected = '', cls = '') {
return `<label class="ch-pick${cls ? ` ${cls}` : ''}" title="How hard it thinks"><span class="ch-sr">Effort</span>${ico('sub')}<select name="effort" data-ch-effort data-ov-select="bare" data-ov-title="Effort">${EFFORT.map(([v, l]) => `<option value="${v}"${selected === v ? ' selected' : ''}>${l}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
}
++ /** Any file (not a program) up to 10 at once: the script adds chips and progress, the plain form posts multipart. */
++ function filePicker() {
++ return `<label class="ch-pick ch-pick-file ch-for-agents" title="Attach files">${ico('attach')}<span class="ch-sr">Attach files</span><input type="file" name="files" multiple data-ch-files></label>`;
++ }
/** The kit's composer with Codes' pickers in its tools, where the work runs under the bar, and its JSON route. */
-- function composer({ action, api = '', placeholder, pickers = '', live = false, stopAction = '', note = '', id = null, where = '' }) {
++ function composer({ action, api = '', placeholder, pickers = '', live = false, stopAction = '', note = '', id = null, where = '', files = false }) {
return thread.composer({
-- action, id, placeholder, label: placeholder, tools: pickers, foot: where, note,
-- attrs: api ? { 'data-ch-api': api } : {},
++ action, id, placeholder, label: placeholder, tools: pickers + (files ? filePicker() : ''),
++ foot: `${files ? '<div class="ch-files" data-ch-files-list role="list" aria-live="polite"></div>' : ''}${where}`, note,
++ attrs: { ...(api ? { 'data-ch-api': api } : {}), ...(files ? { enctype: 'multipart/form-data' } : {}) },
busy: live, stopAction: live && stopAction ? stopAction : null, maxlength: 16000,
});
}
@@@ -536,7 +561,7 @@ function createChatRoutes(ctx) {
const hello = `<div class="ch-hello"><span class="ov-mark" data-size="40" aria-hidden="true"></span><h1>${name ? `What should we build, ${esc(name)}?` : 'What should we build?'}</h1><p>Say it the way you would to a friend.</p>${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}${modes}</div>`;
return frame(req, res, {
title: 'New chat', heading: 'New chat', start: true, empty: hello,
-- composerHtml: composer({ action: '/chat', id: 'ch-start', placeholder: mode === 'new' ? 'Describe your website, game or app' : 'What should change?', pickers, where }) + nudge + ideas,
++ composerHtml: composer({ action: '/chat', id: 'ch-start', placeholder: mode === 'new' ? 'Describe your website, game or app' : 'What should change?', pickers, where, files: true }) + nudge + ideas,
});
});
@@@ -562,7 +587,19 @@ function createChatRoutes(ctx) {
if (!text) return back('Write what you want made or changed first.');
// Something new: OpenVibe AI builds it on openvibe.space. The words go there in the box; nothing is built
// until the person presses Build, so a handed-over description is never spent behind their back.
-- if (mode === 'new') return res.redirect(303, start.spaceHref({ kind: b.kind, dim: b.dim, text }));
++ if (mode === 'new') {
++ const carrying = (Array.isArray(req.files) && req.files.length) || b.attachment_ids;
++ if (carrying) return back('Files go with a task on your code or an OpenVibe site. Something new starts in OpenVibe AI, where you can add them there.');
++ return res.redirect(303, start.spaceHref({ kind: b.kind, dim: b.dim, text }));
++ }
++ // The files this message carries: uploaded before submit by the page's script, or posted with the form now
++ // (no JavaScript). A limit or a program is refused before any task is started.
++ let ids = [];
++ try {
++ ids = await attachmentIds(req);
++ // Check the whole message now, so an over-limit upload is a sentence in the composer, not a 500.
++ if (ids.length && attachments) await attachments.pending(req.viewer.subject, ids);
++ } catch (e) { return back(String((e && e.message) || 'the files could not be attached').slice(0, 300)); }
let repoUrl = '';
let targets = null; // several OpenVibe projects for one request: one task each, the first one opens
if (mode === 'openvibe') {
@@@ -600,12 +637,12 @@ function createChatRoutes(ctx) {
for (const [i, x] of targets.entries()) {
const note = `\n\n(This request spans ${names.join(', ')}. This task is the ${names[i]} part; the others are separate tasks. Change only what this repository needs, and keep the names and behaviour consistent with the other parts.)`;
const each = parseArgs(jobInput(x.url, `${text}${note}`.slice(0, 16000)));
-- const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args: each });
++ const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args: each, attachmentIds: i === 0 ? ids : [] });
if (!first) first = row;
}
return res.redirect(303, `/tasks/${encodeURIComponent(first.id)}`);
}
-- const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args });
++ const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args, attachmentIds: ids });
return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}`);
});
@@@ -624,15 +661,14 @@ function createChatRoutes(ctx) {
const view = await conductor.view(row);
const last = await db.maybe('SELECT max(seq) AS seq FROM codes_session_lines WHERE task_id = $1', [row.id]);
// The agent the next turn runs on: the conductor's own, or the fallback its usage moves it to.
-- const next = (view.usage && view.usage.next) || { agent: 'claude-code', model: row.model || 'opus' };
++ const next = (view.usage && view.usage.next) || { agent: row.agent || 'claude-code', model: row.model };
const nextAgent = agentOf(next.agent);
-- const model = String(next.model || (next.agent === 'claude-code' ? row.model || 'opus' : 'its default model'));
-- const pickers = `<span class="ch-chip ch-chip-static" title="${esc(`The next turn runs ${nextAgent.name} on your machine`)}">${avatar(nextAgent)}${esc(model)}</span><label class="ch-pick ch-pick-file" title="Attach an image">${ico('attach')}<span class="ch-sr">Attach an image</span><input type="file" accept="image/*" multiple data-ch-images></label>`;
++ const pickers = `<span class="ch-chip ch-chip-static" title="${esc(`The next turn runs ${nextAgent.name} on your machine`)}">${avatar(nextAgent)}${esc(String(next.model || 'its default model'))}</span>` + whoPicker(`${next.agent}|${next.model || ''}`, { label: 'Who leads this task', ready: await readyFor(req), catalog: await aiModels() });
return frame(req, res, {
currentId: row.id, title: 'Conductor', heading: titleOf(row), state, panel: await sideHtml(row, view),
turns: [...errorTurns, ...await conductorThread(row, view)],
streamUrl: `/api/v1/tasks/${encodeURIComponent(row.id)}/events?last_event_id=${last && last.seq != null ? Number(last.seq) : 0}`,
-- composerHtml: row.outcome === 'stopped' ? '' : composer({ action: `/tasks/${encodeURIComponent(row.id)}/messages`, api: `/api/v1/conductor/${encodeURIComponent(row.id)}/messages`, placeholder: live ? 'Say something: it is the next turn' : 'What next?', pickers, note: live ? 'A message waits until the current turn ends; it is not lost.' : '' }),
++ composerHtml: row.outcome === 'stopped' ? '' : composer({ action: `/tasks/${encodeURIComponent(row.id)}/messages`, api: `/api/v1/conductor/${encodeURIComponent(row.id)}/messages`, placeholder: live ? 'Say something: it is the next turn' : 'What next?', pickers, files: true, note: live ? 'A message waits until the current turn ends; it is not lost.' : '' }),
});
}
// The stream starts after the last line this page already shows.
@@@ -646,7 +682,7 @@ function createChatRoutes(ctx) {
composerHtml: row.outcome === 'stopped' ? '' : composer({
action: `/tasks/${encodeURIComponent(row.id)}/continue`, api: `/api/v1/tasks/${encodeURIComponent(row.id)}/continue`, live,
stopAction: `/tasks/${encodeURIComponent(row.id)}/stop`,
-- placeholder: live ? 'Wait for this turn, or stop it' : 'Ask for a change, or tell it what to do next', pickers,
++ placeholder: live ? 'Wait for this turn, or stop it' : 'Ask for a change, or tell it what to do next', pickers, files: true,
}),
});
});
@@@ -664,7 +700,14 @@ function createChatRoutes(ctx) {
// The same agent with another model carries on; another agent takes over.
const agent = who.agent && who.agent !== row.agent ? who.agent : null;
const effort = ['low', 'medium', 'high', 'xhigh', 'max'].includes(b.effort) ? b.effort : null;
-- const out = await tasks.continueTask(row, { note: String(b.text || '').slice(0, 4000), agent, model: who.model, effort });
++ let ids = [];
++ try {
++ ids = await attachmentIds(req);
++ if (ids.length && attachments) await attachments.pending(req.viewer.subject, ids);
++ } catch (e) {
++ return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}?error=${encodeURIComponent(String((e && e.message) || 'the files could not be attached').slice(0, 300))}`);
++ }
++ const out = await tasks.continueTask(row, { note: String(b.text || '').slice(0, 4000), agent, model: who.model, effort, attachmentIds: ids });
return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}${out.error ? `?error=${encodeURIComponent(out.error)}` : ''}`);
});
ddiff --git a/server/http/conductor-api.js b/server/http/conductor-api.js
iindex 737b1cc..5a7b4c7 100644
---- a/server/http/conductor-api.js
++++ b/server/http/conductor-api.js
@@@ -1,17 +1,18 @@
'use strict';
/**
-- * The conductor's API (Codes C9b), under /api/v1. A conductor is one long-lived Claude Code session on a machine the
-- * person chose; a turn is a message, and each turn resumes the session. The approve MCP tool (bin/mcp.js) and
++ * The conductor's API (Codes C9b), under /api/v1. A conductor is one long-lived agent session on a machine the person
++ * chose (their "Who leads your tasks" choice, Claude Code on Opus by default); a turn is a message, and each turn
++ * resumes the session. The approve MCP tool (bin/mcp.js) and
* `openvibe-codes mcp` speak these routes with the person's own Network token.
*
-- * POST /conductor create it (folder, machine?, model?, safety?, note?)
++ * POST /conductor create it (folder, machine?, agent?, model?, safety?, note?, attachment_ids?)
* GET /conductor/:id the conductor, its one attempt and its turns
-- * POST /conductor/:id/messages a message: the next turn, queued while one runs
++ * POST /conductor/:id/messages a message: the next turn, queued while one runs (attachment_ids?, agent?)
* POST /conductor/:id/stop stop the running turn and everything queued
* POST /conductor/:id/tasks a sub-task on another harness (never Anthropic: see below)
* GET /tasks/:id/lines?limit=20 the last lines of a task's session (what `codes_task_show` prints)
-- * GET /tasks/:id/messages/:mid/attachments/:i one image, for the machine to write as a file
++ * GET /tasks/:id/messages/:mid/attachments/:i one attached file, for the machine to write in the workspace
* POST /tasks/:id/approvals the approve MCP asks the person (tool, input, summary)
* GET /tasks/:id/approvals/:aid?wait=1 the decision, long-polled
* GET /approvals the person's waiting approvals, across tasks
@@@ -147,9 +148,12 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,
try {
const made = await conductor.create({
subject: req.person.subject, project, folder, machine, pin: pin.device,
-- model: typeof b.model === 'string' && b.model ? b.model : 'opus',
++ // Who leads it: the picker's choice, else the person's "Who leads your tasks" setting.
++ agent: typeof b.agent === 'string' && b.agent ? b.agent : null,
++ ...(typeof b.model === 'string' ? { model: b.model } : {}),
safety: typeof b.safety === 'string' && b.safety ? b.safety : 'ask',
note: typeof b.note === 'string' ? b.note : null,
++ attachmentIds: Array.isArray(b.attachment_ids) ? b.attachment_ids : [],
});
log.info(`[Codes] conductor ${made.task.id} started for ${req.person.subject}`);
return res.status(201).json(await conductor.view(made.task));
@@@ -169,8 +173,19 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,
const row = await ownConductor(req, req.params.id);
if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such conductor of yours');
const b = req.body && typeof req.body === 'object' ? req.body : {};
++ // A per-task leader: the composer's picker ("who") changes this conversation, not the saved setting.
++ const who = typeof b.who === 'string' && b.who !== '' ? require('../data/models').parse(b.who, harnesses, { aiCatalog: !!(config.aiCatalog && config.aiCatalog.enabled) }) : null;
++ if (b.who && !who) return problem(req, res, 422, 'codes.agent_unknown', 'who is one of GET /api/v1/harnesses/models');
try {
-- const out = await conductor.message(row, { text: b.text, attachments: b.attachments, token: (req.person && req.person.token) || null });
++ const out = await conductor.message(row, {
++ text: b.text, attachments: b.attachments,
++ attachmentIds: Array.isArray(b.attachment_ids) ? b.attachment_ids : [],
++ ...(who && who.agent ? { agent: who.agent } : {}),
++ ...(who && who.agent ? { model: who.model || '' } : {}),
++ ...(!who && typeof b.agent === 'string' && b.agent ? { agent: b.agent } : {}),
++ ...(!who && typeof b.model === 'string' ? { model: b.model } : {}),
++ token: (req.person && req.person.token) || null,
++ });
return res.status(201).json({
message: conductor.presentMessage(out.message),
started: !!(out.started && out.started.job),
ddiff --git a/server/http/me-api.js b/server/http/me-api.js
iindex 089d546..e4c6eff 100644
---- a/server/http/me-api.js
++++ b/server/http/me-api.js
@@@ -10,6 +10,8 @@
* GET /me/tasks/:id → codes.task@1
* POST /me/tasks/:id/cancel → codes.task@1
* GET /me/repos?q=… → { repos: [{ full_name, url, … }] }: the Home picker's search (Codes C4b)
++ * GET /me/settings → { settings }: the person's choices, including who leads their tasks
++ * PUT /me/settings → { settings }: save any of them (a field left out keeps what it had)
*/
const express = require('express');
const { http, validate } = require('openvibe-contracts');
@@@ -21,7 +23,7 @@ const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const DELEGABLE = new Set(['owner', 'admin']);
--function createMeApi({ config, store, sso, run, harnesses, actorLimits, tasks = null, github = null, log = console }) {
++function createMeApi({ config, store, sso, run, harnesses, userSettings = null, actorLimits, tasks = null, github = null, log = console }) {
const r = asyncRouter();
const db = store.db;
const taskStore = tasks || createRunTasks({ store, run, config, log });
@@@ -102,6 +104,34 @@ function createMeApi({ config, store, sso, run, harnesses, actorLimits, tasks =
return res.status(201).json({ task: taskStore.present(row), created: true });
});
++ /**
++ * The person's choices over the API (owner 2026-11-07): the same row the Settings page reads, including the agent
++ * and model that lead their tasks (`conductor_agent`, `conductor_model`; empty means Claude Code on Opus). A field
++ * that is left out keeps what it had, as the page's form does.
++ */
++ r.get('/me/settings', actorLimits.reads('codes.read'), async (req, res) => {
++ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to read your settings');
++ if (!userSettings) return problem(req, res, 503, 'settings.unavailable', 'settings are not available here');
++ return res.json({ settings: await userSettings.get(req.viewer.subject) });
++ });
++
++ r.put('/me/settings', actorLimits.budget('codes.task.start'), json, async (req, res) => {
++ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to save your settings');
++ if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site JSON posts are refused');
++ if (!userSettings) return problem(req, res, 503, 'settings.unavailable', 'settings are not available here');
++ const b = req.body;
++ if (!b || typeof b !== 'object' || Array.isArray(b)) return problem(req, res, 422, 'settings.invalid', 'send an object of settings');
++ if (b.conductor_agent !== undefined) {
++ const v = b.conductor_agent == null ? null : String(b.conductor_agent);
++ if (v !== null && !harnesses.list().some((a) => a.id === v)) return problem(req, res, 422, 'settings.conductor_agent_unknown', 'conductor_agent is one of GET /api/v1/harnesses, or empty for Claude Code');
++ }
++ try {
++ return res.json({ settings: await userSettings.save(req.viewer.subject, b) });
++ } catch (e) {
++ return problem(req, res, e.status || 500, e.code || 'settings.failed', String((e && e.message) || 'the settings could not be saved'));
++ }
++ });
++
/**
* The Home composer's repository search (Codes C4b): the person's own repositories used here, first, then GitHub's
* public answer. Codes holds no GitHub account of a person, so the public search is what it can read; a GitHub that
ddiff --git a/server/http/moved.js b/server/http/moved.js
iindex 8af00c6..b03ae97 100644
---- a/server/http/moved.js
++++ b/server/http/moved.js
@@@ -21,7 +21,7 @@ const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing'
// own API (/me), the Action Center read (codes.actions.read), a person's own connections (Codes C3), the conductor
// with its approvals inbox (Codes C9b), the fleet their machines report (Codes C9c) and the workspace knowledge
// their own agents read and add (Know K5).
--const API_STAYS = /^\/api\/v1\/(harnesses|tasks|task\/[^/]+\/share|skill\/[^/]+\/share|skills|memory-store|share|shared-with-me|actions|me|connections|conductor|approvals|fleet|knowledge)(\/|$)/;
++const API_STAYS = /^\/api\/v1\/(harnesses|tasks|task\/[^/]+\/share|skill\/[^/]+\/share|skills|memory-store|share|shared-with-me|actions|me|connections|conductor|approvals|fleet|knowledge|attachments)(\/|$)/;
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
ddiff --git a/server/http/upload.js b/server/http/upload.js
nnew file mode 100644
iindex 0000000..208118c
---- /dev/null
++++ b/server/http/upload.js
@@@ -0,0 +1,83 @@
++'use strict';
++
++/**
++ * Multipart support for the composers (owner 2026-11-07): without JavaScript a form with files is a plain
++ * `multipart/form-data` post, and the server reads it here. The Web Request parser in Node (undici) does the boundary
++ * work; this module caps the bytes, turns the files into `{ name, type, buffer }`, and keeps the fields a form sends.
++ *
++ * readMultipart(req, { maxBytes }) → { fields: { name: value | [values] }, files: [{ name, type, buffer }] }
++ * readBody(req, { maxBytes }) → a raw body (the upload API's application/octet-stream)
++ * formBody({ urlencoded, maxBytes }) Express middleware: urlencoded or multipart, into req.body/req.files
++ */
++const express = require('express');
++
++const DEFAULT_MAX = 50 * 1024 * 1024;
++
++const tooLarge = () => Object.assign(new Error('the body is larger than this form accepts'), { status: 413, code: 'attachment.body_too_large' });
++
++/** Every byte of a request, stopping at the cap rather than trusting Content-Length. */
++async function readBytes(req, maxBytes = DEFAULT_MAX) {
++ const cap = Number(maxBytes) > 0 ? Number(maxBytes) : DEFAULT_MAX;
++ const known = Number(req.headers['content-length']);
++ if (Number.isFinite(known) && known > cap + 1024 * 1024) throw tooLarge();
++ const chunks = [];
++ let total = 0;
++ for await (const chunk of req) {
++ total += chunk.length;
++ if (total > cap + 1024 * 1024) throw tooLarge();
++ chunks.push(chunk);
++ }
++ return Buffer.concat(chunks);
++}
++
++const isMultipart = (req) => /^multipart\/form-data\b/i.test(String(req.headers['content-type'] || ''));
++
++/** A multipart body as its fields and files. */
++async function readMultipart(req, { maxBytes = DEFAULT_MAX } = {}) {
++ const type = String(req.headers['content-type'] || '');
++ if (!isMultipart(req)) throw Object.assign(new Error('that is not a multipart form'), { status: 415, code: 'attachment.content_type' });
++ const body = await readBytes(req, maxBytes);
++ let form;
++ try {
++ form = await new Request('http://codes.invalid/form', { method: 'POST', headers: { 'content-type': type }, body });
++ form = await form.formData();
++ } catch (e) {
++ throw Object.assign(new Error('the form could not be read'), { status: 400, code: 'attachment.bad_form' });
++ }
++ const fields = {};
++ const files = [];
++ const put = (key, value) => {
++ if (!(key in fields)) fields[key] = value;
++ else if (Array.isArray(fields[key])) fields[key].push(value);
++ else fields[key] = [fields[key], value];
++ };
++ for (const [key, value] of form.entries()) {
++ if (typeof value === 'string') { put(key, value); continue; }
++ if (!value || typeof value.arrayBuffer !== 'function') continue;
++ files.push({ name: String(value.name || 'file'), type: String(value.type || ''), buffer: Buffer.from(await value.arrayBuffer()) });
++ }
++ return { fields, files };
++}
++
++/** The body of an upload (application/octet-stream) as bytes. */
++const readBody = (req, { maxBytes = DEFAULT_MAX } = {}) => readBytes(req, maxBytes);
++
++/**
++ * One middleware for a page that takes both kinds of form: the ordinary urlencoded post (with or without JavaScript)
++ * and the multipart one the no-JavaScript composer sends. req.body is the fields; req.files is the files.
++ */
++function formBody({ urlencoded = express.urlencoded({ extended: false, limit: '1mb' }), maxBytes = DEFAULT_MAX } = {}) {
++ return (req, res, next) => {
++ if (!isMultipart(req)) return urlencoded(req, res, (err) => next(err));
++ readMultipart(req, { maxBytes }).then(({ fields, files }) => {
++ req.body = fields;
++ req.files = files;
++ next();
++ }, (err) => {
++ if (err && err.status) { res.status(err.status).type('text/plain').send(`${err.message}\n`); return; }
++ next(err);
++ });
++ };
++}
++
++module.exports = { readMultipart, readBody, readBytes, formBody, isMultipart, DEFAULT_MAX };
ddiff --git a/test/attachments.test.js b/test/attachments.test.js
nnew file mode 100644
iindex 0000000..85a4337
---- /dev/null
++++ b/test/attachments.test.js
@@@ -0,0 +1,251 @@
++'use strict';
++
++/**
++ * Full file support in every composer (owner 2026-11-07): any file that is not a program, at most 10 files, 20 MB each
++ * and 50 MB per message; images, PDFs, text and code, zips. The page's script uploads first through
++ * /api/v1/attachments (a chip, a thumbnail, progress, a ×); without JavaScript the same form posts multipart. Every
++ * file is owner-only, listed under its message, and written for the agent under
++ * .openvibe/attachments/<message>/<name>, named in the turn's prompt.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++const { runJob } = require('../harness/job');
++
++const FILE = /^fil_[0-9A-HJKMNP-TV-Z]{26}$/;
++const MSG = /^msg_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++function multipart(fields, files) {
++ const boundary = `----ovtest${Date.now()}${Math.random().toString(36).slice(2)}`;
++ const parts = [];
++ for (const [key, value] of Object.entries(fields || {})) {
++ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${value}\r\n`));
++ }
++ for (const file of files || []) {
++ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${file.field || 'files'}"; filename="${file.name}"\r\nContent-Type: ${file.type || 'application/octet-stream'}\r\n\r\n`));
++ parts.push(file.buffer);
++ parts.push(Buffer.from('\r\n'));
++ }
++ parts.push(Buffer.from(`--${boundary}--\r\n`));
++ return { body: Buffer.concat(parts), headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } };
++}
++
++(async () => {
++ const calls = [];
++ let n = 0;
++ const run = {
++ enabled: true,
++ async submit(o) { n++; calls.push(o); return { id: `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2L${String(n).padStart(2, '0')}`, state: 'queued' }; },
++ async read(jobId) { return { id: jobId, state: 'queued', result: null }; },
++ async cancel(jobId) { return { id: jobId, state: 'cancelled' }; },
++ async streamUrl() { throw new Error('not used'); },
++ };
++ const t = await boot({ run });
++ const ada = t.network.addUser('ada-files');
++ const bo = t.network.addUser('bo-files');
++ const db = t.ctx.store.db;
++ const upload = (name, buffer, type = 'application/octet-stream', as = ada) => t.get('/api/v1/attachments', {
++ as, method: 'POST', body: buffer, headers: { 'content-type': type, 'x-file-name': name },
++ });
++ let taskId = null;
++
++ try {
++ await check('a text file, a PDF and a zip go up; Codes says what the bytes are, not the client', async () => {
++ const txt = await upload('notes.txt', Buffer.from('the plan\nsecond line\n'));
++ assert.strictEqual(txt.status, 201, txt.text);
++ const one = txt.json().attachment;
++ assert.ok(FILE.test(one.id));
++ assert.strictEqual(one.name, 'notes.txt');
++ assert.strictEqual(one.mime, 'text/plain');
++ assert.strictEqual(one.size, 21);
++ assert.strictEqual(one.image, false);
++ const pdf = await upload('brief.pdf', Buffer.from('%PDF-1.4\n...'));
++ assert.strictEqual(pdf.json().attachment.mime, 'application/pdf');
++ const zip = await upload('code.zip', Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.from('rest')]));
++ assert.strictEqual(zip.json().attachment.mime, 'application/zip');
++ // The same bytes with an image MIME claimed: the bytes decide.
++ const lie = await upload('not-a-picture.png', Buffer.from('plain words'), 'image/png');
++ assert.strictEqual(lie.json().attachment.mime, 'text/plain');
++ // Another person's upload is theirs alone.
++ assert.strictEqual((await upload('secret.txt', Buffer.from('mine'), 'text/plain', bo)).status, 201);
++ assert.strictEqual((await t.get(`/api/v1/attachments/${one.id}`, { as: bo })).status, 404);
++ });
++
++ await check('programs are refused by extension and by magic bytes, and an empty file is refused', async () => {
++ assert.strictEqual((await upload('setup.exe', Buffer.from('MZ fake'))).status, 422, 'by its extension');
++ assert.strictEqual((await upload('innocent.txt', Buffer.from('MZ\x90\x00'))).status, 422, 'a PE renamed .txt is still a PE');
++ assert.strictEqual((await upload('run.sh', Buffer.from('#!/bin/sh\nrm -rf /\n'))).status, 422, 'a shebang script');
++ const elf = await upload('data.bin', Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46]), Buffer.from('ELF')]));
++ assert.strictEqual(elf.status, 422, 'an ELF with a harmless-looking name');
++ assert.strictEqual((await upload('empty.txt', Buffer.alloc(0))).status, 422);
++ });
++
++ await check('one file is at most 20 MB, and 10 files / 50 MB is the message cap', async () => {
++ const big = await upload('big.dat', Buffer.alloc(20 * 1024 * 1024 + 1));
++ assert.strictEqual(big.status, 413);
++ // Three 18 MB records: the bytes are not needed to test the sum, only the sizes the rows carry.
++ const ids = [];
++ for (let i = 0; i < 3; i++) {
++ const id = t.ctx.store.newId('fil');
++ await db.exec("INSERT INTO codes_attachments (id, subject, name, mime, size, data, created_at) VALUES ($1, $2, $3, 'application/octet-stream', $4, $5, now())",
++ [id, ada.subject, `large-${i}.dat`, 18 * 1024 * 1024, Buffer.from([0])]);
++ ids.push(id);
++ }
++ const posted = await t.get('/chat', { as: ada, form: { mode: 'code', repo: 'https://github.com/OpenVibers/OpenVibe.Codes', text: 'read these', attachment_ids: ids } });
++ assert.strictEqual(posted.status, 303);
++ assert.match(String(posted.headers.get('location')), /error=.*50%20MB/, 'refused in words, no task started');
++ await db.exec('DELETE FROM codes_attachments WHERE id = ANY($1::text[])', [ids]);
++ // At most ten: eleven tiny files are refused too.
++ const many = [];
++ for (let i = 0; i < 11; i++) many.push((await upload(`loose-${i}.txt`, Buffer.from(`file ${i}`))).json().attachment.id);
++ const tooMany = await t.get('/chat', { as: ada, form: { mode: 'code', repo: 'https://github.com/OpenVibers/OpenVibe.Codes', text: 'many', attachment_ids: many } });
++ assert.strictEqual(tooMany.status, 303);
++ assert.match(String(tooMany.headers.get('location')), /error=.*at%20most%2010%20files/);
++ for (const id of many) await t.get(`/api/v1/attachments/${id}`, { as: ada, method: 'DELETE' });
++ });
++
++ await check('with JavaScript the files are already up, and the form names their ids', async () => {
++ const up = await upload('from-script.txt', Buffer.from('uploaded first\n'));
++ assert.strictEqual(up.status, 201, up.text);
++ const id = up.json().attachment.id;
++ const made = await t.get('/chat', { as: ada, form: { mode: 'code', repo: 'https://github.com/OpenVibers/OpenVibe.Codex', text: 'Read the uploaded file.', who: 'codex|', attachment_ids: id } });
++ assert.strictEqual(made.status, 303, made.text.slice(0, 300));
++ const task = String(made.headers.get('location') || '').split('/').pop();
++ const sub = calls.at(-1).args;
++ assert.strictEqual(sub.attachments.length, 1);
++ assert.strictEqual(sub.attachments[0].name, 'from-script.txt');
++ assert.match(sub.prompt, new RegExp(`\\.openvibe/attachments/${sub.attachments[0].dir}/from-script\\.txt`));
++ const row = await db.maybe('SELECT * FROM codes_attachments WHERE id = $1', [id]);
++ assert.strictEqual(row.task_id, task);
++ assert.strictEqual(Number(row.attempt_n), 1);
++ });
++
++ await check('without JavaScript a multipart start form carries the files, lists them, and names them in the prompt', async () => {
++ const mp = multipart({ mode: 'code', repo: 'https://github.com/OpenVibers/OpenVibe.Codes', text: 'Read the plan and the screenshot.', who: 'codex|' }, [
++ { name: 'plan.md', type: 'text/markdown', buffer: Buffer.from('# Plan\ndo the thing\n') },
++ { name: 'shot.png', type: 'image/png', buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]) },
++ ]);
++ const made = await t.get('/chat', { as: ada, method: 'POST', body: mp.body, headers: mp.headers });
++ assert.strictEqual(made.status, 303, made.text.slice(0, 300));
++ taskId = String(made.headers.get('location') || '').split('/').pop();
++ const row = await db.one('SELECT * FROM run_tasks WHERE id = $1', [taskId]);
++ assert.strictEqual(row.prompt, 'Read the plan and the screenshot.', 'the task keeps the person’s words');
++ const sub = calls.at(-1).args;
++ assert.strictEqual(sub.attachments.length, 2);
++ assert.strictEqual(sub.attachments[0].name, 'plan.md');
++ assert.match(sub.attachments[0].url, /\/api\/v1\/attachments\/fil_/);
++ const folder = sub.attachments[0].dir;
++ assert.ok(MSG.test(folder), `the message folder is a message id (${folder})`);
++ assert.match(sub.prompt, new RegExp(`\\.openvibe/attachments/${folder}/plan\\.md`), 'the prompt says where the file is');
++ assert.match(sub.prompt, /\.openvibe\/attachments\/.+shot\.png/);
++ const rows = await db.many('SELECT * FROM codes_attachments WHERE task_id = $1 ORDER BY name', [taskId]);
++ assert.strictEqual(rows.length, 2);
++ assert.strictEqual(Number(rows[0].attempt_n), 1);
++ assert.strictEqual(rows[0].message_id, folder);
++ assert.strictEqual(rows[0].mime, 'text/markdown');
++ assert.strictEqual(rows[1].mime, 'image/png');
++ const page = await t.get(`/tasks/${taskId}`, { as: ada });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('plan.md') && page.text.includes('shot.png'), 'listed on the message');
++ assert.match(page.text, /href="\/api\/v1\/attachments\/fil_/);
++ // Owner-only: another person cannot read the bytes, and the attachment is bound to its message.
++ const bytes = await t.get(`/api/v1/attachments/${rows[0].id}`, { as: ada });
++ assert.strictEqual(bytes.status, 200);
++ assert.strictEqual(bytes.buffer.toString(), '# Plan\ndo the thing\n');
++ assert.strictEqual((await t.get(`/api/v1/attachments/${rows[0].id}`, { as: bo })).status, 404);
++ // The agent on the person's machine reads it with their Network token, not a browser session.
++ const byToken = await t.get(`/api/v1/attachments/${rows[0].id}`, { bearer: t.network.userToken(ada) });
++ assert.strictEqual(byToken.status, 200);
++ assert.strictEqual(byToken.buffer.toString(), '# Plan\ndo the thing\n');
++ assert.strictEqual((await t.get(`/api/v1/attachments/${rows[0].id}`, { bearer: t.network.userToken(bo) })).status, 404);
++ assert.strictEqual((await t.get(`/api/v1/attachments/${rows[0].id}`, { as: ada, method: 'DELETE' })).status, 409);
++ });
++
++ await check('the composers offer files without JavaScript too: a multipart form and a file input', async () => {
++ const home = await t.get('/', { as: ada });
++ assert.strictEqual(home.status, 200);
++ assert.match(home.text, /<form class="ovt-composer" id="ch-start"[^>]*enctype="multipart\/form-data"/, 'the start form posts multipart');
++ assert.match(home.text, /<input type="file" name="files" multiple data-ch-files>/, 'and has the file input');
++ assert.match(home.text, /data-ch-files-list/, 'with the chips list the script fills');
++ const task = await t.get(`/tasks/${taskId}`, { as: ada });
++ assert.match(task.text, /enctype="multipart\/form-data"/, 'the reply composer too');
++ assert.match(task.text, /data-ch-files/);
++ });
++
++ await check('a multipart reply carries its own files on the next turn', async () => {
++ // The first turn ended before the person writes again: the test's Run never sends that event.
++ await db.exec("UPDATE run_tasks SET state = 'succeeded', updated_at = $2 WHERE id = $1", [taskId, Date.now()]);
++ await db.exec("UPDATE codes_attempts SET state = 'done', finished_at = now() WHERE task_id = $1", [taskId]);
++ const before = calls.length;
++ const mp = multipart({ text: 'Now the second file.' }, [
++ { name: 'next.md', type: 'text/markdown', buffer: Buffer.from('next steps\n') },
++ ]);
++ const r = await t.get(`/tasks/${taskId}/continue`, { as: ada, method: 'POST', body: mp.body, headers: mp.headers });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ assert.strictEqual(calls.length, before + 1);
++ const sub = calls.at(-1).args;
++ assert.strictEqual(sub.attachments.length, 1);
++ assert.strictEqual(sub.attachments[0].name, 'next.md');
++ const folder = sub.attachments[0].dir;
++ assert.match(sub.prompt, new RegExp(`\\.openvibe/attachments/${folder}/next\\.md`));
++ const row = await db.maybe('SELECT * FROM codes_attachments WHERE task_id = $1 AND attempt_n = 2', [taskId]);
++ assert.ok(row && row.name === 'next.md');
++ const page = await t.get(`/tasks/${taskId}`, { as: ada });
++ assert.ok(page.text.includes('next.md'));
++ });
++
++ await check('the agent gets the files in its workspace and the prompt names each path', async () => {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-codes-files-'));
++ const server = http.createServer((_req, res) => { res.setHeader('content-type', 'text/plain'); res.end('the attached words\n'); });
++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
++ const url = `http://127.0.0.1:${server.address().port}/file`;
++ let seen = null;
++ const harness = {
++ run: async function* ({ prompt, cwd }) {
++ seen = { prompt, cwd };
++ const target = path.join(cwd, '.openvibe', 'attachments', 'msg_01J8Z3Q4R5S6T7V8W9X0Y1Z2M1', 'my notes.txt');
++ assert.ok(fs.existsSync(target), `the file is in the workspace (${target})`);
++ assert.strictEqual(fs.readFileSync(target, 'utf8'), 'the attached words\n');
++ yield { type: 'system', subtype: 'init', agent: 'codex', session_id: 'sess-file' };
++ yield { type: 'result', subtype: 'success', is_error: false, result: 'read it' };
++ },
++ };
++ try {
++ const input = JSON.stringify({
++ kind: 'conductor', folder: dir, prompt: 'Read the file.', agent: 'codex', safety: 'ask',
++ attachments: [{ name: 'my notes.txt', mime: 'text/plain', url, dir: 'msg_01J8Z3Q4R5S6T7V8W9X0Y1Z2M1' }],
++ });
++ const result = await runJob({ input, harness, emit() {}, writeResult() {}, env: {}, tmp: os.tmpdir() });
++ assert.strictEqual(result.ok, true, JSON.stringify(result));
++ assert.ok(seen && seen.cwd === dir);
++ assert.match(seen.prompt, /\.openvibe\/attachments\/msg_01J8Z3Q4R5S6T7V8W9X0Y1Z2M1\/my notes\.txt/);
++ assert.match(seen.prompt, /Files the owner attached/);
++ } finally {
++ await new Promise((resolve) => server.close(resolve));
++ fs.rmSync(dir, { recursive: true, force: true });
++ }
++ });
++
++ await check('GET/PUT /api/v1/me/settings carries who leads your tasks, and a made-up agent is refused', async () => {
++ const before = await t.get('/api/v1/me/settings', { as: ada });
++ assert.strictEqual(before.status, 200, before.text);
++ assert.strictEqual(before.json().settings.conductor_agent, 'claude-code');
++ assert.strictEqual(before.json().settings.conductor_model, 'opus');
++ const saved = await t.get('/api/v1/me/settings', { as: ada, method: 'PUT', json: { conductor_agent: 'codex', conductor_model: 'gpt-5.5' } });
++ assert.strictEqual(saved.status, 200, saved.text);
++ assert.strictEqual(saved.json().settings.conductor_agent, 'codex');
++ assert.strictEqual(saved.json().settings.conductor_model, 'gpt-5.5');
++ const read = await t.get('/api/v1/me/settings', { as: ada });
++ assert.strictEqual(read.json().settings.conductor_agent, 'codex');
++ const bad = await t.get('/api/v1/me/settings', { as: ada, method: 'PUT', json: { conductor_agent: 'made-up' } });
++ assert.strictEqual(bad.status, 422);
++ assert.strictEqual(bad.json().code, 'settings.conductor_agent_unknown');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/conductor-failover.test.js b/test/conductor-failover.test.js
iindex 382c152..2874cd4 100644
---- a/test/conductor-failover.test.js
++++ b/test/conductor-failover.test.js
@@@ -213,7 +213,8 @@ const HOUR = 3600_000;
assert.strictEqual(job.parseArgs(JSON.stringify({ ...base, agent: 'codex', safety: 'ask' }), {}).permission, 'edit');
assert.strictEqual(job.parseArgs(JSON.stringify({ ...base, agent: 'opencode', safety: 'full' }), {}).permission, 'full');
assert.strictEqual(job.parseArgs(JSON.stringify({ ...base, agent: 'claude-code', safety: 'full' }), {}).permission, 'edit', 'Claude Code keeps its own permission modes');
-- assert.strictEqual(job.parseArgs(JSON.stringify({ ...base, agent: 'aider' }), {}).agent, 'claude-code');
++ assert.strictEqual(job.parseArgs(JSON.stringify({ ...base, agent: 'aider' }), {}).agent, 'aider', 'every harness may lead');
++ assert.strictEqual(job.parseArgs(JSON.stringify({ ...base, agent: 'not-a-harness' }), {}).agent, 'claude-code');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-codes-failover-'));
try {
const parsed = job.parseArgs(JSON.stringify({ ...base, agent: 'codex' }), {});
ddiff --git a/test/conductor.test.js b/test/conductor.test.js
iindex 00caf23..d032e17 100644
---- a/test/conductor.test.js
++++ b/test/conductor.test.js
@@@ -195,20 +195,21 @@ const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
await check('the start form: a model list the login offers, and the folders the machine has worked in', async () => {
const res = await get('/conductor');
assert.strictEqual(res.status, 200);
-- assert.match(res.text, /<select name="model" data-c4-model>/, 'the model is a list');
-- for (const label of ['Opus 5.5 (default)', 'Sonnet 5.5', 'Haiku 5.5', 'Other…']) assert.ok(res.text.includes(label), `${label} is offered`);
++ assert.match(res.text, /<select name="who" data-c4-model>/, 'who leads is a list');
++ for (const label of ['Claude Code · Opus', 'Claude Code · Sonnet', 'Codex', 'OpenCode', 'DeepSeek']) assert.ok(res.text.includes(label), `${label} is offered`);
assert.match(res.text, /data-c4-folder-pick/, 'the recent folders are a choice beside the typed path');
assert.ok(res.text.includes(`data-machine="${NODE}"`), 'this conductor&rsquo;s folder is one, on its machine');
assert.ok(res.text.includes('/home/ada/openvibe'), 'and it is the folder this person has used');
});
-- await check('a recent folder and a model the person named start a conductor', async () => {
-- const res = await t.get('/conductor', { as: ada, form: { folder: '', folder_pick: '/home/ada/openvibe', machine: NODE, model: 'other', model_other: 'claude-opus-5-5', safety: 'ask' } });
++ await check('a recent folder and a leader the person picked start a conductor', async () => {
++ const res = await t.get('/conductor', { as: ada, form: { folder: '', folder_pick: '/home/ada/openvibe', machine: NODE, who: 'codex|gpt-5.5', safety: 'ask' } });
assert.strictEqual(res.status, 303, res.text);
const id = decodeURIComponent(String(res.headers.get('location') || '').split('/').pop());
const row = await rowOf(id);
assert.strictEqual(row.folder, '/home/ada/openvibe', 'the picked folder');
-- assert.strictEqual(row.model, 'claude-opus-5-5', 'the model behind Other…');
++ assert.strictEqual(row.agent, 'codex', 'the leader the person picked');
++ assert.strictEqual(row.model, 'gpt-5.5', 'and its model');
await post(`/api/v1/conductor/${id}/stop`, {});
});
@@@ -220,6 +221,49 @@ const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
assert.match(res.text, /What next\?|Say something/, 'the box for the next turn');
});
++ await check('who leads your tasks: the saved setting starts a conductor, and a message can change just this one', async () => {
++ await t.ctx.userSettings.save(ada.subject, { conductor_agent: 'codex', conductor_model: 'gpt-5.5' });
++ const made = await post('/api/v1/conductor', { folder: '/home/ada/openvibe', machine: NODE, safety: 'ask', note: 'lead with codex' });
++ assert.strictEqual(made.status, 201, made.text);
++ const id = made.json().task.id;
++ const row = await rowOf(id);
++ assert.strictEqual(row.agent, 'codex', 'the setting leads the new conductor');
++ assert.strictEqual(row.model, 'gpt-5.5');
++ const submit = submits().at(-1)[1];
++ assert.strictEqual(submit.args.agent, 'codex');
++ assert.strictEqual(submit.args.model, 'gpt-5.5');
++ // The composer's picker changes this conversation, not the saved setting.
++ const next = await post(`/api/v1/conductor/${id}/messages`, { text: 'lead this one too', who: 'opencode|' });
++ assert.strictEqual(next.status, 201, next.text);
++ const retargeted = await rowOf(id);
++ assert.strictEqual(retargeted.agent, 'opencode');
++ assert.strictEqual(retargeted.model, null);
++ const still = await t.ctx.userSettings.get(ada.subject);
++ assert.strictEqual(still.conductor_agent, 'codex', 'the setting itself did not move');
++ await post(`/api/v1/conductor/${id}/stop`, {});
++ });
++
++ await check('a conductor’s first turn takes the files uploaded before it was made', async () => {
++ const up = await t.get('/api/v1/attachments', { as: ada, method: 'POST', body: Buffer.from('the hand-over notes\n'), headers: { 'content-type': 'text/plain', 'x-file-name': 'notes.txt' } });
++ assert.strictEqual(up.status, 201, up.text);
++ const file = up.json().attachment;
++ const made = await post('/api/v1/conductor', { folder: '/home/ada/openvibe', machine: NODE, agent: 'codex', attachment_ids: [file.id] });
++ assert.strictEqual(made.status, 201, made.text);
++ const id = made.json().task.id;
++ assert.strictEqual(made.json().messages[0].attachments.length, 1);
++ assert.strictEqual(made.json().messages[0].attachments[0].name, 'notes.txt');
++ const stored = await db.maybe('SELECT * FROM codes_attachments WHERE id = $1', [file.id]);
++ assert.strictEqual(stored.task_id, id);
++ assert.strictEqual(stored.message_id, made.json().messages[0].id);
++ const submit = submits().at(-1)[1];
++ assert.strictEqual(submit.args.attachments.length, 1);
++ assert.strictEqual(submit.args.attachments[0].name, 'notes.txt');
++ assert.match(submit.args.attachments[0].url, /\/api\/v1\/tasks\/.+\/messages\/.+\/attachments\/0/);
++ const bytes = await t.ctx.conductor.attachment(id, made.json().messages[0].id, 0);
++ assert.strictEqual(bytes.buffer.toString(), 'the hand-over notes\n');
++ await post(`/api/v1/conductor/${id}/stop`, {});
++ });
++
await check('stopping a conductor stops its turn and its queue: nothing carries on', async () => {
const queued = await post(`/api/v1/conductor/${taskId}/messages`, { text: 'one more' });
assert.strictEqual(queued.status, 201, queued.text);