openvibers/openvibe.codes Commits
Conductor failover: aware of usage, falls back to another agent near the limit (CLI 0.7.0) (#269)
Changes
@@@ -60,6 +60,7 @@"the conductor (C9b): one Claude Code session on the person's own machine, driven from the browser or the phone, with its approvals","the job runner (CLI 0.6.0): each turn at the effort a person picks, in each agent's own words, and a conversation's next turn starting from the work before it (the last diff applied with git apply --3way, and the conversation so far), on any agent","the job runner (CLI 0.6.1): \"edit\" means edits on the person's own computer (OpenCode and Command Code run unattended only in a Node sandbox or for full; Aider suggests no shell commands there), and a private repository clones with a token the machine holds (GITHUB_TOKEN, GITLAB_TOKEN, OPENVIBE_GIT_CREDENTIALS) sent as a header, never in the URL or .git/config",++ "The conductor's failover (CLI 0.7.0, owner 2026-11-03): Settings \u2192 Conductor picks up to three fallback agents (Codex, OpenCode) with a model each and a switch point (90% of a usage window by default); before each turn the conductor's own agent and the fallbacks are read against the machine's usage windows, the turn runs on the first with room and returns to the primary once its window frees; each agent resumes its own session and the one taking over is told what happened while it was away; the conductor's page shows each agent's usage and which takes the next turn","OpenVibe Git through the person's own account (/git, CLI 0.6.2): link once (the forge's OAuth2 consent with PKCE, tokens sealed with CODES_SEAL_KEY), make a repository in your name and start a chat on it; a task on a forge repository takes the person's credential line to their machine for git only (never the agent), and the branch and its pull request open on OpenVibe Git"],"updated": "2026-11-03"@@@ -98,6 +98,13 @@ const codex = {: ['exec', '--json', '--skip-git-repo-check', '-C', t.cwd, '-s', sandbox];if (t.model) args.push('-m', t.model);if (t.effort) args.push('-c', `model_reasoning_effort="${effortFor('codex', t.effort)}"`);++ // An MCP server this session may use (the conductor's Codes tools, when Codex takes a conductor's turn): its++ // command and arguments as config overrides, TOML values. Nothing secret rides here: the launcher holds the token.++ const mcp = t.options && t.options.mcpServer;++ if (mcp && /^[a-z][a-z0-9_-]{0,30}$/.test(String(mcp.name || '')) && mcp.command) {++ args.push('-c', `mcp_servers.${mcp.name}.command=${JSON.stringify(String(mcp.command))}`);++ args.push('-c', `mcp_servers.${mcp.name}.args=${JSON.stringify((mcp.args || []).map(String))}`);++ }args.push(t.prompt);const startedAt = Date.now();const parser = jsonLines(codex, (e, emit, st) => {@@@ -160,6 +167,8 @@ const opencode = {if (t.model) args.push('-m', t.model);if (t.resume) args.push('-s', t.resume);args.push('--', t.prompt);++ // A config file for this run (the conductor's Codes MCP server), merged over the person's own by OpenCode.++ const configFile = t.options && typeof t.options.configFile === 'string' ? t.options.configFile : null;const startedAt = Date.now();const parser = jsonLines(opencode, (e, emit, st) => {st.startedAt = startedAt;@@@ -191,7 +200,8 @@ const opencode = {emit(ev.result({ ok: !!final && !st.failed, text: final || st.failed || exitReason(info, 'OpenCode'), sessionId: st.sessionId, model: t.model, startedAt, turns: st.turns || 0, costUsd: u.cost,usage: ev.usage({ input: u.input, output: u.output, cacheRead: u.read, cacheWrite: u.write }) }));});-- return spawnAgent({ cmd: this.bin, args, cwd: t.cwd, env: ctx.env, timeoutMs: t.timeoutMs, signal: t.signal, parser });++ const env = configFile ? { ...(ctx.env || {}), OPENCODE_CONFIG: configFile } : ctx.env;++ return spawnAgent({ cmd: this.bin, args, cwd: t.cwd, env, timeoutMs: t.timeoutMs, signal: t.signal, parser });},};@@@ -19,7 +19,9 @@ const path = require('path');const { execFile } = require('child_process');const { collectProof } = require('../engine/proof');--const JOB_PROTOCOL = '1.0.0'; // the job's contract with the Node (stdin, stdout lines, fd 3): unchanged by 1.1's lines++const JOB_PROTOCOL = '1.0.0';++/** The agents a conductor's turn may run on: the CLIs that resume a session and start the Codes MCP server. */++const CONDUCTOR_AGENTS = ['claude-code', 'codex', 'opencode']; // the job's contract with the Node (stdin, stdout lines, fd 3): unchanged by 1.1's linesconst LINES = '1.1'; // 1.1: lines carry step ids, inputs, results, progress, helpers; the result keeps `log`const MAX_PROMPT = 16_000;const MAX_PATCH = 180_000;@@@ -156,8 +158,9 @@ function parseArgs(raw, env = process.env) {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.++ // A conductor's turn (Codes C9b): one agent 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. The agent++ // is Claude Code, or the fallback the server chose when Claude Code is near its usage limit (Codex or OpenCode).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');@@@ -172,13 +175,17 @@ function parseArgs(raw, env = process.env) {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);++ const agent = CONDUCTOR_AGENTS.includes(a.agent) ? a.agent : 'claude-code';++ // Claude Code asks the owner through its permission prompt tool; Codex and OpenCode have none, so "ask" and++ // "project" run them with edits only (their own sandbox), and "full" with the whole computer.++ const permission = agent !== 'claude-code' && safety === 'full' ? 'full' : 'edit';return {-- kind, repo: null, ref: null, prompt, permission: 'edit', push: null,++ kind, repo: null, ref: null, prompt, permission, 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,...(effort ? { effort } : {}),-- agent: 'claude-code',++ agent,...(files.length ? { attachments: files } : {}),...(resume ? { resume } : {}),...(Object.keys(wanted).length ? { env: wanted } : {}),@@@ -424,6 +431,31 @@ function conductorMcpConfig(file, job, env) {return file;}++/**++ * The Codes MCP server for an agent that takes no MCP config file (Codex, OpenCode): a small shell script in the job's++ * own folder (0700) that sets the same three variables and starts `openvibe-codes mcp`. The token stays in that file,++ * never on a command line (this machine's process list is readable). → the command and its arguments.++ */++function conductorMcpLauncher(file, job, env) {++ const token = codesToken(env);++ const bin = path.join(__dirname, '..', 'bin', 'openvibe-codes.js');++ const q = (v) => `'${String(v).replace(/'/g, "'\\''")}'`;++ const lines = ['#!/bin/sh'];++ if (job.codesUrl) lines.push(`export OPENVIBE_CODES_URL=${q(job.codesUrl)}`);++ if (job.taskId) lines.push(`export OPENVIBE_CONDUCTOR_TASK=${q(job.taskId)}`);++ if (token) lines.push(`export OPENVIBE_TOKEN=${q(token)}`);++ lines.push(`exec ${q(process.execPath)} ${q(bin)} mcp`);++ fs.writeFileSync(file, `${lines.join('\n')}\n`, { mode: 0o700 });++ return { name: 'codes', command: '/bin/sh', args: [file] };++}++++/** OpenCode's own config file for one turn (OPENCODE_CONFIG, merged over the person's): the Codes MCP server. */++function opencodeConfig(file, server) {++ const config = { $schema: 'https://opencode.ai/config.json', mcp: { [server.name]: { type: 'local', command: [server.command, ...server.args], enabled: true } } };++ 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 = [];@@@ -602,13 +634,22 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tmsay({ 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' } : {}),-- };++ if (job.agent === 'codex' || job.agent === 'opencode') {++ // A fallback agent (the server's failover): the same Codes tools, started by a launcher script.++ const server = job.mcp ? conductorMcpLauncher(path.join(dir, 'codes-mcp.sh'), job, env) : null;++ adapterOptions = server++ ? (job.agent === 'codex' ? { mcpServer: server } : { configFile: opencodeConfig(path.join(dir, 'opencode.json'), server) })++ : {};++ say({ t: 'step', text: `This turn runs on ${job.agent === 'codex' ? 'Codex' : 'OpenCode'}` });++ } else {++ 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' });@@@ -698,4 +739,4 @@ function resultWriter(emit) {};}--module.exports = { gitAuthFor, credentialFor, giteaHosts, pushForge, cloneProblem, runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, jobEnv, ENV_NAMES, JOB_PROTOCOL, codesToken, conductorMcpConfig, writeAttachments };++module.exports = { CONDUCTOR_AGENTS, conductorMcpLauncher, opencodeConfig, gitAuthFor, credentialFor, giteaHosts, pushForge, cloneProblem, runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, jobEnv, ENV_NAMES, JOB_PROTOCOL, codesToken, conductorMcpConfig, writeAttachments };nnew file mode 100644@@@ -0,0 +1,10 @@++-- phase: expand++-- The conductor's failover (owner 2026-11-03): a person picks up to three agents (and models) their conductor falls back++-- to when its own agent is near or at its usage limit, and the point at which it switches. Each turn records the agent++-- and model it ran on, and why it switched, so the conversation shows the hand-over and the next turn of the same agent++-- resumes that agent's own session (server/domain/conductor-failover.js).++ALTER TABLE codes_user_settings ADD COLUMN IF NOT EXISTS conductor_fallbacks jsonb; -- [{ agent, model }], in order++ALTER TABLE codes_user_settings ADD COLUMN IF NOT EXISTS conductor_switch_pct smallint; -- 50..95; NULL = 90++ALTER TABLE codes_conductor_messages ADD COLUMN IF NOT EXISTS agent text; -- the agent the turn ran on; NULL = claude-code (before 0023)++ALTER TABLE codes_conductor_messages ADD COLUMN IF NOT EXISTS model text; -- the model it was asked for++ALTER TABLE codes_conductor_messages ADD COLUMN IF NOT EXISTS switch_note text; -- why it is not the primary, in plain words@@@ -1,12 +1,12 @@{"name": "openvibe-codes",-- "version": "0.6.2",++ "version": "0.7.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-codes",-- "version": "0.6.2",++ "version": "0.7.0","license": "AGPL-3.0-only","dependencies": {"cookie-parser": "^1.4.7",@@@ -1,6 +1,6 @@{"name": "openvibe-codes",-- "version": "0.6.2",++ "version": "0.7.0","private": true,"description": "OpenVibe.Codes: the open coding-agent harness. Run a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or your own model, with routing by capability and cost and hand-offs between agents; plus the site with the agent catalog, a public routing API and the way to improve OpenVibe itself.","main": "harness/index.js",@@@ -305,3 +305,16 @@.c4-demo-ok{padding:2px 9px;border-radius:999px;font-size:12px;font-weight:700;color:color-mix(in srgb,#22c55e 55%,var(--c4-ink,var(--text-primary,#eaf0f1)));background:color-mix(in srgb,#22c55e 14%,transparent)}.c4-composer-front{margin:8px 0 4px}.c4-composer-foot{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;margin-top:10px}++/* The conductor's failover (2026-11-03): the fallback rows in Settings, the usage card, and a turn that switched agent. */++.c4-fallbacks{display:grid;gap:10px}++.c4-fallback{display:grid;grid-template-columns:28px minmax(0,1.3fr) minmax(0,1fr);gap:12px;align-items:end;padding:12px;border:1px solid var(--c4-line);border-radius:10px;background:var(--c4-ghost)}++.c4-fallback-n{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;align-self:center;background:var(--c4-open-bg);color:var(--c4-open-ink);font-size:12px;font-weight:700}++.c4-fallback:has(select option[value=""]:checked){background:transparent;border-style:dashed}++.c4-fallback:has(select option[value=""]:checked) .c4-fallback-n{background:var(--c4-quiet-bg);color:var(--c4-muted)}++.c4-chain-next{box-shadow:inset 3px 0 0 var(--c4-accent);padding-left:10px}++.c4-usage-note{margin:12px 0 0;padding:10px 12px;border-radius:8px;background:var(--c4-open-bg);color:var(--c4-open-ink)}++.c4-turn-tags{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;justify-content:flex-end}++.c4-turn-agent{min-height:24px;padding:2px 9px;font-size:11px}++.c4-turn-switch{display:flex;align-items:flex-start;gap:8px;margin:0 0 10px;padding:9px 11px;border-radius:8px;background:var(--c4-open-bg);color:var(--c4-open-ink);font-size:12px;line-height:1.45}++.c4-turn-switch svg{width:15px;height:15px;flex:none;margin-top:1px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}++@media (max-width:640px){.c4-fallback{grid-template-columns:28px minmax(0,1fr)}.c4-fallback>label:last-child{grid-column:2}}@@@ -154,3 +154,12 @@ html[data-ov-motion=reduced] .ch-spark-list[aria-hidden],html[data-ov-motion=red.ch-mode b{font-size:14px}.ch-mode small{font-size:12px;line-height:1.35}}++/* The conductor's usage in the side panel (the failover, 2026-11-03): a meter per agent, the one that takes the next turn. */++.ch .ovt-panel-rows .ch-usage-meter{display:block;height:5px;margin-top:6px;border-radius:3px;background:var(--ch-hover);overflow:hidden}++.ch .ch-usage-meter>span{display:block;height:100%;border-radius:3px;background:var(--ch-ok)}++.ch .ch-usage-meter.is-near>span{background:var(--ch-warn)}++.ch .ch-usage-meter.is-full>span{background:var(--ch-bad)}++.ch .ch-usage-next{font-style:normal;font-weight:600;font-size:11px;color:var(--ch-accent);margin-left:4px}++.ch .ch-usage-note{margin:8px 0 0;padding:8px 10px;border-radius:8px;background:var(--ch-hover);color:var(--ch-soft);font-size:12px;line-height:1.45}++.ch .ch-usage-foot{margin:8px 0 0;color:var(--ch-muted);font-size:12px;line-height:1.45}++.ch .ch-usage-foot a{color:var(--ch-accent)}@@@ -120,7 +120,7 @@ async function createApp(opts = {}) {ctx.approvals = createApprovals({ store, config, log, notify: ctx.notify, userSettings: ctx.userSettings });ctx.conductor = createConductor({store, config, log, run, attempts, tasks, sessions, approvals: ctx.approvals, notify: ctx.notify,-- fetchImpl: fetchImpl || globalThis.fetch,++ fetchImpl: fetchImpl || globalThis.fetch, fleet, userSettings,});tasks.setConductor(ctx.conductor);nnew file mode 100644@@@ -0,0 +1,183 @@++'use strict';++++/**++ * The conductor's failover (owner 2026-11-03): "aware of current usage, and the person can configure a fail-over++ * agent/harness/model for their orchestrator for when their primary one is approaching or has maxed out its usage".++ *++ * A conductor has a chain: its own agent and model first, then up to three fallbacks the person picked in Settings++ * (codes_user_settings.conductor_fallbacks). Before each turn the chain is read against the fleet — what the person's++ * machine says about each agent: installed, signed in, every limit window and when it frees (fleet.js) — and the turn++ * runs on the first agent whose fullest window is under the person's switch point (conductor_switch_pct, 90% unless++ * they chose another). When the primary's windows free again, the next turn goes back to it on its own.++ *++ * usageOf(row, now) one agent's reading → { state, pct, window, resets_at }++ * choose({ chain, fleet, switchPct, now }) → { pick, index, switched, note, usage: [...] }++ * cleanFallbacks(list) the fallbacks a form or an API sent, checked → [{ agent, model }]++ * resetsLabel(iso, now) "resets in 3 h" / "resets Thu 15:00 UTC"++ *++ * The states: ok (room), near (at or over the switch point, still usable), full (a window at the router's FULL_PCT, or++ * no room), login (installed, not signed in), absent (the machine reported its agents and this one is not there),++ * unknown (no reading: the machine never reported, or reported nothing on this agent). An unknown primary is used, as it++ * was before any machine reported; an unknown fallback is tried only after every known one.++ */++++const { windowFull, FULL_PCT } = require('../../engine/router');++++/** The agents a conductor's turn can run on: the CLIs that resume a session and take the Codes MCP tools. */++const CONDUCTOR_AGENTS = ['claude-code', 'codex', 'opencode'];++const AGENT_NAMES = { 'claude-code': 'Claude Code', codex: 'Codex', opencode: 'OpenCode' };++const MODEL = /^[A-Za-z0-9._:/-]{1,100}$/;++const MAX_FALLBACKS = 3;++const DEFAULT_SWITCH_PCT = 90;++const MIN_SWITCH_PCT = 50;++/** The switch point is never above the router's own "full": a window at 95% takes no new work anywhere in Codes. */++const MAX_SWITCH_PCT = FULL_PCT;++++const nameOf = (agent) => AGENT_NAMES[agent] || agent;++++/** A switch point from a form or an API: a whole percentage between 50 and 95; anything else is refused. */++function cleanSwitchPct(v) {++ if (v === undefined) return undefined;++ if (v === null || v === '') return null;++ const n = Number(v);++ if (!Number.isFinite(n) || n < MIN_SWITCH_PCT || n > MAX_SWITCH_PCT) {++ throw Object.assign(new Error(`the switch point is a percentage from ${MIN_SWITCH_PCT} to ${MAX_SWITCH_PCT}`), { status: 422, code: 'settings.switch_invalid' });++ }++ return Math.round(n);++}++++/**++ * The fallbacks a form or an API sent: an ordered list of { agent, model }, at most three, each agent one a conductor++ * can run on, no agent twice. An empty model means the agent's own default. Anything else is refused with a 422.++ */++function cleanFallbacks(list) {++ if (list === undefined) return undefined;++ if (list === null || list === '') return [];++ if (!Array.isArray(list)) throw Object.assign(new Error('the fallbacks are a list'), { status: 422, code: 'settings.fallbacks_invalid' });++ const out = [];++ for (const item of list) {++ if (item == null || item === '') continue;++ const agent = String((typeof item === 'object' ? item.agent : item) || '').trim();++ if (!agent) continue;++ if (!CONDUCTOR_AGENTS.includes(agent)) {++ throw Object.assign(new Error(`a conductor can fall back to ${CONDUCTOR_AGENTS.map(nameOf).join(', ')}`), { status: 422, code: 'settings.fallback_agent' });++ }++ const rawModel = typeof item === 'object' && item.model != null ? String(item.model).trim() : '';++ if (rawModel && !MODEL.test(rawModel)) throw Object.assign(new Error('a fallback\'s model is a model name, or empty for the agent\'s default'), { status: 422, code: 'settings.fallback_model' });++ if (out.some((x) => x.agent === agent && x.model === (rawModel || null))) continue;++ out.push({ agent, model: rawModel || null });++ }++ if (out.length > MAX_FALLBACKS) throw Object.assign(new Error(`at most ${MAX_FALLBACKS} fallbacks`), { status: 422, code: 'settings.fallbacks_too_many' });++ return out;++}++++/** The fallbacks as stored (jsonb), read back defensively: a bad row reads as none. */++function storedFallbacks(v) {++ let list = v;++ if (typeof list === 'string') { try { list = JSON.parse(list); } catch { list = null; } }++ if (!Array.isArray(list)) return [];++ try { return cleanFallbacks(list); } catch { return []; }++}++++const isoOf = (v) => {++ if (v == null || v === '') return null;++ const t = Date.parse(String(v));++ return Number.isFinite(t) ? new Date(t).toISOString() : null;++};++++/** "resets in 40 min" / "resets in 5 h" within a day, else "resets Thu 15:00 UTC". */++function resetsLabel(at, now = Date.now()) {++ const t = Date.parse(String(at || ''));++ if (!Number.isFinite(t)) return null;++ const ms = t - now;++ if (ms <= 0) return 'free again';++ if (ms < 60 * 60_000) return `resets in ${Math.max(1, Math.round(ms / 60_000))} min`;++ if (ms < 24 * 60 * 60_000) return `resets in ${Math.round(ms / (60 * 60_000))} h`;++ const d = new Date(t);++ const day = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getUTCDay()];++ return `resets ${day} ${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')} UTC`;++}++++/**++ * One agent's reading, from its fleet row (fleet.byHarness, or one machine's row when the conductor is pinned to it).++ * The windows that already reset since the reading count as free. `pct` is the fullest window still ahead.++ */++function usageOf(row, switchPct = DEFAULT_SWITCH_PCT, now = Date.now()) {++ if (!row) return { state: 'unknown', pct: null, window: null, resets_at: null };++ if (row.installed === false) return { state: 'absent', pct: null, window: null, resets_at: null };++ if (row.signed_in === false) return { state: 'login', pct: null, window: null, resets_at: null };++ let top = null;++ for (const w of Array.isArray(row.windows) ? row.windows : []) {++ if (!w) continue;++ const resets = Date.parse(String(w.resets_at || ''));++ if (Number.isFinite(resets) && resets <= now) continue; // freed since it was read++ let pct = Number(w.pct);++ if (!Number.isFinite(pct)) {++ const used = Number(w.used); const limit = Number(w.limit);++ pct = Number.isFinite(used) && Number.isFinite(limit) && limit > 0 ? (used / limit) * 100 : NaN;++ }++ if (!Number.isFinite(pct)) continue;++ if (!top || pct > top.pct) top = { pct: Math.round(pct), window: w.name || 'window', resets_at: isoOf(w.resets_at), full: windowFull(w, now) };++ }++ const balanceGone = row.room === false && !(top && top.full);++ if (top && top.full) return { state: 'full', pct: top.pct, window: top.window, resets_at: top.resets_at };++ if (balanceGone) return { state: 'full', pct: top ? top.pct : null, window: top ? top.window : null, resets_at: top ? top.resets_at : null };++ if (top && top.pct >= switchPct) return { state: 'near', pct: top.pct, window: top.window, resets_at: top.resets_at };++ return { state: 'ok', pct: top ? top.pct : null, window: top ? top.window : null, resets_at: top ? top.resets_at : null };++}++++/** "Claude Code is at 93% of its week (resets Thu 15:00 UTC)" — what the switch note and the page say. */++function describe(entry, u, now = Date.now()) {++ const name = nameOf(entry.agent);++ if (u.state === 'login') return `${name} is not signed in on your machine`;++ if (u.state === 'absent') return `${name} is not installed on your machine`;++ if (u.state === 'unknown') return `${name} has not reported its usage`;++ const when = resetsLabel(u.resets_at, now);++ if (u.pct == null) return `${name} has no room left`;++ return `${name} is at ${u.pct}% of its ${u.window || 'window'}${when ? ` (${when})` : ''}`;++}++++/**++ * The agent this turn runs on. `chain` is [primary, ...fallbacks] as { agent, model }; `fleet` maps an agent to its++ * reading (or is null when nothing was ever read). The first agent with room under the switch point takes the turn;++ * with none, the first that is near but not full; with none of those, an unknown fallback; with nothing at all, the++ * primary (its own answer then says what the limit is — Codes never blocks a turn on a reading alone).++ */++function choose({ chain, fleet = null, switchPct = DEFAULT_SWITCH_PCT, now = Date.now() } = {}) {++ const list = (Array.isArray(chain) ? chain : []).filter((x) => x && x.agent);++ const point = Number.isFinite(Number(switchPct)) ? Number(switchPct) : DEFAULT_SWITCH_PCT;++ const usage = list.map((entry, i) => {++ const u = usageOf(fleet && fleet[entry.agent], point, now);++ return { agent: entry.agent, name: nameOf(entry.agent), model: entry.model || null, role: i === 0 ? 'primary' : 'fallback', ...u, label: describe(entry, u, now) };++ });++ if (!list.length) return { pick: null, index: -1, switched: false, note: null, usage };++ // The primary with no reading is used as it always was; a fallback with no reading waits behind every known one.++ const order = [++ (u, i) => u.state === 'ok' || (i === 0 && u.state === 'unknown'),++ (u) => u.state === 'near',++ (u) => u.state === 'unknown',++ ];++ let index = -1;++ for (const test of order) {++ index = usage.findIndex((u, i) => test(u, i));++ if (index >= 0) break;++ }++ const allOut = index < 0;++ if (allOut) index = 0;++ const pick = list[index];++ const primary = usage[0];++ let note = null;++ if (index > 0) {++ note = `${primary.label}; this turn runs on ${nameOf(pick.agent)}${pick.model ? ` (${pick.model})` : ''}.`;++ } else if (allOut && list.length > 1) {++ note = `Every agent in your chain is at its limit (${usage.map((u) => u.label).join('; ')}); this turn tries ${nameOf(pick.agent)}.`;++ } else if (allOut && primary.state === 'full') {++ note = `${primary.label}, and no fallback is set (Settings → Conductor); this turn tries it anyway.`;++ }++ return { pick: { agent: pick.agent, model: pick.model || null }, index, switched: index > 0, note, usage };++}++++module.exports = {++ CONDUCTOR_AGENTS, AGENT_NAMES, MAX_FALLBACKS, DEFAULT_SWITCH_PCT, MIN_SWITCH_PCT, MAX_SWITCH_PCT,++ cleanFallbacks, cleanSwitchPct, storedFallbacks, usageOf, choose, describe, resetsLabel, nameOf,++};@@@ -20,10 +20,18 @@** 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.++ *++ * Failover (owner 2026-11-03, conductor-failover.js): before each turn the conductor's agent and the fallbacks the++ * person chose in Settings are read against their machine's usage windows; the turn runs on the first with room under++ * the switch point, and goes back to the primary once its window frees. Each agent keeps its own session (the turn's++ * row records the agent, the model and the session it ended with), and an agent that takes over is first told what++ * happened in the conversation since it last took part.*/const fs = require('fs');const path = require('path');++const failover = require('./conductor-failover');++const { windowFull } = require('../../engine/router');const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);const MESSAGE_STATES = ['queued', 'running', 'done', 'failed', 'stopped'];@@@ -36,6 +44,11 @@ const MAX_ATTACHMENTS = 4;const MAX_ATTACHMENT_BYTES = 2 * 1024 * 1024;const MAX_NOTE = 4000;const MAX_FOLDER = 4096;++/** The most of the conversation an agent taking over is told, so the turn stays inside a job's 16,000 characters. */++const MAX_CATCH_UP = 9000;++/** A fleet reading older than this is read again when the person writes (their token is at hand then). */++const FLEET_FRESH_MS = 5 * 60_000;++const PRIMARY_AGENT = 'claude-code';const iso = (v) => {if (!v) return null;@@@ -76,7 +89,24 @@ function cleanAttachments(list) {return out;}--function createConductor({ store, config = {}, log = console, run, attempts, tasks, sessions, approvals = null, notify = null, fetchImpl = null }) {++/** The agent and the session a turn's job reported (its result log's agent line; the job's own session field). */++function ranOf(result) {++ const d = asObject(result) || {};++ const log = Array.isArray(d.log) ? d.log : [];++ let agent = null; let session = null;++ for (const line of log) {++ if (!line || typeof line !== 'object' || line.t !== 'agent') continue;++ if (!agent && line.agent) agent = String(line.agent).slice(0, 40);++ if (!session && line.session) session = String(line.session).slice(0, 200);++ }++ if (!agent && Array.isArray(d.agents) && d.agents[0]) agent = String(d.agents[0]).slice(0, 40);++ if (!session && typeof d.session === 'string' && d.session) session = d.session.slice(0, 200);++ return { agent, session };++}++++const agentOf = (m) => (m && m.agent) || PRIMARY_AGENT;++++function createConductor({ store, config = {}, log = console, run, attempts, tasks, sessions, approvals = null, notify = null, fetchImpl = null, fleet = null, userSettings = null }) {const db = store.db;const fetch = fetchImpl || globalThis.fetch;const baseUrl = String(config.baseUrl || '').replace(/\/+$/, '');@@@ -175,28 +205,39 @@ function createConductor({ store, config = {}, log = console, run, attempts, tasreturn made;}-- /** A message the person writes. Starts the turn when the conductor is idle; otherwise it waits. */-- async function message(task, { text, attachments = [] } = {}) {++ /**++ * A message the person writes. Starts the turn when the conductor is idle; otherwise it waits. With the person's++ * token at hand, a fleet reading older than five minutes is read again first, so the turn's agent is chosen on++ * today's usage.++ */++ async function message(task, { text, attachments = [], token = null } = {}) {const made = await enqueue(task, { text, attachments });++ await refreshFleet(task.subject, token);const started = await startNext(task.id);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) {++ /**++ * The job input of one turn: the same folder and safety; the agent, model and session of the turn's choice (`over`,++ * from startNext), or the conductor's own agent and the session the attempt carries.++ */++ function turnArgs(task, message, attempt, over = null) {const files = Array.isArray(message.attachments) ? message.attachments : [];++ const agent = (over && over.agent) || PRIMARY_AGENT;++ const model = over && 'model' in over ? over.model : (task.model || 'opus');++ const resume = over && 'resume' in over ? over.resume : (attempt && attempt.session) || null;return {kind: 'conductor',task_id: task.id,message_id: message.id,folder: task.folder,-- prompt: message.prompt,-- agent: 'claude-code',-- model: task.model || 'opus',++ prompt: (over && over.prompt) || message.prompt,++ agent,++ ...(model ? { model } : {}),safety: task.safety || 'ask',mcp: true,codes_url: baseUrl,-- ...(attempt && attempt.session ? { resume: attempt.session } : {}),++ ...(resume ? { resume } : {}),// The image's bytes stay in the database: the job fetches it with the person's token and writes it as a// file on the machine, so a turn's input stays far inside Run's limit....(files.length@@@ -205,6 +246,98 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas};}++ /** Read the person's machines again when the stored reading is older than FLEET_FRESH_MS. Never throws. */++ async function refreshFleet(subject, token) {++ if (!fleet || !token || typeof fleet.refresh !== 'function') return;++ try {++ const { at } = await fleet.latest(subject);++ const age = at ? Date.now() - Date.parse(at) : Infinity;++ if (Number.isFinite(age) && age < FLEET_FRESH_MS) return;++ await fleet.refresh(subject, token);++ } catch (e) {++ log.warn(`[Codes] conductor: the fleet of ${subject} could not be read: ${String(e && e.message).slice(0, 160)}`);++ }++ }++++ /** The conductor's chain: its own agent and model, then the person's fallbacks (none twice). */++ async function chainFor(task) {++ const primary = { agent: task.agent || PRIMARY_AGENT, model: task.model || 'opus' };++ let settings = null;++ if (userSettings) { try { settings = await userSettings.get(task.subject); } catch { settings = null; } }++ const fallbacks = (settings && settings.conductor_fallbacks) || [];++ const chain = [primary, ...fallbacks.filter((f) => !(f.agent === primary.agent && (f.model || null) === primary.model))];++ return { chain, switchPct: (settings && settings.conductor_switch_pct) || failover.DEFAULT_SWITCH_PCT };++ }++++ /**++ * What the person's machines say about each agent: the machine the conductor is pinned to when it is (its folder and++ * sessions are there, so only its own agents count), else the best across their machines. null with no reading.++ */++ async function fleetFor(task) {++ if (!fleet) return null;++ try {++ if (task.pin) {++ const { machines } = await fleet.latest(task.subject);++ const here = (machines || []).find((m) => m.id === task.pin);++ if (!here || !here.agents.length) return null;++ const out = {};++ for (const a of here.agents) {++ const windows = Array.isArray(a.windows) ? a.windows : [];++ out[a.agent] = { installed: a.installed, signed_in: a.signed_in, windows, room: a.installed && a.signed_in && !windows.some((w) => windowFull(w)) };++ }++ return out;++ }++ const by = await fleet.byHarness(task.subject);++ return by && Object.keys(by).length ? by : null;++ } catch (e) {++ log.warn(`[Codes] conductor ${task.id}: the fleet could not be read: ${String(e && e.message).slice(0, 160)}`);++ return null;++ }++ }++++ /** The choice for the next turn (conductor-failover.js choose), with the chain it was made from. */++ async function choiceFor(task) {++ const { chain, switchPct } = await chainFor(task);++ const reading = await fleetFor(task);++ return { ...failover.choose({ chain, fleet: reading, switchPct }), switchPct, chain };++ }++++ /**++ * What an agent taking over is told first: who it is carrying on from, and the conversation since it last took++ * part (all of it, the hand-over brief first, when it never did) — each turn's message and answer, newest kept when++ * it is long.++ */++ function catchUp(earlier, agent, fromAgent, note) {++ const lastOwn = [...earlier].reverse().find((m) => agentOf(m) === agent && m.session);++ const since = lastOwn ? earlier.filter((m) => Number(m.n) > Number(lastOwn.n)) : earlier;++ if (!since.length) return '';++ const brief = !lastOwn && since[0] && Number(since[0].n) === 1 ? since[0] : null;++ const turns = since.filter((m) => m !== brief);++ const clip = (v, n) => { const t = String(v || '').trim(); return t.length > n ? `${t.slice(0, n)}…` : t; };++ const blocks = turns.map((m) => {++ const r = asObject(m.result);++ const answer = r && typeof r.summary === 'string' ? r.summary : (m.error ? `(the turn did not finish: ${m.error})` : '(no answer recorded)');++ return `### Turn ${m.n} — ${failover.nameOf(agentOf(m))}\n\n**The owner:** ${clip(m.prompt, 1200)}\n\n**The answer:** ${clip(answer, 1500)}`;++ });++ const briefText = brief ? clip(brief.prompt, 4000) : '';++ let budget = MAX_CATCH_UP - briefText.length;++ const kept = [];++ for (let i = blocks.length - 1; i >= 0; i--) {++ if (blocks[i].length > budget) break;++ kept.unshift(blocks[i]);++ budget -= blocks[i].length;++ }++ const dropped = blocks.length - kept.length;++ const head = `## You are taking over this conversation\n\nYou are the owner's conductor, carrying on from ${failover.nameOf(fromAgent)}${note ? ` (${note.replace(/\.$/, '')})` : ''}. Below is what happened ${lastOwn ? 'since you last took part' : 'so far'}; then the owner's new message. Carry on from there: the same plan, the same Codes tools.`;++ return [++ head,++ briefText ? `## The hand-over brief this conversation started with\n\n${briefText}` : '',++ dropped ? `(${dropped} earlier turn${dropped === 1 ? '' : 's'} left out for length.)` : '',++ kept.length ? `## The turns\n\n${kept.join('\n\n')}` : '',++ '---\n\n## The owner\'s new message\n',++ ].filter(Boolean).join('\n\n');++ }++/*** 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.@@@ -230,10 +363,35 @@ function createConductor({ store, config = {}, log = console, run, attempts, tasif (taken.error || taken.running || taken.idle || taken.stopped) return taken;const { task, message: turn, attempt } = taken;++ // The agent this turn runs on (failover), the session it resumes, and — when it is not the agent of the last++ // turn — what happened in the conversation while it was away.++ let over = null;++ try {++ const choice = await choiceFor(task);++ const pick = choice.pick || { agent: PRIMARY_AGENT, model: task.model || 'opus' };++ const earlier = await db.many('SELECT * FROM codes_conductor_messages WHERE task_id = $1 AND n < $2 ORDER BY n ASC', [task.id, turn.n]);++ const ran = earlier.filter((m) => m.started_at);++ const previous = ran.length ? agentOf(ran[ran.length - 1]) : null;++ const own = [...ran].reverse().find((m) => agentOf(m) === pick.agent && m.session);++ let resume = own ? own.session : null;++ if (!resume && pick.agent === PRIMARY_AGENT && !earlier.some((m) => m.agent)) resume = (attempt && attempt.session) || null;++ let prompt = turn.prompt;++ if (previous && previous !== pick.agent) {++ const intro = catchUp(ran, pick.agent, previous, choice.note);++ if (intro) prompt = `${intro}\n${turn.prompt}`.slice(0, MAX_MESSAGE);++ }++ over = { agent: pick.agent, model: pick.model, resume, prompt };++ await db.exec('UPDATE codes_conductor_messages SET agent = $2, model = $3, switch_note = $4 WHERE id = $1',++ [turn.id, pick.agent, pick.model || null, choice.note ? String(choice.note).slice(0, 1000) : null]);++ if (choice.note) log.info && log.info(`[Codes] conductor ${task.id}: ${choice.note}`);++ } catch (e) {++ log.warn(`[Codes] conductor ${task.id}: the failover could not choose, the turn runs on its own agent: ${String(e && e.message).slice(0, 200)}`);++ over = null;++ }let job;try {job = await run.submit({-- subject: task.subject, project: task.project_id, args: turnArgs(task, turn, attempt), key: turn.id,++ subject: task.subject, project: task.project_id, args: turnArgs(task, turn, attempt, over), key: turn.id,limits: { wall_ms: wallMs, cpu_ms: wallMs, mem_bytes: 4 * 1024 ** 3 }, ttlMs: Math.min(86_400_000, wallMs + 10 * 60_000),// Pinned to the machine the person chose (Codes C9c): the turn resumes a session whose folder and// memory are there, so Run must not place it anywhere else.@@@ -250,7 +408,7 @@ function createConductor({ store, config = {}, log = console, run, attempts, tasreturn { 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 });++ if (attempt) await attempts.bind(attempt.id, { job_id: job.id, state: 'queued', machine: task.machine, model: (over && over.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`,@@@ -314,10 +472,22 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas: null;if (turn && turn.state === 'running') {const ok = task.state === 'succeeded';++ // The session this turn ended with belongs to the agent that ran it: the next turn of that agent++ // resumes it. A machine whose Codes runner is older runs Claude Code whatever was asked; the row then++ // says so, and keeps the session under the agent that really ran.++ const ran = ranOf(task.result);++ const asked = turn.agent || PRIMARY_AGENT;++ const agent = ran.agent && failover.CONDUCTOR_AGENTS.includes(ran.agent) ? ran.agent : asked;++ const session = ran.session || (agent === PRIMARY_AGENT && !turn.agent ? (attempt && attempt.session) : null) || null;++ let note = turn.switch_note || null;++ if (agent !== asked) {++ note = `${note ? `${note} ` : ''}Your machine ran ${failover.nameOf(agent)} instead of ${failover.nameOf(asked)}: its Codes runner is older than the switch. Update OpenVibe Node on it to let the conductor switch agents.`.slice(0, 1000);++ }await db.exec(-- `UPDATE codes_conductor_messages SET state = $2, result = $3::jsonb, error = $4, session = $5, finished_at = $6::timestamptz WHERE id = $1`,++ `UPDATE codes_conductor_messages SET state = $2, result = $3::jsonb, error = $4, session = $5, finished_at = $6::timestamptz,++ agent = $7, switch_note = $8 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()],++ ok ? null : String(task.error || 'the turn failed').slice(0, 1000), session, task.finished_at || store.iso(), agent, note],);} 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",@@@ -357,6 +527,7 @@ function createConductor({ store, config = {}, log = console, run, attempts, tasprompt: 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,++ agent: agentOf(m), agent_name: failover.nameOf(agentOf(m)), model: m.model || null, switch_note: m.switch_note || 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),@@@ -369,6 +540,19 @@ function createConductor({ store, config = {}, log = console, run, attempts, tasconst 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) : [];++ // The usage the next turn will be chosen on: each agent of the chain, how full its fullest window is, when it++ // frees, and which one the next turn would take.++ let usage = null;++ try {++ const choice = await choiceFor(task);++ usage = {++ switch_pct: choice.switchPct,++ next: choice.pick,++ note: choice.note,++ fallbacks: choice.chain.length - 1,++ chain: choice.usage.map((u) => ({ ...u, resets: failover.resetsLabel(u.resets_at) })),++ };++ } catch { usage = null; }return {task: tasks.present(task),kind: 'conductor',@@@ -380,6 +564,7 @@ function createConductor({ store, config = {}, log = console, run, attempts, tasattempt: attempt ? attempts.present(attempt) : null,messages: messages.map(presentMessage),approvals: pending.map((a) => approvals.present(a)),++ usage,};}@@@ -394,9 +579,9 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas}return {-- create, enqueue, message, startNext, onTurnEnd, stop, view, presentMessage, attachment, fragment, turnArgs, projectFor,++ create, enqueue, message, startNext, onTurnEnd, stop, view, presentMessage, attachment, fragment, turnArgs, projectFor, choiceFor, refreshFleet,SAFETY, MESSAGE_STATES, MAX_MESSAGE, MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES,};}--module.exports = { createConductor, validFolder, cleanAttachments, SAFETY, MODEL, NODE };++module.exports = { createConductor, validFolder, cleanAttachments, ranOf, SAFETY, MODEL, NODE };@@@ -8,11 +8,17 @@** createUserSettings({ store })* get(subject) → { default_agent, default_machine, notify_done, notify_needs, safety,-- * steps, minutes, no_cloud, only_free }++ * steps, minutes, no_cloud, only_free, conductor_fallbacks,++ * conductor_switch_pct }* save(subject, patch) → the saved row, in the same shape*++ * The conductor's failover (migration 0023): the agents and models it falls back to, in order, and the percentage of a++ * usage window at which it switches (server/domain/conductor-failover.js).++ ** Only a usr_ subject is accepted: a service token has no preferences of its own to keep.*/++const failover = require('./conductor-failover');++const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const SAFETY = ['ask', 'project', 'full'];const AGENT = /^[a-z0-9-]{1,40}$/;@@@ -25,6 +31,8 @@ const DEFAULTS = Object.freeze({steps: null, minutes: null,// Placement A1: the person's rules, both off until they choose them.no_cloud: false, only_free: false,++ // The conductor's failover: no fallback until the person picks one; it switches at 90% of a window.++ conductor_fallbacks: [], conductor_switch_pct: failover.DEFAULT_SWITCH_PCT,});/** A nullable limit from a form or an API: '' / 'off' / 0 / null mean no limit; a negative or silly number is refused. */@@@ -39,7 +47,7 @@ function limit(v, field) {}function present(row) {-- if (!row) return { ...DEFAULTS };++ if (!row) return { ...DEFAULTS, conductor_fallbacks: [] };return {default_agent: row.default_agent || null,default_machine: row.default_machine || null,@@@ -50,6 +58,8 @@ function present(row) {minutes: row.minutes == null ? null : Number(row.minutes),no_cloud: row.no_cloud === true,only_free: row.only_free === true,++ conductor_fallbacks: failover.storedFallbacks(row.conductor_fallbacks),++ conductor_switch_pct: row.conductor_switch_pct == null ? failover.DEFAULT_SWITCH_PCT : Number(row.conductor_switch_pct),};}@@@ -96,15 +106,23 @@ function createUserSettings({ store }) {if (steps !== undefined) next.steps = steps;const minutes = limit(patch.minutes, 'minutes per task');if (minutes !== undefined) next.minutes = minutes;++ // Settings → Conductor: the fallbacks in order (each an agent a conductor can run on, with an optional model)++ // and the switch point. A form that did not send them keeps what was saved.++ const fallbacks = failover.cleanFallbacks(patch.conductor_fallbacks);++ if (fallbacks !== undefined) next.conductor_fallbacks = fallbacks;++ const switchPct = failover.cleanSwitchPct(patch.conductor_switch_pct);++ if (switchPct !== undefined) next.conductor_switch_pct = switchPct == null ? failover.DEFAULT_SWITCH_PCT : switchPct;await db.exec(`INSERT INTO codes_user_settings (subject, default_agent, default_machine, notify_done, notify_needs, safety,-- steps, minutes, no_cloud, only_free, updated_at)-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now())++ steps, minutes, no_cloud, only_free, conductor_fallbacks, conductor_switch_pct, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, now())ON CONFLICT (subject) DO UPDATE SET default_agent = EXCLUDED.default_agent, default_machine = EXCLUDED.default_machine,notify_done = EXCLUDED.notify_done, notify_needs = EXCLUDED.notify_needs, safety = EXCLUDED.safety,steps = EXCLUDED.steps, minutes = EXCLUDED.minutes, no_cloud = EXCLUDED.no_cloud,-- only_free = EXCLUDED.only_free, updated_at = now()`,-- [subject, next.default_agent, next.default_machine, next.notify_done, next.notify_needs, next.safety, next.steps, next.minutes, next.no_cloud, next.only_free],++ only_free = EXCLUDED.only_free, conductor_fallbacks = EXCLUDED.conductor_fallbacks,++ conductor_switch_pct = EXCLUDED.conductor_switch_pct, updated_at = now()`,++ [subject, next.default_agent, next.default_machine, next.notify_done, next.notify_needs, next.safety, next.steps, next.minutes, next.no_cloud, next.only_free,++ JSON.stringify(next.conductor_fallbacks || []), next.conductor_switch_pct],);return next;}@@@ -26,6 +26,9 @@ const safeId = (s) => /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(s || ''));const money = (n) => Number.isFinite(Number(n)) ? `$${Number(n).toFixed(2)}` : '—';// The models a Claude Code plan login offers; the alias is what the CLI is given, so it survives a point release.const MODELS = [['opus', 'Opus 5.5 (default)'], ['sonnet', 'Sonnet 5.5'], ['haiku', 'Haiku 5.5']];++const { CONDUCTOR_AGENTS, AGENT_NAMES: failoverNames } = require('../domain/conductor-failover');++/** The agents a conductor may fall back to: every conductor agent but Claude Code, which is its own. */++const CONDUCTOR_FALLBACKS = CONDUCTOR_AGENTS.filter((a) => a !== 'claude-code');const modelLabel = (m) => ({ opus: 'Opus 5.5', sonnet: 'Sonnet 5.5', haiku: 'Haiku 5.5' }[m] || m || 'Opus 5.5');const age = (value) => {const numeric = Number(value);@@@ -43,6 +46,19 @@ const whenUtc = (value) => {const soon = mins <= 0 ? 'now' : mins < 60 ? `in ${mins} min` : mins < 2880 ? `in ${Math.round(mins / 60)} h` : `in ${Math.round(mins / 1440)} d`;return text ? `frees ${soon} · ${text} UTC` : null;};++/** One usage window as a meter: its name, how full it is, and when it frees. */++const usageMeter = (w) => {++ const pct = Math.max(0, Math.min(100, Number(w.pct) || 0));++ const full = Number(w.pct) >= 95;++ const free = whenUtc(w.resets_at);++ const used = w.used != null && w.limit != null ? `${w.used} of ${w.limit}` : (w.pct != null ? `${Math.round(Number(w.pct))}%` : '');++ return `<div class="c4-meter"><span class="c4-meter-name">${esc(w.name || 'window')}</span><span class="c4-meter-track" role="img" aria-label="${esc(`${w.name || 'window'}: ${Math.round(pct)}% used${full ? ', full' : ''}`)}"><span class="c4-meter-fill${full ? ' is-full' : ''}" style="width:${pct}%"></span></span><span class="c4-meter-note">${used ? `<b>${esc(used)}</b>` : ''}${used && free ? ' · ' : ''}${esc(free || (used ? '' : 'no reading'))}</span></div>`;++};++/** The conductor's chain as its page says it: each agent's usage state, in a word and a tone. */++const CHAIN_STATE = {++ ok: ['Has room', 'ok'], near: ['Nearly full', 'skip'], full: ['Full', 'bad'], login: ['Sign in', 'bad'],++ absent: ['Not installed', 'skip'], unknown: ['Not reported', 'skip'],++};const FLEET_STATE = {ok: ['Has room', 'ok'], low: ['Nearly full', 'skip'], login: ['Sign in', 'bad'], limited: ['Full', 'bad'],empty: ['Out of credit', 'bad'], absent: ['Not installed', 'skip'], unknown: ['Not reported', 'skip'],@@@ -280,7 +296,11 @@ ${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class=": '';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>`;++ // Which agent answered: said on every turn once the conversation has more than one (the failover).++ const ranOn = m.agent && (m.agent !== 'claude-code' || m.switch_note)++ ? `<span class="c4-chip c4-turn-agent">${esc(m.agent_name || m.agent)}${m.model ? ` · ${esc(modelLabel(m.model))}` : ''}</span>` : '';++ const switched = m.switch_note ? `<p class="c4-turn-switch">${icon('<path d="M4 8h13l-3-3M20 16H7l3 3"/>')}<span>${esc(m.switch_note)}</span></p>` : '';++ return `<li class="c4-turn c4-turn-${esc(m.state)}">${switched}<div class="c4-turn-head"><span class="c4-turn-who">${m.n === 1 ? 'Hand-over brief' : esc(`You · turn ${m.n}`)}</span><span class="c4-turn-tags">${ranOn}${pill(word, kind)}</span></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. */@@@ -301,11 +321,20 @@ ${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="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>`: '';++ // Usage (the failover): each agent of the chain, how full it is, and which one the next turn takes.++ const usage = view.usage;++ const chainRows = usage && usage.chain && usage.chain.length ? usage.chain.map((u, i) => {++ const [label, tone] = CHAIN_STATE[u.state] || CHAIN_STATE.unknown;++ const meter = u.pct != null ? usageMeter({ name: u.window, pct: u.pct, resets_at: u.resets_at }) : '';++ const next = usage.next && usage.next.agent === u.agent && (usage.next.model || null) === (u.model || null);++ return `<li class="c4-row c4-fleet-row${next ? ' c4-chain-next' : ''}"><span class="c4-row-dot${u.state === 'ok' ? ' c4-dot-ok' : ['full', 'login'].includes(u.state) ? ' c4-dot-bad' : ''}" aria-hidden="true"></span><span class="c4-row-label">${esc(u.name)}${u.model ? ` · ${esc(modelLabel(u.model))}` : ''}</span><span class="c4-row-note">${i === 0 ? 'its own agent' : `fallback ${i}`}${next ? ' · <b>takes the next turn</b>' : ''}</span>${pill(label, tone)}${meter ? `<div class="c4-fleet-windows">${meter}</div>` : ''}</li>`;++ }).join('') : '';++ const usageCard = chainRows ? `<section class="c4-card c4-conductor-usage" aria-labelledby="c4-usage-h"><div class="c4-card-head"><h2 id="c4-usage-h">Usage</h2><a href="/settings#conductor">${usage.fallbacks ? 'Fallbacks' : 'Add a fallback'}</a></div><ul class="c4-rows c4-fleet">${chainRows}</ul>${usage.note ? `<p class="c4-hint c4-usage-note">${esc(usage.note)}</p>` : ''}${usage.fallbacks ? `<p class="c4-hint">At ${esc(usage.switch_pct)}% of any window the next turn moves to the first fallback with room, and comes back once the window frees.</p>` : `<p class="c4-hint">No fallback yet. <a href="/settings#conductor">Pick one</a>, and when ${esc(usage.chain[0].name)} nears its limit your conductor carries on with it instead of stopping.</p>`}</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(modelLabel(row.model))}</span><span class="c4-chip">safety: ${esc(row.safety || 'ask')}</span><span class="c4-chip">${esc(machineLabel || 'your machine')}</span></div><p class="c4-hint">One Claude Code session on your own machine, with your own login. Each message is the next turn of the same conversation; its work is carried out through the Codes tools, on your other agents.</p></section>${approvals}<section class="c4-card c4-turns"><div class="c4-card-head"><h2>Turns</h2><span class="c4-muted">${(view.messages || []).length} so far</span></div>${turns ? `<ol class="c4-turn-list">${turns}</ol>` : '<div class="c4-emptyline">Its first turn is starting.</div>'}</section>${transcript}${composer}`;++ const content = `${outcome}<section class="c4-card c4-conductor-head"><div class="c4-task-heading"><div class="c4-task-title-wrap"><h1>Conductor</h1></div><span class="c4-status c4-${esc(row.state)}">${esc(conductorState(row))}</span></div><div class="c4-chips" aria-label="Conductor details"><span class="c4-chip">${esc(row.folder || 'a folder')}</span><span class="c4-chip">${esc(modelLabel(row.model))}</span><span class="c4-chip">safety: ${esc(row.safety || 'ask')}</span><span class="c4-chip">${esc(machineLabel || 'your machine')}</span></div><p class="c4-hint">One Claude Code session on your own machine, with your own login. Each message is the next turn of the same conversation; its work is carried out through the Codes tools, on your other agents.</p></section>${approvals}${usageCard}<section class="c4-card c4-turns"><div class="c4-card-head"><h2>Turns</h2><span class="c4-muted">${(view.messages || []).length} so far</span></div>${turns ? `<ol class="c4-turn-list">${turns}</ol>` : '<div class="c4-emptyline">Its first turn is starting.</div>'}</section>${transcript}${composer}`;page(req, res, 'Conductor', wrap(content));}@@@ -528,7 +557,7 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductorsconst 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 });++ await conductor.message(row, { text: (req.body || {}).text, token: req.viewer.token || null });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))}`);@@@ -584,13 +613,7 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors}const rows = fleet ? await fleet.agents(req.viewer.subject, harnesses).catch(() => []) : [];const latest = fleet ? await fleet.latest(req.viewer.subject).catch(() => ({ at: null })) : { at: null };-- const meter = (w) => {-- const pct = Math.max(0, Math.min(100, Number(w.pct) || 0));-- const full = Number(w.pct) >= 95;-- const free = whenUtc(w.resets_at);-- const used = w.used != null && w.limit != null ? `${w.used} of ${w.limit}` : (w.pct != null ? `${Math.round(Number(w.pct))}%` : '');-- return `<div class="c4-meter"><span class="c4-meter-name">${esc(w.name || 'window')}</span><span class="c4-meter-track" role="img" aria-label="${esc(`${w.name || 'window'}: ${Math.round(pct)}% used${full ? ', full' : ''}`)}"><span class="c4-meter-fill${full ? ' is-full' : ''}" style="width:${pct}%"></span></span><span class="c4-meter-note">${used ? `<b>${esc(used)}</b>` : ''}${used && free ? ' · ' : ''}${esc(free || (used ? '' : 'no reading'))}</span></div>`;-- };++ const meter = usageMeter;const list = rows.map((row) => {const [label, kind] = FLEET_STATE[row.state] || FLEET_STATE.unknown;const where = (row.machines || []).map((m) => m.name || m.machine).filter(Boolean).slice(0, 3).join(', ');@@@ -742,6 +765,28 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-if (value != null && !choices.some(([v]) => v === Number(value))) choices.push([Number(value), Number(value).toLocaleString('en-US')]);return `<select name="${name}">${choices.map(([v, label]) => `<option value="${v == null ? '' : v}"${(v == null ? value == null : Number(value) === v) ? ' selected' : ''}>${esc(label)}${v == null ? '' : ` ${unit}`}</option>`).join('')}</select>`;};++ // Settings → Conductor (the failover): the agents a conductor can fall back to, each said with what the person's++ // machines report about it (ready, how full, signed out), so a pick that cannot run is visible before it is made.++ const fleetRows = viewer(req) && fleet ? await fleet.agents(req.viewer.subject, harnesses).catch(() => []) : [];++ const fleetBy = new Map(fleetRows.map((r) => [r.agent, r]));++ const readiness = (agent) => {++ const r = fleetBy.get(agent);++ if (!r) return 'not reported';++ const [word] = FLEET_STATE[r.state] || FLEET_STATE.unknown;++ return r.used ? `${word.toLowerCase()}, ${Math.round(r.used)}% used` : word.toLowerCase();++ };++ const fallbacks = (settings && settings.conductor_fallbacks) || [];++ const fallbackRow = (i) => {++ const f = fallbacks[i] || { agent: '', model: '' };++ const options = CONDUCTOR_FALLBACKS.map((a) => `<option value="${a}"${f.agent === a ? ' selected' : ''} data-state="${esc((fleetBy.get(a) || {}).state || 'unknown')}">${esc(failoverNames[a])} — ${esc(readiness(a))}</option>`).join('');++ return `<div class="c4-fallback"><span class="c4-fallback-n" aria-hidden="true">${i + 1}</span><label>Fallback ${i + 1} <select name="fallback_agent_${i + 1}"><option value="">None</option>${options}</select></label><label>Model <input name="fallback_model_${i + 1}" value="${esc(f.model || '')}" placeholder="its default" autocomplete="off" spellcheck="false" maxlength="100"></label></div>`;++ };++ const switchPct = (settings && settings.conductor_switch_pct) || 90;++ const conductorSection = viewer(req) ? `<h3 class="c4-subhead" id="conductor">Conductor</h3>++<p class="c4-hint">When your conductor's agent nears a usage limit (its 5-hour or weekly window), the next turn runs on the first fallback below that has room, and returns once the window frees. Each agent keeps its own session; the one taking over is told what happened while it was away.</p>++<div class="c4-fallbacks">${[0, 1, 2].map(fallbackRow).join('')}</div>++<div class="c4-form-grid"><label>Switch at <select name="conductor_switch_pct">${[70, 80, 85, 90, 95].map((v) => `<option value="${v}"${switchPct === v ? ' selected' : ''}>${v}% of a window</option>`).join('')}</select></label></div>++<p class="c4-hint">Codex and OpenCode have no approval prompt: on "Ask first" they work with edits in the folder only. Your machine needs OpenVibe Node with Codes 0.7 or newer to switch.</p>` : '';const content = `${header('Settings', 'Your choices, remembered for your account.')}${viewer(req) ? `${flash(req)}<section class="c4-card"><form class="c4-form" method="post" action="/settings"><div class="c4-form-grid"><label>Default agent <select name="default_agent"><option value="">Auto</option>${agentOptions}</select></label>@@@ -765,6 +810,7 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-</div><h3 class="c4-subhead">Safety</h3><div class="c4-safety" role="group" aria-label="Safety level">${SAFETY_OPTIONS.map(([value, title, desc]) => `<label class="c4-safety-card"><input type="radio" name="safety" value="${value}"${settings.safety === value ? ' checked' : ''}><span><b>${title}</b><small>${desc}</small></span></label>`).join('')}</div>++${conductorSection}<button class="c4-button" type="submit">Save settings</button></form></section>` : signIn(req)}`;page(req, res, 'Settings', wrap(content));@@@ -784,6 +830,11 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-...(b.safety ? { safety: b.safety } : {}),steps: b.steps === undefined ? undefined : b.steps,minutes: b.minutes === undefined ? undefined : b.minutes,++ // The conductor's fallbacks, in the order of the form's rows; an empty row is none.++ ...(b.fallback_agent_1 !== undefined ? {++ conductor_fallbacks: [1, 2, 3].map((n) => ({ agent: b[`fallback_agent_${n}`] || '', model: b[`fallback_model_${n}`] || '' })).filter((f) => f.agent),++ } : {}),++ ...(b.conductor_switch_pct !== undefined ? { conductor_switch_pct: b.conductor_switch_pct } : {}),});return res.redirect(303, '/settings?ok=Settings%20saved');} catch (e) {@@@ -268,17 +268,22 @@ function createChatRoutes(ctx) {async function conductorThread(row, view) {const out = [];const names = agentNames();-- const a = agentOf('claude-code', names);const live = LIVE.includes(row.state);const msgs = view.messages || [];-- const who = { ...a, sub: row.model || '' };++ let previous = null;for (const m of msgs) {++ // The agent that answered this turn (the failover may have moved it), and a line where it changed.++ const a = agentOf(m.agent || 'claude-code', names);++ const who = { ...a, sub: m.model || (m.agent && m.agent !== 'claude-code' ? '' : row.model) || '' };++ if (m.switch_note) out.push(thread.divider({ text: m.switch_note }));++ else if (previous && m.agent && m.agent !== previous) out.push(thread.divider({ text: `${a.name} carries on` }));++ if (m.agent) previous = m.agent;const files = (m.attachments || []).map((f, i) => ({ href: `/api/v1/tasks/${encodeURIComponent(row.id)}/messages/${encodeURIComponent(m.id)}/attachments/${i}`, name: f.name, image: /^image\//.test(String(f.mime || '')) }));const mine = { text: m.n === 1 ? (m.prompt || 'Pick up the plan') : m.prompt, caption: m.n === 1 ? 'Hand-over brief' : '', files };const id = `m${Number(m.n) || out.length + 1}`;if (m.state === 'running' || (live && m === msgs[msgs.length - 1] && m.state !== 'done')) {const lines = await db.many('SELECT seq, at, kind, body FROM codes_session_lines WHERE task_id = $1 ORDER BY seq DESC LIMIT 300', [row.id]);-- const r = agentReply(a, lines.reverse(), { live: true, attempt: { model: row.model } });++ const r = agentReply(a, lines.reverse(), { live: true, attempt: { model: m.model || row.model } });out.push(thread.turn({ id, open: true, state: 'running', you: mine, who: r.who, reply: r.reply }));} else if (m.result && m.result.summary) {out.push(thread.turn({ id, you: mine, who, reply: thread.answer({ html: markdown(m.result.summary) }) }));@@@ -291,7 +296,7 @@ function createChatRoutes(ctx) {}// What waits for a yes, as a reply of its own at the end of the thread.const asks = (view.approvals || []).map((ap) => approvalCard(row, ap)).join('');-- if (asks) out.push(thread.turn({ id: 'asks', who, reply: asks }));++ if (asks) out.push(thread.turn({ id: 'asks', who: { ...agentOf('claude-code', names), sub: row.model || '' }, reply: asks }));return out;}const approvalCard = (row, ap) => thread.card({@@@ -376,7 +381,27 @@ function createChatRoutes(ctx) {}/** The panel: who worked on it, and where the work is. */-- async function sideHtml(row) {++ /**++ * The conductor's usage (the failover): each agent of its chain, how full its fullest window is and when it frees,++ * and which one takes the next turn. Fallbacks are chosen in Settings.++ */++ function usageSection(usage) {++ if (!usage || !usage.chain || !usage.chain.length) return null;++ const words = { ok: 'has room', near: 'nearly full', full: 'full', login: 'not signed in', absent: 'not installed', unknown: 'no reading yet' };++ const rows = usage.chain.map((u, i) => {++ const next = usage.next && usage.next.agent === u.agent && (usage.next.model || null) === (u.model || null);++ const pct = u.pct == null ? null : Math.max(0, Math.min(100, Number(u.pct)));++ const meter = pct == null ? '' : `<span class="ch-usage-meter${u.state === 'full' ? ' is-full' : u.state === 'near' ? ' is-near' : ''}" role="img" aria-label="${esc(`${u.window || 'window'}: ${pct}% used`)}"><span style="width:${pct}%"></span></span>`;++ const sub = [i === 0 ? 'its own agent' : `fallback ${i}`, u.pct != null ? `${u.pct}% of ${u.window || 'a window'}` : words[u.state] || u.state, u.resets].filter(Boolean).join(' · ');++ return { who: agentOf(u.agent, agentNames()), html: `<b>${esc(u.name)}${u.model ? ` · ${esc(u.model)}` : ''}${next ? ' <em class="ch-usage-next">next turn</em>' : ''}</b><small>${esc(sub)}</small>${meter}` };++ });++ const foot = usage.fallbacks++ ? `<p class="ch-usage-foot">At ${esc(usage.switch_pct)}% of a window the next turn moves to the first fallback with room. <a href="/settings#conductor">Change</a></p>`++ : `<p class="ch-usage-foot">No fallback yet: when ${esc(usage.chain[0].name)} runs out, the conductor waits. <a href="/settings#conductor">Add a fallback</a></p>`;++ return { title: 'Usage', rows, html: `${usage.note ? `<p class="ch-usage-note">${esc(usage.note)}</p>` : ''}${foot}` };++ }++++ async function sideHtml(row, view = null) {const names = agentNames();const attempts = tasks?.attempts ? await tasks.attempts.list(row.id) : [];const result = asObject(row.result) || {};@@@ -388,6 +413,7 @@ function createChatRoutes(ctx) {].filter(Boolean);const people = attempts.map((a) => { const g = agentOf(a.agent, names); return { who: g, title: g.name, sub: [a.model, tasks.attempts.wordsOf && a.reason ? tasks.attempts.wordsOf(a.reason) : a.state].filter(Boolean).join(' · ') }; });return thread.panel({ id: 'ch-side', title: 'What it did', sections: [++ view && view.usage ? usageSection(view.usage) : null,people.length ? { title: 'Agents', rows: people } : null,where.length ? { title: 'Where', rows: where } : null,] });@@@ -555,10 +581,13 @@ function createChatRoutes(ctx) {if (row.kind === 'conductor') {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 model = String(row.model || 'opus');-- const pickers = `<span class="ch-chip ch-chip-static" title="The conductor runs Claude Code on your machine">${avatar(agentOf('claude-code'))}${esc(model)}</span><label class="ch-pick ch-pick-file" title="Attach an image">${ico('attach')}<span class="ch-sr">Attach an image</span><input type="file" accept="image/*" multiple data-ch-images></label>`;++ // The agent the next turn runs on: the conductor's own, or the fallback its usage moves it to.++ const next = (view.usage && view.usage.next) || { agent: 'claude-code', model: row.model || 'opus' };++ const nextAgent = agentOf(next.agent);++ const model = String(next.model || (next.agent === 'claude-code' ? row.model || 'opus' : 'its default model'));++ const pickers = `<span class="ch-chip ch-chip-static" title="${esc(`The next turn runs ${nextAgent.name} on your machine`)}">${avatar(nextAgent)}${esc(model)}</span><label class="ch-pick ch-pick-file" title="Attach an image">${ico('attach')}<span class="ch-sr">Attach an image</span><input type="file" accept="image/*" multiple data-ch-images></label>`;return frame(req, res, {-- currentId: row.id, title: 'Conductor', heading: titleOf(row), state, panel: await sideHtml(row),++ currentId: row.id, title: 'Conductor', heading: titleOf(row), state, panel: await sideHtml(row, view),turns: [...errorTurns, ...await conductorThread(row, view)],streamUrl: `/api/v1/tasks/${encodeURIComponent(row.id)}/events?last_event_id=${last && last.seq != null ? Number(last.seq) : 0}`,composerHtml: row.outcome === 'stopped' ? '' : composer({ action: `/tasks/${encodeURIComponent(row.id)}/messages`, api: `/api/v1/conductor/${encodeURIComponent(row.id)}/messages`, placeholder: live ? 'Say something: it is the next turn' : 'What next?', pickers, note: live ? 'A message waits until the current turn ends; it is not lost.' : '' }),@@@ -170,7 +170,7 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,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 });++ const out = await conductor.message(row, { text: b.text, attachments: b.attachments, token: (req.person && req.person.token) || null });return res.status(201).json({message: conductor.presentMessage(out.message),started: !!(out.started && out.started.job),nnew file mode 100644@@@ -0,0 +1,234 @@++'use strict';++++/**++ * The conductor's failover (owner 2026-11-03): before each turn the conductor's own agent and the fallbacks the person++ * chose are read against their machine's usage windows, and the turn runs on the first with room under the switch++ * point. What this checks:++ * - the choice itself (conductor-failover.js): room, near, full, signed out, a window that already freed, no reading;++ * - the settings: fallbacks and the switch point are checked and kept;++ * - a conductor whose Claude Code is at 93% of its week runs its turn on Codex, comes back when the week frees, and++ * each agent resumes its own session; the agent taking over is told what happened while it was away;++ * - a machine whose runner is too old to switch is said so on the turn;++ * - the harness takes a conductor turn on Codex or OpenCode, with the Codes MCP server in a 0700 launcher.++ */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { boot, check, done } = require('./helpers/boot');++const failover = require('../server/domain/conductor-failover');++const job = require('../harness/job');++++const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2F1';++const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2F1';++const HOUR = 3600_000;++++(async () => {++ const now = Date.now();++ const later = new Date(now + 50 * HOUR).toISOString();++ const chain = [{ agent: 'claude-code', model: 'opus' }, { agent: 'codex', model: 'gpt-5.5' }, { agent: 'opencode', model: null }];++ const row = (pct, extra = {}) => ({ installed: true, signed_in: true, windows: pct == null ? [] : [{ name: 'week', pct, resets_at: later }], ...extra });++++ await check('the choice: the first agent under the switch point takes the turn', async () => {++ let c = failover.choose({ chain, fleet: { 'claude-code': row(40), codex: row(10) }, now });++ assert.deepStrictEqual(c.pick, { agent: 'claude-code', model: 'opus' });++ assert.strictEqual(c.switched, false);++ assert.strictEqual(c.note, null);++++ c = failover.choose({ chain, fleet: { 'claude-code': row(93), codex: row(20) }, now });++ assert.deepStrictEqual(c.pick, { agent: 'codex', model: 'gpt-5.5' }, 'near its limit, the next agent with room takes it');++ assert.strictEqual(c.switched, true);++ assert.match(c.note, /Claude Code is at 93% of its week \(resets in 2 d|resets \w{3} \d\d:\d\d UTC\)/);++ assert.match(c.note, /this turn runs on Codex \(gpt-5\.5\)/);++++ c = failover.choose({ chain, fleet: { 'claude-code': row(93), codex: row(20) }, switchPct: 95, now });++ assert.strictEqual(c.pick.agent, 'claude-code', 'the person\'s switch point decides when it moves');++++ c = failover.choose({ chain, fleet: { 'claude-code': row(100), codex: row(91), opencode: row(96) }, now });++ assert.strictEqual(c.pick.agent, 'codex', 'with nothing under the point, a near agent before a full one');++++ c = failover.choose({ chain, fleet: { 'claude-code': row(92), codex: row(99), opencode: row(null, { signed_in: false }) }, now });++ assert.strictEqual(c.pick.agent, 'claude-code', 'a near primary beats full and signed-out fallbacks');++++ c = failover.choose({ chain, fleet: { 'claude-code': row(100), codex: row(99), opencode: row(97) }, now });++ assert.strictEqual(c.pick.agent, 'claude-code', 'all at their limit: the primary tries, and its own answer says the limit');++ assert.match(c.note, /^Every agent in your chain is at its limit/);++++ c = failover.choose({ chain: chain.slice(0, 1), fleet: { 'claude-code': row(100) }, now });++ assert.match(c.note, /no fallback is set/);++++ c = failover.choose({ chain, fleet: { 'claude-code': row(null, { signed_in: false }), codex: row(5) }, now });++ assert.strictEqual(c.pick.agent, 'codex', 'a signed-out primary hands over');++++ const freed = { installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: new Date(now - HOUR).toISOString() }] };++ c = failover.choose({ chain, fleet: { 'claude-code': freed, codex: row(5) }, now });++ assert.strictEqual(c.pick.agent, 'claude-code', 'a window whose reset has passed is free again');++++ c = failover.choose({ chain, fleet: null, now });++ assert.strictEqual(c.pick.agent, 'claude-code', 'no reading at all: the primary, as before any machine reported');++ c = failover.choose({ chain, fleet: { 'claude-code': row(97) }, now });++ assert.strictEqual(c.pick.agent, 'codex', 'an unreported fallback is still tried before a full primary');++ assert.strictEqual(c.usage[1].state, 'unknown');++ });++++ await check('the settings: fallbacks and the switch point are checked', async () => {++ assert.deepStrictEqual(failover.cleanFallbacks([{ agent: 'codex', model: 'gpt-5.5' }, { agent: 'opencode', model: '' }, '']),++ [{ agent: 'codex', model: 'gpt-5.5' }, { agent: 'opencode', model: null }]);++ assert.throws(() => failover.cleanFallbacks([{ agent: 'aider' }]), /fall back to/);++ assert.throws(() => failover.cleanFallbacks([{ agent: 'codex', model: 'gpt 5; rm -rf' }]), /model name/);++ assert.throws(() => failover.cleanFallbacks([{ agent: 'codex', model: 'a' }, { agent: 'codex', model: 'b' }, { agent: 'opencode' }, { agent: 'claude-code', model: 'sonnet' }]), /at most 3/);++ assert.strictEqual(failover.cleanSwitchPct('85'), 85);++ assert.throws(() => failover.cleanSwitchPct(99), /50 to 95/);++ assert.strictEqual(failover.resetsLabel(new Date(now + 30 * 60_000).toISOString(), now), 'resets in 30 min');++ });++++ // ── The conductor, end to end on the in-process services ──++ const calls = [];++ const jobs = new Map();++ let counter = 0;++ const run = {++ enabled: true,++ async submit(o) {++ counter++;++ const id = `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2F${String(counter).padStart(2, '0')}`;++ jobs.set(id, { id, state: 'queued', args: o.args });++ calls.push(o);++ return { id, state: 'queued' };++ },++ async read(jobId) { return jobs.get(jobId) || { id: jobId, state: 'running' }; },++ async 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');++ t.network.addNode(ada.subject, { principal: NODE, node_id: 'node_ada', name: 'ada-laptop', status: 'active', last_seen_at: new Date().toISOString() });++ const machine = t.bot.addMachine({ id: DEVICE, node_id: NODE, name: 'ada-laptop', online: true });++ const report = (claudePct, codexPct) => {++ machine.fleet = [++ { agent: 'claude-code', installed: true, signed_in: true, windows: [{ name: '5h', pct: 10, resets_at: new Date(Date.now() + 2 * HOUR).toISOString() }, { name: 'week', pct: claudePct, resets_at: later }] },++ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: codexPct, resets_at: later }] },++ ];++ machine.fleet_at = new Date().toISOString();++ };++ const refresh = async () => t.ctx.fleet.refresh(ada.subject, t.network.userToken(ada));++ const post = (p, body) => t.get(p, { as: ada, 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 endTurn = async (id, { agent, session, summary = 'Done that.' }) => {++ const r = await rowOf(id);++ const doc = { id: r.job_id, state: 'succeeded', result: { ok: true, summary, log: [{ t: 'agent', agent, session }] } };++ const updated = await t.ctx.tasks.applyEvent(r, { state: 'succeeded', job: doc, error: null });++ return await t.ctx.tasks.afterEnd(updated.id);++ };++++ let taskId = null;++ try {++ await check('Claude Code at 93% of its week: the first turn runs on the fallback', async () => {++ await t.ctx.userSettings.save(ada.subject, { conductor_fallbacks: [{ agent: 'codex', model: 'gpt-5.5' }] });++ report(93, 20);++ await refresh();++ const res = await post('/api/v1/conductor', { folder: '/home/ada/work', machine: NODE, model: 'opus', safety: 'ask' });++ assert.strictEqual(res.status, 201, res.text);++ taskId = res.json().task.id;++ const submit = calls.at(-1);++ assert.strictEqual(submit.args.agent, 'codex');++ assert.strictEqual(submit.args.model, 'gpt-5.5');++ assert.strictEqual(submit.args.resume, undefined);++ assert.doesNotMatch(submit.args.prompt, /taking over/, 'the first turn needs no catch-up: it is the brief');++ const [first] = await messagesOf(taskId);++ assert.strictEqual(first.agent, 'codex');++ assert.match(first.switch_note, /Claude Code is at 93% of its week/);++ const view = res.json();++ assert.strictEqual(view.messages[0].agent, 'codex');++ assert.strictEqual(view.usage.chain.length, 2);++ assert.strictEqual(view.usage.chain[0].state, 'near');++ assert.strictEqual(view.usage.chain[1].state, 'ok');++ });++++ await check('the week frees: the next turn is Claude Code again, told what Codex did', async () => {++ await endTurn(taskId, { agent: 'codex', session: 'thr_codex_1', summary: 'Split the plan into three tasks.' });++ assert.strictEqual((await messagesOf(taskId))[0].session, 'thr_codex_1', 'Codex\'s session is kept on its turn');++ report(12, 20);++ await refresh();++ const res = await post(`/api/v1/conductor/${taskId}/messages`, { text: 'carry on with the second task' });++ assert.strictEqual(res.status, 201, res.text);++ const submit = calls.at(-1);++ assert.strictEqual(submit.args.agent, 'claude-code');++ assert.strictEqual(submit.args.model, 'opus');++ assert.strictEqual(submit.args.resume, undefined, 'Claude Code has no session of its own yet in this conversation');++ assert.match(submit.args.prompt, /You are taking over this conversation/);++ assert.match(submit.args.prompt, /carrying on from Codex/);++ assert.match(submit.args.prompt, /carry on with the second task$/);++ assert.ok(submit.args.prompt.length <= 16000, 'a turn stays inside a job\'s prompt size');++ const m = (await messagesOf(taskId))[1];++ assert.strictEqual(m.prompt, 'carry on with the second task', 'the person\'s own words are what the row keeps');++ assert.strictEqual(m.agent, 'claude-code');++ assert.strictEqual(m.switch_note, null);++ });++++ await check('near again: Codex resumes its own session and hears turn 2', async () => {++ await endTurn(taskId, { agent: 'claude-code', session: 'sess_claude_2', summary: 'Started the second task on OpenCode.' });++ report(95, 30);++ await refresh();++ await post(`/api/v1/conductor/${taskId}/messages`, { text: 'and the third' });++ const submit = calls.at(-1);++ assert.strictEqual(submit.args.agent, 'codex');++ assert.strictEqual(submit.args.resume, 'thr_codex_1', 'each agent resumes its own session');++ assert.match(submit.args.prompt, /since you last took part/);++ assert.match(submit.args.prompt, /Turn 2 — Claude Code/);++ assert.match(submit.args.prompt, /Started the second task on OpenCode\./);++ assert.doesNotMatch(submit.args.prompt, /hand-over brief this conversation started with/, 'Codex already read the brief');++ });++++ await check('a runner too old to switch ran Claude Code: the turn says so, and the session stays with Claude Code', async () => {++ await endTurn(taskId, { agent: 'claude-code', session: 'sess_claude_2' });++ const m = (await messagesOf(taskId))[2];++ assert.strictEqual(m.agent, 'claude-code');++ assert.match(m.switch_note, /Update OpenVibe Node/);++ report(10, 30);++ await refresh();++ await post(`/api/v1/conductor/${taskId}/messages`, { text: 'status?' });++ assert.strictEqual(calls.at(-1).args.resume, 'sess_claude_2');++ });++++ await check('the pages say it: the conductor\'s usage card and the Settings section', async () => {++ const page = await t.get(`/tasks/${taskId}`, { as: ada });++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /<h3>Usage<\/h3>/, 'the side panel says the usage');++ assert.match(page.text, /fallback 1 · 30% of week/);++ assert.match(page.text, /class="ovt-divider"><span>.*Claude Code is at 93% of its week/, 'the switch is a line in the thread');++ const settings = await t.get('/settings', { as: ada });++ assert.match(settings.text, /id="conductor"/);++ assert.match(settings.text, /<option value="codex" selected data-state="ok">Codex — has room, 30% used<\/option>/);++ assert.match(settings.text, /name="fallback_model_1" value="gpt-5\.5"/);++ });++ } finally {++ await t.close();++ }++++ await check('the harness: a conductor turn on Codex or OpenCode, with the Codes tools in a launcher', async () => {++ const base = { kind: 'conductor', folder: '/home/ada/work', prompt: 'go', task_id: 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2F1', mcp: true, codes_url: 'https://codes.test' };++ assert.strictEqual(job.parseArgs(JSON.stringify({ ...base, agent: 'codex', safety: 'ask' }), {}).agent, 'codex');++ assert.strictEqual(job.parseArgs(JSON.stringify({ ...base, agent: 'codex', safety: 'ask' }), {}).permission, 'edit');++ assert.strictEqual(job.parseArgs(JSON.stringify({ ...base, agent: 'opencode', safety: 'full' }), {}).permission, 'full');++ assert.strictEqual(job.parseArgs(JSON.stringify({ ...base, agent: 'claude-code', safety: 'full' }), {}).permission, 'edit', 'Claude Code keeps its own permission modes');++ assert.strictEqual(job.parseArgs(JSON.stringify({ ...base, agent: 'aider' }), {}).agent, 'claude-code');++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-codes-failover-'));++ try {++ const parsed = job.parseArgs(JSON.stringify({ ...base, agent: 'codex' }), {});++ const server = job.conductorMcpLauncher(path.join(dir, 'codes-mcp.sh'), parsed, { OPENVIBE_TOKEN: "tok'en" });++ assert.strictEqual(server.command, '/bin/sh');++ const st = fs.statSync(server.args[0]);++ assert.strictEqual(st.mode & 0o077, 0, 'only the job may read the launcher');++ const text = fs.readFileSync(server.args[0], 'utf8');++ assert.match(text, /export OPENVIBE_TOKEN='tok'\\''en'/, 'the token is quoted for the shell');++ assert.match(text, /export OPENVIBE_CONDUCTOR_TASK='tsk_/);++ assert.match(text, /exec '.+' '.+openvibe-codes\.js' mcp/);++ const oc = JSON.parse(fs.readFileSync(job.opencodeConfig(path.join(dir, 'opencode.json'), server), 'utf8'));++ assert.deepStrictEqual(oc.mcp.codes, { type: 'local', command: ['/bin/sh', server.args[0]], enabled: true });++ } finally { fs.rmSync(dir, { recursive: true, force: true }); }++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -18,7 +18,7 @@ const BUDGETS = {jsRawKB: 351, // 349.9 with openvibe-shared 4.106.2 (2026-11-02: the site's name reads whole on a signed-in phone; the balances stack); 348.7 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 345.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 342.2 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 341.4 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 340.6 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 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: 85.2, // 84.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 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: 74, // 73.2 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 72.2 with 2026-11-01: phone table cards (2026-11-01); 70.7 after the front door: a visitor's example conversation, its hand-over line and result row (public/css/c4.css, 1.4 KB, 2026-10-31); 69.3 after the Memory M2b review: the quiet Remove link, the phone sources fold, the in-place note menu and the danger buttons (public/css/c4.css, 2.2 KB); 67.1 after Memory M2b: the Memory page's sources, notes, note menus, conflicts and tabs (public/css/c4.css, 2.4 KB); 63.0 after C9c: the fleet's rows and its meters on the Agents page (public/css/c4.css, 1.2 KB); 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.++ cssRawKB: 75, // 74.7 after the conductor failover: the fallback rows in Settings, the usage card, a switched turn (public/css/c4.css, 1.3 KB, 2026-11-03); 73.2 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 72.2 with 2026-11-01: phone table cards (2026-11-01); 70.7 after the front door: a visitor's example conversation, its hand-over line and result row (public/css/c4.css, 1.4 KB, 2026-10-31); 69.3 after the Memory M2b review: the quiet Remove link, the phone sources fold, the in-place note menu and the danger buttons (public/css/c4.css, 2.2 KB); 67.1 after Memory M2b: the Memory page's sources, notes, note menus, conflicts and tabs (public/css/c4.css, 2.4 KB); 63.0 after C9c: the fleet's rows and its meters on the Agents page (public/css/c4.css, 1.2 KB); 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: 15.5, // 15.1 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 14.2 after the Memory M2b review (the same quiet link, sources fold, in-place menu and danger buttons); 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};
@@ -60,6 +60,7 @@
@@ -60,6 +60,7 @@
"the conductor (C9b): one Claude Code session on the person's own machine, driven from the browser or the phone, with its approvals",
"the conductor (C9b): one Claude Code session on the person's own machine, driven from the browser or the phone, with its approvals",
"the job runner (CLI 0.6.0): each turn at the effort a person picks, in each agent's own words, and a conversation's next turn starting from the work before it (the last diff applied with git apply --3way, and the conversation so far), on any agent",
"the job runner (CLI 0.6.0): each turn at the effort a person picks, in each agent's own words, and a conversation's next turn starting from the work before it (the last diff applied with git apply --3way, and the conversation so far), on any agent",
"the job runner (CLI 0.6.1): \"edit\" means edits on the person's own computer (OpenCode and Command Code run unattended only in a Node sandbox or for full; Aider suggests no shell commands there), and a private repository clones with a token the machine holds (GITHUB_TOKEN, GITLAB_TOKEN, OPENVIBE_GIT_CREDENTIALS) sent as a header, never in the URL or .git/config",
"the job runner (CLI 0.6.1): \"edit\" means edits on the person's own computer (OpenCode and Command Code run unattended only in a Node sandbox or for full; Aider suggests no shell commands there), and a private repository clones with a token the machine holds (GITHUB_TOKEN, GITLAB_TOKEN, OPENVIBE_GIT_CREDENTIALS) sent as a header, never in the URL or .git/config",
+ "The conductor's failover (CLI 0.7.0, owner 2026-11-03): Settings \u2192 Conductor picks up to three fallback agents (Codex, OpenCode) with a model each and a switch point (90% of a usage window by default); before each turn the conductor's own agent and the fallbacks are read against the machine's usage windows, the turn runs on the first with room and returns to the primary once its window frees; each agent resumes its own session and the one taking over is told what happened while it was away; the conductor's page shows each agent's usage and which takes the next turn",
"OpenVibe Git through the person's own account (/git, CLI 0.6.2): link once (the forge's OAuth2 consent with PKCE, tokens sealed with CODES_SEAL_KEY), make a repository in your name and start a chat on it; a task on a forge repository takes the person's credential line to their machine for git only (never the agent), and the branch and its pull request open on OpenVibe Git"
"OpenVibe Git through the person's own account (/git, CLI 0.6.2): link once (the forge's OAuth2 consent with PKCE, tokens sealed with CODES_SEAL_KEY), make a repository in your name and start a chat on it; a task on a forge repository takes the person's credential line to their machine for git only (never the agent), and the branch and its pull request open on OpenVibe Git"
],
],
"updated": "2026-11-03"
"updated": "2026-11-03"
@@ -98,6 +98,13 @@ const codex = {
@@ -98,6 +98,13 @@ const codex = {
: ['exec', '--json', '--skip-git-repo-check', '-C', t.cwd, '-s', sandbox];
: ['exec', '--json', '--skip-git-repo-check', '-C', t.cwd, '-s', sandbox];
if (t.model) args.push('-m', t.model);
if (t.model) args.push('-m', t.model);
if (t.effort) args.push('-c', `model_reasoning_effort="${effortFor('codex', t.effort)}"`);
if (t.effort) args.push('-c', `model_reasoning_effort="${effortFor('codex', t.effort)}"`);
+ // An MCP server this session may use (the conductor's Codes tools, when Codex takes a conductor's turn): its
+ // command and arguments as config overrides, TOML values. Nothing secret rides here: the launcher holds the token.
+ const mcp = t.options && t.options.mcpServer;
+ if (mcp && /^[a-z][a-z0-9_-]{0,30}$/.test(String(mcp.name || '')) && mcp.command) {
+ args.push('-c', `mcp_servers.${mcp.name}.command=${JSON.stringify(String(mcp.command))}`);
+ args.push('-c', `mcp_servers.${mcp.name}.args=${JSON.stringify((mcp.args || []).map(String))}`);
+ }
args.push(t.prompt);
args.push(t.prompt);
const startedAt = Date.now();
const startedAt = Date.now();
const parser = jsonLines(codex, (e, emit, st) => {
const parser = jsonLines(codex, (e, emit, st) => {
@@ -160,6 +167,8 @@ const opencode = {
@@ -160,6 +167,8 @@ const opencode = {
if (t.model) args.push('-m', t.model);
if (t.model) args.push('-m', t.model);
if (t.resume) args.push('-s', t.resume);
if (t.resume) args.push('-s', t.resume);
args.push('--', t.prompt);
args.push('--', t.prompt);
+ // A config file for this run (the conductor's Codes MCP server), merged over the person's own by OpenCode.
+ const configFile = t.options && typeof t.options.configFile === 'string' ? t.options.configFile : null;
const startedAt = Date.now();
const startedAt = Date.now();
const parser = jsonLines(opencode, (e, emit, st) => {
const parser = jsonLines(opencode, (e, emit, st) => {
st.startedAt = startedAt;
st.startedAt = startedAt;
@@ -191,7 +200,8 @@ const opencode = {
@@ -191,7 +200,8 @@ const opencode = {
emit(ev.result({ ok: !!final && !st.failed, text: final || st.failed || exitReason(info, 'OpenCode'), sessionId: st.sessionId, model: t.model, startedAt, turns: st.turns || 0, costUsd: u.cost,
emit(ev.result({ ok: !!final && !st.failed, text: final || st.failed || exitReason(info, 'OpenCode'), sessionId: st.sessionId, model: t.model, startedAt, turns: st.turns || 0, costUsd: u.cost,
usage: ev.usage({ input: u.input, output: u.output, cacheRead: u.read, cacheWrite: u.write }) }));
usage: ev.usage({ input: u.input, output: u.output, cacheRead: u.read, cacheWrite: u.write }) }));
});
});
- return spawnAgent({ cmd: this.bin, args, cwd: t.cwd, env: ctx.env, timeoutMs: t.timeoutMs, signal: t.signal, parser });
+ const env = configFile ? { ...(ctx.env || {}), OPENCODE_CONFIG: configFile } : ctx.env;
+ return spawnAgent({ cmd: this.bin, args, cwd: t.cwd, env, timeoutMs: t.timeoutMs, signal: t.signal, parser });
},
},
};
};
@@ -19,7 +19,9 @@ const path = require('path');
@@ -19,7 +19,9 @@ const path = require('path');
const { execFile } = require('child_process');
const { execFile } = require('child_process');
const { collectProof } = require('../engine/proof');
const { collectProof } = require('../engine/proof');
-const JOB_PROTOCOL = '1.0.0'; // the job's contract with the Node (stdin, stdout lines, fd 3): unchanged by 1.1's lines
+const JOB_PROTOCOL = '1.0.0';
+/** The agents a conductor's turn may run on: the CLIs that resume a session and start the Codes MCP server. */
+const CONDUCTOR_AGENTS = ['claude-code', 'codex', 'opencode']; // the job's contract with the Node (stdin, stdout lines, fd 3): unchanged by 1.1's lines
const LINES = '1.1'; // 1.1: lines carry step ids, inputs, results, progress, helpers; the result keeps `log`
const LINES = '1.1'; // 1.1: lines carry step ids, inputs, results, progress, helpers; the result keeps `log`
const MAX_PROMPT = 16_000;
const MAX_PROMPT = 16_000;
const MAX_PATCH = 180_000;
const MAX_PATCH = 180_000;
@@ -156,8 +158,9 @@ function parseArgs(raw, env = process.env) {
@@ -156,8 +158,9 @@ function parseArgs(raw, env = process.env) {
const kind = a.kind === 'bench' ? 'bench' : a.kind === 'conductor' ? 'conductor' : 'code';
const kind = a.kind === 'bench' ? 'bench' : a.kind === 'conductor' ? 'conductor' : 'code';
const prompt = typeof a.prompt === 'string' ? a.prompt.trim() : '';
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`);
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.
+ // A conductor's turn (Codes C9b): one agent 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. The agent
+ // is Claude Code, or the fallback the server chose when Claude Code is near its usage limit (Codex or OpenCode).
if (kind === 'conductor') {
if (kind === 'conductor') {
const folder = String(a.folder || '').trim();
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');
if (!folder.startsWith('/') || folder.length > 4096 || /[\0-\x1f\x7f]/.test(folder)) throw new Error('folder must be an absolute path');
@@ -172,13 +175,17 @@ function parseArgs(raw, env = process.env) {
@@ -172,13 +175,17 @@ function parseArgs(raw, env = process.env) {
const resumeIn = a.resume == null || a.resume === '' ? env.RESUME : a.resume;
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 resume = typeof resumeIn === 'string' && /^[A-Za-z0-9._:-]{1,200}$/.test(resumeIn) ? resumeIn : null;
const wanted = jobEnv(a);
const wanted = jobEnv(a);
+ const agent = CONDUCTOR_AGENTS.includes(a.agent) ? a.agent : 'claude-code';
+ // Claude Code asks the owner through its permission prompt tool; Codex and OpenCode have none, so "ask" and
+ // "project" run them with edits only (their own sandbox), and "full" with the whole computer.
+ const permission = agent !== 'claude-code' && safety === 'full' ? 'full' : 'edit';
return {
return {
- kind, repo: null, ref: null, prompt, permission: 'edit', push: null,
+ kind, repo: null, ref: null, prompt, permission, push: null,
folder, safety, mcp: a.mcp === true, codesUrl,
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,
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,
model: typeof a.model === 'string' && a.model.length <= 100 ? a.model : null,
...(effort ? { effort } : {}),
...(effort ? { effort } : {}),
- agent: 'claude-code',
+ agent,
...(files.length ? { attachments: files } : {}),
...(files.length ? { attachments: files } : {}),
...(resume ? { resume } : {}),
...(resume ? { resume } : {}),
...(Object.keys(wanted).length ? { env: wanted } : {}),
...(Object.keys(wanted).length ? { env: wanted } : {}),
@@ -424,6 +431,31 @@ function conductorMcpConfig(file, job, env) {
@@ -424,6 +431,31 @@ function conductorMcpConfig(file, job, env) {
return file;
return file;
}
}
+/**
+ * The Codes MCP server for an agent that takes no MCP config file (Codex, OpenCode): a small shell script in the job's
+ * own folder (0700) that sets the same three variables and starts `openvibe-codes mcp`. The token stays in that file,
+ * never on a command line (this machine's process list is readable). → the command and its arguments.
+ */
+function conductorMcpLauncher(file, job, env) {
+ const token = codesToken(env);
+ const bin = path.join(__dirname, '..', 'bin', 'openvibe-codes.js');
+ const q = (v) => `'${String(v).replace(/'/g, "'\\''")}'`;
+ const lines = ['#!/bin/sh'];
+ if (job.codesUrl) lines.push(`export OPENVIBE_CODES_URL=${q(job.codesUrl)}`);
+ if (job.taskId) lines.push(`export OPENVIBE_CONDUCTOR_TASK=${q(job.taskId)}`);
+ if (token) lines.push(`export OPENVIBE_TOKEN=${q(token)}`);
+ lines.push(`exec ${q(process.execPath)} ${q(bin)} mcp`);
+ fs.writeFileSync(file, `${lines.join('\n')}\n`, { mode: 0o700 });
+ return { name: 'codes', command: '/bin/sh', args: [file] };
+}
+
+/** OpenCode's own config file for one turn (OPENCODE_CONFIG, merged over the person's): the Codes MCP server. */
+function opencodeConfig(file, server) {
+ const config = { $schema: 'https://opencode.ai/config.json', mcp: { [server.name]: { type: 'local', command: [server.command, ...server.args], enabled: true } } };
+ 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). */
/** 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 }) {
async function writeAttachments(job, { dir, env, fetchImpl, say }) {
const out = [];
const out = [];
@@ -602,13 +634,22 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
@@ -602,13 +634,22 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
say({ t: 'step', text: `${images.length} image${images.length === 1 ? '' : 's'} attached` });
say({ t: 'step', text: `${images.length} image${images.length === 1 ? '' : 's'} attached` });
}
}
job = { ...job, prompt };
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' } : {}),
- };
+ if (job.agent === 'codex' || job.agent === 'opencode') {
+ // A fallback agent (the server's failover): the same Codes tools, started by a launcher script.
+ const server = job.mcp ? conductorMcpLauncher(path.join(dir, 'codes-mcp.sh'), job, env) : null;
+ adapterOptions = server
+ ? (job.agent === 'codex' ? { mcpServer: server } : { configFile: opencodeConfig(path.join(dir, 'opencode.json'), server) })
+ : {};
+ say({ t: 'step', text: `This turn runs on ${job.agent === 'codex' ? 'Codex' : 'OpenCode'}` });
+ } else {
+ 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' });
@@ -698,4 +739,4 @@ function resultWriter(emit) {
@@ -698,4 +739,4 @@ function resultWriter(emit) {
};
};
}
}
-module.exports = { gitAuthFor, credentialFor, giteaHosts, pushForge, cloneProblem, runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, jobEnv, ENV_NAMES, JOB_PROTOCOL, codesToken, conductorMcpConfig, writeAttachments };
+module.exports = { CONDUCTOR_AGENTS, conductorMcpLauncher, opencodeConfig, gitAuthFor, credentialFor, giteaHosts, pushForge, cloneProblem, runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, jobEnv, ENV_NAMES, JOB_PROTOCOL, codesToken, conductorMcpConfig, writeAttachments };
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+-- phase: expand
+-- The conductor's failover (owner 2026-11-03): a person picks up to three agents (and models) their conductor falls back
+-- to when its own agent is near or at its usage limit, and the point at which it switches. Each turn records the agent
+-- and model it ran on, and why it switched, so the conversation shows the hand-over and the next turn of the same agent
+-- resumes that agent's own session (server/domain/conductor-failover.js).
+ALTER TABLE codes_user_settings ADD COLUMN IF NOT EXISTS conductor_fallbacks jsonb; -- [{ agent, model }], in order
+ALTER TABLE codes_user_settings ADD COLUMN IF NOT EXISTS conductor_switch_pct smallint; -- 50..95; NULL = 90
+ALTER TABLE codes_conductor_messages ADD COLUMN IF NOT EXISTS agent text; -- the agent the turn ran on; NULL = claude-code (before 0023)
+ALTER TABLE codes_conductor_messages ADD COLUMN IF NOT EXISTS model text; -- the model it was asked for
+ALTER TABLE codes_conductor_messages ADD COLUMN IF NOT EXISTS switch_note text; -- why it is not the primary, in plain words
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-codes",
"name": "openvibe-codes",
- "version": "0.6.2",
+ "version": "0.7.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-codes",
"name": "openvibe-codes",
- "version": "0.6.2",
+ "version": "0.7.0",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-codes",
"name": "openvibe-codes",
- "version": "0.6.2",
+ "version": "0.7.0",
"private": true,
"private": true,
"description": "OpenVibe.Codes: the open coding-agent harness. Run a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or your own model, with routing by capability and cost and hand-offs between agents; plus the site with the agent catalog, a public routing API and the way to improve OpenVibe itself.",
"description": "OpenVibe.Codes: the open coding-agent harness. Run a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or your own model, with routing by capability and cost and hand-offs between agents; plus the site with the agent catalog, a public routing API and the way to improve OpenVibe itself.",
"main": "harness/index.js",
"main": "harness/index.js",
@@ -305,3 +305,16 @@
@@ -305,3 +305,16 @@
.c4-demo-ok{padding:2px 9px;border-radius:999px;font-size:12px;font-weight:700;color:color-mix(in srgb,#22c55e 55%,var(--c4-ink,var(--text-primary,#eaf0f1)));background:color-mix(in srgb,#22c55e 14%,transparent)}
.c4-demo-ok{padding:2px 9px;border-radius:999px;font-size:12px;font-weight:700;color:color-mix(in srgb,#22c55e 55%,var(--c4-ink,var(--text-primary,#eaf0f1)));background:color-mix(in srgb,#22c55e 14%,transparent)}
.c4-composer-front{margin:8px 0 4px}
.c4-composer-front{margin:8px 0 4px}
.c4-composer-foot{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;margin-top:10px}
.c4-composer-foot{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;margin-top:10px}
+/* The conductor's failover (2026-11-03): the fallback rows in Settings, the usage card, and a turn that switched agent. */
+.c4-fallbacks{display:grid;gap:10px}
+.c4-fallback{display:grid;grid-template-columns:28px minmax(0,1.3fr) minmax(0,1fr);gap:12px;align-items:end;padding:12px;border:1px solid var(--c4-line);border-radius:10px;background:var(--c4-ghost)}
+.c4-fallback-n{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;align-self:center;background:var(--c4-open-bg);color:var(--c4-open-ink);font-size:12px;font-weight:700}
+.c4-fallback:has(select option[value=""]:checked){background:transparent;border-style:dashed}
+.c4-fallback:has(select option[value=""]:checked) .c4-fallback-n{background:var(--c4-quiet-bg);color:var(--c4-muted)}
+.c4-chain-next{box-shadow:inset 3px 0 0 var(--c4-accent);padding-left:10px}
+.c4-usage-note{margin:12px 0 0;padding:10px 12px;border-radius:8px;background:var(--c4-open-bg);color:var(--c4-open-ink)}
+.c4-turn-tags{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;justify-content:flex-end}
+.c4-turn-agent{min-height:24px;padding:2px 9px;font-size:11px}
+.c4-turn-switch{display:flex;align-items:flex-start;gap:8px;margin:0 0 10px;padding:9px 11px;border-radius:8px;background:var(--c4-open-bg);color:var(--c4-open-ink);font-size:12px;line-height:1.45}
+.c4-turn-switch svg{width:15px;height:15px;flex:none;margin-top:1px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
+@media (max-width:640px){.c4-fallback{grid-template-columns:28px minmax(0,1fr)}.c4-fallback>label:last-child{grid-column:2}}
@@ -154,3 +154,12 @@ html[data-ov-motion=reduced] .ch-spark-list[aria-hidden],html[data-ov-motion=red
@@ -154,3 +154,12 @@ html[data-ov-motion=reduced] .ch-spark-list[aria-hidden],html[data-ov-motion=red
.ch-mode b{font-size:14px}
.ch-mode b{font-size:14px}
.ch-mode small{font-size:12px;line-height:1.35}
.ch-mode small{font-size:12px;line-height:1.35}
}
}
+/* The conductor's usage in the side panel (the failover, 2026-11-03): a meter per agent, the one that takes the next turn. */
+.ch .ovt-panel-rows .ch-usage-meter{display:block;height:5px;margin-top:6px;border-radius:3px;background:var(--ch-hover);overflow:hidden}
+.ch .ch-usage-meter>span{display:block;height:100%;border-radius:3px;background:var(--ch-ok)}
+.ch .ch-usage-meter.is-near>span{background:var(--ch-warn)}
+.ch .ch-usage-meter.is-full>span{background:var(--ch-bad)}
+.ch .ch-usage-next{font-style:normal;font-weight:600;font-size:11px;color:var(--ch-accent);margin-left:4px}
+.ch .ch-usage-note{margin:8px 0 0;padding:8px 10px;border-radius:8px;background:var(--ch-hover);color:var(--ch-soft);font-size:12px;line-height:1.45}
+.ch .ch-usage-foot{margin:8px 0 0;color:var(--ch-muted);font-size:12px;line-height:1.45}
+.ch .ch-usage-foot a{color:var(--ch-accent)}
@@ -120,7 +120,7 @@ async function createApp(opts = {}) {
@@ -120,7 +120,7 @@ async function createApp(opts = {}) {
ctx.approvals = createApprovals({ store, config, log, notify: ctx.notify, userSettings: ctx.userSettings });
ctx.approvals = createApprovals({ store, config, log, notify: ctx.notify, userSettings: ctx.userSettings });
ctx.conductor = createConductor({
ctx.conductor = createConductor({
store, config, log, run, attempts, tasks, sessions, approvals: ctx.approvals, notify: ctx.notify,
store, config, log, run, attempts, tasks, sessions, approvals: ctx.approvals, notify: ctx.notify,
- fetchImpl: fetchImpl || globalThis.fetch,
+ fetchImpl: fetchImpl || globalThis.fetch, fleet, userSettings,
});
});
tasks.setConductor(ctx.conductor);
tasks.setConductor(ctx.conductor);
new file mode 100644
new file mode 100644
@@ -0,0 +1,183 @@
@@ -0,0 +1,183 @@
+'use strict';
+
+/**
+ * The conductor's failover (owner 2026-11-03): "aware of current usage, and the person can configure a fail-over
+ * agent/harness/model for their orchestrator for when their primary one is approaching or has maxed out its usage".
+ *
+ * A conductor has a chain: its own agent and model first, then up to three fallbacks the person picked in Settings
+ * (codes_user_settings.conductor_fallbacks). Before each turn the chain is read against the fleet — what the person's
+ * machine says about each agent: installed, signed in, every limit window and when it frees (fleet.js) — and the turn
+ * runs on the first agent whose fullest window is under the person's switch point (conductor_switch_pct, 90% unless
+ * they chose another). When the primary's windows free again, the next turn goes back to it on its own.
+ *
+ * usageOf(row, now) one agent's reading → { state, pct, window, resets_at }
+ * choose({ chain, fleet, switchPct, now }) → { pick, index, switched, note, usage: [...] }
+ * cleanFallbacks(list) the fallbacks a form or an API sent, checked → [{ agent, model }]
+ * resetsLabel(iso, now) "resets in 3 h" / "resets Thu 15:00 UTC"
+ *
+ * The states: ok (room), near (at or over the switch point, still usable), full (a window at the router's FULL_PCT, or
+ * no room), login (installed, not signed in), absent (the machine reported its agents and this one is not there),
+ * unknown (no reading: the machine never reported, or reported nothing on this agent). An unknown primary is used, as it
+ * was before any machine reported; an unknown fallback is tried only after every known one.
+ */
+
+const { windowFull, FULL_PCT } = require('../../engine/router');
+
+/** The agents a conductor's turn can run on: the CLIs that resume a session and take the Codes MCP tools. */
+const CONDUCTOR_AGENTS = ['claude-code', 'codex', 'opencode'];
+const AGENT_NAMES = { 'claude-code': 'Claude Code', codex: 'Codex', opencode: 'OpenCode' };
+const MODEL = /^[A-Za-z0-9._:/-]{1,100}$/;
+const MAX_FALLBACKS = 3;
+const DEFAULT_SWITCH_PCT = 90;
+const MIN_SWITCH_PCT = 50;
+/** The switch point is never above the router's own "full": a window at 95% takes no new work anywhere in Codes. */
+const MAX_SWITCH_PCT = FULL_PCT;
+
+const nameOf = (agent) => AGENT_NAMES[agent] || agent;
+
+/** A switch point from a form or an API: a whole percentage between 50 and 95; anything else is refused. */
+function cleanSwitchPct(v) {
+ if (v === undefined) return undefined;
+ if (v === null || v === '') return null;
+ const n = Number(v);
+ if (!Number.isFinite(n) || n < MIN_SWITCH_PCT || n > MAX_SWITCH_PCT) {
+ throw Object.assign(new Error(`the switch point is a percentage from ${MIN_SWITCH_PCT} to ${MAX_SWITCH_PCT}`), { status: 422, code: 'settings.switch_invalid' });
+ }
+ return Math.round(n);
+}
+
+/**
+ * The fallbacks a form or an API sent: an ordered list of { agent, model }, at most three, each agent one a conductor
+ * can run on, no agent twice. An empty model means the agent's own default. Anything else is refused with a 422.
+ */
+function cleanFallbacks(list) {
+ if (list === undefined) return undefined;
+ if (list === null || list === '') return [];
+ if (!Array.isArray(list)) throw Object.assign(new Error('the fallbacks are a list'), { status: 422, code: 'settings.fallbacks_invalid' });
+ const out = [];
+ for (const item of list) {
+ if (item == null || item === '') continue;
+ const agent = String((typeof item === 'object' ? item.agent : item) || '').trim();
+ if (!agent) continue;
+ if (!CONDUCTOR_AGENTS.includes(agent)) {
+ throw Object.assign(new Error(`a conductor can fall back to ${CONDUCTOR_AGENTS.map(nameOf).join(', ')}`), { status: 422, code: 'settings.fallback_agent' });
+ }
+ const rawModel = typeof item === 'object' && item.model != null ? String(item.model).trim() : '';
+ if (rawModel && !MODEL.test(rawModel)) throw Object.assign(new Error('a fallback\'s model is a model name, or empty for the agent\'s default'), { status: 422, code: 'settings.fallback_model' });
+ if (out.some((x) => x.agent === agent && x.model === (rawModel || null))) continue;
+ out.push({ agent, model: rawModel || null });
+ }
+ if (out.length > MAX_FALLBACKS) throw Object.assign(new Error(`at most ${MAX_FALLBACKS} fallbacks`), { status: 422, code: 'settings.fallbacks_too_many' });
+ return out;
+}
+
+/** The fallbacks as stored (jsonb), read back defensively: a bad row reads as none. */
+function storedFallbacks(v) {
+ let list = v;
+ if (typeof list === 'string') { try { list = JSON.parse(list); } catch { list = null; } }
+ if (!Array.isArray(list)) return [];
+ try { return cleanFallbacks(list); } catch { return []; }
+}
+
+const isoOf = (v) => {
+ if (v == null || v === '') return null;
+ const t = Date.parse(String(v));
+ return Number.isFinite(t) ? new Date(t).toISOString() : null;
+};
+
+/** "resets in 40 min" / "resets in 5 h" within a day, else "resets Thu 15:00 UTC". */
+function resetsLabel(at, now = Date.now()) {
+ const t = Date.parse(String(at || ''));
+ if (!Number.isFinite(t)) return null;
+ const ms = t - now;
+ if (ms <= 0) return 'free again';
+ if (ms < 60 * 60_000) return `resets in ${Math.max(1, Math.round(ms / 60_000))} min`;
+ if (ms < 24 * 60 * 60_000) return `resets in ${Math.round(ms / (60 * 60_000))} h`;
+ const d = new Date(t);
+ const day = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getUTCDay()];
+ return `resets ${day} ${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')} UTC`;
+}
+
+/**
+ * One agent's reading, from its fleet row (fleet.byHarness, or one machine's row when the conductor is pinned to it).
+ * The windows that already reset since the reading count as free. `pct` is the fullest window still ahead.
+ */
+function usageOf(row, switchPct = DEFAULT_SWITCH_PCT, now = Date.now()) {
+ if (!row) return { state: 'unknown', pct: null, window: null, resets_at: null };
+ if (row.installed === false) return { state: 'absent', pct: null, window: null, resets_at: null };
+ if (row.signed_in === false) return { state: 'login', pct: null, window: null, resets_at: null };
+ let top = null;
+ for (const w of Array.isArray(row.windows) ? row.windows : []) {
+ if (!w) continue;
+ const resets = Date.parse(String(w.resets_at || ''));
+ if (Number.isFinite(resets) && resets <= now) continue; // freed since it was read
+ let pct = Number(w.pct);
+ if (!Number.isFinite(pct)) {
+ const used = Number(w.used); const limit = Number(w.limit);
+ pct = Number.isFinite(used) && Number.isFinite(limit) && limit > 0 ? (used / limit) * 100 : NaN;
+ }
+ if (!Number.isFinite(pct)) continue;
+ if (!top || pct > top.pct) top = { pct: Math.round(pct), window: w.name || 'window', resets_at: isoOf(w.resets_at), full: windowFull(w, now) };
+ }
+ const balanceGone = row.room === false && !(top && top.full);
+ if (top && top.full) return { state: 'full', pct: top.pct, window: top.window, resets_at: top.resets_at };
+ if (balanceGone) return { state: 'full', pct: top ? top.pct : null, window: top ? top.window : null, resets_at: top ? top.resets_at : null };
+ if (top && top.pct >= switchPct) return { state: 'near', pct: top.pct, window: top.window, resets_at: top.resets_at };
+ return { state: 'ok', pct: top ? top.pct : null, window: top ? top.window : null, resets_at: top ? top.resets_at : null };
+}
+
+/** "Claude Code is at 93% of its week (resets Thu 15:00 UTC)" — what the switch note and the page say. */
+function describe(entry, u, now = Date.now()) {
+ const name = nameOf(entry.agent);
+ if (u.state === 'login') return `${name} is not signed in on your machine`;
+ if (u.state === 'absent') return `${name} is not installed on your machine`;
+ if (u.state === 'unknown') return `${name} has not reported its usage`;
+ const when = resetsLabel(u.resets_at, now);
+ if (u.pct == null) return `${name} has no room left`;
+ return `${name} is at ${u.pct}% of its ${u.window || 'window'}${when ? ` (${when})` : ''}`;
+}
+
+/**
+ * The agent this turn runs on. `chain` is [primary, ...fallbacks] as { agent, model }; `fleet` maps an agent to its
+ * reading (or is null when nothing was ever read). The first agent with room under the switch point takes the turn;
+ * with none, the first that is near but not full; with none of those, an unknown fallback; with nothing at all, the
+ * primary (its own answer then says what the limit is — Codes never blocks a turn on a reading alone).
+ */
+function choose({ chain, fleet = null, switchPct = DEFAULT_SWITCH_PCT, now = Date.now() } = {}) {
+ const list = (Array.isArray(chain) ? chain : []).filter((x) => x && x.agent);
+ const point = Number.isFinite(Number(switchPct)) ? Number(switchPct) : DEFAULT_SWITCH_PCT;
+ const usage = list.map((entry, i) => {
+ const u = usageOf(fleet && fleet[entry.agent], point, now);
+ return { agent: entry.agent, name: nameOf(entry.agent), model: entry.model || null, role: i === 0 ? 'primary' : 'fallback', ...u, label: describe(entry, u, now) };
+ });
+ if (!list.length) return { pick: null, index: -1, switched: false, note: null, usage };
+ // The primary with no reading is used as it always was; a fallback with no reading waits behind every known one.
+ const order = [
+ (u, i) => u.state === 'ok' || (i === 0 && u.state === 'unknown'),
+ (u) => u.state === 'near',
+ (u) => u.state === 'unknown',
+ ];
+ let index = -1;
+ for (const test of order) {
+ index = usage.findIndex((u, i) => test(u, i));
+ if (index >= 0) break;
+ }
+ const allOut = index < 0;
+ if (allOut) index = 0;
+ const pick = list[index];
+ const primary = usage[0];
+ let note = null;
+ if (index > 0) {
+ note = `${primary.label}; this turn runs on ${nameOf(pick.agent)}${pick.model ? ` (${pick.model})` : ''}.`;
+ } else if (allOut && list.length > 1) {
+ note = `Every agent in your chain is at its limit (${usage.map((u) => u.label).join('; ')}); this turn tries ${nameOf(pick.agent)}.`;
+ } else if (allOut && primary.state === 'full') {
+ note = `${primary.label}, and no fallback is set (Settings → Conductor); this turn tries it anyway.`;
+ }
+ return { pick: { agent: pick.agent, model: pick.model || null }, index, switched: index > 0, note, usage };
+}
+
+module.exports = {
+ CONDUCTOR_AGENTS, AGENT_NAMES, MAX_FALLBACKS, DEFAULT_SWITCH_PCT, MIN_SWITCH_PCT, MAX_SWITCH_PCT,
+ cleanFallbacks, cleanSwitchPct, storedFallbacks, usageOf, choose, describe, resetsLabel, nameOf,
+};
@@ -20,10 +20,18 @@
@@ -20,10 +20,18 @@
*
*
* The conductor uses the person's own Claude Code login on the machine (Node's plan_logins, opt-in and read-only, for
* 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.
* one job) and never a key of ours. It sets no steps or minutes budget; a turn's wall time is Run's job cap.
+ *
+ * Failover (owner 2026-11-03, conductor-failover.js): before each turn the conductor's agent and the fallbacks the
+ * person chose in Settings are read against their machine's usage windows; the turn runs on the first with room under
+ * the switch point, and goes back to the primary once its window frees. Each agent keeps its own session (the turn's
+ * row records the agent, the model and the session it ended with), and an agent that takes over is first told what
+ * happened in the conversation since it last took part.
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
+const failover = require('./conductor-failover');
+const { windowFull } = require('../../engine/router');
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);
const MESSAGE_STATES = ['queued', 'running', 'done', 'failed', 'stopped'];
const MESSAGE_STATES = ['queued', 'running', 'done', 'failed', 'stopped'];
@@ -36,6 +44,11 @@ const MAX_ATTACHMENTS = 4;
@@ -36,6 +44,11 @@ const MAX_ATTACHMENTS = 4;
const MAX_ATTACHMENT_BYTES = 2 * 1024 * 1024;
const MAX_ATTACHMENT_BYTES = 2 * 1024 * 1024;
const MAX_NOTE = 4000;
const MAX_NOTE = 4000;
const MAX_FOLDER = 4096;
const MAX_FOLDER = 4096;
+/** The most of the conversation an agent taking over is told, so the turn stays inside a job's 16,000 characters. */
+const MAX_CATCH_UP = 9000;
+/** A fleet reading older than this is read again when the person writes (their token is at hand then). */
+const FLEET_FRESH_MS = 5 * 60_000;
+const PRIMARY_AGENT = 'claude-code';
const iso = (v) => {
const iso = (v) => {
if (!v) return null;
if (!v) return null;
@@ -76,7 +89,24 @@ function cleanAttachments(list) {
@@ -76,7 +89,24 @@ function cleanAttachments(list) {
return out;
return out;
}
}
-function createConductor({ store, config = {}, log = console, run, attempts, tasks, sessions, approvals = null, notify = null, fetchImpl = null }) {
+/** The agent and the session a turn's job reported (its result log's agent line; the job's own session field). */
+function ranOf(result) {
+ const d = asObject(result) || {};
+ const log = Array.isArray(d.log) ? d.log : [];
+ let agent = null; let session = null;
+ for (const line of log) {
+ if (!line || typeof line !== 'object' || line.t !== 'agent') continue;
+ if (!agent && line.agent) agent = String(line.agent).slice(0, 40);
+ if (!session && line.session) session = String(line.session).slice(0, 200);
+ }
+ if (!agent && Array.isArray(d.agents) && d.agents[0]) agent = String(d.agents[0]).slice(0, 40);
+ if (!session && typeof d.session === 'string' && d.session) session = d.session.slice(0, 200);
+ return { agent, session };
+}
+
+const agentOf = (m) => (m && m.agent) || PRIMARY_AGENT;
+
+function createConductor({ store, config = {}, log = console, run, attempts, tasks, sessions, approvals = null, notify = null, fetchImpl = null, fleet = null, userSettings = null }) {
const db = store.db;
const db = store.db;
const fetch = fetchImpl || globalThis.fetch;
const fetch = fetchImpl || globalThis.fetch;
const baseUrl = String(config.baseUrl || '').replace(/\/+$/, '');
const baseUrl = String(config.baseUrl || '').replace(/\/+$/, '');
@@ -175,28 +205,39 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
@@ -175,28 +205,39 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
return made;
return made;
}
}
- /** A message the person writes. Starts the turn when the conductor is idle; otherwise it waits. */
- async function message(task, { text, attachments = [] } = {}) {
+ /**
+ * A message the person writes. Starts the turn when the conductor is idle; otherwise it waits. With the person's
+ * token at hand, a fleet reading older than five minutes is read again first, so the turn's agent is chosen on
+ * today's usage.
+ */
+ async function message(task, { text, attachments = [], token = null } = {}) {
const made = await enqueue(task, { text, attachments });
const made = await enqueue(task, { text, attachments });
+ await refreshFleet(task.subject, token);
const started = await startNext(task.id);
const started = await startNext(task.id);
return { message: made, started };
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) {
+ /**
+ * The job input of one turn: the same folder and safety; the agent, model and session of the turn's choice (`over`,
+ * from startNext), or the conductor's own agent and the session the attempt carries.
+ */
+ function turnArgs(task, message, attempt, over = null) {
const files = Array.isArray(message.attachments) ? message.attachments : [];
const files = Array.isArray(message.attachments) ? message.attachments : [];
+ const agent = (over && over.agent) || PRIMARY_AGENT;
+ const model = over && 'model' in over ? over.model : (task.model || 'opus');
+ const resume = over && 'resume' in over ? over.resume : (attempt && attempt.session) || null;
return {
return {
kind: 'conductor',
kind: 'conductor',
task_id: task.id,
task_id: task.id,
message_id: message.id,
message_id: message.id,
folder: task.folder,
folder: task.folder,
- prompt: message.prompt,
- agent: 'claude-code',
- model: task.model || 'opus',
+ prompt: (over && over.prompt) || message.prompt,
+ agent,
+ ...(model ? { model } : {}),
safety: task.safety || 'ask',
safety: task.safety || 'ask',
mcp: true,
mcp: true,
codes_url: baseUrl,
codes_url: baseUrl,
- ...(attempt && attempt.session ? { resume: attempt.session } : {}),
+ ...(resume ? { resume } : {}),
// The image's bytes stay in the database: the job fetches it with the person's token and writes it as a
// 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.
// file on the machine, so a turn's input stays far inside Run's limit.
...(files.length
...(files.length
@@ -205,6 +246,98 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
@@ -205,6 +246,98 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
};
};
}
}
+ /** Read the person's machines again when the stored reading is older than FLEET_FRESH_MS. Never throws. */
+ async function refreshFleet(subject, token) {
+ if (!fleet || !token || typeof fleet.refresh !== 'function') return;
+ try {
+ const { at } = await fleet.latest(subject);
+ const age = at ? Date.now() - Date.parse(at) : Infinity;
+ if (Number.isFinite(age) && age < FLEET_FRESH_MS) return;
+ await fleet.refresh(subject, token);
+ } catch (e) {
+ log.warn(`[Codes] conductor: the fleet of ${subject} could not be read: ${String(e && e.message).slice(0, 160)}`);
+ }
+ }
+
+ /** The conductor's chain: its own agent and model, then the person's fallbacks (none twice). */
+ async function chainFor(task) {
+ const primary = { agent: task.agent || PRIMARY_AGENT, model: task.model || 'opus' };
+ let settings = null;
+ if (userSettings) { try { settings = await userSettings.get(task.subject); } catch { settings = null; } }
+ const fallbacks = (settings && settings.conductor_fallbacks) || [];
+ const chain = [primary, ...fallbacks.filter((f) => !(f.agent === primary.agent && (f.model || null) === primary.model))];
+ return { chain, switchPct: (settings && settings.conductor_switch_pct) || failover.DEFAULT_SWITCH_PCT };
+ }
+
+ /**
+ * What the person's machines say about each agent: the machine the conductor is pinned to when it is (its folder and
+ * sessions are there, so only its own agents count), else the best across their machines. null with no reading.
+ */
+ async function fleetFor(task) {
+ if (!fleet) return null;
+ try {
+ if (task.pin) {
+ const { machines } = await fleet.latest(task.subject);
+ const here = (machines || []).find((m) => m.id === task.pin);
+ if (!here || !here.agents.length) return null;
+ const out = {};
+ for (const a of here.agents) {
+ const windows = Array.isArray(a.windows) ? a.windows : [];
+ out[a.agent] = { installed: a.installed, signed_in: a.signed_in, windows, room: a.installed && a.signed_in && !windows.some((w) => windowFull(w)) };
+ }
+ return out;
+ }
+ const by = await fleet.byHarness(task.subject);
+ return by && Object.keys(by).length ? by : null;
+ } catch (e) {
+ log.warn(`[Codes] conductor ${task.id}: the fleet could not be read: ${String(e && e.message).slice(0, 160)}`);
+ return null;
+ }
+ }
+
+ /** The choice for the next turn (conductor-failover.js choose), with the chain it was made from. */
+ async function choiceFor(task) {
+ const { chain, switchPct } = await chainFor(task);
+ const reading = await fleetFor(task);
+ return { ...failover.choose({ chain, fleet: reading, switchPct }), switchPct, chain };
+ }
+
+ /**
+ * What an agent taking over is told first: who it is carrying on from, and the conversation since it last took
+ * part (all of it, the hand-over brief first, when it never did) — each turn's message and answer, newest kept when
+ * it is long.
+ */
+ function catchUp(earlier, agent, fromAgent, note) {
+ const lastOwn = [...earlier].reverse().find((m) => agentOf(m) === agent && m.session);
+ const since = lastOwn ? earlier.filter((m) => Number(m.n) > Number(lastOwn.n)) : earlier;
+ if (!since.length) return '';
+ const brief = !lastOwn && since[0] && Number(since[0].n) === 1 ? since[0] : null;
+ const turns = since.filter((m) => m !== brief);
+ const clip = (v, n) => { const t = String(v || '').trim(); return t.length > n ? `${t.slice(0, n)}…` : t; };
+ const blocks = turns.map((m) => {
+ const r = asObject(m.result);
+ const answer = r && typeof r.summary === 'string' ? r.summary : (m.error ? `(the turn did not finish: ${m.error})` : '(no answer recorded)');
+ return `### Turn ${m.n} — ${failover.nameOf(agentOf(m))}\n\n**The owner:** ${clip(m.prompt, 1200)}\n\n**The answer:** ${clip(answer, 1500)}`;
+ });
+ const briefText = brief ? clip(brief.prompt, 4000) : '';
+ let budget = MAX_CATCH_UP - briefText.length;
+ const kept = [];
+ for (let i = blocks.length - 1; i >= 0; i--) {
+ if (blocks[i].length > budget) break;
+ kept.unshift(blocks[i]);
+ budget -= blocks[i].length;
+ }
+ const dropped = blocks.length - kept.length;
+ const head = `## You are taking over this conversation\n\nYou are the owner's conductor, carrying on from ${failover.nameOf(fromAgent)}${note ? ` (${note.replace(/\.$/, '')})` : ''}. Below is what happened ${lastOwn ? 'since you last took part' : 'so far'}; then the owner's new message. Carry on from there: the same plan, the same Codes tools.`;
+ return [
+ head,
+ briefText ? `## The hand-over brief this conversation started with\n\n${briefText}` : '',
+ dropped ? `(${dropped} earlier turn${dropped === 1 ? '' : 's'} left out for length.)` : '',
+ kept.length ? `## The turns\n\n${kept.join('\n\n')}` : '',
+ '---\n\n## The owner\'s new message\n',
+ ].filter(Boolean).join('\n\n');
+ }
+
/**
/**
* Start the next queued message, if no turn is running. The task row is locked while the message is taken, so two
* 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.
* presses start one turn and not two.
@@ -230,10 +363,35 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
@@ -230,10 +363,35 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
if (taken.error || taken.running || taken.idle || taken.stopped) return taken;
if (taken.error || taken.running || taken.idle || taken.stopped) return taken;
const { task, message: turn, attempt } = taken;
const { task, message: turn, attempt } = taken;
+ // The agent this turn runs on (failover), the session it resumes, and — when it is not the agent of the last
+ // turn — what happened in the conversation while it was away.
+ let over = null;
+ try {
+ const choice = await choiceFor(task);
+ const pick = choice.pick || { agent: PRIMARY_AGENT, model: task.model || 'opus' };
+ const earlier = await db.many('SELECT * FROM codes_conductor_messages WHERE task_id = $1 AND n < $2 ORDER BY n ASC', [task.id, turn.n]);
+ const ran = earlier.filter((m) => m.started_at);
+ const previous = ran.length ? agentOf(ran[ran.length - 1]) : null;
+ const own = [...ran].reverse().find((m) => agentOf(m) === pick.agent && m.session);
+ let resume = own ? own.session : null;
+ if (!resume && pick.agent === PRIMARY_AGENT && !earlier.some((m) => m.agent)) resume = (attempt && attempt.session) || null;
+ let prompt = turn.prompt;
+ if (previous && previous !== pick.agent) {
+ const intro = catchUp(ran, pick.agent, previous, choice.note);
+ if (intro) prompt = `${intro}\n${turn.prompt}`.slice(0, MAX_MESSAGE);
+ }
+ over = { agent: pick.agent, model: pick.model, resume, prompt };
+ await db.exec('UPDATE codes_conductor_messages SET agent = $2, model = $3, switch_note = $4 WHERE id = $1',
+ [turn.id, pick.agent, pick.model || null, choice.note ? String(choice.note).slice(0, 1000) : null]);
+ if (choice.note) log.info && log.info(`[Codes] conductor ${task.id}: ${choice.note}`);
+ } catch (e) {
+ log.warn(`[Codes] conductor ${task.id}: the failover could not choose, the turn runs on its own agent: ${String(e && e.message).slice(0, 200)}`);
+ over = null;
+ }
let job;
let job;
try {
try {
job = await run.submit({
job = await run.submit({
- subject: task.subject, project: task.project_id, args: turnArgs(task, turn, attempt), key: turn.id,
+ subject: task.subject, project: task.project_id, args: turnArgs(task, turn, attempt, over), 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),
limits: { wall_ms: wallMs, cpu_ms: wallMs, mem_bytes: 4 * 1024 ** 3 }, ttlMs: Math.min(86_400_000, wallMs + 10 * 60_000),
// Pinned to the machine the person chose (Codes C9c): the turn resumes a session whose folder and
// Pinned to the machine the person chose (Codes C9c): the turn resumes a session whose folder and
// memory are there, so Run must not place it anywhere else.
// memory are there, so Run must not place it anywhere else.
@@ -250,7 +408,7 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
@@ -250,7 +408,7 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
return { error: why };
return { error: why };
}
}
await store.tx(async () => {
await store.tx(async () => {
- if (attempt) await attempts.bind(attempt.id, { job_id: job.id, state: 'queued', machine: task.machine, model: task.model });
+ if (attempt) await attempts.bind(attempt.id, { job_id: job.id, state: 'queued', machine: task.machine, model: (over && over.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 codes_conductor_messages SET job_id = $2 WHERE id = $1", [turn.id, job.id]);
await db.exec(
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`,
`UPDATE run_tasks SET job_id = $2, state = $3, started_at = COALESCE($4::timestamptz, started_at), updated_at = $5 WHERE id = $1`,
@@ -314,10 +472,22 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
@@ -314,10 +472,22 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
: null;
: null;
if (turn && turn.state === 'running') {
if (turn && turn.state === 'running') {
const ok = task.state === 'succeeded';
const ok = task.state === 'succeeded';
+ // The session this turn ended with belongs to the agent that ran it: the next turn of that agent
+ // resumes it. A machine whose Codes runner is older runs Claude Code whatever was asked; the row then
+ // says so, and keeps the session under the agent that really ran.
+ const ran = ranOf(task.result);
+ const asked = turn.agent || PRIMARY_AGENT;
+ const agent = ran.agent && failover.CONDUCTOR_AGENTS.includes(ran.agent) ? ran.agent : asked;
+ const session = ran.session || (agent === PRIMARY_AGENT && !turn.agent ? (attempt && attempt.session) : null) || null;
+ let note = turn.switch_note || null;
+ if (agent !== asked) {
+ note = `${note ? `${note} ` : ''}Your machine ran ${failover.nameOf(agent)} instead of ${failover.nameOf(asked)}: its Codes runner is older than the switch. Update OpenVibe Node on it to let the conductor switch agents.`.slice(0, 1000);
+ }
await db.exec(
await db.exec(
- `UPDATE codes_conductor_messages SET state = $2, result = $3::jsonb, error = $4, session = $5, finished_at = $6::timestamptz WHERE id = $1`,
+ `UPDATE codes_conductor_messages SET state = $2, result = $3::jsonb, error = $4, session = $5, finished_at = $6::timestamptz,
+ agent = $7, switch_note = $8 WHERE id = $1`,
[turn.id, ok ? 'done' : 'failed', task.result == null ? null : JSON.stringify(task.result),
[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()],
+ ok ? null : String(task.error || 'the turn failed').slice(0, 1000), session, task.finished_at || store.iso(), agent, note],
);
);
} else if (turn && turn.state !== 'done' && turn.state !== 'failed' && turn.state !== 'stopped') {
} 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",
await db.exec("UPDATE codes_conductor_messages SET state = $2, finished_at = COALESCE(finished_at, $3::timestamptz) WHERE id = $1",
@@ -357,6 +527,7 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
@@ -357,6 +527,7 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
prompt: String(m.prompt || '').slice(0, 16000),
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) })),
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,
job_id: m.job_id || null, session: m.session || null,
+ agent: agentOf(m), agent_name: failover.nameOf(agentOf(m)), model: m.model || null, switch_note: m.switch_note || 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,
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,
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),
created_at: iso(m.created_at), started_at: iso(m.started_at), finished_at: iso(m.finished_at),
@@ -369,6 +540,19 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
@@ -369,6 +540,19 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
const attempt = await attemptOf(task.id);
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 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) : [];
const pending = approvals ? await approvals.pending(task.id) : [];
+ // The usage the next turn will be chosen on: each agent of the chain, how full its fullest window is, when it
+ // frees, and which one the next turn would take.
+ let usage = null;
+ try {
+ const choice = await choiceFor(task);
+ usage = {
+ switch_pct: choice.switchPct,
+ next: choice.pick,
+ note: choice.note,
+ fallbacks: choice.chain.length - 1,
+ chain: choice.usage.map((u) => ({ ...u, resets: failover.resetsLabel(u.resets_at) })),
+ };
+ } catch { usage = null; }
return {
return {
task: tasks.present(task),
task: tasks.present(task),
kind: 'conductor',
kind: 'conductor',
@@ -380,6 +564,7 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
@@ -380,6 +564,7 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
attempt: attempt ? attempts.present(attempt) : null,
attempt: attempt ? attempts.present(attempt) : null,
messages: messages.map(presentMessage),
messages: messages.map(presentMessage),
approvals: pending.map((a) => approvals.present(a)),
approvals: pending.map((a) => approvals.present(a)),
+ usage,
};
};
}
}
@@ -394,9 +579,9 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
@@ -394,9 +579,9 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
}
}
return {
return {
- create, enqueue, message, startNext, onTurnEnd, stop, view, presentMessage, attachment, fragment, turnArgs, projectFor,
+ create, enqueue, message, startNext, onTurnEnd, stop, view, presentMessage, attachment, fragment, turnArgs, projectFor, choiceFor, refreshFleet,
SAFETY, MESSAGE_STATES, MAX_MESSAGE, MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES,
SAFETY, MESSAGE_STATES, MAX_MESSAGE, MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES,
};
};
}
}
-module.exports = { createConductor, validFolder, cleanAttachments, SAFETY, MODEL, NODE };
+module.exports = { createConductor, validFolder, cleanAttachments, ranOf, SAFETY, MODEL, NODE };
@@ -8,11 +8,17 @@
@@ -8,11 +8,17 @@
*
*
* createUserSettings({ store })
* createUserSettings({ store })
* get(subject) → { default_agent, default_machine, notify_done, notify_needs, safety,
* get(subject) → { default_agent, default_machine, notify_done, notify_needs, safety,
- * steps, minutes, no_cloud, only_free }
+ * steps, minutes, no_cloud, only_free, conductor_fallbacks,
+ * conductor_switch_pct }
* save(subject, patch) → the saved row, in the same shape
* save(subject, patch) → the saved row, in the same shape
*
*
+ * The conductor's failover (migration 0023): the agents and models it falls back to, in order, and the percentage of a
+ * usage window at which it switches (server/domain/conductor-failover.js).
+ *
* Only a usr_ subject is accepted: a service token has no preferences of its own to keep.
* Only a usr_ subject is accepted: a service token has no preferences of its own to keep.
*/
*/
+const failover = require('./conductor-failover');
+
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SAFETY = ['ask', 'project', 'full'];
const SAFETY = ['ask', 'project', 'full'];
const AGENT = /^[a-z0-9-]{1,40}$/;
const AGENT = /^[a-z0-9-]{1,40}$/;
@@ -25,6 +31,8 @@ const DEFAULTS = Object.freeze({
@@ -25,6 +31,8 @@ const DEFAULTS = Object.freeze({
steps: null, minutes: null,
steps: null, minutes: null,
// Placement A1: the person's rules, both off until they choose them.
// Placement A1: the person's rules, both off until they choose them.
no_cloud: false, only_free: false,
no_cloud: false, only_free: false,
+ // The conductor's failover: no fallback until the person picks one; it switches at 90% of a window.
+ conductor_fallbacks: [], conductor_switch_pct: failover.DEFAULT_SWITCH_PCT,
});
});
/** A nullable limit from a form or an API: '' / 'off' / 0 / null mean no limit; a negative or silly number is refused. */
/** A nullable limit from a form or an API: '' / 'off' / 0 / null mean no limit; a negative or silly number is refused. */
@@ -39,7 +47,7 @@ function limit(v, field) {
@@ -39,7 +47,7 @@ function limit(v, field) {
}
}
function present(row) {
function present(row) {
- if (!row) return { ...DEFAULTS };
+ if (!row) return { ...DEFAULTS, conductor_fallbacks: [] };
return {
return {
default_agent: row.default_agent || null,
default_agent: row.default_agent || null,
default_machine: row.default_machine || null,
default_machine: row.default_machine || null,
@@ -50,6 +58,8 @@ function present(row) {
@@ -50,6 +58,8 @@ function present(row) {
minutes: row.minutes == null ? null : Number(row.minutes),
minutes: row.minutes == null ? null : Number(row.minutes),
no_cloud: row.no_cloud === true,
no_cloud: row.no_cloud === true,
only_free: row.only_free === true,
only_free: row.only_free === true,
+ conductor_fallbacks: failover.storedFallbacks(row.conductor_fallbacks),
+ conductor_switch_pct: row.conductor_switch_pct == null ? failover.DEFAULT_SWITCH_PCT : Number(row.conductor_switch_pct),
};
};
}
}
@@ -96,15 +106,23 @@ function createUserSettings({ store }) {
@@ -96,15 +106,23 @@ function createUserSettings({ store }) {
if (steps !== undefined) next.steps = steps;
if (steps !== undefined) next.steps = steps;
const minutes = limit(patch.minutes, 'minutes per task');
const minutes = limit(patch.minutes, 'minutes per task');
if (minutes !== undefined) next.minutes = minutes;
if (minutes !== undefined) next.minutes = minutes;
+ // Settings → Conductor: the fallbacks in order (each an agent a conductor can run on, with an optional model)
+ // and the switch point. A form that did not send them keeps what was saved.
+ const fallbacks = failover.cleanFallbacks(patch.conductor_fallbacks);
+ if (fallbacks !== undefined) next.conductor_fallbacks = fallbacks;
+ const switchPct = failover.cleanSwitchPct(patch.conductor_switch_pct);
+ if (switchPct !== undefined) next.conductor_switch_pct = switchPct == null ? failover.DEFAULT_SWITCH_PCT : switchPct;
await db.exec(
await db.exec(
`INSERT INTO codes_user_settings (subject, default_agent, default_machine, notify_done, notify_needs, safety,
`INSERT INTO codes_user_settings (subject, default_agent, default_machine, notify_done, notify_needs, safety,
- steps, minutes, no_cloud, only_free, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now())
+ steps, minutes, no_cloud, only_free, conductor_fallbacks, conductor_switch_pct, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, now())
ON CONFLICT (subject) DO UPDATE SET default_agent = EXCLUDED.default_agent, default_machine = EXCLUDED.default_machine,
ON CONFLICT (subject) DO UPDATE SET default_agent = EXCLUDED.default_agent, default_machine = EXCLUDED.default_machine,
notify_done = EXCLUDED.notify_done, notify_needs = EXCLUDED.notify_needs, safety = EXCLUDED.safety,
notify_done = EXCLUDED.notify_done, notify_needs = EXCLUDED.notify_needs, safety = EXCLUDED.safety,
steps = EXCLUDED.steps, minutes = EXCLUDED.minutes, no_cloud = EXCLUDED.no_cloud,
steps = EXCLUDED.steps, minutes = EXCLUDED.minutes, no_cloud = EXCLUDED.no_cloud,
- only_free = EXCLUDED.only_free, updated_at = now()`,
- [subject, next.default_agent, next.default_machine, next.notify_done, next.notify_needs, next.safety, next.steps, next.minutes, next.no_cloud, next.only_free],
+ only_free = EXCLUDED.only_free, conductor_fallbacks = EXCLUDED.conductor_fallbacks,
+ conductor_switch_pct = EXCLUDED.conductor_switch_pct, updated_at = now()`,
+ [subject, next.default_agent, next.default_machine, next.notify_done, next.notify_needs, next.safety, next.steps, next.minutes, next.no_cloud, next.only_free,
+ JSON.stringify(next.conductor_fallbacks || []), next.conductor_switch_pct],
);
);
return next;
return next;
}
}
@@ -26,6 +26,9 @@ const safeId = (s) => /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(s || ''));
@@ -26,6 +26,9 @@ const safeId = (s) => /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(s || ''));
const money = (n) => Number.isFinite(Number(n)) ? `$${Number(n).toFixed(2)}` : '—';
const money = (n) => Number.isFinite(Number(n)) ? `$${Number(n).toFixed(2)}` : '—';
// The models a Claude Code plan login offers; the alias is what the CLI is given, so it survives a point release.
// The models a Claude Code plan login offers; the alias is what the CLI is given, so it survives a point release.
const MODELS = [['opus', 'Opus 5.5 (default)'], ['sonnet', 'Sonnet 5.5'], ['haiku', 'Haiku 5.5']];
const MODELS = [['opus', 'Opus 5.5 (default)'], ['sonnet', 'Sonnet 5.5'], ['haiku', 'Haiku 5.5']];
+const { CONDUCTOR_AGENTS, AGENT_NAMES: failoverNames } = require('../domain/conductor-failover');
+/** The agents a conductor may fall back to: every conductor agent but Claude Code, which is its own. */
+const CONDUCTOR_FALLBACKS = CONDUCTOR_AGENTS.filter((a) => a !== 'claude-code');
const modelLabel = (m) => ({ opus: 'Opus 5.5', sonnet: 'Sonnet 5.5', haiku: 'Haiku 5.5' }[m] || m || 'Opus 5.5');
const modelLabel = (m) => ({ opus: 'Opus 5.5', sonnet: 'Sonnet 5.5', haiku: 'Haiku 5.5' }[m] || m || 'Opus 5.5');
const age = (value) => {
const age = (value) => {
const numeric = Number(value);
const numeric = Number(value);
@@ -43,6 +46,19 @@ const whenUtc = (value) => {
@@ -43,6 +46,19 @@ const whenUtc = (value) => {
const soon = mins <= 0 ? 'now' : mins < 60 ? `in ${mins} min` : mins < 2880 ? `in ${Math.round(mins / 60)} h` : `in ${Math.round(mins / 1440)} d`;
const soon = mins <= 0 ? 'now' : mins < 60 ? `in ${mins} min` : mins < 2880 ? `in ${Math.round(mins / 60)} h` : `in ${Math.round(mins / 1440)} d`;
return text ? `frees ${soon} · ${text} UTC` : null;
return text ? `frees ${soon} · ${text} UTC` : null;
};
};
+/** One usage window as a meter: its name, how full it is, and when it frees. */
+const usageMeter = (w) => {
+ const pct = Math.max(0, Math.min(100, Number(w.pct) || 0));
+ const full = Number(w.pct) >= 95;
+ const free = whenUtc(w.resets_at);
+ const used = w.used != null && w.limit != null ? `${w.used} of ${w.limit}` : (w.pct != null ? `${Math.round(Number(w.pct))}%` : '');
+ return `<div class="c4-meter"><span class="c4-meter-name">${esc(w.name || 'window')}</span><span class="c4-meter-track" role="img" aria-label="${esc(`${w.name || 'window'}: ${Math.round(pct)}% used${full ? ', full' : ''}`)}"><span class="c4-meter-fill${full ? ' is-full' : ''}" style="width:${pct}%"></span></span><span class="c4-meter-note">${used ? `<b>${esc(used)}</b>` : ''}${used && free ? ' · ' : ''}${esc(free || (used ? '' : 'no reading'))}</span></div>`;
+};
+/** The conductor's chain as its page says it: each agent's usage state, in a word and a tone. */
+const CHAIN_STATE = {
+ ok: ['Has room', 'ok'], near: ['Nearly full', 'skip'], full: ['Full', 'bad'], login: ['Sign in', 'bad'],
+ absent: ['Not installed', 'skip'], unknown: ['Not reported', 'skip'],
+};
const FLEET_STATE = {
const FLEET_STATE = {
ok: ['Has room', 'ok'], low: ['Nearly full', 'skip'], login: ['Sign in', 'bad'], limited: ['Full', 'bad'],
ok: ['Has room', 'ok'], low: ['Nearly full', 'skip'], login: ['Sign in', 'bad'], limited: ['Full', 'bad'],
empty: ['Out of credit', 'bad'], absent: ['Not installed', 'skip'], unknown: ['Not reported', 'skip'],
empty: ['Out of credit', 'bad'], absent: ['Not installed', 'skip'], unknown: ['Not reported', 'skip'],
@@ -280,7 +296,11 @@ ${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="
@@ -280,7 +296,11 @@ ${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="
: '';
: '';
const answer = m.result && m.result.summary ? `<pre class="c4-turn-answer" tabindex="0">${esc(m.result.summary)}</pre>` : '';
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>` : '';
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>`;
+ // Which agent answered: said on every turn once the conversation has more than one (the failover).
+ const ranOn = m.agent && (m.agent !== 'claude-code' || m.switch_note)
+ ? `<span class="c4-chip c4-turn-agent">${esc(m.agent_name || m.agent)}${m.model ? ` · ${esc(modelLabel(m.model))}` : ''}</span>` : '';
+ const switched = m.switch_note ? `<p class="c4-turn-switch">${icon('<path d="M4 8h13l-3-3M20 16H7l3 3"/>')}<span>${esc(m.switch_note)}</span></p>` : '';
+ return `<li class="c4-turn c4-turn-${esc(m.state)}">${switched}<div class="c4-turn-head"><span class="c4-turn-who">${m.n === 1 ? 'Hand-over brief' : esc(`You · turn ${m.n}`)}</span><span class="c4-turn-tags">${ranOn}${pill(word, kind)}</span></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. */
/** One waiting approval: the command the person reads, and the two answers. */
@@ -301,11 +321,20 @@ ${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="
@@ -301,11 +321,20 @@ ${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="
const approvals = pending.length
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>`
? `<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>`
: '';
: '';
+ // Usage (the failover): each agent of the chain, how full it is, and which one the next turn takes.
+ const usage = view.usage;
+ const chainRows = usage && usage.chain && usage.chain.length ? usage.chain.map((u, i) => {
+ const [label, tone] = CHAIN_STATE[u.state] || CHAIN_STATE.unknown;
+ const meter = u.pct != null ? usageMeter({ name: u.window, pct: u.pct, resets_at: u.resets_at }) : '';
+ const next = usage.next && usage.next.agent === u.agent && (usage.next.model || null) === (u.model || null);
+ return `<li class="c4-row c4-fleet-row${next ? ' c4-chain-next' : ''}"><span class="c4-row-dot${u.state === 'ok' ? ' c4-dot-ok' : ['full', 'login'].includes(u.state) ? ' c4-dot-bad' : ''}" aria-hidden="true"></span><span class="c4-row-label">${esc(u.name)}${u.model ? ` · ${esc(modelLabel(u.model))}` : ''}</span><span class="c4-row-note">${i === 0 ? 'its own agent' : `fallback ${i}`}${next ? ' · <b>takes the next turn</b>' : ''}</span>${pill(label, tone)}${meter ? `<div class="c4-fleet-windows">${meter}</div>` : ''}</li>`;
+ }).join('') : '';
+ const usageCard = chainRows ? `<section class="c4-card c4-conductor-usage" aria-labelledby="c4-usage-h"><div class="c4-card-head"><h2 id="c4-usage-h">Usage</h2><a href="/settings#conductor">${usage.fallbacks ? 'Fallbacks' : 'Add a fallback'}</a></div><ul class="c4-rows c4-fleet">${chainRows}</ul>${usage.note ? `<p class="c4-hint c4-usage-note">${esc(usage.note)}</p>` : ''}${usage.fallbacks ? `<p class="c4-hint">At ${esc(usage.switch_pct)}% of any window the next turn moves to the first fallback with room, and comes back once the window frees.</p>` : `<p class="c4-hint">No fallback yet. <a href="/settings#conductor">Pick one</a>, and when ${esc(usage.chain[0].name)} nears its limit your conductor carries on with it instead of stopping.</p>`}</section>` : '';
const outcome = row.outcome === 'unfinished' || row.outcome === 'stopped'
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>` : '';
? `<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 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 composer = row.outcome === 'stopped' ? '' : `<section class="c4-card c4-conductor-composer"><form method="post" action="/tasks/${encodeURIComponent(row.id)}/messages" data-c4-conductor-form data-conductor-api="/api/v1/conductor/${encodeURIComponent(row.id)}/messages"><label for="c4-message">${live ? 'Say something: it is the next turn' : 'What next?'}</label><textarea id="c4-message" name="text" rows="3" placeholder="Tell your conductor what to do next, or ask what it is doing" required></textarea><div class="c4-conductor-actions"><label class="c4-file">Attach an image<input type="file" accept="image/*" multiple data-c4-images></label><button class="c4-button" type="submit">Send</button></div><p class="c4-hint" data-c4-message-status role="status">${live ? 'A message waits until the current turn ends; it is not lost.' : ''}</p></form></section>`;
- const content = `${outcome}<section class="c4-card c4-conductor-head"><div class="c4-task-heading"><div class="c4-task-title-wrap"><h1>Conductor</h1></div><span class="c4-status c4-${esc(row.state)}">${esc(conductorState(row))}</span></div><div class="c4-chips" aria-label="Conductor details"><span class="c4-chip">${esc(row.folder || 'a folder')}</span><span class="c4-chip">${esc(modelLabel(row.model))}</span><span class="c4-chip">safety: ${esc(row.safety || 'ask')}</span><span class="c4-chip">${esc(machineLabel || 'your machine')}</span></div><p class="c4-hint">One Claude Code session on your own machine, with your own login. Each message is the next turn of the same conversation; its work is carried out through the Codes tools, on your other agents.</p></section>${approvals}<section class="c4-card c4-turns"><div class="c4-card-head"><h2>Turns</h2><span class="c4-muted">${(view.messages || []).length} so far</span></div>${turns ? `<ol class="c4-turn-list">${turns}</ol>` : '<div class="c4-emptyline">Its first turn is starting.</div>'}</section>${transcript}${composer}`;
+ const content = `${outcome}<section class="c4-card c4-conductor-head"><div class="c4-task-heading"><div class="c4-task-title-wrap"><h1>Conductor</h1></div><span class="c4-status c4-${esc(row.state)}">${esc(conductorState(row))}</span></div><div class="c4-chips" aria-label="Conductor details"><span class="c4-chip">${esc(row.folder || 'a folder')}</span><span class="c4-chip">${esc(modelLabel(row.model))}</span><span class="c4-chip">safety: ${esc(row.safety || 'ask')}</span><span class="c4-chip">${esc(machineLabel || 'your machine')}</span></div><p class="c4-hint">One Claude Code session on your own machine, with your own login. Each message is the next turn of the same conversation; its work is carried out through the Codes tools, on your other agents.</p></section>${approvals}${usageCard}<section class="c4-card c4-turns"><div class="c4-card-head"><h2>Turns</h2><span class="c4-muted">${(view.messages || []).length} so far</span></div>${turns ? `<ol class="c4-turn-list">${turns}</ol>` : '<div class="c4-emptyline">Its first turn is starting.</div>'}</section>${transcript}${composer}`;
page(req, res, 'Conductor', wrap(content));
page(req, res, 'Conductor', wrap(content));
}
}
@@ -528,7 +557,7 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors
@@ -528,7 +557,7 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors
const row = await db.maybe("SELECT * FROM run_tasks WHERE id = $1 AND subject = $2 AND kind = 'conductor'", [req.params.id, req.viewer.subject]);
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');
if (!row) return res.status(404).send('Not found');
try {
try {
- await conductor.message(row, { text: (req.body || {}).text });
+ await conductor.message(row, { text: (req.body || {}).text, token: req.viewer.token || null });
return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}`);
return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}`);
} catch (e) {
} 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))}`);
return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}?error=${encodeURIComponent(String((e && e.message) || 'the message could not be sent').slice(0, 180))}`);
@@ -584,13 +613,7 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors
@@ -584,13 +613,7 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors
}
}
const rows = fleet ? await fleet.agents(req.viewer.subject, harnesses).catch(() => []) : [];
const rows = fleet ? await fleet.agents(req.viewer.subject, harnesses).catch(() => []) : [];
const latest = fleet ? await fleet.latest(req.viewer.subject).catch(() => ({ at: null })) : { at: null };
const latest = fleet ? await fleet.latest(req.viewer.subject).catch(() => ({ at: null })) : { at: null };
- const meter = (w) => {
- const pct = Math.max(0, Math.min(100, Number(w.pct) || 0));
- const full = Number(w.pct) >= 95;
- const free = whenUtc(w.resets_at);
- const used = w.used != null && w.limit != null ? `${w.used} of ${w.limit}` : (w.pct != null ? `${Math.round(Number(w.pct))}%` : '');
- return `<div class="c4-meter"><span class="c4-meter-name">${esc(w.name || 'window')}</span><span class="c4-meter-track" role="img" aria-label="${esc(`${w.name || 'window'}: ${Math.round(pct)}% used${full ? ', full' : ''}`)}"><span class="c4-meter-fill${full ? ' is-full' : ''}" style="width:${pct}%"></span></span><span class="c4-meter-note">${used ? `<b>${esc(used)}</b>` : ''}${used && free ? ' · ' : ''}${esc(free || (used ? '' : 'no reading'))}</span></div>`;
- };
+ const meter = usageMeter;
const list = rows.map((row) => {
const list = rows.map((row) => {
const [label, kind] = FLEET_STATE[row.state] || FLEET_STATE.unknown;
const [label, kind] = FLEET_STATE[row.state] || FLEET_STATE.unknown;
const where = (row.machines || []).map((m) => m.name || m.machine).filter(Boolean).slice(0, 3).join(', ');
const where = (row.machines || []).map((m) => m.name || m.machine).filter(Boolean).slice(0, 3).join(', ');
@@ -742,6 +765,28 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
@@ -742,6 +765,28 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
if (value != null && !choices.some(([v]) => v === Number(value))) choices.push([Number(value), Number(value).toLocaleString('en-US')]);
if (value != null && !choices.some(([v]) => v === Number(value))) choices.push([Number(value), Number(value).toLocaleString('en-US')]);
return `<select name="${name}">${choices.map(([v, label]) => `<option value="${v == null ? '' : v}"${(v == null ? value == null : Number(value) === v) ? ' selected' : ''}>${esc(label)}${v == null ? '' : ` ${unit}`}</option>`).join('')}</select>`;
return `<select name="${name}">${choices.map(([v, label]) => `<option value="${v == null ? '' : v}"${(v == null ? value == null : Number(value) === v) ? ' selected' : ''}>${esc(label)}${v == null ? '' : ` ${unit}`}</option>`).join('')}</select>`;
};
};
+ // Settings → Conductor (the failover): the agents a conductor can fall back to, each said with what the person's
+ // machines report about it (ready, how full, signed out), so a pick that cannot run is visible before it is made.
+ const fleetRows = viewer(req) && fleet ? await fleet.agents(req.viewer.subject, harnesses).catch(() => []) : [];
+ const fleetBy = new Map(fleetRows.map((r) => [r.agent, r]));
+ const readiness = (agent) => {
+ const r = fleetBy.get(agent);
+ if (!r) return 'not reported';
+ const [word] = FLEET_STATE[r.state] || FLEET_STATE.unknown;
+ return r.used ? `${word.toLowerCase()}, ${Math.round(r.used)}% used` : word.toLowerCase();
+ };
+ const fallbacks = (settings && settings.conductor_fallbacks) || [];
+ const fallbackRow = (i) => {
+ const f = fallbacks[i] || { agent: '', model: '' };
+ const options = CONDUCTOR_FALLBACKS.map((a) => `<option value="${a}"${f.agent === a ? ' selected' : ''} data-state="${esc((fleetBy.get(a) || {}).state || 'unknown')}">${esc(failoverNames[a])} — ${esc(readiness(a))}</option>`).join('');
+ return `<div class="c4-fallback"><span class="c4-fallback-n" aria-hidden="true">${i + 1}</span><label>Fallback ${i + 1} <select name="fallback_agent_${i + 1}"><option value="">None</option>${options}</select></label><label>Model <input name="fallback_model_${i + 1}" value="${esc(f.model || '')}" placeholder="its default" autocomplete="off" spellcheck="false" maxlength="100"></label></div>`;
+ };
+ const switchPct = (settings && settings.conductor_switch_pct) || 90;
+ const conductorSection = viewer(req) ? `<h3 class="c4-subhead" id="conductor">Conductor</h3>
+<p class="c4-hint">When your conductor's agent nears a usage limit (its 5-hour or weekly window), the next turn runs on the first fallback below that has room, and returns once the window frees. Each agent keeps its own session; the one taking over is told what happened while it was away.</p>
+<div class="c4-fallbacks">${[0, 1, 2].map(fallbackRow).join('')}</div>
+<div class="c4-form-grid"><label>Switch at <select name="conductor_switch_pct">${[70, 80, 85, 90, 95].map((v) => `<option value="${v}"${switchPct === v ? ' selected' : ''}>${v}% of a window</option>`).join('')}</select></label></div>
+<p class="c4-hint">Codex and OpenCode have no approval prompt: on "Ask first" they work with edits in the folder only. Your machine needs OpenVibe Node with Codes 0.7 or newer to switch.</p>` : '';
const content = `${header('Settings', 'Your choices, remembered for your account.')}${viewer(req) ? `${flash(req)}<section class="c4-card"><form class="c4-form" method="post" action="/settings">
const content = `${header('Settings', 'Your choices, remembered for your account.')}${viewer(req) ? `${flash(req)}<section class="c4-card"><form class="c4-form" method="post" action="/settings">
<div class="c4-form-grid">
<div class="c4-form-grid">
<label>Default agent <select name="default_agent"><option value="">Auto</option>${agentOptions}</select></label>
<label>Default agent <select name="default_agent"><option value="">Auto</option>${agentOptions}</select></label>
@@ -765,6 +810,7 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
@@ -765,6 +810,7 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
</div>
</div>
<h3 class="c4-subhead">Safety</h3>
<h3 class="c4-subhead">Safety</h3>
<div class="c4-safety" role="group" aria-label="Safety level">${SAFETY_OPTIONS.map(([value, title, desc]) => `<label class="c4-safety-card"><input type="radio" name="safety" value="${value}"${settings.safety === value ? ' checked' : ''}><span><b>${title}</b><small>${desc}</small></span></label>`).join('')}</div>
<div class="c4-safety" role="group" aria-label="Safety level">${SAFETY_OPTIONS.map(([value, title, desc]) => `<label class="c4-safety-card"><input type="radio" name="safety" value="${value}"${settings.safety === value ? ' checked' : ''}><span><b>${title}</b><small>${desc}</small></span></label>`).join('')}</div>
+${conductorSection}
<button class="c4-button" type="submit">Save settings</button>
<button class="c4-button" type="submit">Save settings</button>
</form></section>` : signIn(req)}`;
</form></section>` : signIn(req)}`;
page(req, res, 'Settings', wrap(content));
page(req, res, 'Settings', wrap(content));
@@ -784,6 +830,11 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
@@ -784,6 +830,11 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
...(b.safety ? { safety: b.safety } : {}),
...(b.safety ? { safety: b.safety } : {}),
steps: b.steps === undefined ? undefined : b.steps,
steps: b.steps === undefined ? undefined : b.steps,
minutes: b.minutes === undefined ? undefined : b.minutes,
minutes: b.minutes === undefined ? undefined : b.minutes,
+ // The conductor's fallbacks, in the order of the form's rows; an empty row is none.
+ ...(b.fallback_agent_1 !== undefined ? {
+ conductor_fallbacks: [1, 2, 3].map((n) => ({ agent: b[`fallback_agent_${n}`] || '', model: b[`fallback_model_${n}`] || '' })).filter((f) => f.agent),
+ } : {}),
+ ...(b.conductor_switch_pct !== undefined ? { conductor_switch_pct: b.conductor_switch_pct } : {}),
});
});
return res.redirect(303, '/settings?ok=Settings%20saved');
return res.redirect(303, '/settings?ok=Settings%20saved');
} catch (e) {
} catch (e) {
@@ -268,17 +268,22 @@ function createChatRoutes(ctx) {
@@ -268,17 +268,22 @@ function createChatRoutes(ctx) {
async function conductorThread(row, view) {
async function conductorThread(row, view) {
const out = [];
const out = [];
const names = agentNames();
const names = agentNames();
- const a = agentOf('claude-code', names);
const live = LIVE.includes(row.state);
const live = LIVE.includes(row.state);
const msgs = view.messages || [];
const msgs = view.messages || [];
- const who = { ...a, sub: row.model || '' };
+ let previous = null;
for (const m of msgs) {
for (const m of msgs) {
+ // The agent that answered this turn (the failover may have moved it), and a line where it changed.
+ const a = agentOf(m.agent || 'claude-code', names);
+ const who = { ...a, sub: m.model || (m.agent && m.agent !== 'claude-code' ? '' : row.model) || '' };
+ if (m.switch_note) out.push(thread.divider({ text: m.switch_note }));
+ else if (previous && m.agent && m.agent !== previous) out.push(thread.divider({ text: `${a.name} carries on` }));
+ if (m.agent) previous = m.agent;
const files = (m.attachments || []).map((f, i) => ({ href: `/api/v1/tasks/${encodeURIComponent(row.id)}/messages/${encodeURIComponent(m.id)}/attachments/${i}`, name: f.name, image: /^image\//.test(String(f.mime || '')) }));
const files = (m.attachments || []).map((f, i) => ({ href: `/api/v1/tasks/${encodeURIComponent(row.id)}/messages/${encodeURIComponent(m.id)}/attachments/${i}`, name: f.name, image: /^image\//.test(String(f.mime || '')) }));
const mine = { text: m.n === 1 ? (m.prompt || 'Pick up the plan') : m.prompt, caption: m.n === 1 ? 'Hand-over brief' : '', files };
const mine = { text: m.n === 1 ? (m.prompt || 'Pick up the plan') : m.prompt, caption: m.n === 1 ? 'Hand-over brief' : '', files };
const id = `m${Number(m.n) || out.length + 1}`;
const id = `m${Number(m.n) || out.length + 1}`;
if (m.state === 'running' || (live && m === msgs[msgs.length - 1] && m.state !== 'done')) {
if (m.state === 'running' || (live && m === msgs[msgs.length - 1] && m.state !== 'done')) {
const lines = await db.many('SELECT seq, at, kind, body FROM codes_session_lines WHERE task_id = $1 ORDER BY seq DESC LIMIT 300', [row.id]);
const lines = await db.many('SELECT seq, at, kind, body FROM codes_session_lines WHERE task_id = $1 ORDER BY seq DESC LIMIT 300', [row.id]);
- const r = agentReply(a, lines.reverse(), { live: true, attempt: { model: row.model } });
+ const r = agentReply(a, lines.reverse(), { live: true, attempt: { model: m.model || row.model } });
out.push(thread.turn({ id, open: true, state: 'running', you: mine, who: r.who, reply: r.reply }));
out.push(thread.turn({ id, open: true, state: 'running', you: mine, who: r.who, reply: r.reply }));
} else if (m.result && m.result.summary) {
} else if (m.result && m.result.summary) {
out.push(thread.turn({ id, you: mine, who, reply: thread.answer({ html: markdown(m.result.summary) }) }));
out.push(thread.turn({ id, you: mine, who, reply: thread.answer({ html: markdown(m.result.summary) }) }));
@@ -291,7 +296,7 @@ function createChatRoutes(ctx) {
@@ -291,7 +296,7 @@ function createChatRoutes(ctx) {
}
}
// What waits for a yes, as a reply of its own at the end of the thread.
// What waits for a yes, as a reply of its own at the end of the thread.
const asks = (view.approvals || []).map((ap) => approvalCard(row, ap)).join('');
const asks = (view.approvals || []).map((ap) => approvalCard(row, ap)).join('');
- if (asks) out.push(thread.turn({ id: 'asks', who, reply: asks }));
+ if (asks) out.push(thread.turn({ id: 'asks', who: { ...agentOf('claude-code', names), sub: row.model || '' }, reply: asks }));
return out;
return out;
}
}
const approvalCard = (row, ap) => thread.card({
const approvalCard = (row, ap) => thread.card({
@@ -376,7 +381,27 @@ function createChatRoutes(ctx) {
@@ -376,7 +381,27 @@ function createChatRoutes(ctx) {
}
}
/** The panel: who worked on it, and where the work is. */
/** The panel: who worked on it, and where the work is. */
- async function sideHtml(row) {
+ /**
+ * The conductor's usage (the failover): each agent of its chain, how full its fullest window is and when it frees,
+ * and which one takes the next turn. Fallbacks are chosen in Settings.
+ */
+ function usageSection(usage) {
+ if (!usage || !usage.chain || !usage.chain.length) return null;
+ const words = { ok: 'has room', near: 'nearly full', full: 'full', login: 'not signed in', absent: 'not installed', unknown: 'no reading yet' };
+ const rows = usage.chain.map((u, i) => {
+ const next = usage.next && usage.next.agent === u.agent && (usage.next.model || null) === (u.model || null);
+ const pct = u.pct == null ? null : Math.max(0, Math.min(100, Number(u.pct)));
+ const meter = pct == null ? '' : `<span class="ch-usage-meter${u.state === 'full' ? ' is-full' : u.state === 'near' ? ' is-near' : ''}" role="img" aria-label="${esc(`${u.window || 'window'}: ${pct}% used`)}"><span style="width:${pct}%"></span></span>`;
+ const sub = [i === 0 ? 'its own agent' : `fallback ${i}`, u.pct != null ? `${u.pct}% of ${u.window || 'a window'}` : words[u.state] || u.state, u.resets].filter(Boolean).join(' · ');
+ return { who: agentOf(u.agent, agentNames()), html: `<b>${esc(u.name)}${u.model ? ` · ${esc(u.model)}` : ''}${next ? ' <em class="ch-usage-next">next turn</em>' : ''}</b><small>${esc(sub)}</small>${meter}` };
+ });
+ const foot = usage.fallbacks
+ ? `<p class="ch-usage-foot">At ${esc(usage.switch_pct)}% of a window the next turn moves to the first fallback with room. <a href="/settings#conductor">Change</a></p>`
+ : `<p class="ch-usage-foot">No fallback yet: when ${esc(usage.chain[0].name)} runs out, the conductor waits. <a href="/settings#conductor">Add a fallback</a></p>`;
+ return { title: 'Usage', rows, html: `${usage.note ? `<p class="ch-usage-note">${esc(usage.note)}</p>` : ''}${foot}` };
+ }
+
+ async function sideHtml(row, view = null) {
const names = agentNames();
const names = agentNames();
const attempts = tasks?.attempts ? await tasks.attempts.list(row.id) : [];
const attempts = tasks?.attempts ? await tasks.attempts.list(row.id) : [];
const result = asObject(row.result) || {};
const result = asObject(row.result) || {};
@@ -388,6 +413,7 @@ function createChatRoutes(ctx) {
@@ -388,6 +413,7 @@ function createChatRoutes(ctx) {
].filter(Boolean);
].filter(Boolean);
const people = attempts.map((a) => { const g = agentOf(a.agent, names); return { who: g, title: g.name, sub: [a.model, tasks.attempts.wordsOf && a.reason ? tasks.attempts.wordsOf(a.reason) : a.state].filter(Boolean).join(' · ') }; });
const people = attempts.map((a) => { const g = agentOf(a.agent, names); return { who: g, title: g.name, sub: [a.model, tasks.attempts.wordsOf && a.reason ? tasks.attempts.wordsOf(a.reason) : a.state].filter(Boolean).join(' · ') }; });
return thread.panel({ id: 'ch-side', title: 'What it did', sections: [
return thread.panel({ id: 'ch-side', title: 'What it did', sections: [
+ view && view.usage ? usageSection(view.usage) : null,
people.length ? { title: 'Agents', rows: people } : null,
people.length ? { title: 'Agents', rows: people } : null,
where.length ? { title: 'Where', rows: where } : null,
where.length ? { title: 'Where', rows: where } : null,
] });
] });
@@ -555,10 +581,13 @@ function createChatRoutes(ctx) {
@@ -555,10 +581,13 @@ function createChatRoutes(ctx) {
if (row.kind === 'conductor') {
if (row.kind === 'conductor') {
const view = await conductor.view(row);
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 last = await db.maybe('SELECT max(seq) AS seq FROM codes_session_lines WHERE task_id = $1', [row.id]);
- const model = String(row.model || 'opus');
- const pickers = `<span class="ch-chip ch-chip-static" title="The conductor runs Claude Code on your machine">${avatar(agentOf('claude-code'))}${esc(model)}</span><label class="ch-pick ch-pick-file" title="Attach an image">${ico('attach')}<span class="ch-sr">Attach an image</span><input type="file" accept="image/*" multiple data-ch-images></label>`;
+ // The agent the next turn runs on: the conductor's own, or the fallback its usage moves it to.
+ const next = (view.usage && view.usage.next) || { agent: 'claude-code', model: row.model || 'opus' };
+ const nextAgent = agentOf(next.agent);
+ const model = String(next.model || (next.agent === 'claude-code' ? row.model || 'opus' : 'its default model'));
+ const pickers = `<span class="ch-chip ch-chip-static" title="${esc(`The next turn runs ${nextAgent.name} on your machine`)}">${avatar(nextAgent)}${esc(model)}</span><label class="ch-pick ch-pick-file" title="Attach an image">${ico('attach')}<span class="ch-sr">Attach an image</span><input type="file" accept="image/*" multiple data-ch-images></label>`;
return frame(req, res, {
return frame(req, res, {
- currentId: row.id, title: 'Conductor', heading: titleOf(row), state, panel: await sideHtml(row),
+ currentId: row.id, title: 'Conductor', heading: titleOf(row), state, panel: await sideHtml(row, view),
turns: [...errorTurns, ...await conductorThread(row, view)],
turns: [...errorTurns, ...await conductorThread(row, view)],
streamUrl: `/api/v1/tasks/${encodeURIComponent(row.id)}/events?last_event_id=${last && last.seq != null ? Number(last.seq) : 0}`,
streamUrl: `/api/v1/tasks/${encodeURIComponent(row.id)}/events?last_event_id=${last && last.seq != null ? Number(last.seq) : 0}`,
composerHtml: row.outcome === 'stopped' ? '' : composer({ action: `/tasks/${encodeURIComponent(row.id)}/messages`, api: `/api/v1/conductor/${encodeURIComponent(row.id)}/messages`, placeholder: live ? 'Say something: it is the next turn' : 'What next?', pickers, note: live ? 'A message waits until the current turn ends; it is not lost.' : '' }),
composerHtml: row.outcome === 'stopped' ? '' : composer({ action: `/tasks/${encodeURIComponent(row.id)}/messages`, api: `/api/v1/conductor/${encodeURIComponent(row.id)}/messages`, placeholder: live ? 'Say something: it is the next turn' : 'What next?', pickers, note: live ? 'A message waits until the current turn ends; it is not lost.' : '' }),
@@ -170,7 +170,7 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,
@@ -170,7 +170,7 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,
if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such conductor of yours');
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 b = req.body && typeof req.body === 'object' ? req.body : {};
try {
try {
- const out = await conductor.message(row, { text: b.text, attachments: b.attachments });
+ const out = await conductor.message(row, { text: b.text, attachments: b.attachments, token: (req.person && req.person.token) || null });
return res.status(201).json({
return res.status(201).json({
message: conductor.presentMessage(out.message),
message: conductor.presentMessage(out.message),
started: !!(out.started && out.started.job),
started: !!(out.started && out.started.job),
new file mode 100644
new file mode 100644
@@ -0,0 +1,234 @@
@@ -0,0 +1,234 @@
+'use strict';
+
+/**
+ * The conductor's failover (owner 2026-11-03): before each turn the conductor's own agent and the fallbacks the person
+ * chose are read against their machine's usage windows, and the turn runs on the first with room under the switch
+ * point. What this checks:
+ * - the choice itself (conductor-failover.js): room, near, full, signed out, a window that already freed, no reading;
+ * - the settings: fallbacks and the switch point are checked and kept;
+ * - a conductor whose Claude Code is at 93% of its week runs its turn on Codex, comes back when the week frees, and
+ * each agent resumes its own session; the agent taking over is told what happened while it was away;
+ * - a machine whose runner is too old to switch is said so on the turn;
+ * - the harness takes a conductor turn on Codex or OpenCode, with the Codes MCP server in a 0700 launcher.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { boot, check, done } = require('./helpers/boot');
+const failover = require('../server/domain/conductor-failover');
+const job = require('../harness/job');
+
+const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2F1';
+const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2F1';
+const HOUR = 3600_000;
+
+(async () => {
+ const now = Date.now();
+ const later = new Date(now + 50 * HOUR).toISOString();
+ const chain = [{ agent: 'claude-code', model: 'opus' }, { agent: 'codex', model: 'gpt-5.5' }, { agent: 'opencode', model: null }];
+ const row = (pct, extra = {}) => ({ installed: true, signed_in: true, windows: pct == null ? [] : [{ name: 'week', pct, resets_at: later }], ...extra });
+
+ await check('the choice: the first agent under the switch point takes the turn', async () => {
+ let c = failover.choose({ chain, fleet: { 'claude-code': row(40), codex: row(10) }, now });
+ assert.deepStrictEqual(c.pick, { agent: 'claude-code', model: 'opus' });
+ assert.strictEqual(c.switched, false);
+ assert.strictEqual(c.note, null);
+
+ c = failover.choose({ chain, fleet: { 'claude-code': row(93), codex: row(20) }, now });
+ assert.deepStrictEqual(c.pick, { agent: 'codex', model: 'gpt-5.5' }, 'near its limit, the next agent with room takes it');
+ assert.strictEqual(c.switched, true);
+ assert.match(c.note, /Claude Code is at 93% of its week \(resets in 2 d|resets \w{3} \d\d:\d\d UTC\)/);
+ assert.match(c.note, /this turn runs on Codex \(gpt-5\.5\)/);
+
+ c = failover.choose({ chain, fleet: { 'claude-code': row(93), codex: row(20) }, switchPct: 95, now });
+ assert.strictEqual(c.pick.agent, 'claude-code', 'the person\'s switch point decides when it moves');
+
+ c = failover.choose({ chain, fleet: { 'claude-code': row(100), codex: row(91), opencode: row(96) }, now });
+ assert.strictEqual(c.pick.agent, 'codex', 'with nothing under the point, a near agent before a full one');
+
+ c = failover.choose({ chain, fleet: { 'claude-code': row(92), codex: row(99), opencode: row(null, { signed_in: false }) }, now });
+ assert.strictEqual(c.pick.agent, 'claude-code', 'a near primary beats full and signed-out fallbacks');
+
+ c = failover.choose({ chain, fleet: { 'claude-code': row(100), codex: row(99), opencode: row(97) }, now });
+ assert.strictEqual(c.pick.agent, 'claude-code', 'all at their limit: the primary tries, and its own answer says the limit');
+ assert.match(c.note, /^Every agent in your chain is at its limit/);
+
+ c = failover.choose({ chain: chain.slice(0, 1), fleet: { 'claude-code': row(100) }, now });
+ assert.match(c.note, /no fallback is set/);
+
+ c = failover.choose({ chain, fleet: { 'claude-code': row(null, { signed_in: false }), codex: row(5) }, now });
+ assert.strictEqual(c.pick.agent, 'codex', 'a signed-out primary hands over');
+
+ const freed = { installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: new Date(now - HOUR).toISOString() }] };
+ c = failover.choose({ chain, fleet: { 'claude-code': freed, codex: row(5) }, now });
+ assert.strictEqual(c.pick.agent, 'claude-code', 'a window whose reset has passed is free again');
+
+ c = failover.choose({ chain, fleet: null, now });
+ assert.strictEqual(c.pick.agent, 'claude-code', 'no reading at all: the primary, as before any machine reported');
+ c = failover.choose({ chain, fleet: { 'claude-code': row(97) }, now });
+ assert.strictEqual(c.pick.agent, 'codex', 'an unreported fallback is still tried before a full primary');
+ assert.strictEqual(c.usage[1].state, 'unknown');
+ });
+
+ await check('the settings: fallbacks and the switch point are checked', async () => {
+ assert.deepStrictEqual(failover.cleanFallbacks([{ agent: 'codex', model: 'gpt-5.5' }, { agent: 'opencode', model: '' }, '']),
+ [{ agent: 'codex', model: 'gpt-5.5' }, { agent: 'opencode', model: null }]);
+ assert.throws(() => failover.cleanFallbacks([{ agent: 'aider' }]), /fall back to/);
+ assert.throws(() => failover.cleanFallbacks([{ agent: 'codex', model: 'gpt 5; rm -rf' }]), /model name/);
+ assert.throws(() => failover.cleanFallbacks([{ agent: 'codex', model: 'a' }, { agent: 'codex', model: 'b' }, { agent: 'opencode' }, { agent: 'claude-code', model: 'sonnet' }]), /at most 3/);
+ assert.strictEqual(failover.cleanSwitchPct('85'), 85);
+ assert.throws(() => failover.cleanSwitchPct(99), /50 to 95/);
+ assert.strictEqual(failover.resetsLabel(new Date(now + 30 * 60_000).toISOString(), now), 'resets in 30 min');
+ });
+
+ // ── The conductor, end to end on the in-process services ──
+ const calls = [];
+ const jobs = new Map();
+ let counter = 0;
+ const run = {
+ enabled: true,
+ async submit(o) {
+ counter++;
+ const id = `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2F${String(counter).padStart(2, '0')}`;
+ jobs.set(id, { id, state: 'queued', args: o.args });
+ calls.push(o);
+ return { id, state: 'queued' };
+ },
+ async read(jobId) { return jobs.get(jobId) || { id: jobId, state: 'running' }; },
+ async 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');
+ t.network.addNode(ada.subject, { principal: NODE, node_id: 'node_ada', name: 'ada-laptop', status: 'active', last_seen_at: new Date().toISOString() });
+ const machine = t.bot.addMachine({ id: DEVICE, node_id: NODE, name: 'ada-laptop', online: true });
+ const report = (claudePct, codexPct) => {
+ machine.fleet = [
+ { agent: 'claude-code', installed: true, signed_in: true, windows: [{ name: '5h', pct: 10, resets_at: new Date(Date.now() + 2 * HOUR).toISOString() }, { name: 'week', pct: claudePct, resets_at: later }] },
+ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: codexPct, resets_at: later }] },
+ ];
+ machine.fleet_at = new Date().toISOString();
+ };
+ const refresh = async () => t.ctx.fleet.refresh(ada.subject, t.network.userToken(ada));
+ const post = (p, body) => t.get(p, { as: ada, 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 endTurn = async (id, { agent, session, summary = 'Done that.' }) => {
+ const r = await rowOf(id);
+ const doc = { id: r.job_id, state: 'succeeded', result: { ok: true, summary, log: [{ t: 'agent', agent, session }] } };
+ const updated = await t.ctx.tasks.applyEvent(r, { state: 'succeeded', job: doc, error: null });
+ return await t.ctx.tasks.afterEnd(updated.id);
+ };
+
+ let taskId = null;
+ try {
+ await check('Claude Code at 93% of its week: the first turn runs on the fallback', async () => {
+ await t.ctx.userSettings.save(ada.subject, { conductor_fallbacks: [{ agent: 'codex', model: 'gpt-5.5' }] });
+ report(93, 20);
+ await refresh();
+ const res = await post('/api/v1/conductor', { folder: '/home/ada/work', machine: NODE, model: 'opus', safety: 'ask' });
+ assert.strictEqual(res.status, 201, res.text);
+ taskId = res.json().task.id;
+ const submit = calls.at(-1);
+ assert.strictEqual(submit.args.agent, 'codex');
+ assert.strictEqual(submit.args.model, 'gpt-5.5');
+ assert.strictEqual(submit.args.resume, undefined);
+ assert.doesNotMatch(submit.args.prompt, /taking over/, 'the first turn needs no catch-up: it is the brief');
+ const [first] = await messagesOf(taskId);
+ assert.strictEqual(first.agent, 'codex');
+ assert.match(first.switch_note, /Claude Code is at 93% of its week/);
+ const view = res.json();
+ assert.strictEqual(view.messages[0].agent, 'codex');
+ assert.strictEqual(view.usage.chain.length, 2);
+ assert.strictEqual(view.usage.chain[0].state, 'near');
+ assert.strictEqual(view.usage.chain[1].state, 'ok');
+ });
+
+ await check('the week frees: the next turn is Claude Code again, told what Codex did', async () => {
+ await endTurn(taskId, { agent: 'codex', session: 'thr_codex_1', summary: 'Split the plan into three tasks.' });
+ assert.strictEqual((await messagesOf(taskId))[0].session, 'thr_codex_1', 'Codex\'s session is kept on its turn');
+ report(12, 20);
+ await refresh();
+ const res = await post(`/api/v1/conductor/${taskId}/messages`, { text: 'carry on with the second task' });
+ assert.strictEqual(res.status, 201, res.text);
+ const submit = calls.at(-1);
+ assert.strictEqual(submit.args.agent, 'claude-code');
+ assert.strictEqual(submit.args.model, 'opus');
+ assert.strictEqual(submit.args.resume, undefined, 'Claude Code has no session of its own yet in this conversation');
+ assert.match(submit.args.prompt, /You are taking over this conversation/);
+ assert.match(submit.args.prompt, /carrying on from Codex/);
+ assert.match(submit.args.prompt, /carry on with the second task$/);
+ assert.ok(submit.args.prompt.length <= 16000, 'a turn stays inside a job\'s prompt size');
+ const m = (await messagesOf(taskId))[1];
+ assert.strictEqual(m.prompt, 'carry on with the second task', 'the person\'s own words are what the row keeps');
+ assert.strictEqual(m.agent, 'claude-code');
+ assert.strictEqual(m.switch_note, null);
+ });
+
+ await check('near again: Codex resumes its own session and hears turn 2', async () => {
+ await endTurn(taskId, { agent: 'claude-code', session: 'sess_claude_2', summary: 'Started the second task on OpenCode.' });
+ report(95, 30);
+ await refresh();
+ await post(`/api/v1/conductor/${taskId}/messages`, { text: 'and the third' });
+ const submit = calls.at(-1);
+ assert.strictEqual(submit.args.agent, 'codex');
+ assert.strictEqual(submit.args.resume, 'thr_codex_1', 'each agent resumes its own session');
+ assert.match(submit.args.prompt, /since you last took part/);
+ assert.match(submit.args.prompt, /Turn 2 — Claude Code/);
+ assert.match(submit.args.prompt, /Started the second task on OpenCode\./);
+ assert.doesNotMatch(submit.args.prompt, /hand-over brief this conversation started with/, 'Codex already read the brief');
+ });
+
+ await check('a runner too old to switch ran Claude Code: the turn says so, and the session stays with Claude Code', async () => {
+ await endTurn(taskId, { agent: 'claude-code', session: 'sess_claude_2' });
+ const m = (await messagesOf(taskId))[2];
+ assert.strictEqual(m.agent, 'claude-code');
+ assert.match(m.switch_note, /Update OpenVibe Node/);
+ report(10, 30);
+ await refresh();
+ await post(`/api/v1/conductor/${taskId}/messages`, { text: 'status?' });
+ assert.strictEqual(calls.at(-1).args.resume, 'sess_claude_2');
+ });
+
+ await check('the pages say it: the conductor\'s usage card and the Settings section', async () => {
+ const page = await t.get(`/tasks/${taskId}`, { as: ada });
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /<h3>Usage<\/h3>/, 'the side panel says the usage');
+ assert.match(page.text, /fallback 1 · 30% of week/);
+ assert.match(page.text, /class="ovt-divider"><span>.*Claude Code is at 93% of its week/, 'the switch is a line in the thread');
+ const settings = await t.get('/settings', { as: ada });
+ assert.match(settings.text, /id="conductor"/);
+ assert.match(settings.text, /<option value="codex" selected data-state="ok">Codex — has room, 30% used<\/option>/);
+ assert.match(settings.text, /name="fallback_model_1" value="gpt-5\.5"/);
+ });
+ } finally {
+ await t.close();
+ }
+
+ await check('the harness: a conductor turn on Codex or OpenCode, with the Codes tools in a launcher', async () => {
+ const base = { kind: 'conductor', folder: '/home/ada/work', prompt: 'go', task_id: 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2F1', mcp: true, codes_url: 'https://codes.test' };
+ assert.strictEqual(job.parseArgs(JSON.stringify({ ...base, agent: 'codex', safety: 'ask' }), {}).agent, 'codex');
+ assert.strictEqual(job.parseArgs(JSON.stringify({ ...base, agent: 'codex', safety: 'ask' }), {}).permission, 'edit');
+ assert.strictEqual(job.parseArgs(JSON.stringify({ ...base, agent: 'opencode', safety: 'full' }), {}).permission, 'full');
+ assert.strictEqual(job.parseArgs(JSON.stringify({ ...base, agent: 'claude-code', safety: 'full' }), {}).permission, 'edit', 'Claude Code keeps its own permission modes');
+ assert.strictEqual(job.parseArgs(JSON.stringify({ ...base, agent: 'aider' }), {}).agent, 'claude-code');
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-codes-failover-'));
+ try {
+ const parsed = job.parseArgs(JSON.stringify({ ...base, agent: 'codex' }), {});
+ const server = job.conductorMcpLauncher(path.join(dir, 'codes-mcp.sh'), parsed, { OPENVIBE_TOKEN: "tok'en" });
+ assert.strictEqual(server.command, '/bin/sh');
+ const st = fs.statSync(server.args[0]);
+ assert.strictEqual(st.mode & 0o077, 0, 'only the job may read the launcher');
+ const text = fs.readFileSync(server.args[0], 'utf8');
+ assert.match(text, /export OPENVIBE_TOKEN='tok'\\''en'/, 'the token is quoted for the shell');
+ assert.match(text, /export OPENVIBE_CONDUCTOR_TASK='tsk_/);
+ assert.match(text, /exec '.+' '.+openvibe-codes\.js' mcp/);
+ const oc = JSON.parse(fs.readFileSync(job.opencodeConfig(path.join(dir, 'opencode.json'), server), 'utf8'));
+ assert.deepStrictEqual(oc.mcp.codes, { type: 'local', command: ['/bin/sh', server.args[0]], enabled: true });
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -18,7 +18,7 @@ const BUDGETS = {
@@ -18,7 +18,7 @@ const BUDGETS = {
jsRawKB: 351, // 349.9 with openvibe-shared 4.106.2 (2026-11-02: the site's name reads whole on a signed-in phone; the balances stack); 348.7 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 345.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 342.2 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 341.4 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 340.6 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 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)
jsRawKB: 351, // 349.9 with openvibe-shared 4.106.2 (2026-11-02: the site's name reads whole on a signed-in phone; the balances stack); 348.7 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 345.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 342.2 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 341.4 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 340.6 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 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: 85.2, // 84.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 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)
jsBrotliKB: 85.2, // 84.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 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: 74, // 73.2 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 72.2 with 2026-11-01: phone table cards (2026-11-01); 70.7 after the front door: a visitor's example conversation, its hand-over line and result row (public/css/c4.css, 1.4 KB, 2026-10-31); 69.3 after the Memory M2b review: the quiet Remove link, the phone sources fold, the in-place note menu and the danger buttons (public/css/c4.css, 2.2 KB); 67.1 after Memory M2b: the Memory page's sources, notes, note menus, conflicts and tabs (public/css/c4.css, 2.4 KB); 63.0 after C9c: the fleet's rows and its meters on the Agents page (public/css/c4.css, 1.2 KB); 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.
+ cssRawKB: 75, // 74.7 after the conductor failover: the fallback rows in Settings, the usage card, a switched turn (public/css/c4.css, 1.3 KB, 2026-11-03); 73.2 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 72.2 with 2026-11-01: phone table cards (2026-11-01); 70.7 after the front door: a visitor's example conversation, its hand-over line and result row (public/css/c4.css, 1.4 KB, 2026-10-31); 69.3 after the Memory M2b review: the quiet Remove link, the phone sources fold, the in-place note menu and the danger buttons (public/css/c4.css, 2.2 KB); 67.1 after Memory M2b: the Memory page's sources, notes, note menus, conflicts and tabs (public/css/c4.css, 2.4 KB); 63.0 after C9c: the fleet's rows and its meters on the Agents page (public/css/c4.css, 1.2 KB); 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: 15.5, // 15.1 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 14.2 after the Memory M2b review (the same quiet link, sources fold, in-place menu and danger buttons); 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.
cssBrotliKB: 15.5, // 15.1 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 14.2 after the Memory M2b review (the same quiet link, sources fold, in-place menu and danger buttons); 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
};
};