openvibers/openvibe.codes Commits
Codes C9b: the conductor (#184)
Changes
nnew file mode 100644@@@ -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();@@@ -37,6 +37,8 @@ const USAGE = `openvibe-codes ${VERSION} — code with any agent, from your termOpenVibe 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 ofyour 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; }@@@ -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 };nnew file mode 100644@@@ -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.@@@ -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) => {@@@ -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@@@ -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, tmreturn 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, tmresult.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 };nnew file mode 100644@@@ -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;@@@ -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. */@@@ -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) => {@@@ -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' },];@@@ -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 ───────────────────────────────────────────────@@@ -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)) },nnew file mode 100644@@@ -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 };@@@ -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],);}nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -48,16 +48,21 @@ function createRunClient({ config, fetchImpl = globalThis.fetch, now = () => Datif (!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 });@@@ -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, ouif (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, ouif (!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, oureturn {start, refresh, applyJob, applyEvent, recordState, afterEnd, continueTask, stopTask, proof: taskProof,-- latestProject, present, who, attempts: attemptStore,++ latestProject, present, who, attempts: attemptStore, setConductor,};}@@@ -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: 'Defunction 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</hif (!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</hpage(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: [] };nnew file mode 100644@@@ -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 };@@@ -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}/`);nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });@@@ -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}`;nnew file mode 100644@@@ -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); });@@@ -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.7jsFiles: 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};@@@ -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',
new file mode 100644
new file mode 100644
@@ -0,0 +1,344 @@
@@ -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();
@@ -37,6 +37,8 @@ const USAGE = `openvibe-codes ${VERSION} — code with any agent, from your term
@@ -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 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
openvibe-codes task start|list|show|continue|stop coding tasks on openvibe.codes, as you, on one of
your machines (openvibe-codes task --help)
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
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
(your own OpenAI-compatible server) · OPENVIBE_CODES_HOME (where runs are kept) · OPENVIBE_TOKEN (your
@@ -97,6 +99,8 @@ async function main() {
@@ -97,6 +99,8 @@ async function main() {
const [cmd, ...rest] = process.argv.slice(2);
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.
// Tasks on openvibe.codes: the person's own token, the server's API. Nothing here runs an agent.
if (cmd === 'task') return task(rest);
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);
const o = parse(rest);
if (!cmd || cmd === 'help' || o.help || cmd === '--help' || cmd === '-h') { out(USAGE); return 0; }
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; }
if (cmd === '--version' || cmd === '-v' || cmd === 'version') { out(VERSION); return 0; }
@@ -347,4 +347,4 @@ async function task(argv) {
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -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.
@@ -56,11 +56,19 @@ function versionOf(bin, env = process.env, args = ['--version']) {
@@ -56,11 +56,19 @@ function versionOf(bin, env = process.env, args = ['--version']) {
const claudeCode = {
const claudeCode = {
id: 'claude-code', name: 'Claude Code', kind: 'cli', bin: 'claude',
id: 'claude-code', name: 'Claude Code', kind: 'cli', bin: 'claude',
start(t, ctx = {}) {
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];
const args = ['-p', t.prompt, '--output-format', 'stream-json', '--verbose', '--permission-mode', mode];
if (t.model) args.push('--model', t.model);
if (t.model) args.push('--model', t.model);
if (t.resume) args.push('--resume', t.resume);
if (t.resume) args.push('--resume', t.resume);
if (t.maxTurns) args.push('--max-turns', String(t.maxTurns));
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.
// Even unattended, a run never pushes: publishing is the person's step.
args.push('--disallowedTools', 'Bash(git push:*)');
args.push('--disallowedTools', 'Bash(git push:*)');
const parser = jsonLines(claudeCode, (o, emit, st) => {
const parser = jsonLines(claudeCode, (o, emit, st) => {
@@ -154,7 +154,7 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,
@@ -154,7 +154,7 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,
return decision;
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 (!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');
if (!['read', 'edit', 'full'].includes(permission)) throw new Error('permission is read, edit or full');
const runId = store.newRunId();
const runId = store.newRunId();
@@ -199,7 +199,12 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,
@@ -199,7 +199,12 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,
const tally = ev.createTally();
const tally = ev.createTally();
const controller = new AbortController();
const controller = new AbortController();
if (signal) signal.addEventListener('abort', () => controller.abort(), { once: true });
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 stalled = false;
let res = null;
let res = null;
let pending = null; // the next() still in flight when the stall timer won the race
let pending = null; // the next() still in flight when the stall timer won the race
@@ -95,7 +95,36 @@ function parseArgs(raw, env = process.env) {
@@ -95,7 +95,36 @@ function parseArgs(raw, env = process.env) {
let a;
let a;
try { a = JSON.parse(String(raw || '').trim() || '{}'); } catch { throw new Error('the job input is not JSON'); }
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');
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;
let repo = null;
if (kind === 'code') {
if (kind === 'code') {
try { repo = new URL(String(a.repo || '')); } catch { throw new Error('repo must be a repository URL'); }
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) {
@@ -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 (!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');
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);
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');
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.
// review is a read-only task (research, an audit, a review): the harness's own `read` level.
@@ -243,6 +270,73 @@ function githubRepo(url) {
@@ -243,6 +270,73 @@ function githubRepo(url) {
return m ? { owner: m[1], name: m[2] } : null;
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
* 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
* 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
@@ -300,26 +394,52 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
return doc;
return doc;
}
}
const dir = fs.mkdtempSync(path.join(tmp, 'codes-'));
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: '' };
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;
let base = null;
try {
try {
+ let adapterOptions = null;
if (job.kind === 'code') {
if (job.kind === 'code') {
say({ t: 'step', text: `Cloning ${scrub(job.repo)}${job.ref ? ` (${job.ref})` : ''}` });
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 });
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();
base = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: work })).trim();
result.base = base;
result.base = base;
say({ t: 'step', text: `Working on ${base}` });
say({ t: 'step', text: `Working on ${base}` });
+ } 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 {
} else {
fs.mkdirSync(work, { recursive: true });
fs.mkdirSync(work, { recursive: true });
say({ t: 'step', text: 'Preparing an empty benchmark workspace' });
say({ t: 'step', text: 'Preparing an empty benchmark workspace' });
}
}
const run = harness.run({
const run = harness.run({
prompt: job.prompt, task: 'edit', agent: job.agent, model: job.model, cwd: work,
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
// A task carried on in the same conversation resumes its session (RESUME in the environment, or the job's
// own resume field).
// own resume field).
resume: job.resume || null,
resume: job.resume || null,
+ ...(adapterOptions ? { adapterOptions } : {}),
});
});
let last = null;
let last = null;
for await (const e of run) {
for await (const e of run) {
@@ -335,11 +455,14 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
@@ -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;
result.cost_usd = last && last.total_cost_usd ? last.total_cost_usd : 0;
const usage = last && last.usage && typeof last.usage === 'object' ? last.usage : {};
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);
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.
// 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.
// 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' });
say({ t: 'step', text: 'Collecting the proof' });
try {
try {
const collected = await collectProof({
const collected = await collectProof({
@@ -389,4 +512,4 @@ function resultWriter(emit) {
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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;
@@ -152,6 +152,37 @@
@@ -152,6 +152,37 @@
.c4-chip[href]{min-height:44px;align-items:center;padding:8px 12px}
.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}
.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).
/* 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 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. */
the last line of the page and the footer clear it when the page is scrolled to the end. */
@@ -144,6 +144,87 @@
@@ -144,6 +144,87 @@
if (stream.readyState === EventSource.CLOSED) { stream.close(); window.clearInterval(timer); }
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.
// 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.
// Server-rendered options already hold the saved ones, so the field works with this script blocked.
document.querySelectorAll('[data-c4-repo-search]').forEach((input) => {
document.querySelectorAll('[data-c4-repo-search]').forEach((input) => {
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
* about it (codes_events_inbox);
* about it (codes_events_inbox);
* - the run tokens minted for their attempts (codes_run_tokens, C3: ids and expiry, never the token);
* - 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 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);
* - the events Codes sent about them (codes_outbox);
* - telemetry samples that name them: the SDK covers telemetry_samples itself (telemetry.json, then subject NULL).
* - telemetry samples that name them: the SDK covers telemetry_samples itself (telemetry.json, then subject NULL).
*
*
@@ -43,6 +44,15 @@ const TASK_KEYED = [
@@ -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_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_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 },
{ 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' },
{ table: 'codes_events_inbox', file: null, kind: 'events_received' },
];
];
@@ -31,6 +31,7 @@ const { createRunRoutes } = require('./http/run');
@@ -31,6 +31,7 @@ const { createRunRoutes } = require('./http/run');
const { createTasksApi } = require('./http/tasks-api');
const { createTasksApi } = require('./http/tasks-api');
const { createConnectionsApi } = require('./http/connections-api');
const { createConnectionsApi } = require('./http/connections-api');
const { createActionsApi } = require('./http/actions-api');
const { createActionsApi } = require('./http/actions-api');
+const { createConductorApi } = require('./http/conductor-api');
const { createRunClient } = require('./domain/run-client');
const { createRunClient } = require('./domain/run-client');
const { createRunTasks } = require('./domain/run-tasks');
const { createRunTasks } = require('./domain/run-tasks');
const { createConnections } = require('./domain/connections');
const { createConnections } = require('./domain/connections');
@@ -38,6 +39,9 @@ const { createRunTokens } = require('./domain/run-tokens');
@@ -38,6 +39,9 @@ const { createRunTokens } = require('./domain/run-tokens');
const { createAttempts } = require('./domain/attempts');
const { createAttempts } = require('./domain/attempts');
const { createBenchPublisher } = require('./domain/bench-publisher');
const { createBenchPublisher } = require('./domain/bench-publisher');
const { createSessionLines } = require('./domain/session-lines');
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 { createOutbox, createPublisher } = require('./events/outbox');
const { createEventsConsumer } = require('./events-consumer');
const { createEventsConsumer } = require('./events-consumer');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
@@ -90,6 +94,15 @@ async function createApp(opts = {}) {
@@ -90,6 +94,15 @@ async function createApp(opts = {}) {
ctx.userSettings = createUserSettings({ store });
ctx.userSettings = createUserSettings({ store });
ctx.github = createGitHub({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });
ctx.github = createGitHub({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });
ctx.usage = createUsage({ store });
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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -194,6 +207,11 @@ async function createApp(opts = {}) {
@@ -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.
// 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', createConnectionsApi({ config, store, connections, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createActionsApi({ config, store, keys, run, tasks, 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 }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
@@ -128,6 +128,13 @@ function load(env = process.env) {
@@ -128,6 +128,13 @@ function load(env = process.env) {
know: { lessons: String(env.CODES_KNOW_LESSONS || env.OPENVIBE_CODES_LESSONS || '').trim() },
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.
// 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)) },
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.
// 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)) },
sse: { heartbeatMs: Math.max(1000, int(env.CODES_SSE_HEARTBEAT_MS, 15_000)) },
new file mode 100644
new file mode 100644
@@ -0,0 +1,186 @@
@@ -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 };
@@ -172,16 +172,17 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
@@ -172,16 +172,17 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
throw new Error('the attempt could not be written');
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(
return await db.one(
`UPDATE codes_attempts SET job_id = COALESCE($2, job_id),
`UPDATE codes_attempts SET job_id = COALESCE($2, job_id),
state = COALESCE($3, state),
state = COALESCE($3, state),
machine = COALESCE($4, machine),
machine = COALESCE($4, machine),
model = COALESCE($5, model),
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 *`,
WHERE id = $1 RETURNING *`,
- [id, job_id, state, machine, model, started_at],
+ [id, job_id, state, machine, model, started_at, session],
);
);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,396 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -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 };
@@ -48,16 +48,21 @@ function createRunClient({ config, fetchImpl = globalThis.fetch, now = () => Dat
@@ -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}`);
if (!res.ok) throw new RunError(res.status, (doc && doc.code) || 'run.error', (doc && (doc.detail || doc.title)) || `Run answered ${res.status}`);
return doc;
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 = {
const body = {
class: 'code', artifact: { name: 'openvibe-codes', version: JOB_PROTOCOL }, args,
class: 'code', artifact: { name: 'openvibe-codes', version: JOB_PROTOCOL }, args,
limits: {
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'] },
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 });
const out = await call('POST', '/api/v1/jobs', { subject, project, body, key });
@@ -140,7 +140,7 @@ function usageOf(result) {
@@ -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 db = store.db;
const who = (row) => ({ subject: row.subject, project: row.project_id });
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).
// 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
@@ -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.
// 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 taskProof = proof || require('./proof').createProof({ store, config, log });
const attemptStore = attempts || require('./attempts').createAttempts({ 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. */
/** 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']);
const ENDED = new Set(['done', 'failed', 'stopped', 'budget-stopped', 'interrupted']);
@@ -295,25 +301,35 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -295,25 +301,35 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
if (attempt) {
if (attempt) {
if (TERMINAL.has(state)) {
if (TERMINAL.has(state)) {
const usage = usageOf(updated.result);
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 {
} else {
await attemptStore.bind(attempt.id, {
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
@@ -458,6 +474,8 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
if (!row) return null;
if (!row) return null;
if (!TERMINAL.has(row.state)) return row;
if (!TERMINAL.has(row.state)) return row;
if (row.outcome === 'stopped') return row; // a person stopped it: nothing carries it on
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);
const actorRef = asObject(row.actor_ref);
if (actorRef && actorRef.bench) {
if (actorRef && actorRef.bench) {
const done = row.state === 'succeeded';
const done = row.state === 'succeeded';
@@ -656,7 +674,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -656,7 +674,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
return {
return {
start, refresh, applyJob, applyEvent, recordState, afterEnd, continueTask, stopTask, proof: taskProof,
start, refresh, applyJob, applyEvent, recordState, afterEnd, continueTask, stopTask, proof: taskProof,
- latestProject, present, who, attempts: attemptStore,
+ latestProject, present, who, attempts: attemptStore, setConductor,
};
};
}
}
@@ -23,6 +23,7 @@ const { homeJsonLd } = require('./discovery');
@@ -23,6 +23,7 @@ const { homeJsonLd } = require('./discovery');
const LINKS = [
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"/>'],
['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"/>'],
['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"/>'],
['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"/>'],
['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"/>'],
['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
@@ -59,7 +60,7 @@ const PROVIDER_NAMES = { anthropic: 'Anthropic', openai: 'OpenAI', deepseek: 'De
function createC4Routes(ctx) {
function createC4Routes(ctx) {
const r = asyncRouter();
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 db = store.db;
const form = express.urlencoded({ extended: false, limit: '16kb' });
const form = express.urlencoded({ extended: false, limit: '16kb' });
const page = (req, res, title, body, extra = {}) => {
const page = (req, res, title, body, extra = {}) => {
@@ -233,11 +234,67 @@ ${patch ? `<details class="c4-fold c4-fold-wide"><summary><span class="c4-row-la
@@ -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>`;
${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 ────────────────────────────────────────────────────────────────────────────────────────────────────
// ── Home ────────────────────────────────────────────────────────────────────────────────────────────────────
r.get('/', async (req, res) => {
r.get('/', async (req, res) => {
const rows = await ownTasks(req, 8);
const rows = await ownTasks(req, 8);
const signedIn = !!viewer(req);
const signedIn = !!viewer(req);
const settings = signedIn ? await userSettings.get(req.viewer.subject) : null;
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 repos = signedIn ? await recentRepos(req) : [];
const mine = signedIn ? await machinesOf(req, res) : { ok: false, machines: [] };
const mine = signedIn ? await machinesOf(req, res) : { ok: false, machines: [] };
const used = signedIn ? await lastUsedMachines(req) : [];
const used = signedIn ? await lastUsedMachines(req) : [];
@@ -247,7 +304,8 @@ ${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="
@@ -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 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.')}
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>
<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>
<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 && !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>`}`;
${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
@@ -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');
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]);
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');
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 live = ['queued', 'placed', 'running'].includes(row.state);
const attempts = tasks?.attempts ? await tasks.attempts.list(row.id) : [];
const attempts = tasks?.attempts ? await tasks.attempts.list(row.id) : [];
const attemptItems = attempts.map((attempt) => {
const attemptItems = attempts.map((attempt) => {
@@ -327,6 +395,96 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h
@@ -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));
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) ────────────────────────────────────────────
// ── Agents: the person's connections (keys live in OpenVibe.AI) ────────────────────────────────────────────
const connectionsCard = async (req) => {
const connectionsCard = async (req) => {
let list = { keys: [], plans: [] };
let list = { keys: [], plans: [] };
new file mode 100644
new file mode 100644
@@ -0,0 +1,273 @@
@@ -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 };
@@ -17,9 +17,10 @@ const SERVICES_ORIGIN = 'https://openvibe.services';
@@ -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.
// 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_PREFIXES = ['/docs', '/projects', '/releases', '/apps', '/oauth', '/tools', '/manifests', '/staff'];
const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency'];
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}/`);
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,199 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,199 @@
@@ -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); });
@@ -37,7 +37,7 @@ async function boot(opts = {}) {
@@ -37,7 +37,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.
// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.
const testdb = await require('./db').testDb();
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();
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 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}`;
const base = `http://127.0.0.1:${server.address().port}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,202 @@
@@ -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); });
@@ -15,11 +15,11 @@ const BUDGETS = {
@@ -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)
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
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.
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.
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
externalFiles: 2, // 0
};
};
@@ -52,6 +52,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -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/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/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/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/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-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',
'server/events/outbox.js': 'OpenVibe.Events publish (EVENTS_URL, configured) of fixed codes.task.* payloads; no URL from a person',