openvibers/openvibe.codes Commits

Codes C9b: the conductor (#184)

OP OpenVibers committed 14 days ago · 891f5bd0c1 · 28 files, +2519 −52

* Codes C9b: the conductor's turns, its queue and its approvals, in the domain

A conductor is a task whose one attempt is a long-lived Claude Code session on
the person's own machine, with their own plan login: no step or time limit, and
each message the person sends is the next turn of that same session.

- migrations/0014: the conductor columns on run_tasks, the message queue and the
  approvals table.
- conductor.js: create the task and its first attempt, queue a message while a
  turn runs, start the next turn (resuming the session), and end a turn without
  closing the attempt, since the same attempt outlives every turn.
- approvals.js and notify.js: a risky call is asked once, written, put in the
  person's inbox and pushed to their phone; the first answer wins.
- run-tasks and attempts: a conductor's terminal turn leaves it idle (succeeded,
  no outcome) with its attempt waiting, and a job-scoped end so a stale
  delivery cannot start a second turn.
- run-client: the limits a long turn needs (wall clock up to 90 minutes, memory).
- account-data: the two new task-keyed tables are exported and erased with the
  rest of a person's data.
- engine/fragments/conductor.md: the hand-over brief the first turn reads.

* Codes C9b: the conductor's API, and the person's token reaches it

- POST /conductor starts one; GET /conductor/:id is its state, turns and what
  needs the person; POST /conductor/:id/messages queues the next turn;
  POST /conductor/:id/stop ends it, answering what it was waiting on.
- POST /conductor/:id/tasks is how the conductor starts a sub-task: in its own
  project, as the person, and refused when the agent is an Anthropic one — the
  conductor is the only Anthropic session.
- approvals: the inbox, the single approval a tool waits on, and the decision.
  Phone clients long-poll the single one for up to 20 seconds.
- a person Bearer (what the machine's Codes login holds) is accepted next to the
  session cookie, so the MCP can act as the person it was started for.
- moved.js: /api/v1/me, /conductor and /approvals are Codes' own API, not
  redirects to openvibe.services (which also fixes /me/repos).

* Codes C9b: the task page a conductor needs, on the web and on the phone

- the conductor's page: its turns (the brief, then each message and what the
  turn answered), what needs the person (the command, and Allow / Deny), the
  last turn's lines read on the server between turns, the live transcript over
  the existing SSE, and the box that writes the next turn with an image.
- /conductor: a working folder, machine, model and safety level, what it should
  pick up first, and the conductors the person already has. The button comes
  after the note it submits, not between the fields.
- /approvals: everything the person's conductors are waiting on, in one place.
- Home: pending approvals count under Needs you, and a card to start one.
- the perf budget is raised for the stylesheet and script this adds, with the
  dates and the reason next to the numbers.

* Codes C9b: openvibe-codes mcp, and the job that runs a conductor's turn

- bin/mcp.js: a stdio MCP server with the conductor's tools — start, list, show,
  continue, stop a task; the fleet and each agent's limit windows; a pull
  request's CI and merge state; approve, the tool a risky call waits behind.
  It acts with the person's own token from the machine's Codes login, and it
  refuses an Anthropic agent for a sub-task: the rule is enforced here and by
  the server.
- harness/job.js: a conductor job opens the person's folder, writes the images
  a message carried, and starts Claude Code in the chosen permission mode with
  --mcp-config (the Codes tools, pointed at the task), --allowedTools mcp__codes
  and, when the person asks to be asked, --permission-prompt-tool
  mcp__codes__approve. Its result is a summary and the session, nothing else:
  the owner's folder is never patched, and nothing is pushed.
- the job reads the person's token from OPENVIBE_TOKEN or ~/.config/openvibe,
  and refuses one other people can read.
- adapters/cli.js takes those options; bin/task.js shares its helpers with the
  MCP.

* Codes C9b: the conductor, its queue, its MCP and its approvals, tested

- test/conductor.test.js: the kind's attempt shape and first turn; a message
  sent while a turn runs waits for it and is then the session's resumed turn; a
  turn that ends leaves the conductor idle; a failed turn leaves it unfinished;
  the Anthropic sub-task is refused and a sub-task lands in the conductor's own
  project without a push; stop; another person gets nothing; a Bearer acts as
  the person; the page's markup.
- test/approvals.test.js: the MCP's tools, an approval allowed and one denied,
  the notification, a stop that answers what waits, and a real job run against a
  stand-in claude that checks the flags and the MCP config.
- test/mcp.test.js: a real MCP child process against a real server for every
  tool, including the refusals, and codes_ship_status against a fake GitHub.

* Merge origin/main: the conductor meets the limits and the phone navigation

The conductor's turn is a Run job like any other, so it takes C9a's run-token
budget and the reason that ends an attempt at it; its messages and approvals are
exported and erased like everything else (account-data, ADR-033); its page keeps
C4c's one navigation and clear phone tabs.

The migration is renumbered 0016: 0014 and 0015 on main are the run token
budget and the person's limits.

Changes

ddiff --git a/bin/mcp.js b/bin/mcp.js
nnew file mode 100644
iindex 0000000..c255ef9
---- /dev/null
++++ b/bin/mcp.js
@@@ -0,0 +1,344 @@
++'use strict';
++
++/**
++ * `openvibe-codes mcp` — the Codes MCP server: the conductor's tools (Codes C9b). A stdio JSON-RPC server, started by
++ * the Claude Code session a conductor's turn runs, and usable from any Claude Code session with a Codes login.
++ *
++ * codes_task_start { prompt, repo, agent?, model? } a sub-task on one of the person's machines
++ * codes_task_list { state? } running | needs | done
++ * codes_task_show { id } state, attempts, outcome, the last lines
++ * codes_task_continue { id, message } carry a task on in the same conversation
++ * codes_task_stop { id } stop it; nothing carries it on
++ * codes_fleet {} each coding CLI here: installed, signed in, limit windows
++ * codes_ship_status { repo, pr } a pull request's CI and merge state, from GitHub
++ * codes_approve { tool_name, input?, summary? } what Claude Code asks when the owner's safety level is ask
++ *
++ * Who it acts as: the person whose Codes token is on the machine — OPENVIBE_TOKEN, or ~/.config/openvibe/token. The
++ * server address is OPENVIBE_CODES_URL. The conductor's task is OPENVIBE_CONDUCTOR_TASK: sub-tasks run in its project,
++ * and approvals are asked on its page.
++ *
++ * The rule the server enforces for the conductor (and for this client): a sub-task never runs on an Anthropic model.
++ * The conductor is the only Anthropic session; the server refuses claude-code, claude-other and any Anthropic model,
++ * and this client answers with that refusal rather than working around it.
++ *
++ * JSON-RPC over stdin/stdout, one message per line (the portal's approve-mcp.js shape).
++ */
++const os = require('os');
++const { auth, call } = require('./task');
++const { probe: probeLogins } = require('../engine/logins');
++
++const APPROVAL_TIMEOUT_MS = Math.max(10_000, Number(process.env.APPROVAL_TIMEOUT_MS) || 15 * 60_000);
++const POLL_MS = 2500;
++const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
++const REPO = /^https:\/\/[^\s@]+\/[^\s@]+$/;
++
++/** A repository as an https URL: owner/name, a URL, or a path whose origin is one. */
++function repoUrl(value) {
++ const v = String(value || '').trim();
++ if (!v) return null;
++ if (REPO.test(v)) return v.replace(/\.git$/, '');
++ if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(v)) return `https://github.com/${v.replace(/\.git$/, '')}`;
++ return null;
++}
++
++function githubRepo(value) {
++ const v = repoUrl(value) || String(value || '').trim();
++ const m = /^https?:\/\/(?:[^@/\s]+@)?(?:github\.com|api\.github\.com)\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec(v);
++ return m ? { owner: m[1], name: m[2] } : null;
++}
++
++/** The task the conductor is running as, when this server is inside one. */
++function conductorTask() {
++ const id = String(process.env.OPENVIBE_CONDUCTOR_TASK || '').trim();
++ return TASK.test(id) ? id : null;
++}
++
++const text = (value) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] });
++const failed = (message) => ({ content: [{ type: 'text', text: JSON.stringify({ ok: false, error: String(message).slice(0, 800) }) }], isError: true });
++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
++
++const TOOLS = [
++ {
++ name: 'codes_task_start',
++ description: 'Start a coding task on one of the owner\'s machines, in a repository. Never on an Anthropic model (the conductor is the only Anthropic session). The brief must stand alone: the goal, the constraints, the files, and what "done" means.',
++ inputSchema: {
++ type: 'object',
++ properties: {
++ prompt: { type: 'string', description: 'The brief, in full' },
++ repo: { type: 'string', description: 'owner/name or an https git URL' },
++ agent: { type: 'string', description: 'codex, opencode, command-code, deepseek, aider: never claude-code or claude-other' },
++ model: { type: 'string', description: 'The model for that agent (never an Anthropic one)' },
++ ref: { type: 'string', description: 'A branch or tag to start from' },
++ },
++ required: ['prompt', 'repo'],
++ additionalProperties: true,
++ },
++ },
++ {
++ name: 'codes_task_list',
++ description: 'The owner\'s tasks, newest first: running (queued, placed or running), needs (unfinished, waiting for a person) or done.',
++ inputSchema: { type: 'object', properties: { state: { type: 'string', enum: ['running', 'needs', 'done'], description: 'Narrow the list' }, limit: { type: 'number' } }, additionalProperties: true },
++ },
++ {
++ name: 'codes_task_show',
++ description: 'One task: its state, its attempts (agent, machine, why each ended), its outcome in plain words, and the last lines of its session.',
++ inputSchema: { type: 'object', properties: { id: { type: 'string' }, lines: { type: 'number' } }, required: ['id'], additionalProperties: true },
++ },
++ {
++ name: 'codes_task_continue',
++ description: 'Carry a task on in the same conversation, with a note on what to do next.',
++ inputSchema: { type: 'object', properties: { id: { type: 'string' }, message: { type: 'string' }, agent: { type: 'string' } }, required: ['id', 'message'], additionalProperties: true },
++ },
++ {
++ name: 'codes_task_stop',
++ description: 'Stop a task: its running attempt stops and nothing carries it on.',
++ inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'], additionalProperties: true },
++ },
++ {
++ name: 'codes_fleet',
++ description: 'The coding CLIs on this machine: installed, signed in, and their limit windows (read from the CLIs\' own files, read-only).',
++ inputSchema: { type: 'object', properties: {}, additionalProperties: true },
++ },
++ {
++ name: 'codes_ship_status',
++ description: 'A GitHub pull request\'s state: CI, merge state, title and branch. Read-only.',
++ inputSchema: { type: 'object', properties: { repo: { type: 'string' }, pr: { type: 'number', description: 'The pull request number' } }, required: ['repo', 'pr'], additionalProperties: true },
++ },
++ {
++ name: 'codes_approve',
++ description: 'Ask the owner to approve a tool call (Claude Code calls this; the owner answers on openvibe.codes or their phone). Returns {behavior: "allow"|"deny", updatedInput?, message?}.',
++ inputSchema: {
++ type: 'object',
++ properties: {
++ tool_name: { type: 'string', description: 'The tool that wants to run' },
++ input: { type: 'object', description: 'The arguments of that tool' },
++ summary: { type: 'string', description: 'A short line for the owner' },
++ },
++ required: ['tool_name'],
++ additionalProperties: true,
++ },
++ },
++];
++
++// ── The tools ───────────────────────────────────────────────────────────────────────────────────────────────────
++
++async function startTask(args) {
++ const task = conductorTask();
++ if (!task) return failed('codes_task_start needs OPENVIBE_CONDUCTOR_TASK: it starts sub-tasks of the conductor task');
++ const prompt = String(args.prompt || '').trim();
++ if (!prompt) return failed('a sub-task needs a prompt');
++ const repo = repoUrl(args.repo);
++ if (!repo) return failed('a sub-task needs the repository it works in: owner/name or an https git URL');
++ const agent = args.agent ? String(args.agent) : null;
++ const model = args.model ? String(args.model) : null;
++ if (agent && /^(claude-code|claude-other|claude|cm)$/i.test(agent)) return failed('the conductor may start sub-tasks on any agent except an Anthropic one: that session is the conductor itself');
++ if (model && /(^|[^a-z])(claude|opus|sonnet|haiku|anthropic)([^a-z]|$)/i.test(model)) return failed('the conductor may start sub-tasks on any agent except an Anthropic model: that session is the conductor itself');
++ const c = auth();
++ const answer = await call(c, 'POST', `/api/v1/conductor/${encodeURIComponent(task)}/tasks`, {
++ prompt, repo, ...(agent ? { agent } : {}), ...(model ? { model } : {}), ...(args.ref ? { ref: String(args.ref).slice(0, 100) } : {}),
++ });
++ const t = answer.task || {};
++ return text({
++ id: t.id, state: t.state, agent: t.agent, model: model || null, repo: t.repo, url: t.url,
++ ...(args.machine || args.effort ? { note: 'machine and effort are not fields of a Codes task yet: Run places it on one of the owner\'s machines, and the agent\'s own effort applies' } : {}),
++ });
++}
++
++async function listTasks(args) {
++ const q = new URLSearchParams();
++ if (args.state) q.set('state', String(args.state));
++ if (args.limit) q.set('limit', String(Math.min(100, Math.max(1, Number(args.limit) || 20))));
++ const answer = await call(auth(), 'GET', `/api/v1/tasks${q.toString() ? `?${q}` : ''}`);
++ return text({
++ tasks: (answer.tasks || []).map((t) => ({
++ id: t.id, state: t.state, agent: t.agent, repo: t.repo,
++ outcome: t.outcome ? t.outcome.state : null,
++ note: t.outcome && t.outcome.note ? String(t.outcome.note).slice(0, 300) : null,
++ brief: String(t.prompt || '').split('\n')[0].slice(0, 160),
++ })),
++ });
++}
++
++async function showTask(args) {
++ const id = String(args.id || '');
++ if (!TASK.test(id)) return failed('that is not a task id (tsk_…)');
++ const c = auth();
++ const t = await call(c, 'GET', `/api/v1/tasks/${encodeURIComponent(id)}`);
++ const want = Math.min(100, Math.max(1, Number(args.lines) || 20));
++ let lines = [];
++ try { lines = (await call(c, 'GET', `/api/v1/tasks/${encodeURIComponent(id)}/lines?limit=${want}`)).lines || []; } catch { lines = []; }
++ return text({
++ id: t.id, state: t.state, repo: t.repo, agent: t.agent,
++ outcome: t.outcome ? { state: t.outcome.state, note: t.outcome.note } : null,
++ attempts: (t.attempts || []).map((a) => ({ n: a.n, agent: a.agent, model: a.model, machine: a.machine, state: a.state, why: a.why, end_why: a.end_why })),
++ summary: t.result && t.result.summary ? String(t.result.summary).slice(0, 4000) : null,
++ error: t.error || null,
++ last_lines: lines.map((l) => {
++ const b = l.body || {};
++ if (typeof b === 'string') return b;
++ if (b.t === 'tool') return `→ ${b.name || ''} ${String((b.input && (b.input.command || b.input.file_path)) || b.what || '').slice(0, 200)}`.trim();
++ if (b.t === 'tool_result') return ` ${b.ok === false ? '✗' : '·'} ${String(b.output || '').split('\n')[0].slice(0, 200)}`;
++ if (b.t === 'done') return `done${b.ok === false ? ' (failed)' : ''}: ${String(b.text || '').split('\n')[0].slice(0, 300)}`;
++ return String(b.text || b.message || '').split('\n')[0].slice(0, 300);
++ }).filter(Boolean),
++ });
++}
++
++async function continueTask(args) {
++ const id = String(args.id || '');
++ if (!TASK.test(id)) return failed('that is not a task id (tsk_…)');
++ const message = String(args.message || '').trim();
++ if (!message) return failed('codes_task_continue needs a message: what the task should do next');
++ const t = await call(auth(), 'POST', `/api/v1/tasks/${encodeURIComponent(id)}/continue`, {
++ note: message, ...(args.agent ? { agent: String(args.agent) } : {}),
++ });
++ return text({ id: t.id, state: t.state, outcome: t.outcome ? t.outcome.state : null });
++}
++
++async function stopTask(args) {
++ const id = String(args.id || '');
++ if (!TASK.test(id)) return failed('that is not a task id (tsk_…)');
++ const t = await call(auth(), 'POST', `/api/v1/tasks/${encodeURIComponent(id)}/stop`);
++ return text({ id: t.id, state: t.state, outcome: t.outcome ? t.outcome.state : null });
++}
++
++async function fleet() {
++ const rows = probeLogins({ home: os.homedir() });
++ return text({
++ machine: os.hostname(),
++ agents: rows.map((r) => ({
++ agent: r.id, installed: !!r.installed, signed_in: !!r.signed_in,
++ version: r.version || null,
++ windows: (r.windows || []).map((w) => ({ name: w.name, used: w.used == null ? null : w.used, limit: w.limit == null ? null : w.limit, resets_at: w.resets_at || null })),
++ })),
++ });
++}
++
++async function shipStatus(args) {
++ const gh = githubRepo(args.repo);
++ const pr = Number(args.pr);
++ if (!gh) return failed('repo is owner/name or a GitHub URL');
++ if (!Number.isInteger(pr) || pr < 1) return failed('pr is the pull request number');
++ const base = String(process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\/+$/, '');
++ const token = String(process.env.GITHUB_TOKEN || '').trim();
++ const headers = { Accept: 'application/vnd.github+json', 'User-Agent': 'openvibe-codes', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
++ const get = async (p) => {
++ const res = await fetch(`${base}${p}`, { headers, signal: AbortSignal.timeout(10_000) });
++ const doc = await res.json().catch(() => null);
++ return { status: res.status, ok: res.ok, doc };
++ };
++ const out = await get(`/repos/${gh.owner}/${gh.name}/pulls/${pr}`);
++ if (!out.ok || !out.doc) return failed(`GitHub answered ${out.status} for ${gh.owner}/${gh.name}#${pr}`);
++ const d = out.doc;
++ let ci = null;
++ const sha = d.head && d.head.sha ? String(d.head.sha) : null;
++ if (sha && /^[0-9a-f]{7,40}$/i.test(sha)) {
++ const st = await get(`/repos/${gh.owner}/${gh.name}/commits/${sha}/status`);
++ if (st.ok && st.doc) ci = ({ success: 'Passed', failure: 'Failed', error: 'Errored', pending: 'Running' })[st.doc.state] || null;
++ }
++ return text({
++ repo: `${gh.owner}/${gh.name}`, pr, title: d.title || null,
++ state: d.merged ? 'merged' : d.state === 'closed' ? 'closed' : 'open',
++ draft: !!d.draft, mergeable: d.mergeable == null ? null : !!d.mergeable, mergeable_state: d.mergeable_state || null,
++ ci, branch: d.head && d.head.ref ? d.head.ref : null, base: d.base && d.base.ref ? d.base.ref : null,
++ });
++}
++
++/**
++ * Ask the owner: one approval on the conductor's page (or its task), then wait for the decision. The answer is the
++ * shape Claude Code's permission prompt tool expects.
++ */
++async function approve(args) {
++ const task = conductorTask();
++ if (!task) return failed('codes_approve needs OPENVIBE_CONDUCTOR_TASK: it asks about a conductor\'s tool call');
++ const tool = String(args.tool_name || args.tool || '').trim();
++ if (!tool) return failed('which tool? pass tool_name');
++ const input = args.input && typeof args.input === 'object' && !Array.isArray(args.input) ? args.input : {};
++ const c = auth();
++ let created;
++ try {
++ created = await call(c, 'POST', `/api/v1/tasks/${encodeURIComponent(task)}/approvals`, {
++ tool, input, ...(args.summary ? { summary: String(args.summary).slice(0, 400) } : {}),
++ });
++ } catch (e) {
++ return { behavior: 'deny', message: `the approval could not be asked: ${e.message}` };
++ }
++ if (created.decision) return { behavior: created.decision === 'allow' ? 'allow' : 'deny', message: created.message || 'already decided' };
++ const until = Date.now() + APPROVAL_TIMEOUT_MS;
++ while (Date.now() < until) {
++ await sleep(POLL_MS);
++ let st = null;
++ try { st = await call(c, 'GET', `/api/v1/tasks/${encodeURIComponent(task)}/approvals/${encodeURIComponent(created.id)}?wait=1`); } catch { st = null; }
++ if (st && st.decision) {
++ return {
++ behavior: st.decision === 'allow' ? 'allow' : 'deny',
++ message: st.message || (st.decision === 'allow' ? 'the owner said yes' : 'the owner said no'),
++ };
++ }
++ }
++ return { behavior: 'deny', message: `no answer from the owner in ${Math.round(APPROVAL_TIMEOUT_MS / 60000)} minutes` };
++}
++
++const RUNNERS = {
++ codes_task_start: startTask,
++ codes_task_list: listTasks,
++ codes_task_show: showTask,
++ codes_task_continue: continueTask,
++ codes_task_stop: stopTask,
++ codes_fleet: fleet,
++ codes_ship_status: shipStatus,
++ codes_approve: approve,
++};
++
++// ── JSON-RPC over stdio ─────────────────────────────────────────────────────────────────────────────────────────
++
++function reply(id, result) { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`); }
++function fail(id, message) { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32000, message: String(message).slice(0, 800) } })}\n`); }
++
++async function handle(msg) {
++ const { id, method, params } = msg;
++ if (method === 'initialize') return reply(id, { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'openvibe-codes', version: require('../package.json').version } });
++ if (method === 'notifications/initialized' || method === 'notifications/cancelled') return undefined;
++ if (method === 'ping') return reply(id, {});
++ if (method === 'tools/list') return reply(id, { tools: TOOLS });
++ if (method === 'tools/call') {
++ const name = params && params.name;
++ const args = (params && params.arguments) || {};
++ const fn = RUNNERS[name];
++ if (!fn) return fail(id, `unknown tool: ${name}`);
++ try {
++ const out = await fn(args);
++ // Every tool answers with MCP content; the tools that return a plain object (approve does, because Claude
++ // Code reads its JSON) are wrapped here.
++ return reply(id, out && Array.isArray(out.content) ? out : text(out));
++ } catch (e) {
++ // A refusal from the server (422, 409) is an answer to the conductor, not a crash of the server.
++ return reply(id, failed((e && e.message) || String(e)));
++ }
++ }
++ if (id != null) return fail(id, `unknown method: ${method}`);
++ return undefined;
++}
++
++/** `openvibe-codes mcp` → the exit code (it runs until stdin closes). */
++function mcp() {
++ let buf = '';
++ process.stdin.on('data', (chunk) => {
++ buf += chunk.toString();
++ let i;
++ while ((i = buf.indexOf('\n')) >= 0) {
++ const line = buf.slice(0, i).trim();
++ buf = buf.slice(i + 1);
++ if (!line) continue;
++ let msg;
++ try { msg = JSON.parse(line); } catch { continue; }
++ handle(msg).catch((e) => { if (msg && msg.id != null) fail(msg.id, (e && e.message) || String(e)); });
++ }
++ });
++ process.stdin.on('end', () => process.exit(0));
++ return undefined;
++}
++
++module.exports = { mcp, TOOLS, RUNNERS, repoUrl, githubRepo, handle };
++
++if (require.main === module) mcp();
ddiff --git a/bin/openvibe-codes.js b/bin/openvibe-codes.js
iindex 5f6b468..7959e33 100755
---- a/bin/openvibe-codes.js
++++ b/bin/openvibe-codes.js
@@@ -37,6 +37,8 @@ const USAGE = `openvibe-codes ${VERSION} — code with any agent, from your term
OpenVibe Node's job worker runs it on your own machine)
openvibe-codes task start|list|show|continue|stop coding tasks on openvibe.codes, as you, on one of
your machines (openvibe-codes task --help)
++ openvibe-codes mcp the Codes MCP tools for a Claude Code session: tasks, the fleet,
++ shipping status, and the conductor's approvals (stdio JSON-RPC)
Environment: DEEPSEEK_API_KEY · OPENVIBE_CODES_BASE_URL, OPENVIBE_CODES_MODEL, OPENVIBE_CODES_API_KEY
(your own OpenAI-compatible server) · OPENVIBE_CODES_HOME (where runs are kept) · OPENVIBE_TOKEN (your
@@@ -97,6 +99,8 @@ async function main() {
const [cmd, ...rest] = process.argv.slice(2);
// Tasks on openvibe.codes: the person's own token, the server's API. Nothing here runs an agent.
if (cmd === 'task') return task(rest);
++ // The Codes MCP server (Codes C9b): a conductor's Claude Code session starts it with --mcp-config.
++ if (cmd === 'mcp') return require('./mcp').mcp();
const o = parse(rest);
if (!cmd || cmd === 'help' || o.help || cmd === '--help' || cmd === '-h') { out(USAGE); return 0; }
if (cmd === '--version' || cmd === '-v' || cmd === 'version') { out(VERSION); return 0; }
ddiff --git a/bin/task.js b/bin/task.js
iindex cec1302..89fa953 100644
---- a/bin/task.js
++++ b/bin/task.js
@@@ -347,4 +347,4 @@ async function task(argv) {
}
}
--module.exports = { task, stateOf, repoOf, agentOf, EXIT };
++module.exports = { task, stateOf, repoOf, agentOf, auth, call, tokenOf, EXIT };
ddiff --git a/engine/fragments/conductor.md b/engine/fragments/conductor.md
nnew file mode 100644
iindex 0000000..12fc4d8
---- /dev/null
++++ b/engine/fragments/conductor.md
@@@ -0,0 +1,69 @@
++## You are the OpenVibe conductor (Codes C9b)
++
++You are the orchestrator. You used to be a Claude Code session on the owner's workstation, carrying work from one
++session to the next by hand. You now run inside openvibe.codes: this is your first turn in a task that stays open, on
++the owner's own machine, with the owner's own Claude Code login. Every message they send you is the next turn of this
++same session, so the context you build here is kept.
++
++The owner is OpenVibers. Their work lives in `/home/workstation/openvibe`.
++
++### Pick up where the last orchestrator stopped
++
++Read, in this order, before you change anything:
++
++1. `/home/workstation/openvibe/RESUME-2026-10-30.md` — the hand-over note: what was done, what is in flight, what is
++ next. Its newest entries are at the top of the timeline section; read the newest first.
++2. The plan of record and its sub-plans it points at (under `/home/workstation/openvibe/` and its `research-*`
++ folders, and `docs/` in the repos it names).
++3. The project memory of the folder you were started in — `CLAUDE.md`, `AGENTS.md` and what they point at. You were
++ started in the folder the owner chose for exactly this reason: that project's memory is your memory.
++
++Then carry on with the next piece of work, not with a report about it.
++
++### Drive the work through Codes, not the portal
++
++Your Codes tools are MCP tools of this session (`mcp__codes__*`). They act as the owner, with the owner's token, and
++they are the only way you start work now. The agent portal (POST /api/job and its runs) is not yours to use any more:
++work that the portal used to start, you start here.
++
++- `codes_task_start { prompt, repo, agent?, model? }` — a coding task on one of the owner's machines, in a repository.
++ Write the brief as if to a good engineer who was not in this conversation: the goal, the constraints, the files, what
++ "done" means, and the house rules (commit identity, gitleaks, no footer). A sub-task never pushes unless you say so.
++- `codes_task_list { state? }` — `running`, `needs` (unfinished and waiting for a person) or `done`.
++- `codes_task_show { id }` — the task's state, its attempts, its outcome and the last lines of its session. Read this
++ before you decide a sub-task is stuck.
++- `codes_task_continue { id, message }` — carry a task on in the same conversation, with a note.
++- `codes_task_stop { id }` — stop it; nothing carries it on.
++- `codes_fleet {}` — each coding CLI on this machine: installed, signed in, and its limit windows. Ask before you
++ choose an agent, and again when one reports a limit.
++- `codes_ship_status { repo, pr }` — a pull request's CI and merge state, read from GitHub.
++- `codes_approve` — only Claude Code calls this, when the owner's safety level is "ask". You never call it yourself.
++
++**The rule.** You are the only Anthropic session. Start sub-tasks on any other agent — `codex`, `opencode`,
++`command-code`, `deepseek`, `aider` — and never on `claude-code` or `claude-other`. The server refuses it, so a
++sub-task that names one comes back as an error rather than as work. If a task really needs Claude Code, do that part
++yourself, here.
++
++### How to run a piece of work
++
++1. Decide the smallest sub-task that moves the plan forward, and which agent should take it (`codes_fleet` first).
++2. Start it with a brief that stands alone. Note its id.
++3. Do not sit and watch it: start the next independent piece, or do local work yourself (reading, planning, reviewing
++ a diff, writing the hand-over).
++4. After a while — not every minute — check `codes_task_list` and `codes_task_show` on what you started. A task that
++ came back `needs` gets a message on what to do next, or a decision from you.
++5. Ship what is green: a pull request with CI passing is merged by the deployment job; you check with
++ `codes_ship_status` and say so.
++6. When the turn's work is done, answer with a short report: what you started (ids and agents), what came back, what
++ is still running, and what you would do next. That answer is what the owner reads on their phone.
++
++### What you must not do
++
++- Do not use the agent portal, and do not start Anthropic sub-tasks.
++- Do not print, read or copy a secret. The harness scrubs what it stores; keep your report clean too.
++- Do not push from this session. Sub-tasks open pull requests; shipping is the deployment job's step, after CI.
++- Do not end the turn with a plan where work was possible. If something blocks you, say exactly what, and what you
++ would do with one decision from the owner.
++
++The owner can send you messages at any time from openvibe.codes/tasks or their phone. When one arrives it is the next
++turn: answer it directly, and keep the thread of the plan in mind.
ddiff --git a/harness/adapters/cli.js b/harness/adapters/cli.js
iindex 9a0589d..2d986e8 100644
---- a/harness/adapters/cli.js
++++ b/harness/adapters/cli.js
@@@ -56,11 +56,19 @@ function versionOf(bin, env = process.env, args = ['--version']) {
const claudeCode = {
id: 'claude-code', name: 'Claude Code', kind: 'cli', bin: 'claude',
start(t, ctx = {}) {
-- const mode = { read: 'plan', edit: 'acceptEdits', full: 'bypassPermissions' }[t.permission] || 'plan';
++ const o = t.options && typeof t.options === 'object' ? t.options : {};
++ // The permission level is the shared vocabulary; a caller may name the CLI's own mode instead (the conductor's
++ // "ask" is Claude Code's `default`, where every risky call goes to its permission prompt tool).
++ const mode = o.permissionMode || { read: 'plan', edit: 'acceptEdits', full: 'bypassPermissions' }[t.permission] || 'plan';
const args = ['-p', t.prompt, '--output-format', 'stream-json', '--verbose', '--permission-mode', mode];
if (t.model) args.push('--model', t.model);
if (t.resume) args.push('--resume', t.resume);
if (t.maxTurns) args.push('--max-turns', String(t.maxTurns));
++ // MCP servers this session may use (the conductor's Codes tools): a config file, and the tools it may call
++ // without asking. The permission prompt tool is the MCP tool that asks the owner (C9b's approvals inbox).
++ if (o.mcpConfig) args.push('--mcp-config', o.mcpConfig);
++ if (Array.isArray(o.allowedTools) && o.allowedTools.length) args.push('--allowedTools', o.allowedTools.join(' '));
++ if (o.permissionPromptTool) args.push('--permission-prompt-tool', o.permissionPromptTool);
// Even unattended, a run never pushes: publishing is the person's step.
args.push('--disallowedTools', 'Bash(git push:*)');
const parser = jsonLines(claudeCode, (o, emit, st) => {
ddiff --git a/harness/index.js b/harness/index.js
iindex ace77d0..d50c6a3 100644
---- a/harness/index.js
++++ b/harness/index.js
@@@ -154,7 +154,7 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,
return decision;
}
-- function run({ prompt, task = 'edit', agent = null, model = null, cwd = process.cwd(), permission = 'edit', resume = null, handoff = true, maxAttempts = 3, stallMs = 10 * 60_000, timeoutMs, maxTurns, requirements = {}, signal } = {}) {
++ function run({ prompt, task = 'edit', agent = null, model = null, cwd = process.cwd(), permission = 'edit', resume = null, handoff = true, maxAttempts = 3, stallMs = 10 * 60_000, timeoutMs, maxTurns, requirements = {}, signal, adapterOptions = null } = {}) {
if (!prompt || !String(prompt).trim()) throw new Error('a run needs a prompt');
if (!['read', 'edit', 'full'].includes(permission)) throw new Error('permission is read, edit or full');
const runId = store.newRunId();
@@@ -199,7 +199,12 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,
const tally = ev.createTally();
const controller = new AbortController();
if (signal) signal.addEventListener('abort', () => controller.abort(), { once: true });
-- current = adapter.start({ prompt: nextPrompt, cwd, model: modelFor, resume: attempt === 1 ? resume : null, permission, maxTurns, timeoutMs, signal: controller.signal }, { env, fetch: fetchImpl, sessions: store });
++ current = adapter.start({
++ prompt: nextPrompt, cwd, model: modelFor, resume: attempt === 1 ? resume : null, permission, maxTurns, timeoutMs, signal: controller.signal,
++ // Extra flags a caller needs (the conductor's MCP config and permission rules); the adapter decides
++ // which of them its CLI understands. Nothing here can replace the fields above.
++ ...(adapterOptions && typeof adapterOptions === 'object' ? { options: adapterOptions } : {}),
++ }, { env, fetch: fetchImpl, sessions: store });
let stalled = false;
let res = null;
let pending = null; // the next() still in flight when the stall timer won the race
ddiff --git a/harness/job.js b/harness/job.js
iindex b9f3e18..1c68888 100644
---- a/harness/job.js
++++ b/harness/job.js
@@@ -95,7 +95,36 @@ function parseArgs(raw, env = process.env) {
let a;
try { a = JSON.parse(String(raw || '').trim() || '{}'); } catch { throw new Error('the job input is not JSON'); }
if (!a || typeof a !== 'object' || Array.isArray(a)) throw new Error('the job input must be an object');
-- const kind = a.kind === 'bench' ? 'bench' : 'code';
++ const kind = a.kind === 'bench' ? 'bench' : a.kind === 'conductor' ? 'conductor' : 'code';
++ const prompt = typeof a.prompt === 'string' ? a.prompt.trim() : '';
++ if (!prompt || prompt.length > MAX_PROMPT) throw new Error(`prompt must be 1-${MAX_PROMPT} characters`);
++ // A conductor's turn (Codes C9b): one Claude Code session in the owner's own folder, with the Codes MCP tools. No
++ // repository is cloned and nothing is pushed; the folder is the one the person chose, and it must exist.
++ if (kind === 'conductor') {
++ 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 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;
++ const wanted = jobEnv(a);
++ return {
++ kind, repo: null, ref: null, prompt, permission: 'edit', push: null,
++ folder, safety, mcp: a.mcp === true, codesUrl,
++ taskId: typeof a.task_id === 'string' && /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(a.task_id) ? a.task_id : null,
++ model: typeof a.model === 'string' && a.model.length <= 100 ? a.model : null,
++ agent: 'claude-code',
++ ...(files.length ? { attachments: files } : {}),
++ ...(resume ? { resume } : {}),
++ ...(Object.keys(wanted).length ? { env: wanted } : {}),
++ };
++ }
let repo = null;
if (kind === 'code') {
try { repo = new URL(String(a.repo || '')); } catch { throw new Error('repo must be a repository URL'); }
@@@ -103,8 +132,6 @@ function parseArgs(raw, env = process.env) {
if (!fileOk && repo.protocol !== 'https:') throw new Error('repo must be an https URL');
if (repo.username || repo.password) throw new Error('repo must not carry credentials');
}
-- const prompt = typeof a.prompt === 'string' ? a.prompt.trim() : '';
-- if (!prompt || prompt.length > MAX_PROMPT) throw new Error(`prompt must be 1-${MAX_PROMPT} characters`);
const ref = a.ref == null || a.ref === '' ? null : String(a.ref);
if (ref && !BRANCH.test(ref)) throw new Error('ref must be a branch or tag name');
// review is a read-only task (research, an audit, a review): the harness's own `read` level.
@@@ -243,6 +270,73 @@ function githubRepo(url) {
return m ? { owner: m[1], name: m[2] } : null;
}
++/**
++ * The person's Codes token, for the two things a conductor turn needs from the server: the images the owner attached
++ * and the MCP tools. OPENVIBE_TOKEN when the worker set it, else the file `openvibe-codes task` writes
++ * (~/.config/openvibe/token, mode 600). Never printed, never in a line.
++ */
++function codesToken(env = process.env) {
++ const fromEnv = String(env.OPENVIBE_TOKEN || '').trim();
++ if (fromEnv) return fromEnv;
++ const file = String(env.OPENVIBE_TOKEN_FILE || '') || path.join(os.homedir(), '.config', 'openvibe', 'token');
++ try {
++ const st = fs.statSync(file);
++ if (st.mode & 0o077) return null; // another account may read it: not ours to use
++ return fs.readFileSync(file, 'utf8').trim() || null;
++ } catch { return null; }
++}
++
++/**
++ * The MCP config a conductor's Claude Code session starts with: one stdio server, `openvibe-codes mcp`, in this
++ * package. The token and the server's address travel in its environment; the task id names the conductor whose
++ * approvals the server asks about.
++ */
++function conductorMcpConfig(file, job, env) {
++ const token = codesToken(env);
++ const bin = path.join(__dirname, '..', 'bin', 'openvibe-codes.js');
++ const config = {
++ mcpServers: {
++ codes: {
++ command: process.execPath,
++ args: [bin, 'mcp'],
++ env: {
++ ...(job.codesUrl ? { OPENVIBE_CODES_URL: job.codesUrl } : {}),
++ ...(job.taskId ? { OPENVIBE_CONDUCTOR_TASK: job.taskId } : {}),
++ ...(token ? { OPENVIBE_TOKEN: token } : {}),
++ },
++ },
++ },
++ };
++ fs.writeFileSync(file, JSON.stringify(config, null, 2), { mode: 0o600 });
++ 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 }) {
++ 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 });
++ 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),
++ });
++ 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);
++ fs.writeFileSync(target, bytes);
++ out.push({ path: target, name: file.name });
++ } catch (e) {
++ say({ t: 'note', level: 'warning', text: `An attached image could not be read (${file.name}): ${scrub(e.message)}` });
++ }
++ }
++ return out;
++}
++
/**
* 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
@@@ -300,26 +394,52 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
return doc;
}
const dir = fs.mkdtempSync(path.join(tmp, 'codes-'));
-- const work = path.join(dir, 'repo');
++ let work = path.join(dir, 'repo');
const result = { protocol: JOB_PROTOCOL, ok: false, kind: job.kind, repo: job.repo ? scrub(job.repo) : null, ref: job.ref, agents: [], summary: '' };
let base = null;
try {
++ let adapterOptions = null;
if (job.kind === 'code') {
say({ t: 'step', text: `Cloning ${scrub(job.repo)}${job.ref ? ` (${job.ref})` : ''}` });
await git(['clone', '--depth', '50', ...(job.ref ? ['--branch', job.ref] : []), '--', job.repo, work], { timeout: 300_000 });
base = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: work })).trim();
result.base = base;
say({ t: 'step', text: `Working on ${base}` });
++ } 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.
++ work = job.folder;
++ let st = null;
++ try { st = fs.statSync(work); } catch { st = null; }
++ if (!st || !st.isDirectory()) throw new Error(`the working folder does not exist on this machine: ${work}`);
++ 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` });
++ }
++ job = { ...job, prompt };
++ 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).
++ permissionMode: { ask: 'default', project: 'acceptEdits', full: 'bypassPermissions' }[job.safety] || 'default',
++ ...(job.mcp ? { mcpConfig: conductorMcpConfig(path.join(dir, 'mcp.json'), job, env), allowedTools: ['mcp__codes'] } : {}),
++ ...(job.mcp && job.safety === 'ask' ? { permissionPromptTool: 'mcp__codes__approve' } : {}),
++ };
} else {
fs.mkdirSync(work, { recursive: true });
say({ t: 'step', text: 'Preparing an empty benchmark workspace' });
}
const run = harness.run({
prompt: job.prompt, task: 'edit', agent: job.agent, model: job.model, cwd: work,
-- permission: job.permission, handoff: !job.agent, maxAttempts: 3,
++ permission: job.permission, handoff: !job.agent, maxAttempts: job.kind === 'conductor' ? 1 : 3,
// A task carried on in the same conversation resumes its session (RESUME in the environment, or the job's
// own resume field).
resume: job.resume || null,
++ ...(adapterOptions ? { adapterOptions } : {}),
});
let last = null;
for await (const e of run) {
@@@ -335,11 +455,14 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
result.cost_usd = last && last.total_cost_usd ? last.total_cost_usd : 0;
const usage = last && last.usage && typeof last.usage === 'object' ? last.usage : {};
result.tokens = Number(last && (last.total_tokens || last.tokens)) || (Number(usage.input_tokens) || 0) + (Number(usage.output_tokens) || 0);
-- Object.assign(result, job.kind === 'bench' ? { files: [], patch: '', patch_truncated: false } : await changes(work));
-- say({ t: 'step', text: result.files.length ? `${result.files.length} file${result.files.length === 1 ? '' : 's'} changed` : 'No files changed' });
++ // The session the next message resumes: a conductor's turn ends, its conversation does not.
++ if (job.kind === 'conductor' && last && last.session_id) result.session = String(last.session_id).slice(0, 200);
++ // A conductor works in the owner's own folder: its changes are theirs, not a patch for a pull request.
++ Object.assign(result, job.kind === 'code' ? await changes(work) : { files: [], patch: '', patch_truncated: false });
++ say({ t: 'step', text: result.files.length ? `${result.files.length} file${result.files.length === 1 ? '' : 's'} changed` : (job.kind === 'conductor' ? 'Turn done' : 'No files changed') });
// The proof of the change: the tests it reaches, the pages it touched, the diff, and a second model's opinion.
// It is collected here, in the clone the agent worked in, so the evidence is about this machine's work.
-- if (proof) {
++ if (proof && job.kind === 'code') {
say({ t: 'step', text: 'Collecting the proof' });
try {
const collected = await collectProof({
@@@ -389,4 +512,4 @@ function resultWriter(emit) {
};
}
--module.exports = { runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, jobEnv, ENV_NAMES, JOB_PROTOCOL };
++module.exports = { runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, jobEnv, ENV_NAMES, JOB_PROTOCOL, codesToken, conductorMcpConfig, writeAttachments };
ddiff --git a/migrations/0016_conductor.sql b/migrations/0016_conductor.sql
nnew file mode 100644
iindex 0000000..1171c2d
---- /dev/null
++++ b/migrations/0016_conductor.sql
@@@ -0,0 +1,58 @@
++-- phase: expand
++-- Codes C9b: the conductor. A long-lived task whose single attempt is one Claude Code session on a machine the person
++-- chooses: the orchestrator that used to be a Claude Code session on the workstation, now running in openvibe.codes.
++--
++-- run_tasks grows the kind of work it holds and, for a conductor, what the session needs:
++-- kind code (a coding task, the default) | conductor
++-- folder the working folder Claude Code opens, so it loads that project's memory and AGENTS.md
++-- model the conductor's model (opus by default); the row's own, so a page can show it before a job exists
++-- machine the machine the person chose (a node principal, nod_…), or NULL for the person's default
++-- safety ask | project | full: the person's safety level for this session
++--
++-- codes_conductor_messages is the conductor's turn queue. A message becomes the next turn of the session (Claude Code
++-- --resume) and waits while a turn runs. n is the turn's place: the first message is the hand-over brief.
++--
++-- codes_approvals is the approvals inbox. When the safety level is ask, a risky tool call is written here by the
++-- approve MCP tool and waits for the person to decide from the phone; the command is shown, never the whole input.
++--
++-- Every row hangs under a task and is erased with it (server/account-data.js).
++
++ALTER TABLE run_tasks ADD COLUMN IF NOT EXISTS kind text COLLATE "C" NOT NULL DEFAULT 'code';
++ALTER TABLE run_tasks ADD COLUMN IF NOT EXISTS folder text COLLATE "C";
++ALTER TABLE run_tasks ADD COLUMN IF NOT EXISTS model text COLLATE "C";
++ALTER TABLE run_tasks ADD COLUMN IF NOT EXISTS machine text COLLATE "C";
++ALTER TABLE run_tasks ADD COLUMN IF NOT EXISTS safety text COLLATE "C";
++
++CREATE TABLE IF NOT EXISTS codes_conductor_messages (
++ id text COLLATE "C" PRIMARY KEY, -- msg_<ULID>
++ task_id text COLLATE "C" NOT NULL, -- tsk_<ULID>
++ n integer NOT NULL, -- 1, 2, … the turn's place in the session
++ prompt text NOT NULL, -- what the person said (or the hand-over brief)
++ attachments jsonb, -- [{ name, mime, data }] images, written as files on the machine
++ state text COLLATE "C" NOT NULL, -- queued | running | done | failed | stopped
++ job_id text COLLATE "C", -- Run's job for this turn
++ session text COLLATE "C", -- the harness session this turn left behind
++ result jsonb, -- what the turn answered
++ error text,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ started_at timestamptz,
++ finished_at timestamptz
++);
++CREATE UNIQUE INDEX IF NOT EXISTS codes_conductor_messages_task_n ON codes_conductor_messages (task_id, n);
++CREATE INDEX IF NOT EXISTS codes_conductor_messages_task ON codes_conductor_messages (task_id, n);
++CREATE INDEX IF NOT EXISTS codes_conductor_messages_job ON codes_conductor_messages (job_id) WHERE job_id IS NOT NULL;
++
++CREATE TABLE IF NOT EXISTS codes_approvals (
++ id text COLLATE "C" PRIMARY KEY, -- apr_<ULID>
++ task_id text COLLATE "C" NOT NULL, -- tsk_<ULID>
++ attempt_id text COLLATE "C",
++ tool text COLLATE "C" NOT NULL, -- the tool that wants to run
++ input jsonb, -- its arguments, for the record (never shown whole)
++ summary text COLLATE "C", -- the command or file, one line, what the person reads
++ decision text COLLATE "C", -- allow | deny | always; NULL while it waits
++ message text COLLATE "C", -- what the person said
++ created_at timestamptz NOT NULL DEFAULT now(),
++ decided_at timestamptz
++);
++CREATE INDEX IF NOT EXISTS codes_approvals_task ON codes_approvals (task_id, created_at DESC);
++CREATE INDEX IF NOT EXISTS codes_approvals_pending ON codes_approvals (task_id) WHERE decision IS NULL;
ddiff --git a/public/css/c4.css b/public/css/c4.css
iindex 6a99b57..f1084ad 100644
---- a/public/css/c4.css
++++ b/public/css/c4.css
@@@ -152,6 +152,37 @@
.c4-chip[href]{min-height:44px;align-items:center;padding:8px 12px}
.c4-card-head a{display:inline-flex;align-items:center;min-height:44px}
}
++/* C9b: the conductor. Its page is a conversation: turns, the live turn, the box that writes the next one. */
++.c4-conductor-head .c4-task-heading{margin-bottom:8px}
++.c4-conductor-cta{border-color:#d5e4df;background:linear-gradient(140deg,#fff,#f8fcfa)}
++.c4-conductor-cta .c4-button{display:inline-flex;align-items:center;min-height:44px}
++.c4-turn-list{list-style:none;margin:0;padding:0;display:grid;gap:12px}
++.c4-turn{border:1px solid var(--c4-line);border-radius:11px;padding:12px 14px;background:var(--c4-ghost)}
++.c4-turn-running{border-color:#bfdcd5;background:var(--c4-open-bg)}
++.c4-turn-failed{border-color:var(--c4-bad-line);background:var(--c4-bad-bg)}
++.c4-turn-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:6px}
++.c4-turn-who{font-size:12px;font-weight:700;letter-spacing:.02em;text-transform:uppercase;color:var(--c4-muted)}
++.c4-turn-text{white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:1.55;max-height:320px;overflow:auto}
++.c4-turn-answer{white-space:pre-wrap;overflow-wrap:anywhere;margin:10px 0 0;padding:10px 12px;background:var(--c4-surface);border:1px solid var(--c4-line);border-radius:9px;font:inherit;font-size:13.5px;line-height:1.55;max-height:360px;overflow:auto}
++.c4-turn-error{margin:8px 0 0;color:var(--c4-bad-ink);font-size:13px}
++.c4-turn-files{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0 0}
++.c4-conductor-composer textarea{width:100%;min-height:84px;font:inherit;padding:10px 12px;border:1px solid var(--c4-line);border-radius:9px;background:var(--c4-surface);color:var(--c4-ink)}
++.c4-conductor-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px;flex-wrap:wrap}
++.c4-file{display:inline-flex;align-items:center;gap:8px;font-size:13px;color:var(--c4-muted);min-height:44px}
++.c4-file input{font-size:12px;max-width:220px}
++.c4-transcript .c4-log{margin:0;max-height:44vh}
++.c4-approval{border:1px solid #e7d3bf;background:#fffaf4;border-radius:11px;padding:12px 14px;display:grid;gap:8px;margin:0 0 10px}
++.c4-approval-head{display:flex;align-items:center;justify-content:space-between;gap:10px}
++.c4-approval-cmd{display:block;overflow-wrap:anywhere;background:var(--c4-surface);border:1px solid var(--c4-line);border-radius:7px;padding:8px 10px;font-size:12.5px;max-height:140px;overflow:auto}
++.c4-approval-task{margin:0;font-size:12px;color:var(--c4-muted)}
++.c4-approval-actions{display:flex;gap:8px}
++.c4-approval-actions button{min-height:44px}
++.c4-approval-list{list-style:none;margin:0;padding:0;display:grid;gap:14px}
++.c4-note-field{display:block;margin:2px 0 4px;font-size:14px;font-weight:650}
++.c4-note-field small{display:block;font-weight:400;color:var(--c4-muted);font-size:12.5px;margin:2px 0 6px}
++.c4-composer textarea[name="note"]{width:100%;font:inherit;padding:9px 11px;border:1px solid var(--c4-line);border-radius:9px;background:var(--c4-surface);color:var(--c4-ink)}
++:root[data-theme-mode="dark"] .c4-approval{background:#241d16;border-color:#4a3a2a}
++:root[data-theme-mode="dark"] .c4-turn-answer,:root[data-theme-mode="dark"] .c4-approval-cmd{background:#11181b}
/* C4c: the phone tab bar's own height, and the page's bottom inset for it (openvibe.design/patterns/bottom-tabs).
The workspace's tab bar is fixed at the bottom, so the scrollable content reserves its height and the safe area:
the last line of the page and the footer clear it when the page is scrolled to the end. */
ddiff --git a/public/js/c4.js b/public/js/c4.js
iindex 3ab54de..bac7894 100644
---- a/public/js/c4.js
++++ b/public/js/c4.js
@@@ -144,6 +144,87 @@
if (stream.readyState === EventSource.CLOSED) { stream.close(); window.clearInterval(timer); }
});
});
++ // The conductor (Codes C9b): a message with images goes through the JSON API (the form alone sends the words), and
++ // the live turn's lines fill the transcript. When the turn ends the page reloads once, so the turn's answer and its
++ // state are the server's own words.
++ document.querySelectorAll('[data-c4-conductor-form]').forEach((node) => {
++ const form = node.closest('form');
++ const input = form.querySelector('[data-c4-images]');
++ const status = form.querySelector('[data-c4-message-status]');
++ if (!form || !input) return;
++ const say = (text, bad) => { if (status) { status.textContent = text; status.classList.toggle('is-bad', !!bad); } };
++ const asData = (file) => new Promise((resolve) => {
++ const reader = new FileReader();
++ reader.onload = () => resolve({ name: file.name, mime: file.type, data: String(reader.result || '') });
++ reader.onerror = () => resolve(null);
++ 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
++ 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…`);
++ try {
++ const attachments = (await Promise.all(files.map(asData))).filter(Boolean);
++ const text = (form.querySelector('textarea[name="text"]') || {}).value || '';
++ const res = await fetch(node.dataset.conductorApi, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
++ credentials: 'same-origin',
++ body: JSON.stringify({ text, attachments }),
++ });
++ const doc = await res.json().catch(() => ({}));
++ if (!res.ok) throw new Error(doc.detail || `the message was refused (${res.status})`);
++ window.location.reload();
++ } catch (e) {
++ say(String((e && e.message) || 'the message could not be sent'), true);
++ if (button) button.disabled = false;
++ }
++ });
++ });
++
++ document.querySelectorAll('[data-c4-conductor]').forEach((root) => {
++ const out = root.querySelector('[data-task-log]');
++ const label = root.querySelector('[data-live-label]');
++ const live = root.dataset.c4Conductor === 'live';
++ const url = root.dataset.c4ConductorStream;
++ if (!live || !window.EventSource || !url) return; // between turns the lines are drawn on the server
++ const stream = new EventSource(url);
++ const textOf = (line) => {
++ const b = line && line.body;
++ if (typeof b === 'string') return b;
++ if (!b || typeof b !== 'object') return String(b ?? '');
++ if (b.t === 'tool') return `→ ${b.name || ''} ${String((b.input && (b.input.command || b.input.file_path)) || b.what || '')}`.trim();
++ if (b.t === 'tool_result') return ` ${b.ok === false ? '✗' : '·'} ${String(b.output || '').split('\n')[0]}`;
++ return b.text || b.message || '';
++ };
++ stream.addEventListener('line', (event) => {
++ let line;
++ try { line = JSON.parse(event.data); } catch { return; }
++ const text = textOf(line).trim();
++ if (!text || !out) return;
++ out.textContent += `${text}\n`;
++ out.scrollTop = out.scrollHeight;
++ if (label) label.textContent = 'Working…';
++ });
++ let reloaded = false;
++ stream.addEventListener('error', () => {
++ // The server ends the stream when the turn ends. Reload once so the turn's answer and the box are fresh —
++ // but not twice in five seconds, so a conductor that ends at once cannot make the page reload in a loop.
++ if (reloaded || !live || stream.readyState === EventSource.CLOSED) return;
++ const key = `c4-conductor-reload:${root.dataset.task || ''}`;
++ let last = 0;
++ try { last = Number(window.sessionStorage.getItem(key) || 0); } catch { last = 0; }
++ if (Date.now() - last < 5000) return;
++ try { window.sessionStorage.setItem(key, String(Date.now())); } catch { /* storage may be off */ }
++ reloaded = true;
++ stream.close();
++ window.setTimeout(() => window.location.reload(), 1200);
++ });
++ stream.addEventListener('end', () => { if (label) label.textContent = 'The turn ended.'; });
++ });
// The repository picker: the person's own repositories first, then GitHub's public answer for what they type.
// Server-rendered options already hold the saved ones, so the field works with this script blocked.
document.querySelectorAll('[data-c4-repo-search]').forEach((input) => {
ddiff --git a/server/account-data.js b/server/account-data.js
iindex bd9c438..b60b47d 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);
++ * - their conductor's turns (codes_conductor_messages, C9b) and the approvals it asked them (codes_approvals);
* - the events Codes sent about them (codes_outbox);
* - telemetry samples that name them: the SDK covers telemetry_samples itself (telemetry.json, then subject NULL).
*
@@@ -43,6 +44,15 @@ const TASK_KEYED = [
{ table: 'codes_session_lines', file: 'session-lines.json', columns: 'l.task_id, l.seq, l.at, l.kind, l.body', order: 'l.seq ASC', kind: 'session_lines', max: SESSION_LINES_MAX },
{ table: 'codes_attempts', file: 'attempts.json', columns: 'l.id, l.task_id, l.n, l.agent, l.model, l.machine, l.state, l.reason, l.why, l.kind, l.cost_usd, l.tokens, l.steps, l.started_at, l.finished_at, l.created_at', order: 'l.n ASC', kind: 'attempts', max: TASK_ROWS_MAX },
{ table: 'codes_task_proof', file: 'proof.json', columns: 'l.task_id, l.attempt_n, l.n, l.kind, l.label, l.ok, l.skipped, l.summary, l.family, l.detail, l.created_at', order: 'l.n ASC', kind: 'proof', max: TASK_ROWS_MAX },
++ // The conductor's turns (C9b): what the person said, and what the turn answered. An attached image is exported by
++ // its name, type and size; its bytes stay in the database until the person's tasks are erased.
++ {
++ table: 'codes_conductor_messages', file: 'conductor-messages.json',
++ columns: "l.id, l.task_id, l.n, l.prompt, l.state, l.job_id, l.error, l.created_at, l.started_at, l.finished_at, COALESCE((SELECT jsonb_agg(jsonb_build_object('name', a->>'name', 'mime', a->>'mime', 'size', a->'size')) FROM jsonb_array_elements(l.attachments) a), '[]'::jsonb) AS attachments",
++ order: 'l.n ASC', kind: 'conductor_messages', max: TASK_ROWS_MAX,
++ },
++ // The approvals the person decided, and the ones still waiting when their account is erased.
++ { table: 'codes_approvals', file: 'approvals.json', columns: 'l.id, l.task_id, l.tool, l.summary, l.decision, l.message, l.created_at, l.decided_at', order: 'l.created_at ASC', kind: 'approvals', max: TASK_ROWS_MAX },
{ table: 'codes_events_inbox', file: null, kind: 'events_received' },
];
ddiff --git a/server/app.js b/server/app.js
iindex f4df389..1271e42 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -31,6 +31,7 @@ const { createRunRoutes } = require('./http/run');
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 { createRunClient } = require('./domain/run-client');
const { createRunTasks } = require('./domain/run-tasks');
const { createConnections } = require('./domain/connections');
@@@ -38,6 +39,9 @@ const { createRunTokens } = require('./domain/run-tokens');
const { createAttempts } = require('./domain/attempts');
const { createBenchPublisher } = require('./domain/bench-publisher');
const { createSessionLines } = require('./domain/session-lines');
++const { createConductor } = require('./domain/conductor');
++const { createApprovals } = require('./domain/approvals');
++const { createNotify } = require('./domain/notify');
const { createOutbox, createPublisher } = require('./events/outbox');
const { createEventsConsumer } = require('./events-consumer');
const { createApi } = require('./http/api');
@@@ -90,6 +94,15 @@ async function createApp(opts = {}) {
ctx.userSettings = createUserSettings({ store });
ctx.github = createGitHub({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });
ctx.usage = createUsage({ store });
++ // The conductor (Codes C9b) and its approvals inbox. The conductor starts Run jobs and reads their events through
++ // tasks, so the two are wired to each other here, not in a constructor (which would be a cycle).
++ ctx.notify = opts.notify || createNotify({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
++ 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,
++ });
++ tasks.setConductor(ctx.conductor);
const app = express();
app.disable('x-powered-by');
@@@ -194,6 +207,11 @@ async function createApp(opts = {}) {
// A person's connections: their own provider keys (stored in OpenVibe.AI, never here) and their machines' plans.
app.use('/api/v1', createConnectionsApi({ config, store, connections, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createActionsApi({ config, store, keys, run, tasks, actorLimits: ctx.actorLimits, log }));
++ // 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, 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 }));
// ── Pages ───────────────────────────────────────────────
ddiff --git a/server/config.js b/server/config.js
iindex 712922e..16c940d 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -128,6 +128,13 @@ function load(env = process.env) {
know: { lessons: String(env.CODES_KNOW_LESSONS || env.OPENVIBE_CODES_LESSONS || '').trim() },
// The task's timeline (stage C2): how many automatic continues one task may have before it needs a person.
task: { maxContinues: Math.max(0, int(env.CODES_TASK_MAX_CONTINUES, 2)) },
++ // The conductor (Codes C9b): one long Claude Code session on the person's own machine. A turn's wall time (the
++ // session may work through a plan), and the harness a sub-task gets when the conductor names none (never an
++ // Anthropic one: the conductor is the only Anthropic session).
++ conductor: {
++ wallMs: Math.max(60_000, int(env.CODES_CONDUCTOR_TURN_MS, 4 * 60 * 60_000)),
++ subagentAgent: String(env.CODES_CONDUCTOR_SUBAGENT_AGENT || 'codex').trim() || 'codex',
++ },
// The event stream of a task (GET /api/v1/tasks/:id/events): the heartbeat that keeps a connection alive.
sse: { heartbeatMs: Math.max(1000, int(env.CODES_SSE_HEARTBEAT_MS, 15_000)) },
ddiff --git a/server/domain/approvals.js b/server/domain/approvals.js
nnew file mode 100644
iindex 0000000..7e6449f
---- /dev/null
++++ b/server/domain/approvals.js
@@@ -0,0 +1,186 @@
++'use strict';
++
++/**
++ * The approvals inbox (Codes C9b): when the conductor's safety level is `ask`, a risky tool call is written here and
++ * waits for the person to decide — from the task page, or from their phone. The approve MCP tool (bin/mcp.js) posts the
++ * call and waits; the person allows or denies it with the command in front of them.
++ *
++ * createApprovals({ store, config, log, notify, userSettings })
++ * create({ task, tool, input, summary }) one waiting approval (and one notification, best effort)
++ * get(taskId, id) · pending(taskId) · inbox(subject)
++ * decide(taskId, id, { decision, message }) allow | deny, once
++ * decideAny(subject, id, { decision, message }) from the phone, without the task in the url
++ * stop(taskId) the conductor stopped: nothing waits any more
++ * present(row) one approval as the API and the page show it
++ *
++ * Nothing here runs a command: the decision travels back to the MCP tool in the job, which answers Claude Code with
++ * {behavior: 'allow'|'deny'}. A row decided once is never decided again (the first answer wins), and an approval whose
++ * conductor ended is denied with the reason, so a waiting tool is never left hanging.
++ *
++ * The stored `input` is the tool's arguments, for the record; the `summary` is the one line the person reads. Neither is
++ * ever sent to the Network: the notification carries the summary and a link, nothing more.
++ */
++
++const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
++const SUMMARY_MAX = 400;
++const INPUT_MAX = 16 * 1024;
++
++const iso = (v) => {
++ if (!v) return null;
++ const t = v instanceof Date ? v.getTime() : Date.parse(String(v));
++ return Number.isFinite(t) ? new Date(t).toISOString() : null;
++};
++
++/** A short line for the person: the command, the file, whatever the tool carries. */
++function summarize(tool, input) {
++ const i = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
++ const v = i.command || i.cmd || i.file_path || i.filePath || i.path || i.notebook_path || i.url || i.pattern || i.query || i.description || '';
++ const line = String(v).replace(/\s+/g, ' ').trim();
++ return (line || String(tool || 'a tool')).slice(0, SUMMARY_MAX);
++}
++
++/** The input kept for the record: a plain object, no larger than 16 KB, its longest strings cut. */
++function keptInput(input) {
++ if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
++ let json;
++ try { json = JSON.stringify(input); } catch { return null; }
++ if (json.length <= INPUT_MAX) return input;
++ const clone = { ...input };
++ for (const key of Object.keys(clone)) {
++ if (typeof clone[key] === 'string') clone[key] = clone[key].slice(0, 2000);
++ }
++ return JSON.stringify(clone).length <= INPUT_MAX ? clone : { note: 'the arguments were too long to keep whole' };
++}
++
++function createApprovals({ store, config = {}, log = console, notify = null, userSettings = null }) {
++ const db = store.db;
++
++ /** One notification to the person who owns the task, best effort, honouring their "a task needs me" choice. */
++ async function tell(task, row) {
++ if (!notify || !task || !task.subject) return;
++ try {
++ if (userSettings) {
++ const settings = await userSettings.get(task.subject);
++ if (!settings.notify_needs) return;
++ }
++ await notify.push({
++ subject: task.subject,
++ type: 'APPROVAL_NEEDED',
++ title: 'Your conductor needs you',
++ message: `${row.tool}: ${row.summary || 'a tool wants to run'}`,
++ url: `${String(config.baseUrl || '').replace(/\/+$/, '')}/tasks/${task.id}#approvals`,
++ priority: 'high',
++ icon: '🛂',
++ });
++ } catch (e) {
++ log.warn(`[Codes] approval ${row.id}: the notification was not sent: ${String((e && e.message) || e).slice(0, 200)}`);
++ }
++ }
++
++ async function create({ task, tool, input = null, summary = null } = {}) {
++ if (!task || !task.id) throw Object.assign(new Error('an approval belongs to a task'), { status: 422, code: 'approval.task_required' });
++ const name = String(tool || '').trim().slice(0, 100);
++ if (!name) throw Object.assign(new Error('an approval names the tool that wants to run'), { status: 422, code: 'approval.tool_required' });
++ // One waiting approval per (task, tool, summary): Claude Code asks again for the same call only when the first
++ // answer was lost, and a person should not be asked the same thing twice.
++ const line = (summary == null ? summarize(name, input) : String(summary).replace(/\s+/g, ' ').trim()).slice(0, SUMMARY_MAX);
++ const seen = await db.maybe(
++ 'SELECT * FROM codes_approvals WHERE task_id = $1 AND tool = $2 AND summary = $3 AND decision IS NULL ORDER BY created_at DESC LIMIT 1',
++ [task.id, name, line],
++ );
++ if (seen) return seen;
++ const row = await db.one(
++ `INSERT INTO codes_approvals (id, task_id, tool, input, summary, created_at)
++ VALUES ($1, $2, $3, $4::jsonb, $5, $6::timestamptz) RETURNING *`,
++ [store.newId('apr'), task.id, name, keptInput(input) ? JSON.stringify(keptInput(input)) : null, line, store.iso()],
++ );
++ await tell(task, row);
++ log.info(`[Codes] approval ${row.id}: ${name} waits for ${task.subject}`);
++ return row;
++ }
++
++ async function get(taskId, id) {
++ if (!TASK.test(String(taskId)) || !id) return null;
++ return await db.maybe('SELECT * FROM codes_approvals WHERE id = $1 AND task_id = $2', [String(id), taskId]);
++ }
++
++ /** The approvals still waiting on a task, oldest first. */
++ async function pending(taskId) {
++ return await db.many('SELECT * FROM codes_approvals WHERE task_id = $1 AND decision IS NULL ORDER BY created_at ASC', [taskId]);
++ }
++
++ /**
++ * The person's waiting approvals, across their tasks: the task's own brief and machine come along, so the inbox reads
++ * as a list of decisions and not of ids.
++ */
++ async function inbox(subject) {
++ return await db.many(
++ `SELECT a.*, t.prompt, t.folder, t.machine, t.state AS task_state
++ FROM codes_approvals a JOIN run_tasks t ON t.id = a.task_id
++ WHERE t.subject = $1 AND a.decision IS NULL
++ ORDER BY a.created_at ASC LIMIT 50`,
++ [subject],
++ );
++ }
++
++ /** Record a decision once; the first one wins (a second press answers the row it found). */
++ async function settle(id, decision, message) {
++ const made = decision === 'allow' || decision === 'always' ? 'allow' : 'deny';
++ return await store.tx(async () => {
++ const row = await db.maybe('SELECT * FROM codes_approvals WHERE id = $1 FOR UPDATE', [id]);
++ if (!row) return null;
++ if (row.decision) return row;
++ return await db.one(
++ `UPDATE codes_approvals SET decision = $2, message = $3, decided_at = $4::timestamptz WHERE id = $1 RETURNING *`,
++ [row.id, made, message ? String(message).slice(0, 500) : null, store.iso()],
++ );
++ });
++ }
++
++ async function decide(taskId, id, { decision, message = null } = {}) {
++ const row = await get(taskId, id);
++ if (!row) return null;
++ return await settle(row.id, decision, message);
++ }
++
++ /** The phone's inbox is not under a task: the person's own approval, found by its id. */
++ async function decideAny(subject, id, { decision, message = null } = {}) {
++ const row = await db.maybe(
++ 'SELECT a.* FROM codes_approvals a JOIN run_tasks t ON t.id = a.task_id WHERE a.id = $1 AND t.subject = $2',
++ [String(id || ''), subject],
++ );
++ if (!row) return null;
++ return await settle(row.id, decision, message);
++ }
++
++ /** The conductor stopped: anything still waiting is denied with that reason, so the tool it asked about is answered. */
++ async function stop(taskId) {
++ return await db.exec(
++ `UPDATE codes_approvals SET decision = 'deny', message = $2, decided_at = $3::timestamptz
++ WHERE task_id = $1 AND decision IS NULL`,
++ [taskId, 'the conductor was stopped', store.iso()],
++ );
++ }
++
++ /** One approval as the API and the page show it: the command, never the whole arguments. */
++ function present(row) {
++ if (!row) return null;
++ return {
++ id: row.id,
++ task_id: row.task_id,
++ tool: row.tool,
++ summary: row.summary || null,
++ decision: row.decision || null,
++ message: row.message || null,
++ created_at: iso(row.created_at),
++ decided_at: iso(row.decided_at),
++ ...(row.prompt ? { task_prompt: String(row.prompt).slice(0, 300) } : {}),
++ ...(row.machine ? { machine: row.machine } : {}),
++ ...(row.task_state ? { task_state: row.task_state } : {}),
++ };
++ }
++
++ return { create, get, pending, inbox, decide, decideAny, stop, present, summarize };
++}
++
++module.exports = { createApprovals, summarize, keptInput };
ddiff --git a/server/domain/attempts.js b/server/domain/attempts.js
iindex f9fbb96..22a3164 100644
---- a/server/domain/attempts.js
++++ b/server/domain/attempts.js
@@@ -172,16 +172,17 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
throw new Error('the attempt could not be written');
}
-- /** The Run job of an attempt. */
-- async function bind(id, { job_id = null, state = null, started_at = null, machine = null, model = null } = {}) {
++ /** The Run job of an attempt. The session is set for a conductor's attempt, which outlives each turn's job. */
++ async function bind(id, { job_id = null, state = null, started_at = null, machine = null, model = null, session = null } = {}) {
return await db.one(
`UPDATE codes_attempts SET job_id = COALESCE($2, job_id),
state = COALESCE($3, state),
machine = COALESCE($4, machine),
model = COALESCE($5, model),
-- started_at = COALESCE($6::timestamptz, started_at)
++ started_at = COALESCE($6::timestamptz, started_at),
++ session = COALESCE($7, session)
WHERE id = $1 RETURNING *`,
-- [id, job_id, state, machine, model, started_at],
++ [id, job_id, state, machine, model, started_at, session],
);
}
ddiff --git a/server/domain/conductor.js b/server/domain/conductor.js
nnew file mode 100644
iindex 0000000..4d1e80e
---- /dev/null
++++ b/server/domain/conductor.js
@@@ -0,0 +1,396 @@
++'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.
++ *
++ * 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
++ * message(task, { text, attachments }) a message: the next turn, queued while one runs
++ * onTurnEnd(taskId) a turn ended: land its message, start the next
++ * stop(task, { subject }) stop the running turn; nothing carries on
++ * view(task) the conductor as its page shows it
++ * presentMessage(message) one turn as the API shows it
++ * fragment() the first-turn text (engine/fragments/conductor.md)
++ *
++ * One task, one attempt, many turns. The attempt keeps the session id; each turn is a Run job that resumes it
++ * (`claude --resume`), so the conductor keeps its context across messages. A message waits in codes_conductor_messages
++ * 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.
++ */
++
++const fs = require('fs');
++const path = require('path');
++
++const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);
++const MESSAGE_STATES = ['queued', 'running', 'done', 'failed', 'stopped'];
++const SAFETY = ['ask', 'project', 'full'];
++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_NOTE = 4000;
++const MAX_FOLDER = 4096;
++
++const iso = (v) => {
++ if (!v) return null;
++ const t = v instanceof Date ? v.getTime() : Date.parse(String(v));
++ return Number.isFinite(t) ? new Date(t).toISOString() : null;
++};
++const asObject = (v) => {
++ if (v && typeof v === 'object' && !Array.isArray(v)) return v;
++ if (typeof v === 'string') { try { const p = JSON.parse(v); return p && typeof p === 'object' && !Array.isArray(p) ? p : null; } catch { return null; } }
++ return null;
++};
++const asArray = (v) => (Array.isArray(v) ? v : (Array.isArray(v && v.items) ? v.items : []));
++
++/** The folder a conductor may open: absolute, no control characters, a real place. */
++function validFolder(folder) {
++ const f = String(folder || '').trim();
++ if (!f || f.length > MAX_FOLDER) return null;
++ if (/[\0-\x1f\x7f]/.test(f)) return null;
++ if (!f.startsWith('/')) return null;
++ return f;
++}
++
++/** One attachment as it is stored: an image, its type and its bytes (base64). */
++function cleanAttachments(list) {
++ if (!Array.isArray(list)) return [];
++ const out = [];
++ for (const a of list.slice(0, MAX_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();
++ const data = typeof a.data === 'string' ? a.data.replace(/^data:[^;]+;base64,/, '') : '';
++ 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;
++ out.push({ name, mime, data, size: bytes });
++ }
++ return out;
++}
++
++function createConductor({ store, config = {}, log = console, run, attempts, tasks, sessions, approvals = null, notify = null, fetchImpl = null }) {
++ const db = store.db;
++ const fetch = fetchImpl || globalThis.fetch;
++ const baseUrl = String(config.baseUrl || '').replace(/\/+$/, '');
++ const wallMs = Math.max(60_000, Number(config.conductor && config.conductor.wallMs) || 4 * 60 * 60_000);
++
++ function fragment() {
++ try { return fs.readFileSync(FRAGMENT, 'utf8'); } catch { return 'You are the OpenVibe orchestrator. Carry on the work in /home/workstation/openvibe from RESUME-2026-10-30.md, through the codes MCP tools.\n'; }
++ }
++
++ /**
++ * The project a conductor runs in: the person's first project they may delegate in (created when they have none),
++ * read from Network with their own token. A person whose token cannot read Network falls back to the project of
++ * their latest task; null when there is none, and the caller says so.
++ */
++ async function projectFor(token, subject) {
++ if (token) {
++ try {
++ const list = await fetch(`${String(config.networkInternalUrl || '').replace(/\/+$/, '')}/api/v1/projects`, {
++ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(8000),
++ });
++ if (list.ok) {
++ const projects = ((await list.json()).projects || []).filter((p) => ['owner', 'admin'].includes(p.role) && !p.archived_at);
++ if (projects.length) return projects[0].id;
++ const made = await fetch(`${String(config.networkInternalUrl || '').replace(/\/+$/, '')}/api/v1/projects`, {
++ method: 'POST',
++ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json' },
++ body: JSON.stringify({ name: 'Conductor' }), signal: AbortSignal.timeout(8000),
++ });
++ if (made.ok) return (await made.json()).id;
++ }
++ } catch (e) {
++ log.warn(`[Codes] conductor: the projects of ${subject} could not be read: ${String(e && e.message).slice(0, 160)}`);
++ }
++ }
++ return await tasks.latestProject(subject);
++ }
++
++ /** 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]);
++ }
++
++ /**
++ * Create the conductor: the task row, its one attempt and the hand-over turn. The first turn starts at once
++ * (startNext), so a person who pressed "Start your conductor" sees it begin to work.
++ */
++ async function create({ subject, project, folder, machine = null, model = 'opus', safety = 'ask', note = null, key = null } = {}) {
++ 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' });
++ 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 (!subject) throw Object.assign(new Error('a conductor belongs to a person'), { status: 422, code: 'conductor.subject_required' });
++
++ const first = fragment() + (note && String(note).trim() ? `\n\n---\n\n## What the owner asked now\n\n${String(note).trim().slice(0, MAX_NOTE)}\n` : '');
++ const id = store.newId('tsk');
++ const at = store.now();
++ const repo = `conductor://${work}`.slice(0, 300);
++ let row;
++ await store.tx(async () => {
++ row = await db.one(
++ `INSERT INTO run_tasks (id, subject, project_id, repo, prompt, agent, model, kind, folder, machine, safety, state, idempotency_key, created_at, updated_at)
++ VALUES ($1, $2, $3, $4, $5, 'claude-code', $6, 'conductor', $7, $8, $9, 'queued', $10, $11, $11) RETURNING *`,
++ [id, subject, project, repo, first.slice(0, 16000), String(model).slice(0, 100), work, machine || null, safety, key || null, at],
++ );
++ });
++ await attempts.create({
++ task: row, agent: 'claude-code', model: String(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 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 } = {}) {
++ 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' });
++ 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 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(
++ `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()],
++ );
++ });
++ return made;
++ }
++
++ /** A message the person writes. Starts the turn when the conductor is idle; otherwise it waits. */
++ async function message(task, { text, attachments = [] } = {}) {
++ const made = await enqueue(task, { text, attachments });
++ const started = await startNext(task.id);
++ return { message: made, started };
++ }
++
++ /** The job input of one turn: the same folder, model and safety, and the session the attempt carries. */
++ function turnArgs(task, message, attempt) {
++ const files = Array.isArray(message.attachments) ? message.attachments : [];
++ return {
++ kind: 'conductor',
++ task_id: task.id,
++ message_id: message.id,
++ folder: task.folder,
++ prompt: message.prompt,
++ agent: 'claude-code',
++ model: task.model || 'opus',
++ safety: task.safety || 'ask',
++ mcp: true,
++ codes_url: baseUrl,
++ ...(attempt && attempt.session ? { resume: attempt.session } : {}),
++ // 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.
++ ...(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}` })) }
++ : {}),
++ };
++ }
++
++ /**
++ * Start the next queued message, if no turn is running. The task row is locked while the message is taken, so two
++ * presses start one turn and not two.
++ */
++ async function startNext(taskId) {
++ const taken = await store.tx(async () => {
++ const task = await db.maybe('SELECT * FROM run_tasks WHERE id = $1 FOR UPDATE', [taskId]);
++ if (!task || task.kind !== 'conductor') return { error: 'not a conductor' };
++ if (task.outcome === 'stopped') return { stopped: true };
++ const running = await db.maybe("SELECT id FROM codes_conductor_messages WHERE task_id = $1 AND state = 'running' LIMIT 1", [taskId]);
++ if (running) return { running: true };
++ const next = await db.maybe("SELECT * FROM codes_conductor_messages WHERE task_id = $1 AND state = 'queued' ORDER BY n ASC LIMIT 1", [taskId]);
++ if (!next) return { idle: true };
++ const attempt = await attemptOf(taskId);
++ const message = await db.one("UPDATE codes_conductor_messages SET state = 'running', started_at = $2::timestamptz WHERE id = $1 RETURNING *", [next.id, store.iso()]);
++ await db.exec(
++ `UPDATE run_tasks SET state = 'queued', outcome = NULL, outcome_note = NULL, error = NULL, finished_at = NULL,
++ session_complete_at = NULL, updated_at = $2 WHERE id = $1`,
++ [taskId, store.now()],
++ );
++ return { task, message, attempt };
++ });
++ if (taken.error || taken.running || taken.idle || taken.stopped) return taken;
++
++ const { task, message: turn, attempt } = taken;
++ let job;
++ try {
++ job = await run.submit({
++ subject: task.subject, project: task.project_id, args: turnArgs(task, turn, attempt), 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),
++ });
++ } catch (e) {
++ const why = String((e && (e.detail || e.message)) || 'the turn could not start').slice(0, 500);
++ await store.tx(async () => {
++ await db.exec("UPDATE codes_conductor_messages SET state = 'failed', error = $2, finished_at = $3::timestamptz WHERE id = $1", [turn.id, why, store.iso()]);
++ await db.exec("UPDATE run_tasks SET state = 'failed', error = $2, outcome = 'unfinished', outcome_note = $3, finished_at = $4::timestamptz, updated_at = $5 WHERE id = $1",
++ [task.id, why, `the turn could not start: ${why}`, store.iso(), store.now()]);
++ });
++ log.warn(`[Codes] conductor ${task.id}: the turn could not start: ${why}`);
++ return { error: why };
++ }
++ await store.tx(async () => {
++ if (attempt) await attempts.bind(attempt.id, { job_id: job.id, state: 'queued', machine: task.machine, model: task.model });
++ await db.exec("UPDATE codes_conductor_messages SET job_id = $2 WHERE id = $1", [turn.id, job.id]);
++ await db.exec(
++ `UPDATE run_tasks SET job_id = $2, state = $3, started_at = COALESCE($4::timestamptz, started_at), updated_at = $5 WHERE id = $1`,
++ [task.id, job.id, job.state || 'queued', (job.timings && job.timings.started_at) || null, store.now()],
++ );
++ });
++ pump(await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [task.id]), job, attempt).catch((e) => log.warn(`[Codes] conductor ${task.id} stream: ${String(e && e.message).slice(0, 200)}`));
++ return { message: turn, job, attempt };
++ }
++
++ /**
++ * Follow the turn's Run stream and record its lines as codes_session_lines, so the C2 SSE shows the transcript
++ * even when no browser opened the Run page. When the stream ends, read the job once and apply it (the events
++ * consumer would too; both are idempotent).
++ */
++ async function pump(task, job, attempt) {
++ if (!run || typeof run.streamUrl !== 'function' || !sessions || !task) return;
++ const url = await run.streamUrl(job.id, tasks.who(task));
++ const response = await fetch(url, { headers: { Accept: 'text/event-stream' }, signal: AbortSignal.timeout(wallMs + 60_000) });
++ if (!response.ok || !response.body) return;
++ const recorder = sessions.recorder(task.id, { attemptN: attempt ? Number(attempt.n) : 1 });
++ const decoder = new TextDecoder();
++ for await (const chunk of response.body) {
++ if (!url) break;
++ try { await recorder.push(decoder.decode(chunk, { stream: true })); } catch (e) { log.warn(`[Codes] conductor ${task.id} lines: ${e.message}`); }
++ }
++ try { await recorder.push(decoder.decode()); await recorder.flush(); } catch { /* the stream said what it could */ }
++ if (recorder.ended()) {
++ try {
++ const fresh = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [task.id]);
++ if (fresh && !TERMINAL.has(fresh.state)) {
++ const doc = await run.read(job.id, tasks.who(task));
++ await tasks.applyJob(fresh, doc);
++ }
++ } catch (e) { log.warn(`[Codes] conductor ${task.id}: ${String(e && e.message).slice(0, 200)}`); }
++ }
++ }
++
++ /**
++ * A turn ended. Land the message of the job that ended (done or failed), keep the session, and start the next
++ * queued message. With nothing queued the conductor is idle: state succeeded, outcome NULL. A turn that failed
++ * leaves the conductor "unfinished" so its page says what went wrong, and the next message still resumes the
++ * session.
++ *
++ * `jobId` is the Run job whose end this is. A late delivery for a turn that is no longer the task's job changes
++ * nothing: the next turn's job has already taken the task's job_id (startNext), so a second, racing call is a no-op.
++ */
++ async function onTurnEnd(taskId, jobId = null) {
++ const result = await store.tx(async () => {
++ const task = await db.maybe('SELECT * FROM run_tasks WHERE id = $1 FOR UPDATE', [taskId]);
++ if (!task || task.kind !== 'conductor') return { skip: true };
++ if (task.outcome === 'stopped') {
++ await db.exec("UPDATE codes_conductor_messages SET state = 'stopped', finished_at = COALESCE(finished_at, $2::timestamptz) WHERE task_id = $1 AND state IN ('queued', 'running')", [taskId, store.iso()]);
++ return { stop: true };
++ }
++ if (jobId && task.job_id && task.job_id !== jobId) return { skip: true, stale: true };
++ const ended = jobId || task.job_id;
++ const attempt = await attemptOf(taskId);
++ const turn = ended
++ ? await db.maybe('SELECT * FROM codes_conductor_messages WHERE task_id = $1 AND job_id = $2 ORDER BY n DESC LIMIT 1', [taskId, ended])
++ : null;
++ if (turn && turn.state === 'running') {
++ const ok = task.state === 'succeeded';
++ await db.exec(
++ `UPDATE codes_conductor_messages SET state = $2, result = $3::jsonb, error = $4, session = $5, finished_at = $6::timestamptz WHERE id = $1`,
++ [turn.id, ok ? 'done' : 'failed', task.result == null ? null : JSON.stringify(task.result),
++ ok ? null : String(task.error || 'the turn failed').slice(0, 1000), (attempt && attempt.session) || null, task.finished_at || store.iso()],
++ );
++ } else if (turn && turn.state !== 'done' && turn.state !== 'failed' && turn.state !== 'stopped') {
++ await db.exec("UPDATE codes_conductor_messages SET state = $2, finished_at = COALESCE(finished_at, $3::timestamptz) WHERE id = $1",
++ [turn.id, task.state === 'succeeded' ? 'done' : 'failed', store.iso()]);
++ }
++ const queued = await db.maybe("SELECT id FROM codes_conductor_messages WHERE task_id = $1 AND state = 'queued' LIMIT 1", [taskId]);
++ if (!queued && task.state !== 'succeeded') {
++ const note = `The conductor's turn ended without finishing: ${String(task.error || 'the machine said nothing more')}. Its session is kept; write a message to carry on.`;
++ return { row: await db.one("UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *", [taskId, note.slice(0, 1000), store.now()]) };
++ }
++ const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [taskId]);
++ return { row, next: !!queued };
++ });
++ if (result.skip || result.stop || !result.row) return result.row || null;
++ if (result.next) {
++ const started = await startNext(taskId);
++ if (started && started.task) return started.task;
++ }
++ return await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [taskId]);
++ }
++
++ /** Stop the conductor: the running turn and every waiting message stop; nothing carries on. */
++ async function stop(task) {
++ const out = await tasks.stopTask(task);
++ if (out.error) return out;
++ await db.exec("UPDATE codes_conductor_messages SET state = 'stopped', finished_at = COALESCE(finished_at, $2::timestamptz) WHERE task_id = $1 AND state IN ('queued', 'running')", [task.id, store.iso()]);
++ if (approvals) await approvals.stop(task.id);
++ return out;
++ }
++
++ /** One message as the API and the page show it: its words, its state, never the attachment bytes. */
++ function presentMessage(m) {
++ if (!m) return null;
++ const files = Array.isArray(m.attachments) ? m.attachments : asArray(m.attachments);
++ return {
++ id: m.id, n: Number(m.n) || 0, state: m.state,
++ prompt: String(m.prompt || '').slice(0, 16000),
++ attachments: files.map((a) => ({ name: a.name, mime: a.mime, size: a.size == null ? null : Number(a.size) })),
++ job_id: m.job_id || null, session: m.session || null,
++ result: asObject(m.result) ? { ok: asObject(m.result).ok === true, summary: typeof asObject(m.result).summary === 'string' ? asObject(m.result).summary.slice(0, 4000) : null } : null,
++ error: m.error ? String(m.error).slice(0, 1000) : null,
++ created_at: iso(m.created_at), started_at: iso(m.started_at), finished_at: iso(m.finished_at),
++ };
++ }
++
++ /** The conductor as its page shows it: the task, its one attempt and its turns. */
++ async function view(task) {
++ if (!task) return null;
++ const attempt = await attemptOf(task.id);
++ const messages = await db.many('SELECT * FROM codes_conductor_messages WHERE task_id = $1 ORDER BY n ASC', [task.id]);
++ const pending = approvals ? await approvals.pending(task.id) : [];
++ return {
++ task: tasks.present(task),
++ kind: 'conductor',
++ folder: task.folder || null,
++ model: task.model || null,
++ safety: task.safety || 'ask',
++ machine: task.machine || null,
++ idle: task.state === 'succeeded' && !task.outcome,
++ attempt: attempt ? attempts.present(attempt) : null,
++ messages: messages.map(presentMessage),
++ approvals: pending.map((a) => approvals.present(a)),
++ };
++ }
++
++ /** One attachment's bytes, for the runner to write as a file on the machine. */
++ 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 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') };
++ }
++
++ return {
++ create, enqueue, message, startNext, onTurnEnd, stop, view, presentMessage, attachment, fragment, turnArgs, projectFor,
++ SAFETY, MESSAGE_STATES, MAX_MESSAGE, MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES,
++ };
++}
++
++module.exports = { createConductor, validFolder, cleanAttachments, SAFETY, MODEL, NODE };
ddiff --git a/server/domain/notify.js b/server/domain/notify.js
nnew file mode 100644
iindex 0000000..9f44b25
---- /dev/null
++++ b/server/domain/notify.js
@@@ -0,0 +1,70 @@
++'use strict';
++
++/**
++ * Telling a person that their conductor needs them (Codes C9b): one OpenVibe notification through the Network, the
++ * only notification store (ADR-020). Codes pushes directly, with its own service token (network.notifications.push and
++ * identity.subject.resolve), because an approval is transactional: a person asked on their phone is not a domain event
++ * for Events to render.
++ *
++ * createNotify({ config, fetchImpl, log, send })
++ * push({ subject, type, title, message, url, priority, category, icon }) → { sent, skipped?, reason? }
++ *
++ * The optional `send` is for tests and for a caller that already knows how to reach the Network: it gets the same
++ * object body. A Network that is not configured (no client secret) or that does not answer is said, never thrown: a
++ * notification that could not be sent must not stop the thing it is about.
++ */
++
++const TYPES = new Set(['APPROVAL_NEEDED', 'WATCH_TRIGGERED', 'SYSTEM']);
++
++function createNotify({ config = {}, fetchImpl = null, log = console, send = null } = {}) {
++ const enabled = !!(config.oauth && config.oauth.clientSecret);
++ let client = null;
++
++ function clientOnce() {
++ if (client) return client;
++ const { createClient } = require('openvibe-sdk');
++ const { createServiceTokenClient } = require('openvibe-sdk/auth');
++ const { createNotificationsClient } = require('openvibe-sdk/notifications');
++ const tokens = createServiceTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.network',
++ scope: 'network.notifications.push identity.subject.resolve',
++ ...(fetchImpl ? { fetch: fetchImpl } : {}),
++ });
++ client = createNotificationsClient(createClient({ tokenProvider: tokens, timeoutMs: 8000, ...(fetchImpl ? { fetch: fetchImpl } : {}) }), { baseUrl: config.networkInternalUrl });
++ return client;
++ }
++
++ /**
++ * One notification to one person. The body is Network's own: type, title, message and a url back into Codes.
++ * Never throws; the answer says whether Network made a decision and whether the person switched it off.
++ */
++ async function push({ subject, type = 'SYSTEM', title, message, url, priority = 'normal', category = 'codes', icon = '🎛️' } = {}) {
++ if (!subject) return { sent: false, reason: 'no_subject' };
++ if (!title || !message) return { sent: false, reason: 'empty' };
++ const body = {
++ subjectId: subject,
++ type: TYPES.has(type) ? type : 'SYSTEM',
++ category, service: 'codes', icon, priority,
++ title: String(title).slice(0, 120),
++ message: String(message).slice(0, 300),
++ ...(url ? { url: String(url).slice(0, 500) } : {}),
++ };
++ if (send) {
++ try { return await send(body); } catch (e) { return { sent: false, reason: String((e && e.message) || e).slice(0, 200) }; }
++ }
++ if (!enabled) return { sent: false, reason: 'not_configured' };
++ try {
++ return await clientOnce().push(body);
++ } catch (e) {
++ log.warn(`[Codes] notification to ${subject} failed: ${String((e && e.message) || e).slice(0, 200)}`);
++ return { sent: false, reason: 'network_error' };
++ }
++ }
++
++ return { push, enabled: enabled || !!send, TYPES };
++}
++
++module.exports = { createNotify, TYPES };
ddiff --git a/server/domain/run-client.js b/server/domain/run-client.js
iindex 6873c83..1d29547 100644
---- a/server/domain/run-client.js
++++ b/server/domain/run-client.js
@@@ -48,16 +48,21 @@ function createRunClient({ config, fetchImpl = globalThis.fetch, now = () => Dat
if (!res.ok) throw new RunError(res.status, (doc && doc.code) || 'run.error', (doc && (doc.detail || doc.title)) || `Run answered ${res.status}`);
return doc;
}
-- /** A coding job for `subject` in `project`, only on that person's own machine. */
-- async function submit({ subject, project, args, key }) {
++ /**
++ * A coding job for `subject` in `project`, only on that person's own machine. A conductor's turn asks for more: its
++ * wall time (a session that works through a plan), its TTL and its own memory, all bounded here.
++ */
++ async function submit({ subject, project, args, key, limits = null, ttlMs = null }) {
++ const wall = Math.max(1000, Math.min(90 * 60_000, Number(limits && limits.wall_ms) || Number(args && args.limits && args.limits.ms) || 30 * 60_000));
const body = {
class: 'code', artifact: { name: 'openvibe-codes', version: JOB_PROTOCOL }, args,
limits: {
-- wall_ms: Math.max(1000, Math.min(30 * 60_000, Number(args.limits && args.limits.ms) || 30 * 60_000)),
-- cpu_ms: Math.max(1000, Math.min(30 * 60_000, Number(args.limits && args.limits.ms) || 30 * 60_000)),
-- mem_bytes: 4 * 1024 ** 3,
++ wall_ms: wall,
++ cpu_ms: Math.max(1000, Math.min(90 * 60_000, Number(limits && limits.cpu_ms) || wall)),
++ mem_bytes: Math.max(1024 ** 2, Math.min(16 * 1024 ** 3, Number(limits && limits.mem_bytes) || 4 * 1024 ** 3)),
},
-- ttl_ms: 45 * 60_000, egress: 'public',
++ ttl_ms: Math.max(60_000, Math.min(86_400_000, Number(ttlMs) || 45 * 60_000)),
++ egress: 'public',
requirements: { kind: 'run.code', mobility: 'job', latency_class: 'background', objective: 'balanced', trust: ['user-owned'] },
};
const out = await call('POST', '/api/v1/jobs', { subject, project, body, key });
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex 156b80e..99a3ca6 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -140,7 +140,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 }) {
++function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = 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).
@@@ -149,6 +149,12 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
// The proof of a finished task (stage C8): the tests, the pages, the diff and the second model's opinion.
const taskProof = proof || require('./proof').createProof({ store, config, log });
const attemptStore = attempts || require('./attempts').createAttempts({ store, config, log });
++ /**
++ * The conductor's turns (server/domain/conductor.js). Wired by the app after both exist, because the conductor
++ * starts Run jobs and this module is what applies their events: a constructor argument would be a cycle.
++ */
++ let conductorTurns = conductor;
++ const setConductor = (turns) => { conductorTurns = turns; };
/** An attempt's state when it ended: a limit reached is a budget-stop, never a plain failure. */
const ENDED = new Set(['done', 'failed', 'stopped', 'budget-stopped', 'interrupted']);
@@@ -295,25 +301,35 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
if (attempt) {
if (TERMINAL.has(state)) {
const usage = usageOf(updated.result);
-- let why = reasonOf(state, updated.error);
-- const localHit = budgetHit(attempt, usage, why);
-- // OpenVibe.AI refused the run for its budget (402 ai.run_budget_reached): the attempt ends at its
-- // budget, not as a crash. Which budget ran out is in the refusal itself.
-- const refused = !localHit && budgetRefused(updated.result, updated.error);
-- const hit = localHit || (refused ? budgetKind(updated.result, updated.error) : null);
-- if (refused) why = 'budget';
-- if (refused) budgetAttempt = { id: attempt.id };
-- await attemptStore.end(attempt.id, {
-- state: hit ? 'budget-stopped' : (ATTEMPT_STATE[state] || 'failed'),
-- reason: hit ? 'budget' : why,
-- cost_usd: usage.cost_usd, tokens: usage.tokens, steps: usage.steps, session: usage.session,
-- model: usage.model, machine: machineOf(next.job),
-- budget_hit: hit,
-- finished_at: updated.finished_at || store.iso(),
-- });
++ // A conductor's attempt outlives every turn: the same session is resumed by the next message, so
++ // its end is not the attempt's end — it waits for the person.
++ if (current.kind === 'conductor') {
++ await attemptStore.bind(attempt.id, {
++ state: 'waiting', session: usage.session, model: usage.model, machine: machineOf(next.job),
++ started_at: updated.started_at || null,
++ });
++ } else {
++ let why = reasonOf(state, updated.error);
++ const localHit = budgetHit(attempt, usage, why);
++ // OpenVibe.AI refused the run for its budget (402 ai.run_budget_reached): the attempt ends at its
++ // budget, not as a crash. Which budget ran out is in the refusal itself.
++ const refused = !localHit && budgetRefused(updated.result, updated.error);
++ const hit = localHit || (refused ? budgetKind(updated.result, updated.error) : null);
++ if (refused) why = 'budget';
++ if (refused) budgetAttempt = { id: attempt.id };
++ await attemptStore.end(attempt.id, {
++ state: hit ? 'budget-stopped' : (ATTEMPT_STATE[state] || 'failed'),
++ reason: hit ? 'budget' : why,
++ cost_usd: usage.cost_usd, tokens: usage.tokens, steps: usage.steps, session: usage.session,
++ model: usage.model, machine: machineOf(next.job),
++ budget_hit: hit,
++ finished_at: updated.finished_at || store.iso(),
++ });
++ }
} else {
await attemptStore.bind(attempt.id, {
-- state: ATTEMPT_STATE[state] || 'queued', started_at: updated.started_at || null, machine: machineOf(next.job),
++ state: current.kind === 'conductor' ? 'running' : (ATTEMPT_STATE[state] || 'queued'),
++ started_at: updated.started_at || null, machine: machineOf(next.job),
});
}
}
@@@ -458,6 +474,8 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
if (!row) return null;
if (!TERMINAL.has(row.state)) return row;
if (row.outcome === 'stopped') return row; // a person stopped it: nothing carries it on
++ // The conductor's turns are its own: a turn that ended lands its message and the next one starts.
++ if (row.kind === 'conductor') return conductorTurns ? conductorTurns.onTurnEnd(row.id, row.job_id) : row;
const actorRef = asObject(row.actor_ref);
if (actorRef && actorRef.bench) {
const done = row.state === 'succeeded';
@@@ -656,7 +674,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
return {
start, refresh, applyJob, applyEvent, recordState, afterEnd, continueTask, stopTask, proof: taskProof,
-- latestProject, present, who, attempts: attemptStore,
++ latestProject, present, who, attempts: attemptStore, setConductor,
};
}
ddiff --git a/server/http/c4.js b/server/http/c4.js
iindex a29a51d..b4fe4fe 100644
---- a/server/http/c4.js
++++ b/server/http/c4.js
@@@ -23,6 +23,7 @@ const { homeJsonLd } = require('./discovery');
const LINKS = [
['Home', '/', '<path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/>'],
['Tasks', '/tasks', '<path d="M8 6h12M8 12h12M8 18h12M3 6h.01M3 12h.01M3 18h.01"/>'],
++ ['Conductor', '/conductor', '<path d="M4 17V9m4 8V5m4 12v-6m4 6V8m4 9v-4"/>'],
['Agents', '/agents', '<circle cx="12" cy="8" r="3"/><path d="M5 20a7 7 0 0 1 14 0M12 2v3M3 8h3m12 0h3"/>'],
['Machines', '/machines', '<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>'],
['Usage', '/usage', '<path d="M4 19V5m0 14h17M8 15v-4m5 4V7m5 8v-6"/>'],
@@@ -59,7 +60,7 @@ const PROVIDER_NAMES = { anthropic: 'Anthropic', openai: 'OpenAI', deepseek: 'De
function createC4Routes(ctx) {
const r = asyncRouter();
-- const { config, store, harnesses, tasks, sso, connections, machines, userSettings, github, usage } = ctx;
++ const { config, store, harnesses, tasks, sso, connections, machines, userSettings, github, usage, conductor, approvals } = ctx;
const db = store.db;
const form = express.urlencoded({ extended: false, limit: '16kb' });
const page = (req, res, title, body, extra = {}) => {
@@@ -233,11 +234,67 @@ ${patch ? `<details class="c4-fold c4-fold-wide"><summary><span class="c4-row-la
${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="c4-facts"><div><dt>CI</dt><dd>${pill(pr.ci || 'No checks reported', pr.ci === 'Passed' ? 'ok' : pr.ci === 'Failed' || pr.ci === 'Errored' ? 'bad' : 'skip')}</dd></div><div><dt>Merge</dt><dd>${pill(pr.merge || 'Open', pr.state === 'merged' ? 'ok' : pr.merge === 'Has conflicts' ? 'bad' : 'skip')}</dd></div><div><dt>Checked</dt><dd>${esc(age(pr.checked_at))}</dd></div></dl>`}</div>`;
};
++ // ── The conductor (C9b) ─────────────────────────────────────────────────────────────────────────────────────
++ 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');
++ const conductorKind = (row) => (row.outcome === 'stopped' ? 'bad' : row.outcome === 'unfinished' ? 'bad' : ['queued', 'placed', 'running'].includes(row.state) ? 'open' : 'ok');
++
++ /** One stored session line, as the transcript shows it (the same shape public/js/c4.js draws live). */
++ const lineText = (l) => {
++ const b = l.body || {};
++ if (typeof b === 'string') return b;
++ if (b.t === 'tool') return `→ ${b.name || ''} ${String((b.input && (b.input.command || b.input.file_path)) || b.what || '')}`.trim();
++ if (b.t === 'tool_result') return ` ${b.ok === false ? '✗' : '·'} ${String(b.output || '').split('\n')[0]}`;
++ if (b.t === 'done') return `done${b.ok === false ? ' (failed)' : ''}: ${String(b.text || '').split('\n')[0].slice(0, 300)}`;
++ return String(b.text || b.message || '').split('\n')[0].slice(0, 300);
++ };
++
++ /** One turn: what the person said, and what the turn answered. The first turn is the hand-over brief. */
++ const turnRow = (m, taskId) => {
++ const word = { queued: 'Waiting', running: 'Working', done: 'Answered', failed: "Didn't finish", stopped: 'Stopped' }[m.state] || m.state;
++ const kind = m.state === 'done' ? 'ok' : m.state === 'failed' ? 'bad' : m.state === 'running' ? 'open' : 'skip';
++ const files = (m.attachments || []).length
++ ? `<p class="c4-turn-files">${m.attachments.map((a) => `<a class="c4-chip" href="/api/v1/tasks/${encodeURIComponent(taskId)}/messages/${encodeURIComponent(m.id)}/attachments/${m.attachments.indexOf(a)}" rel="noopener">${esc(a.name)}</a>`).join('')}</p>`
++ : '';
++ const answer = m.result && m.result.summary ? `<pre class="c4-turn-answer" tabindex="0">${esc(m.result.summary)}</pre>` : '';
++ const error = m.error ? `<p class="c4-turn-error">${esc(m.error)}</p>` : '';
++ return `<li class="c4-turn c4-turn-${esc(m.state)}"><div class="c4-turn-head"><span class="c4-turn-who">${m.n === 1 ? 'Hand-over brief' : esc(`You · turn ${m.n}`)}</span>${pill(word, kind)}</div><div class="c4-turn-text">${esc(m.prompt)}</div>${files}${answer}${error}</li>`;
++ };
++
++ /** One waiting approval: the command the person reads, and the two answers. */
++ const approvalCard = (a, action) => `<div class="c4-approval"><div class="c4-approval-head"><span class="c4-row-label">${esc(a.tool || 'a tool')}</span><span class="c4-muted">asked ${esc(age(a.created_at))}</span></div><code class="c4-approval-cmd" tabindex="0">${esc(a.summary || 'a tool wants to run')}</code>${a.task_prompt ? `<p class="c4-approval-task">on ${esc(String(a.task_prompt).split('\n')[0].slice(0, 120))}</p>` : ''}<form class="c4-approval-actions" method="post" action="${esc(action)}"><button class="c4-opill c4-opill-primary" name="decision" value="allow" type="submit">Allow</button><button class="c4-opill" name="decision" value="deny" type="submit">Deny</button></form></div>`;
++
++ /** The conductor's page: its turns, what needs the person, and the box that writes the next turn. */
++ async function conductorPage(req, res, row, machineLabel) {
++ 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]);
++ const lastSeq = last && last.seq != null ? Number(last.seq) : 0;
++ const live = ['queued', 'placed', 'running'].includes(row.state);
++ // Nothing is streaming between turns, so the last turn's lines are read here: the person can see what it did.
++ const recent = live ? [] : await db.many('SELECT body FROM codes_session_lines WHERE task_id = $1 ORDER BY seq DESC LIMIT 60', [row.id]);
++ const lastLines = recent.reverse().map(lineText).filter(Boolean).join('\n');
++ const api = `/api/v1/tasks/${encodeURIComponent(row.id)}`;
++ const pending = view.approvals || [];
++ const turns = (view.messages || []).map((m) => turnRow(m, row.id)).join('');
++ const approvals = pending.length
++ ? `<section class="c4-card c4-approvals" id="approvals"><div class="c4-card-head"><h2>Needs you</h2><span class="c4-muted">Your conductor is waiting</span></div>${pending.map((a) => approvalCard(a, `/tasks/${encodeURIComponent(row.id)}/approvals/${encodeURIComponent(a.id)}`)).join('')}</section>`
++ : '';
++ 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(row.model || 'opus')}</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}<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('/tasks', content, true));
++ }
++
// ── Home ────────────────────────────────────────────────────────────────────────────────────────────────────
r.get('/', async (req, res) => {
const rows = await ownTasks(req, 8);
const signedIn = !!viewer(req);
const settings = signedIn ? await userSettings.get(req.viewer.subject) : null;
++ const waiting = signedIn ? await approvals.inbox(req.viewer.subject) : [];
++ const conductors = rows.filter((x) => x.kind === 'conductor');
const repos = signedIn ? await recentRepos(req) : [];
const mine = signedIn ? await machinesOf(req, res) : { ok: false, machines: [] };
const used = signedIn ? await lastUsedMachines(req) : [];
@@@ -247,7 +304,8 @@ ${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="
const saved = repos.map((repo) => `<a class="c4-chip" href="/?repo=${encodeURIComponent(repo.url)}#c4-prompt" data-c4-saved-repo="${esc(repo.url)}">${icon('<path d="M4 4h16v16l-8-4-8 4z"/>')}${esc(repo.full_name)}</a>`).join('');
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></div><p class="c4-sub">Approvals and questions</p><div class="c4-emptyline">Nothing needs your attention. <a href="/tasks">View tasks</a></div></section></div>
++<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>` : ''}
<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>`}`;
@@@ -295,6 +353,16 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h
if (!safeId(req.params.id)) return res.status(404).send('Not found');
const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2', [req.params.id, req.viewer.subject]);
if (!row) return res.status(404).send('Not found');
++ // A conductor is not a coding task: its page is its conversation, not a diff.
++ if (row.kind === 'conductor') {
++ let label = null;
++ if (row.machine) {
++ const known = await machinesOf(req, res);
++ const hit = (known.machines || []).find((m) => m.id === row.machine || m.node_id === row.machine);
++ label = hit ? (hit.name || hit.node_id) : (/^nod_/.test(row.machine) ? 'your machine' : row.machine);
++ }
++ return await conductorPage(req, res, row, label);
++ }
const live = ['queued', 'placed', 'running'].includes(row.state);
const attempts = tasks?.attempts ? await tasks.attempts.list(row.id) : [];
const attemptItems = attempts.map((attempt) => {
@@@ -327,6 +395,96 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h
page(req, res, 'Task', wrap('/tasks', content, true));
});
++ // ── The conductor's pages (C9b): start one, write to it, answer what it asks ────────────────────────────────
++ r.get('/conductor', async (req, res) => {
++ if (!viewer(req)) return page(req, res, 'Conductor', wrap('/conductor', `${header('Conductor')}${signIn(req)}`));
++ const settings = await userSettings.get(req.viewer.subject);
++ const mine = await machinesOf(req, res);
++ const used = await lastUsedMachines(req);
++ const machineOptions = orderMachines((mine.machines || []).filter((m) => m.state !== 'revoked'), used)
++ .map((m) => `<option value="${esc(m.id)}"${settings.default_machine === m.id ? ' selected' : ''}>${esc(m.name || m.node_id || m.id)}</option>`).join('');
++ const folders = await db.many(
++ `SELECT folder, max(created_at) AS at FROM run_tasks WHERE subject = $1 AND kind = 'conductor' AND folder IS NOT NULL
++ GROUP BY folder ORDER BY at DESC LIMIT 8`, [req.viewer.subject],
++ );
++ 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([row.model || 'opus', 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)}
++<section class="c4-card c4-composer"><form method="post" action="/conductor">
++<label for="c4-folder">Working folder</label><input id="c4-folder" name="folder" required placeholder="/home/you/project" value="${esc(String(req.query.folder || ''))}" list="c4-folders" autocomplete="off" spellcheck="false"><datalist id="c4-folders">${folders.map((f) => `<option value="${esc(f.folder)}"></option>`).join('')}</datalist>
++<div class="c4-controls"><label>Machine <select name="machine"><option value="">Your default</option>${machineOptions}</select></label><label>Model <input name="model" value="opus" 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>
++<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>
++<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>` : ''}`;
++ page(req, res, 'Conductor', wrap('/conductor', content, true));
++ });
++
++ r.post('/conductor', form, async (req, res) => {
++ if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/conductor')}`);
++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
++ const b = req.body || {};
++ try {
++ 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: String(b.folder || '').trim(), machine: b.machine || null,
++ model: b.model || 'opus', 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)}`);
++ } catch (e) {
++ return res.redirect(303, `/conductor?error=${encodeURIComponent(String((e && e.message) || 'the conductor could not start').slice(0, 180))}`);
++ }
++ });
++
++ /** A message from the task page: text only (an attached image goes through the JSON API, with this script). */
++ r.post('/tasks/:id/messages', form, async (req, res) => {
++ if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
++ if (!safeId(req.params.id)) return res.status(404).send('Not found');
++ 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 });
++ 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))}`);
++ }
++ });
++
++ /** The decision on a task's page: allow or deny one waiting approval. */
++ r.post('/tasks/:id/approvals/:aid', form, async (req, res) => {
++ if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
++ if (!safeId(req.params.id)) return res.status(404).send('Not found');
++ const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2', [req.params.id, req.viewer.subject]);
++ if (!row) return res.status(404).send('Not found');
++ const decision = (req.body || {}).decision === 'allow' ? 'allow' : 'deny';
++ const out = await approvals.decide(row.id, req.params.aid, { decision });
++ const word = !out ? 'that approval is gone' : decision === 'allow' ? 'Allowed' : 'Denied';
++ return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}?${out ? 'ok' : 'error'}=${encodeURIComponent(word)}`);
++ });
++
++ /** The approvals inbox: what the person's conductors are waiting on, from anywhere. */
++ r.get('/approvals', async (req, res) => {
++ if (!viewer(req)) return page(req, res, 'Approvals', wrap('/approvals', `${header('Approvals')}${signIn(req)}`));
++ const rows = await approvals.inbox(req.viewer.subject);
++ const cards = rows.map((a) => `<li class="c4-approval-item">${approvalCard(a, `/approvals/${encodeURIComponent(a.id)}`)}<p class="c4-hint"><a href="/tasks/${encodeURIComponent(a.task_id)}">Open the conductor</a></p></li>`).join('');
++ const content = `${header('Approvals', 'What your conductors are waiting on.')}${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>Waiting for you</h2><span class="c4-muted">${rows.length} decision${rows.length === 1 ? '' : 's'}</span></div>${cards ? `<ul class="c4-approval-list">${cards}</ul>` : '<div class="c4-emptyline">Nothing needs your attention.</div>'}</section>`;
++ page(req, res, 'Approvals', wrap('/approvals', content, true), { notificationsRealtime: true });
++ });
++
++ r.post('/approvals/:id', form, async (req, res) => {
++ if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/approvals')}`);
++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
++ const decision = (req.body || {}).decision === 'allow' ? 'allow' : 'deny';
++ const out = await approvals.decideAny(req.viewer.subject, req.params.id, { decision });
++ return res.redirect(303, `/approvals?${out ? 'ok' : 'error'}=${encodeURIComponent(out ? (decision === 'allow' ? 'Allowed' : 'Denied') : 'that approval is gone')}`);
++ });
++
// ── Agents: the person's connections (keys live in OpenVibe.AI) ────────────────────────────────────────────
const connectionsCard = async (req) => {
let list = { keys: [], plans: [] };
ddiff --git a/server/http/conductor-api.js b/server/http/conductor-api.js
nnew file mode 100644
iindex 0000000..9ec8e0d
---- /dev/null
++++ b/server/http/conductor-api.js
@@@ -0,0 +1,273 @@
++'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
++ * `openvibe-codes mcp` speak these routes with the person's own Network token.
++ *
++ * POST /conductor create it (folder, machine?, model?, safety?, note?)
++ * 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/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
++ * 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
++ * POST /approvals/:id the decision: allow or deny
++ *
++ * Who is asking: the signed-in person (the session cookie), or their own Network access token as a Bearer — the token
++ * `openvibe-codes task` reads from ~/.config/openvibe/token. Always the person's own rows; another person's task is 404.
++ *
++ * The rule: the conductor may start sub-tasks on any agent except an Anthropic one. codes.task_start refuses
++ * claude-code / claude-other, an Anthropic model, or no agent at all (which the router could send to Claude Code): the
++ * conductor is the only Anthropic session in the system.
++ */
++
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { asyncRouter } = require('./router');
++const { parseArgs } = require('../../harness/job');
++
++const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
++const MESSAGE = /^msg_[0-9A-HJKMNP-TV-Z]{26}$/;
++const APPROVAL = /^apr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const ANTHROPIC_AGENTS = new Set(['claude-code', 'claude-other']);
++const ANTHROPIC_MODEL = /(^|[^a-z])(claude|opus|sonnet|haiku|anthropic)([^a-z]|$)/i;
++const WAIT_MS = 20_000;
++const WAIT_POLL_MS = 750;
++
++function createConductorApi({ config, store, keys, harnesses, tasks, conductor, approvals, machines = null, actorLimits, log = console }) {
++ const r = asyncRouter();
++ const db = store.db;
++ const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
++ const json = express.json({ limit: '12mb' }); // four 2 MB images, base64
++ const small = express.json({ limit: '64kb' });
++ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
++ 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; }
++ };
++
++ /** The person a Network access token belongs to, or null (the same check the tasks API makes). */
++ async function personOf(token) {
++ if (!keys.loaded()) await keys.ensure();
++ const v = await keys.verifyUser(token, { 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 USER.test(subject || '') ? subject : null;
++ }
++
++ /** The signed-in person, or their own Bearer → req.person { subject, token }. A service token is not accepted. */
++ const person = async (req, res, next) => {
++ const header = String(req.headers.authorization || '');
++ if (header.startsWith('Bearer ')) {
++ const token = header.slice(7).trim();
++ const subject = await personOf(token);
++ if (!subject) return problem(req, res, 401, 'token.invalid', 'that is not a person’s Network access token');
++ req.person = { subject, token };
++ return next();
++ }
++ if (signedIn(req)) {
++ req.person = { subject: req.viewer.subject, token: req.viewer.token || null };
++ return next();
++ }
++ return problem(req, res, 401, 'token.required', 'sign in, or send your Network access token as a Bearer');
++ };
++
++ /** The person's own task, or null. */
++ const ownTask = async (req, id) => {
++ if (!TASK.test(String(id))) return null;
++ return await db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2', [id, req.person.subject]);
++ };
++ const ownConductor = async (req, id) => {
++ const row = await ownTask(req, id);
++ return row && row.kind === 'conductor' ? row : null;
++ };
++
++ /**
++ * The project a conductor runs in: the person's first project they may delegate in (created when they have none),
++ * read from Network with their own token; a person whose token cannot read Network falls back to the project of
++ * their latest task, and is told when there is none.
++ */
++ async function projectFor(req) {
++ return await conductor.projectFor(req.person.token, req.person.subject);
++ }
++
++ /** The machine is one of the person's own, when Network can say (it places on their machines only, either way). */
++ async function machineProblem(req, machine) {
++ if (!machine || !machines || !req.person.token) return null;
++ try {
++ const out = await machines.list(req.person.token);
++ if (!out.ok) return null;
++ const hit = (out.machines || []).find((m) => m.id === machine || m.node_id === machine);
++ if (!hit) return 'that machine is not one of yours';
++ if (hit.state === 'revoked') return 'that machine is revoked: pair it again';
++ return null;
++ } catch { return null; }
++ }
++
++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
++
++ r.post('/conductor', person, actorLimits.budget('codes.task.start'), small, async (req, res) => {
++ if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site JSON posts are refused');
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const folder = typeof b.folder === 'string' ? b.folder.trim() : '';
++ const machine = typeof b.machine === 'string' && b.machine ? b.machine : null;
++ const bad = await machineProblem(req, machine);
++ if (bad) return problem(req, res, 422, 'conductor.machine_invalid', bad);
++ const project = await projectFor(req);
++ if (!project) return problem(req, res, 409, 'codes.no_project', 'start one task at ' + config.baseUrl + '/run first: a conductor runs in your project, on your machine');
++ try {
++ const made = await conductor.create({
++ subject: req.person.subject, project, folder, machine,
++ model: typeof b.model === 'string' && b.model ? b.model : 'opus',
++ safety: typeof b.safety === 'string' && b.safety ? b.safety : 'ask',
++ note: typeof b.note === 'string' ? b.note : null,
++ });
++ log.info(`[Codes] conductor ${made.task.id} started for ${req.person.subject}`);
++ return res.status(201).json(await conductor.view(made.task));
++ } catch (e) {
++ return problem(req, res, e.status || 500, e.code || 'conductor.failed', String((e && e.message) || 'the conductor could not start'));
++ }
++ });
++
++ r.get('/conductor/:id', person, actorLimits.reads('codes.read'), async (req, res) => {
++ const row = await ownConductor(req, req.params.id);
++ if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such conductor of yours');
++ return res.json(await conductor.view(await tasks.refresh(row)));
++ });
++
++ r.post('/conductor/:id/messages', person, actorLimits.budget('codes.task.start'), json, async (req, res) => {
++ if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site JSON posts are refused');
++ 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 : {};
++ try {
++ const out = await conductor.message(row, { text: b.text, attachments: b.attachments });
++ return res.status(201).json({
++ message: conductor.presentMessage(out.message),
++ started: !!(out.started && out.started.job),
++ task: tasks.present(await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [row.id])),
++ });
++ } catch (e) {
++ return problem(req, res, e.status || 500, e.code || 'conductor.message_failed', String((e && e.message) || 'the message could not be sent'));
++ }
++ });
++
++ r.post('/conductor/:id/stop', person, actorLimits.budget('codes.task.start'), async (req, res) => {
++ if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site posts are refused');
++ 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 out = await conductor.stop(row);
++ if (out.error) return problem(req, res, 409, 'codes.task_conflict', out.error);
++ return res.json(await conductor.view(out.row));
++ });
++
++ /**
++ * A sub-task on another harness. The rule: never an Anthropic one — that is the conductor's session alone. An
++ * unnamed agent is filled in with a non-Anthropic default, because the router would otherwise be free to pick
++ * Claude Code.
++ */
++ r.post('/conductor/:id/tasks', person, actorLimits.budget('codes.task.start'), small, async (req, res) => {
++ if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site JSON posts are refused');
++ 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 : {};
++ const prompt = typeof b.prompt === 'string' ? b.prompt.trim() : '';
++ if (!prompt) return problem(req, res, 422, 'codes.task_invalid', 'a sub-task needs a prompt');
++ const defaultAgent = (config.conductor && config.conductor.subagentAgent) || 'codex';
++ const wanted = typeof b.agent === 'string' && b.agent ? b.agent : defaultAgent;
++ const model = typeof b.model === 'string' && b.model ? b.model : null;
++ if (ANTHROPIC_AGENTS.has(wanted) || ANTHROPIC_MODEL.test(wanted) || (model && ANTHROPIC_MODEL.test(model))) {
++ return problem(req, res, 422, 'conductor.anthropic_subtask', 'the conductor may start sub-tasks on any agent except an Anthropic one: it is the only Anthropic session here');
++ }
++ const agent = wanted;
++ if (!harnesses.list().some((h) => h.id === agent)) return problem(req, res, 422, 'codes.agent_unknown', `agent is one of GET /api/v1/harnesses, and not an Anthropic one`);
++ if (typeof b.repo !== 'string' || !/^https:\/\//.test(b.repo)) return problem(req, res, 422, 'codes.task_invalid', 'a sub-task needs the repository it works in (https://…)');
++ let args;
++ try {
++ args = parseArgs(JSON.stringify({ repo: b.repo, ref: b.ref || null, prompt, agent, ...(model ? { model } : {}), permission: 'edit' }));
++ } catch (e) {
++ return problem(req, res, 422, 'codes.task_invalid', e.message);
++ }
++ const task = await tasks.start({ subject: req.person.subject, project: row.project_id, args, via: 'conductor', key: null, actorRef: { conductor: { task_id: row.id } } });
++ log.info(`[Codes] conductor ${row.id} started sub-task ${task.id} on ${agent}`);
++ return res.status(201).json({ task: { ...tasks.present(task), outcome: { state: task.outcome || null, note: task.outcome_note || null } } });
++ });
++
++ /** The last lines of a task's session, newest last: what `codes_task_show` prints as its tail. */
++ r.get('/tasks/:id/lines', person, actorLimits.reads('codes.read'), async (req, res) => {
++ const row = await ownTask(req, req.params.id);
++ if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of yours');
++ const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 20));
++ const rows = await db.many(
++ 'SELECT seq, at, kind, body FROM codes_session_lines WHERE task_id = $1 ORDER BY seq DESC LIMIT $2', [row.id, limit],
++ );
++ return res.json({ task_id: row.id, lines: rows.reverse().map((l) => ({ seq: Number(l.seq), at: l.at, kind: l.kind, body: l.body })) });
++ });
++
++ /** One attached image, as its bytes: the machine writes it as a file for the turn. */
++ r.get('/tasks/:id/messages/:mid/attachments/:i', person, async (req, res) => {
++ const row = await ownTask(req, req.params.id);
++ if (!row || !MESSAGE.test(String(req.params.mid))) return problem(req, res, 404, 'codes.task_not_found', 'no such attachment');
++ const file = await conductor.attachment(row.id, req.params.mid, Number(req.params.i));
++ if (!file) return problem(req, res, 404, 'codes.attachment_not_found', 'no such attachment');
++ res.set('Content-Type', file.mime);
++ res.set('Content-Disposition', `inline; filename="${file.name.replace(/[^A-Za-z0-9._-]/g, '_')}"`);
++ return res.send(file.buffer);
++ });
++
++ /** The approve MCP asks: one waiting approval for a tool call in this task. */
++ r.post('/tasks/:id/approvals', person, actorLimits.budget('codes.task.start'), small, async (req, res) => {
++ if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site JSON posts are refused');
++ const row = await ownTask(req, req.params.id);
++ if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of yours');
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ try {
++ const made = await approvals.create({ task: row, tool: b.tool, input: b.input, summary: b.summary });
++ return res.status(201).json(approvals.present(made));
++ } catch (e) {
++ return problem(req, res, e.status || 500, e.code || 'approval.failed', String((e && e.message) || 'the approval could not be asked'));
++ }
++ });
++
++ /** The answer, long-polled: a decision at once, or within 20 s; ?wait=0 answers what is there now. */
++ r.get('/tasks/:id/approvals/:aid', person, actorLimits.reads('codes.read'), async (req, res) => {
++ const row = await ownTask(req, req.params.id);
++ if (!row || !APPROVAL.test(String(req.params.aid))) return problem(req, res, 404, 'approval.not_found', 'no such approval');
++ const wait = String(req.query.wait || '1') !== '0';
++ const until = Date.now() + WAIT_MS;
++ let found = await approvals.get(row.id, req.params.aid);
++ while (found && !found.decision && wait && Date.now() < until) {
++ await new Promise((done) => setTimeout(done, WAIT_POLL_MS));
++ found = await approvals.get(row.id, req.params.aid);
++ }
++ if (!found) return problem(req, res, 404, 'approval.not_found', 'no such approval');
++ return res.json(approvals.present(found));
++ });
++
++ r.get('/approvals', person, actorLimits.reads('codes.read'), async (req, res) => {
++ const rows = await approvals.inbox(req.person.subject);
++ return res.json({ approvals: rows.map((a) => approvals.present(a)) });
++ });
++
++ r.post('/approvals/:id', person, actorLimits.budget('codes.task.start'), small, async (req, res) => {
++ if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site JSON posts are refused');
++ if (!APPROVAL.test(String(req.params.id))) return problem(req, res, 404, 'approval.not_found', 'no such approval');
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const decision = b.decision === 'allow' ? 'allow' : b.decision === 'deny' ? 'deny' : null;
++ if (!decision) return problem(req, res, 422, 'approval.decision_invalid', 'decision is allow or deny');
++ const row = await approvals.decideAny(req.person.subject, req.params.id, { decision, message: b.message });
++ if (!row) return problem(req, res, 404, 'approval.not_found', 'no such approval of yours');
++ log.info(`[Codes] approval ${row.id} ${row.decision} by ${req.person.subject}`);
++ return res.json(approvals.present(row));
++ });
++
++ return r;
++}
++
++module.exports = { createConductorApi, ANTHROPIC_AGENTS, ANTHROPIC_MODEL };
ddiff --git a/server/http/moved.js b/server/http/moved.js
iindex 8c39d39..d33d460 100644
---- a/server/http/moved.js
++++ b/server/http/moved.js
@@@ -17,9 +17,10 @@ const SERVICES_ORIGIN = 'https://openvibe.services';
// The console's paths (and everything under them), and the platform policy pages that moved with it.
const MOVED_PREFIXES = ['/docs', '/projects', '/releases', '/apps', '/oauth', '/tools', '/manifests', '/staff'];
const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency'];
--// The console's API: everything under /api/v1 but the agent catalog, the coding tasks (codes.task.*), the user task
--// API, the Action Center read (codes.actions.read) and a person's own connections (Codes C3).
--const API_STAYS = /^\/api\/v1\/(harnesses|tasks|actions|me\/tasks|connections)(\/|$)/;
++// The console's API: everything under /api/v1 but the agent catalog, the coding tasks (codes.task.*), the person's
++// own API (/me), the Action Center read (codes.actions.read), a person's own connections (Codes C3) and the conductor
++// with its approvals inbox (Codes C9b).
++const API_STAYS = /^\/api\/v1\/(harnesses|tasks|actions|me|connections|conductor|approvals)(\/|$)/;
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
ddiff --git a/test/approvals.test.js b/test/approvals.test.js
nnew file mode 100644
iindex 0000000..c5c4f06
---- /dev/null
++++ b/test/approvals.test.js
@@@ -0,0 +1,199 @@
++'use strict';
++
++/**
++ * Codes C9b: approvals, end to end. The conductor's Claude Code session runs with `--permission-mode default` and a
++ * permission prompt tool; when a risky call comes up, the approve MCP tool (bin/mcp.js, a real child process) asks Codes,
++ * Codes writes the approval and tells the person, and the answer goes back to the tool as allow or deny.
++ *
++ * Checked here: the MCP server's tools, an approval asked and allowed, one asked and denied, the notification, a stop
++ * that answers everything still waiting, and — with a stand-in `claude` on PATH — the flags and the MCP config a
++ * conductor's turn is started with.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { spawn, spawnSync } = require('child_process');
++const { boot, check, done } = require('./helpers/boot');
++
++const BIN = path.join(__dirname, '..', 'bin', 'openvibe-codes.js');
++const MCP = path.join(__dirname, '..', 'bin', 'mcp.js');
++const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N2';
++
++/** A child MCP server that answers one JSON-RPC message at a time. */
++function mcpChild(env) {
++ const child = spawn(process.execPath, [MCP], { env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'] });
++ let buffer = '';
++ const waiting = new Map();
++ let next = 1;
++ child.stdout.on('data', (chunk) => {
++ buffer += chunk.toString();
++ let i;
++ while ((i = buffer.indexOf('\n')) >= 0) {
++ const line = buffer.slice(0, i);
++ buffer = buffer.slice(i + 1);
++ if (!line.trim()) continue;
++ let msg;
++ try { msg = JSON.parse(line); } catch { continue; }
++ const done = waiting.get(msg.id);
++ if (done) { waiting.delete(msg.id); done(msg); }
++ }
++ });
++ const send = (method, params) => new Promise((resolve, reject) => {
++ const id = next++;
++ const timer = setTimeout(() => { waiting.delete(id); reject(new Error(`no answer to ${method} in 40 s`)); }, 40_000);
++ waiting.set(id, (msg) => { clearTimeout(timer); resolve(msg); });
++ child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
++ });
++ return {
++ send,
++ async call(name, args) {
++ const msg = await send('tools/call', { name, arguments: args });
++ return msg.result && msg.result.content && msg.result.content[0] ? JSON.parse(msg.result.content[0].text) : null;
++ },
++ close() { try { child.stdin.end(); } catch { /* it is gone */ } try { child.kill(); } catch { /* it is gone */ } },
++ };
++}
++
++(async () => {
++ const calls = [];
++ let counter = 0;
++ const run = {
++ enabled: true,
++ async submit(o) {
++ counter++;
++ const id = `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2M${String(counter).padStart(2, '0')}`;
++ calls.push(['submit', o]);
++ return { id, state: 'queued' };
++ },
++ async read(jobId) { return { id: jobId, state: 'running' }; },
++ async cancel() { return { state: 'cancelled' }; },
++ async streamUrl() { throw new Error('no stream in this test'); },
++ };
++ const notices = [];
++ const notify = { enabled: true, async push(n) { notices.push(n); return { sent: true }; } };
++ const t = await boot({ run, notify });
++ const db = t.ctx.store.db;
++ const ada = t.network.addUser('ada');
++ t.network.addNode(ada.subject, { principal: NODE, node_id: 'node_ada', name: 'ada-laptop', status: 'active', last_seen_at: new Date().toISOString() });
++
++ const created = await t.get('/api/v1/conductor', { as: ada, json: { folder: '/home/ada/openvibe', machine: NODE, safety: 'ask' } });
++ assert.strictEqual(created.status, 201, created.text);
++ const taskId = created.json().task.id;
++ const token = t.network.userToken(ada);
++ const env = { OPENVIBE_TOKEN: token, OPENVIBE_CODES_URL: t.base, OPENVIBE_CONDUCTOR_TASK: taskId, APPROVAL_TIMEOUT_MS: '30000' };
++
++ /** The approval row the MCP just asked for (it is written before the child starts waiting). */
++ const awaiting = async () => {
++ for (let i = 0; i < 100; i++) {
++ const row = await db.maybe('SELECT * FROM codes_approvals WHERE task_id = $1 AND decision IS NULL ORDER BY created_at DESC LIMIT 1', [taskId]);
++ if (row) return row;
++ await new Promise((r) => setTimeout(r, 100));
++ }
++ throw new Error('the MCP never asked for an approval');
++ };
++
++ const mcp = mcpChild(env);
++ try {
++ await check('the MCP server lists the conductor\'s tools, including approve', async () => {
++ await mcp.send('initialize', { protocolVersion: '2024-11-05' });
++ const list = await mcp.send('tools/list', {});
++ const names = (list.result.tools || []).map((x) => x.name);
++ for (const name of ['codes_task_start', 'codes_task_list', 'codes_task_show', 'codes_task_continue', 'codes_task_stop', 'codes_fleet', 'codes_ship_status', 'codes_approve']) {
++ assert.ok(names.includes(name), `${name} is listed`);
++ }
++ });
++
++ await check('a risky call is asked, the person allows it on their phone, and the tool gets its answer', async () => {
++ const answer = mcp.call('codes_approve', { tool_name: 'Bash', input: { command: 'rm -rf build' } });
++ const row = await awaiting();
++ assert.strictEqual(row.tool, 'Bash');
++ assert.strictEqual(row.summary, 'rm -rf build', 'the person reads the command');
++ assert.ok(notices.some((n) => n.type === 'APPROVAL_NEEDED' && /rm -rf build/.test(n.message)), 'the person is told');
++ const inbox = await t.get('/api/v1/approvals', { as: ada });
++ assert.strictEqual(inbox.status, 200, inbox.text);
++ assert.ok(inbox.json().approvals.some((a) => a.id === row.id), 'it is in the inbox');
++ const decided = await t.get(`/api/v1/approvals/${row.id}`, { as: ada, json: { decision: 'allow' } });
++ assert.strictEqual(decided.status, 200, decided.text);
++ const out = await answer;
++ assert.strictEqual(out.behavior, 'allow');
++ assert.strictEqual(out.message, 'the owner said yes');
++ const stored = await db.maybe('SELECT * FROM codes_approvals WHERE id = $1', [row.id]);
++ assert.strictEqual(stored.decision, 'allow');
++ assert.ok(stored.decided_at, 'when it was decided');
++ });
++
++ await check('a denied call is denied, with the person\'s own reason', async () => {
++ const answer = mcp.call('codes_approve', { tool_name: 'Write', input: { file_path: '/etc/hosts' }, summary: 'write /etc/hosts' });
++ const row = await awaiting();
++ assert.strictEqual(row.summary, 'write /etc/hosts');
++ await t.get(`/api/v1/approvals/${row.id}`, { as: ada, json: { decision: 'deny', message: 'not that file' } });
++ const out = await answer;
++ assert.strictEqual(out.behavior, 'deny');
++ assert.strictEqual(out.message, 'not that file');
++ });
++
++ await check('stopping the conductor answers what is still waiting', async () => {
++ const answer = mcp.call('codes_approve', { tool_name: 'Bash', input: { command: 'make deploy' } });
++ const row = await awaiting();
++ const stopped = await t.get(`/api/v1/conductor/${taskId}/stop`, { as: ada, json: {} });
++ assert.strictEqual(stopped.status, 200, stopped.text);
++ const out = await answer;
++ assert.strictEqual(out.behavior, 'deny');
++ const stored = await db.maybe('SELECT * FROM codes_approvals WHERE id = $1', [row.id]);
++ assert.strictEqual(stored.decision, 'deny');
++ assert.match(stored.message, /stopped/);
++ });
++
++ await check('a conductor turn starts Claude Code with the Codes tools and the ask mode (a stand-in claude)', async () => {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-conductor-'));
++ const bin = path.join(dir, 'bin');
++ const folder = path.join(dir, 'project');
++ fs.mkdirSync(bin);
++ fs.mkdirSync(folder);
++ const seen = path.join(dir, 'seen.json');
++ // What the real CLI prints (the recorded shapes), plus what it was started with: the flags and the config.
++ fs.writeFileSync(path.join(bin, 'claude'), `#!${process.execPath}
++'use strict';
++const fs = require('fs');
++const argv = process.argv.slice(2);
++const at = argv.indexOf('--mcp-config');
++const mcp = at >= 0 ? JSON.parse(fs.readFileSync(argv[at + 1], 'utf8')) : null;
++fs.writeFileSync(${JSON.stringify(seen)}, JSON.stringify({ argv, mcp }));
++const out = (o) => console.log(JSON.stringify(o));
++out({ type: 'system', subtype: 'init', session_id: 'cl-conductor', model: 'opus' });
++out({ type: 'assistant', message: { model: 'opus', content: [{ type: 'text', text: 'Carrying the plan on.' }] } });
++out({ type: 'result', subtype: 'success', is_error: false, result: 'Carrying the plan on.', session_id: 'cl-conductor', duration_ms: 5, num_turns: 1, total_cost_usd: 0.02, usage: { input_tokens: 30, output_tokens: 9 } });
++`, { mode: 0o755 });
++ const job = spawnSync(process.execPath, [BIN, 'job'], {
++ cwd: folder,
++ input: JSON.stringify({ kind: 'conductor', folder, prompt: 'carry on', task_id: taskId, mcp: true, codes_url: t.base, safety: 'ask', model: 'opus' }),
++ env: { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH}`, OPENVIBE_CODES_HOME: path.join(dir, 'home'), ...env },
++ encoding: 'utf8', timeout: 60_000,
++ });
++ const doc = job.stdout.trim().split('\n').map((l) => { try { return JSON.parse(l); } catch { return null; } })
++ .find((l) => l && l.t === 'result_doc');
++ assert.ok(doc, job.stdout + job.stderr);
++ assert.strictEqual(doc.result.kind, 'conductor');
++ assert.strictEqual(doc.result.ok, true, JSON.stringify(doc.result));
++ assert.strictEqual(doc.result.summary, 'Carrying the plan on.');
++ assert.deepStrictEqual(doc.result.files, [], 'the owner\'s folder is not patched');
++ const argv = JSON.parse(fs.readFileSync(seen, 'utf8')).argv;
++ assert.strictEqual(argv[argv.indexOf('--permission-mode') + 1], 'default', 'ask is Claude Code\'s default mode');
++ assert.ok(argv.includes('--mcp-config'), argv.join(' '));
++ assert.strictEqual(argv[argv.indexOf('--allowedTools') + 1], 'mcp__codes');
++ assert.strictEqual(argv[argv.indexOf('--permission-prompt-tool') + 1], 'mcp__codes__approve');
++ const config = JSON.parse(fs.readFileSync(seen, 'utf8')).mcp;
++ const codes = config.mcpServers.codes;
++ assert.ok(codes.args[0].endsWith(path.join('bin', 'openvibe-codes.js')), codes.args[0]);
++ assert.strictEqual(codes.args[1], 'mcp');
++ assert.strictEqual(codes.env.OPENVIBE_CONDUCTOR_TASK, taskId);
++ assert.strictEqual(codes.env.OPENVIBE_CODES_URL, t.base);
++ fs.rmSync(dir, { recursive: true, force: true });
++ });
++ } finally {
++ mcp.close();
++ await t.close();
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/conductor.test.js b/test/conductor.test.js
nnew file mode 100644
iindex 0000000..0f1cf32
---- /dev/null
++++ b/test/conductor.test.js
@@@ -0,0 +1,199 @@
++'use strict';
++
++/**
++ * Codes C9b: the conductor. One long-lived task whose single attempt is one Claude Code session on a machine the person
++ * chooses; a message is the next turn of that session (Claude Code `--resume`), queued while a turn runs.
++ *
++ * What this checks: the shape the task, its attempt and its first turn are written with; the hand-over brief; a message
++ * that waits while a turn runs and starts when it ends, resuming the session; an idle conductor (succeeded, no outcome)
++ * and a stopped one; and the server's rule that a conductor may start sub-tasks on any agent except an Anthropic one.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
++const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
++
++(async () => {
++ const calls = [];
++ const jobs = new Map();
++ let counter = 0;
++ const run = {
++ enabled: true,
++ async submit(o) {
++ counter++;
++ const id = `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2K${String(counter).padStart(2, '0')}`;
++ jobs.set(id, { id, state: 'queued', args: o.args });
++ calls.push(['submit', o]);
++ return { id, state: 'queued' };
++ },
++ async read(jobId) { return jobs.get(jobId) || { id: jobId, state: 'running' }; },
++ async cancel(jobId) { calls.push(['cancel', jobId]); const j = jobs.get(jobId); if (j) j.state = 'cancelled'; return j; },
++ async streamUrl() { throw new Error('no stream in this test'); },
++ };
++ const t = await boot({ run });
++ const db = t.ctx.store.db;
++ const ada = t.network.addUser('ada');
++ const bo = t.network.addUser('bo');
++ t.network.addNode(ada.subject, { principal: NODE, node_id: 'node_ada', name: 'ada-laptop', status: 'active', last_seen_at: new Date().toISOString() });
++
++ const submits = () => calls.filter((c) => c[0] === 'submit');
++ const get = (path, as = ada) => t.get(path, { as });
++ const post = (path, body, as = ada) => t.get(path, { as, json: body });
++ const rowOf = async (id) => await db.one('SELECT * FROM run_tasks WHERE id = $1', [id]);
++ const messagesOf = async (id) => await db.many('SELECT * FROM codes_conductor_messages WHERE task_id = $1 ORDER BY n ASC', [id]);
++ const attemptOf = async (id) => await db.one('SELECT * FROM codes_attempts WHERE task_id = $1 ORDER BY n ASC LIMIT 1', [id]);
++
++ /** A turn ends: the state change, then the decision (what the events consumer does). */
++ const endTurn = async (id, { state = 'succeeded', session = null, summary = 'Carried the plan on.' } = {}) => {
++ const row = await rowOf(id);
++ const job = {
++ id: row.job_id, state,
++ result: { ok: state === 'succeeded', summary, log: session ? [{ t: 'agent', agent: 'claude-code', session }] : [] },
++ };
++ const updated = await t.ctx.tasks.applyEvent(row, { state, job, error: state === 'succeeded' ? null : 'the turn failed' });
++ return await t.ctx.tasks.afterEnd(updated.id);
++ };
++
++ let taskId = null;
++ try {
++ await check('a conductor is one task, one attempt and a hand-over brief', async () => {
++ const res = await post('/api/v1/conductor', { folder: '/home/ada/openvibe', machine: NODE, model: 'opus', safety: 'ask', note: 'carry on with C9b' });
++ assert.strictEqual(res.status, 201, res.text);
++ const view = res.json();
++ assert.ok(TASK.test(view.task.id));
++ taskId = view.task.id;
++ const row = await rowOf(taskId);
++ assert.strictEqual(row.kind, 'conductor');
++ assert.strictEqual(row.folder, '/home/ada/openvibe');
++ assert.strictEqual(row.model, 'opus');
++ assert.strictEqual(row.safety, 'ask');
++ assert.strictEqual(row.machine, NODE);
++ assert.strictEqual(row.agent, 'claude-code');
++ assert.strictEqual(row.repo, 'conductor:///home/ada/openvibe');
++ const attempt = await attemptOf(taskId);
++ assert.strictEqual(attempt.agent, 'claude-code');
++ assert.strictEqual(attempt.model, 'opus');
++ assert.strictEqual(attempt.machine, NODE);
++ assert.strictEqual(attempt.kind, 'first');
++ assert.strictEqual(view.messages.length, 1);
++ const first = view.messages[0];
++ assert.strictEqual(first.n, 1);
++ assert.match(first.prompt, /RESUME-2026-10-30\.md/, 'the hand-over brief names the resume note');
++ assert.match(first.prompt, /codes_task_start/, 'the hand-over brief names the Codes tools');
++ assert.strictEqual(first.state, 'running');
++ const submit = submits().at(-1)[1];
++ assert.strictEqual(submit.args.kind, 'conductor');
++ assert.strictEqual(submit.args.folder, '/home/ada/openvibe');
++ assert.strictEqual(submit.args.agent, 'claude-code');
++ assert.strictEqual(submit.args.model, 'opus');
++ assert.strictEqual(submit.args.safety, 'ask');
++ assert.strictEqual(submit.args.mcp, true);
++ assert.strictEqual(submit.args.resume, undefined, 'the first turn starts the session');
++ assert.ok(submit.limits && submit.limits.wall_ms >= 60_000, 'a turn gets a wall time');
++ });
++
++ await check('a message waits while a turn runs, then is the next turn of the same session', async () => {
++ const before = submits().length;
++ const res = await post(`/api/v1/conductor/${taskId}/messages`, { text: 'now do the second piece' });
++ assert.strictEqual(res.status, 201, res.text);
++ const answer = res.json();
++ assert.strictEqual(answer.message.state, 'queued');
++ assert.strictEqual(answer.started, false, 'nothing starts while a turn runs');
++ assert.strictEqual(submits().length, before, 'no job was started');
++
++ const ended = await endTurn(taskId, { session: 'sess_abc' });
++ assert.strictEqual(ended.state, 'queued', 'the next turn is on its way');
++ const messages = await messagesOf(taskId);
++ assert.deepStrictEqual(messages.map((m) => m.state), ['done', 'running']);
++ const attempt = await attemptOf(taskId);
++ assert.strictEqual(attempt.session, 'sess_abc', 'the session is kept on the attempt');
++ const submit = submits().at(-1)[1];
++ assert.strictEqual(submit.args.resume, 'sess_abc', 'the next turn resumes the conversation');
++ assert.strictEqual(submit.args.prompt, 'now do the second piece');
++ });
++
++ await check('a turn that ends with nothing queued leaves the conductor idle, not finished', async () => {
++ const ended = await endTurn(taskId, { session: 'sess_abc', summary: 'Everything is in order.' });
++ assert.strictEqual(ended.state, 'succeeded');
++ assert.strictEqual(ended.outcome, null, 'it is not done: it waits for the person');
++ const attempt = await attemptOf(taskId);
++ assert.strictEqual(attempt.state, 'waiting');
++ const view = (await get(`/api/v1/conductor/${taskId}`)).json();
++ assert.strictEqual(view.idle, true);
++ assert.strictEqual(view.messages.at(-1).state, 'done');
++ assert.strictEqual(view.messages.at(-1).result.summary, 'Everything is in order.');
++ });
++
++ await check('a failing turn is unfinished, and a message still carries on', async () => {
++ const res = await post(`/api/v1/conductor/${taskId}/messages`, { text: 'try once more' });
++ assert.strictEqual(res.status, 201, res.text);
++ const ended = await endTurn(taskId, { state: 'failed' });
++ assert.strictEqual(ended.outcome, 'unfinished');
++ const messages = await messagesOf(taskId);
++ assert.strictEqual(messages.at(-1).state, 'failed');
++ assert.match(ended.outcome_note, /write a message to carry on/i);
++ });
++
++ await check('the rule: no Anthropic sub-task, on any agent, and an unnamed agent is not Anthropic', async () => {
++ const refused = await post(`/api/v1/conductor/${taskId}/tasks`, { prompt: 'do a thing', repo: 'https://github.com/octocat/Hello-World', agent: 'claude-code' });
++ assert.strictEqual(refused.status, 422, refused.text);
++ assert.strictEqual(refused.json().code, 'conductor.anthropic_subtask');
++ const refusedModel = await post(`/api/v1/conductor/${taskId}/tasks`, { prompt: 'do a thing', repo: 'https://github.com/octocat/Hello-World', agent: 'codex', model: 'claude-opus-5-5' });
++ assert.strictEqual(refusedModel.status, 422);
++ const before = submits().length;
++ const started = await post(`/api/v1/conductor/${taskId}/tasks`, { prompt: 'fix the parser', repo: 'https://github.com/octocat/Hello-World' });
++ assert.strictEqual(started.status, 201, started.text);
++ const submit = submits().at(-1)[1];
++ assert.strictEqual(submits().length, before + 1);
++ assert.strictEqual(submit.args.agent, 'codex', 'an unnamed agent is a non-Anthropic one');
++ assert.ok(!submit.args.push, 'a sub-task does not push');
++ const sub = started.json().task;
++ assert.notStrictEqual(sub.id, taskId, 'a sub-task is its own task');
++ const subRow = await rowOf(sub.id);
++ assert.strictEqual(subRow.kind, 'code', 'a sub-task is a coding task, not a conductor');
++ assert.strictEqual(subRow.via, 'conductor');
++ });
++
++ await check('the page: the conductor is a conversation, not a diff', async () => {
++ const res = await get(`/tasks/${taskId}`);
++ assert.strictEqual(res.status, 200);
++ assert.match(res.text, /Hand-over brief/, 'the first turn is labelled');
++ assert.match(res.text, /data-c4-conductor-stream/, 'the transcript streams over the C2 events');
++ assert.match(res.text, /What next\?|Say something/, 'the box for the next turn');
++ });
++
++ 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);
++ const before = submits().length;
++ const stopped = await post(`/api/v1/conductor/${taskId}/stop`, {});
++ assert.strictEqual(stopped.status, 200, stopped.text);
++ assert.strictEqual(stopped.json().task.outcome || 'stopped', 'stopped');
++ const row = await rowOf(taskId);
++ assert.strictEqual(row.outcome, 'stopped');
++ const messages = await messagesOf(taskId);
++ assert.ok(messages.every((m) => m.state !== 'queued' && m.state !== 'running'), JSON.stringify(messages.map((m) => m.state)));
++ assert.strictEqual(submits().length, before, 'no job was started after the stop');
++ });
++
++ await check('a conductor belongs to its person: another person sees a 404', async () => {
++ const res = await get(`/api/v1/conductor/${taskId}`, bo);
++ assert.strictEqual(res.status, 404);
++ });
++
++ await check('the person\'s own token as a Bearer is enough (what the CLI and the MCP use)', async () => {
++ const token = t.network.userToken(ada);
++ const res = await fetch(`${t.base}/api/v1/conductor/${taskId}`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } });
++ const raw = await res.text();
++ assert.strictEqual(res.status, 200, raw);
++ const doc = JSON.parse(raw);
++ assert.strictEqual(doc.task.id, taskId);
++ assert.strictEqual(doc.kind, 'conductor');
++ });
++
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 7cbc2ce..1b4bd69 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -37,7 +37,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.
const testdb = await require('./db').testDb();
-- const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}) });
++ const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.notify ? { notify: opts.notify } : {}) });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
ddiff --git a/test/mcp.test.js b/test/mcp.test.js
nnew file mode 100644
iindex 0000000..4c2249b
---- /dev/null
++++ b/test/mcp.test.js
@@@ -0,0 +1,202 @@
++'use strict';
++
++/**
++ * Codes C9b: `openvibe-codes mcp`, the conductor's tools. One real MCP child process against a real Codes server: each
++ * tool does its work through the person's own token, and the rule at the centre of the conductor — no Anthropic
++ * sub-task — is refused by the client and by the server.
++ *
++ * codes_task_start · codes_task_list · codes_task_show · codes_task_continue · codes_task_stop · codes_fleet ·
++ * codes_ship_status (against a fake GitHub) · codes_approve (its refusal without a conductor task)
++ */
++const assert = require('assert');
++const http = require('http');
++const path = require('path');
++const { spawn } = require('child_process');
++const { boot, check, done } = require('./helpers/boot');
++
++const MCP = path.join(__dirname, '..', 'bin', 'mcp.js');
++const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N3';
++
++function mcpChild(env) {
++ const child = spawn(process.execPath, [MCP], { env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'] });
++ let buffer = '';
++ const waiting = new Map();
++ let next = 1;
++ let stderr = '';
++ child.stdout.on('data', (chunk) => {
++ buffer += chunk.toString();
++ let i;
++ while ((i = buffer.indexOf('\n')) >= 0) {
++ const line = buffer.slice(0, i);
++ buffer = buffer.slice(i + 1);
++ let msg;
++ try { msg = JSON.parse(line); } catch { continue; }
++ const done = waiting.get(msg.id);
++ if (done) { waiting.delete(msg.id); done(msg); }
++ }
++ });
++ child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
++ const send = (method, params) => new Promise((resolve, reject) => {
++ const id = next++;
++ const timer = setTimeout(() => { waiting.delete(id); reject(new Error(`no answer to ${method} in 40 s: ${stderr}`)); }, 40_000);
++ waiting.set(id, (msg) => { clearTimeout(timer); resolve(msg); });
++ child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
++ });
++ return {
++ send,
++ async call(name, args) {
++ const msg = await send('tools/call', { name, arguments: args });
++ return msg.result && msg.result.content && msg.result.content[0] ? JSON.parse(msg.result.content[0].text) : null;
++ },
++ close() { try { child.stdin.end(); } catch { /* gone */ } try { child.kill(); } catch { /* gone */ } },
++ };
++}
++
++/** A GitHub that answers one pull request and its commit status. */
++async function fakeGitHub() {
++ const server = http.createServer((req, res) => {
++ const sha = 'abc1234';
++ const body = req.url.includes('/status')
++ ? { state: 'success', statuses: [] }
++ : { number: 7, title: 'Codes C9b: the conductor', state: 'open', merged: false, draft: false, mergeable: true, mergeable_state: 'clean', head: { sha, ref: 'codes-c9b-conductor' }, base: { ref: 'main' } };
++ res.writeHead(200, { 'Content-Type': 'application/json' });
++ res.end(JSON.stringify(body));
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ return { url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) };
++}
++
++(async () => {
++ const calls = [];
++ let counter = 0;
++ const run = {
++ enabled: true,
++ async submit(o) { counter++; const id = `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2L${String(counter).padStart(2, '0')}`; calls.push(['submit', o]); return { id, state: 'queued' }; },
++ async read(jobId) { return { id: jobId, state: 'running' }; },
++ async cancel() { return { state: 'cancelled' }; },
++ async streamUrl() { throw new Error('no stream in this test'); },
++ };
++ const t = await boot({ run });
++ const db = t.ctx.store.db;
++ const ada = t.network.addUser('ada');
++ t.network.addNode(ada.subject, { principal: NODE, node_id: 'node_ada', name: 'ada-laptop', status: 'active', last_seen_at: new Date().toISOString() });
++ const created = await t.get('/api/v1/conductor', { as: ada, json: { folder: '/home/ada/openvibe', machine: NODE } });
++ assert.strictEqual(created.status, 201, created.text);
++ const conductorId = created.json().task.id;
++ const gh = await fakeGitHub();
++
++ const mcp = mcpChild({
++ OPENVIBE_TOKEN: t.network.userToken(ada),
++ OPENVIBE_CODES_URL: t.base,
++ OPENVIBE_CONDUCTOR_TASK: conductorId,
++ GITHUB_API_URL: gh.url,
++ });
++ const rowOf = async (id) => await db.one('SELECT * FROM run_tasks WHERE id = $1', [id]);
++ const repo = 'https://github.com/octocat/Hello-World';
++ let subTask = null;
++
++ try {
++ await check('initialize: one stdio server, tools, ping', async () => {
++ const init = await mcp.send('initialize', { protocolVersion: '2024-11-05' });
++ assert.strictEqual(init.result.serverInfo.name, 'openvibe-codes');
++ assert.strictEqual((await mcp.send('ping', {})).result !== undefined || true, true);
++ });
++
++ await check('codes_task_start: a sub-task on another agent, in the conductor\'s project', async () => {
++ const before = calls.length;
++ const out = await mcp.call('codes_task_start', { prompt: 'Fix the parser and add a test.', repo: 'octocat/Hello-World', agent: 'codex', model: 'gpt-5' });
++ assert.ok(out.id, JSON.stringify(out));
++ assert.strictEqual(out.repo, repo);
++ assert.strictEqual(calls.length, before + 1);
++ const submit = calls.at(-1)[1];
++ assert.strictEqual(submit.args.agent, 'codex');
++ assert.strictEqual(submit.args.model, 'gpt-5');
++ assert.strictEqual(submit.args.prompt, 'Fix the parser and add a test.');
++ const row = await rowOf(out.id);
++ assert.strictEqual(row.kind, 'code');
++ assert.strictEqual(row.project_id, (await rowOf(conductorId)).project_id, 'the conductor\'s own project');
++ subTask = out.id;
++ });
++
++ await check('codes_task_start: the rule, refused here and not sent', async () => {
++ const anthropic = await mcp.call('codes_task_start', { prompt: 'x', repo, agent: 'claude-code' });
++ assert.match(anthropic.error, /Anthropic/);
++ const model = await mcp.call('codes_task_start', { prompt: 'x', repo, agent: 'codex', model: 'claude-opus-5-5' });
++ assert.match(model.error, /Anthropic/);
++ const noRepo = await mcp.call('codes_task_start', { prompt: 'x' });
++ assert.match(noRepo.error, /repository/);
++ });
++
++ await check('codes_task_list: running, needs and done', async () => {
++ const running = await mcp.call('codes_task_list', { state: 'running' });
++ assert.ok(running.tasks.some((x) => x.id === subTask), JSON.stringify(running));
++ assert.ok(running.tasks.some((x) => x.id === conductorId), 'the conductor is one of the person\'s tasks');
++ const doneList = await mcp.call('codes_task_list', { state: 'done' });
++ assert.ok(!doneList.tasks.some((x) => x.id === subTask), 'a running sub-task is not done');
++ });
++
++ await check('codes_task_show: the state, its attempts and the last lines', async () => {
++ await t.ctx.sessions.append(subTask, [
++ { seq: 1, kind: 'text', at: new Date().toISOString(), body: { t: 'text', text: 'Looking at the parser.' } },
++ { seq: 2, kind: 'tool', at: new Date().toISOString(), body: { t: 'tool', name: 'Read', input: { file_path: 'src/parser.js' } } },
++ ]);
++ const out = await mcp.call('codes_task_show', { id: subTask, lines: 5 });
++ assert.strictEqual(out.id, subTask);
++ assert.strictEqual(out.state, 'queued');
++ assert.strictEqual(out.attempts.length, 1);
++ assert.strictEqual(out.attempts[0].agent, 'codex');
++ assert.ok(out.last_lines.some((l) => /Looking at the parser/.test(l)), JSON.stringify(out.last_lines));
++ assert.ok(out.last_lines.some((l) => /Read src\/parser\.js/.test(l)), JSON.stringify(out.last_lines));
++ });
++
++ await check('codes_task_continue: a note on a task that ended unfinished', async () => {
++ const row = await rowOf(subTask);
++ await t.ctx.tasks.applyEvent(row, { state: 'failed', job: { id: row.job_id, state: 'failed' }, error: 'the machine said no' });
++ const after = await rowOf(subTask);
++ assert.strictEqual(after.state, 'failed');
++ const out = await mcp.call('codes_task_continue', { id: subTask, message: 'the parser test is flaky, fix that first' });
++ assert.strictEqual(out.id, subTask, JSON.stringify(out));
++ const submit = calls.at(-1)[1];
++ assert.match(submit.args.prompt, /flaky/);
++ });
++
++ await check('codes_task_stop: a running sub-task stops', async () => {
++ const started = await mcp.call('codes_task_start', { prompt: 'a second piece', repo, agent: 'opencode' });
++ const out = await mcp.call('codes_task_stop', { id: started.id });
++ assert.strictEqual(out.id, started.id);
++ assert.strictEqual(out.outcome, 'stopped');
++ const row = await rowOf(started.id);
++ assert.strictEqual(row.state, 'cancelled');
++ });
++
++ await check('codes_fleet: each coding CLI on this machine, and its windows', async () => {
++ const out = await mcp.call('codes_fleet', {});
++ assert.ok(Array.isArray(out.agents) && out.agents.length >= 5, JSON.stringify(out));
++ for (const a of out.agents) {
++ assert.strictEqual(typeof a.agent, 'string');
++ assert.strictEqual(typeof a.installed, 'boolean');
++ assert.ok(Array.isArray(a.windows));
++ }
++ });
++
++ await check('codes_ship_status: a pull request\'s CI and merge state, from GitHub', async () => {
++ const out = await mcp.call('codes_ship_status', { repo: 'octocat/Hello-World', pr: 7 });
++ assert.strictEqual(out.state, 'open');
++ assert.strictEqual(out.ci, 'Passed');
++ assert.strictEqual(out.title, 'Codes C9b: the conductor');
++ });
++
++ await check('codes_approve without a conductor task says so, instead of asking nothing', async () => {
++ const other = mcpChild({ OPENVIBE_TOKEN: t.network.userToken(ada), OPENVIBE_CODES_URL: t.base });
++ try {
++ const out = await other.call('codes_approve', { tool_name: 'Bash', input: { command: 'ls' } });
++ assert.match(out.error, /OPENVIBE_CONDUCTOR_TASK/);
++ } finally { other.close(); }
++ });
++ } finally {
++ mcp.close();
++ await gh.close();
++ await t.close();
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 5924b1f..4be65ec 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -15,11 +15,11 @@ const BUDGETS = {
htmlRawKB: 42, // 40.4 with the ten harnesses the catalogue holds (2026-10-30: MiniMax Code and Claude Code on another provider joined it, and the home lists every one); 35.7 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 31.5 with the home's quick start, OpenVibe Git and six FAQs with their FAQPage JSON-LD (2026-10-22; in production the forge's eight repositories add about 2.5); measured 27.0 (fresh database; the showcase home, 2026-09-29: sections + six inline ring icons)
htmlBrotliKB: 11, // 9.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 7.7 with those sections (2026-10-22); 6.7
jsFiles: 6, // C4 adds the live task stream client to the shared theme, navbar, footer and boost scripts.
-- jsRawKB: 330, // 326.2 after C4b: the home page loads the workspace script for its repository search (10.9 KB of its own); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
-- jsBrotliKB: 82, // 80.6 after C4b's repository search on the home page; 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
++ jsRawKB: 340, // 330.9 after C9b: the conductor's message box with its images and its live turn (2.6 KB in js/c4.js); 326.2 after C4b: the home page loads the workspace script for its repository search (10.9 KB of its own); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
++ jsBrotliKB: 84, // 82.9 after C9b's conductor script; 80.6 after C4b's repository search on the home page; 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
cssFiles: 3, // C4 adds its responsive workspace stylesheet alongside codes.css and run.css.
-- cssRawKB: 60, // 58.4 after C4b: the proof rows, the fold-out panels, the shots, the forms that remember choices and their light/dark styles; 46 KB after adding the C4 phone-first workspace, cards, task timeline and light/dark styles.
-- cssBrotliKB: 13, // 12.1 after C4b's rows, panels and forms; 9.9 KB after adding the C4 workspace styles.
++ cssRawKB: 64, // 62.4 after C9b: the conductor's turns, approvals inbox and composer (public/css/c4.css); 58.4 after C4b: the proof rows, the fold-out panels, the shots, the forms that remember choices and their light/dark styles; 46 KB after adding the C4 phone-first workspace, cards, task timeline and light/dark styles.
++ cssBrotliKB: 14, // 13.0 after C9b's conductor styles; 12.1 after C4b's rows, panels and forms; 9.9 KB after adding the C4 workspace styles.
externalFiles: 2, // 0
};
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex f425ed2..376808d 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -52,6 +52,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/domain/connections.js': 'OpenVibe.AI (CODES_AI_URL, configured) for a person\'s own keys: the credential routes and the two model endpoints, with the provider and the subject as path segments; no URL from a person',
'server/domain/run-tokens.js': 'OpenVibe.Network\'s token endpoint (OV_NETWORK_INTERNAL_URL, configured) for a run token; no URL from a person',
'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a nod_ shape, never a URL from a person',
++ 'server/domain/conductor.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, for the project a conductor runs in; and the Run job stream URL run-client.js builds on its own base. No URL from a person',
'server/domain/github.js': 'api.github.com only, at fixed paths: the public repository search and a pull request\'s state. The owner/name/number come from a Codes-stored task result and are checked against GitHub\'s id shapes before they are put in a path',
'server/events-consumer.js': 'OpenVibe.Events subscriptions (EVENTS_URL, configured) at boot; no URL from a person',
'server/events/outbox.js': 'OpenVibe.Events publish (EVENTS_URL, configured) of fixed codes.task.* payloads; no URL from a person',