openvibers/openvibe.codes Commits
Codes shows the agents' session: every step's command, change and output (live while it runs), helpers nested, hand-offs and the plan, on the task page and after the end (job lines 1.1, the result keeps the log); the API loop streams command output; openvibe-shared v4.24.0; harness 0.4.0 (#132)
Changes
@@@ -20,7 +20,7 @@ One place to send a coding task to the agent that fits it — by what the task n| The harness | `openvibe-codes` ([bin/openvibe-codes.js](bin/openvibe-codes.js), [harness/](harness/)) | Runs on a person's own machine with their CLIs and keys; nothing goes through OpenVibe. See **Use the harness** below. || Home | `/` | What the harness is, the agents in the catalog, how a task finds its agent, Improve OpenVibe, and an honest table of what works today and what comes next. || Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. |--| Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). |++| Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). The task page shows the agents' **session** with openvibe-shared/agent-session.js: steps grouped, each step's command, change and output (live while it runs), helpers nested, hand-offs and the plan; after the end, the session the result kept (job lines 1.1, `log`). || The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. || Task API (services) | `POST /api/v1/tasks`, `GET /api/v1/tasks/:id`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404). The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. || Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). |@@@ -35,7 +35,7 @@ Everything is server-rendered and usable without JavaScript.## Use the harness```bash--npm install -g https://codeload.github.com/OpenVibers/OpenVibe.Codes/tar.gz/refs/tags/v0.3.0 # Node.js 22+++npm install -g https://codeload.github.com/OpenVibers/OpenVibe.Codes/tar.gz/refs/tags/v0.4.0 # Node.js 22+openvibe-codes agents # what this machine can run, and why not the restopenvibe-codes run "rename getUser to findUser" # routed, handed off on failure, keptopenvibe-codes run --agent codex --permission read "where is the session cookie set?"@@@ -9,11 +9,11 @@"domain": "openvibe.codes","contracts": "openvibe-contracts v0.222.0","sdk": "openvibe-sdk v0.67.0",-- "shared": "openvibe-shared v4.23.0",++ "shared": "openvibe-shared v4.24.0","packages": ["openvibe-contracts v0.222.0","openvibe-sdk v0.67.0",-- "openvibe-shared v4.23.0"++ "openvibe-shared v4.24.0"],"summary": "Public at https://openvibe.codes (sudo ovhost deploy codes; unit openvibe-codes on 127.0.0.1:4900). Since 2026-10-08 Codes is the open coding-agent harness: openvibe-codes (a command-line tool and Node.js module, v0.2.0) runs a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or a person's own OpenAI-compatible model on their machine, hands it to the next agent when one fails or goes quiet, and keeps every run; the site carries the agent catalog with a public routing API, getting started, Improve OpenVibe and the community documents. The developer console that used to live here moved to OpenVibe.Services (https://openvibe.services) and every one of its addresses answers a permanent redirect there.","works": [@@@ -125,12 +125,12 @@ function runTool(name, args, cwd) {return [`unknown tool ${name}`, true];}--/** run_command, asynchronously (the only tool that waits on something else). */--function runCommand(command, cwd, signal) {++/** run_command, asynchronously (the only tool that waits on something else); onChunk(text) hears its output as it comes. */++function runCommand(command, cwd, signal, onChunk) {return new Promise((resolve) => {const child = spawn('bash', ['-lc', String(command)], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });let out = '';-- const add = (d) => { out = (out + d.toString()).slice(-16_000); };++ const add = (d) => { out = (out + d.toString()).slice(-16_000); if (onChunk) { try { onChunk(d.toString()); } catch { /* a listener never stops the command */ } } };child.stdout.on('data', add); child.stderr.on('data', add);const timer = setTimeout(() => child.kill('SIGKILL'), 120_000);const abort = () => child.kill('SIGKILL');@@@ -140,6 +140,16 @@ function runCommand(command, cwd, signal) {});}++/** What a step shows of its input: an edit its change and a new file its start (clipped, secrets scrubbed), so a++ * reader sees the diff; everything else as the agent gave it. */++function shownInput(name, args) {++ const cut = (s, n) => ev.clip(ev.scrub(s == null ? '' : s), n);++ if (name === 'edit_file') return { file_path: args.path, old_string: cut(args.old_string, 4000), new_string: cut(args.new_string, 4000) };++ if (name === 'write_file') return { file_path: args.path, content: cut(args.content, 4000) };++ if (name === 'run_command') return { command: cut(args.command, 2000) };++ return args;++}++/*** apiAgent({ id, name, baseUrl, keyEnv, model, priceIn, priceOut, token }) → an adapter.* extra(env) → fields added to every request body (OpenVibe.AI's x_openvibe.provider: the project's own provider).@@@ -206,12 +216,25 @@ function apiAgent(spec) {const name = call.function && call.function.name;let args = {};try { args = JSON.parse((call.function && call.function.arguments) || '{}'); } catch { /* answered as an error below */ }-- yield ev.toolUse(call.id, SHOWN_AS[name] || name, name === 'edit_file' || name === 'write_file' ? { file_path: args.path } : args, model);++ yield ev.toolUse(call.id, SHOWN_AS[name] || name, shownInput(name, args), model);let out;const def = TOOL_DEFS[name];if (!def) out = [`unknown tool ${name}`, true];else if (LEVELS[def.level] > level) out = [`${name} needs the ${def.level} permission level`, true];-- else if (name === 'run_command') out = await runCommand(args.command, t.cwd, controller.signal);++ else if (name === 'run_command') {++ // The command's output reaches the stream as it comes (batched every 150 ms), so a++ // reader sees a long test run move instead of waiting for its end.++ const chunks = []; let wake = null; let finished = null;++ const nudge = () => { if (wake) { const w = wake; wake = null; w(); } };++ runCommand(args.command, t.cwd, controller.signal, (d) => { chunks.push(d); nudge(); }).then((r) => { finished = r; nudge(); });++ while (!finished) {++ await new Promise((r) => { wake = r; if (finished || chunks.length) nudge(); });++ if (!finished) await new Promise((r) => setTimeout(r, 150));++ if (chunks.length) yield ev.toolProgress(call.id, chunks.splice(0).join(''));++ }++ if (chunks.length) yield ev.toolProgress(call.id, chunks.splice(0).join(''));++ out = finished;++ }else { try { out = runTool(name, args, t.cwd); } catch (err) { out = [err.message, true]; } }yield ev.toolResult(call.id, out[0], out[1]);messages.push({ role: 'tool', tool_call_id: call.id, content: String(out[0]).slice(0, 16_000) });@@@ -10,6 +10,8 @@* usage? } } what the agent said or called* { type: 'user', message: { content: [ { type: 'tool_result', tool_use_id, content, is_error } ] } } a tool's answer* { type: 'system', subtype: 'usage' | 'warning' | 'error' | 'stderr' | 'handoff', ... } along the way++ * { type: 'system', subtype: 'tool_progress', tool_use_id, text } a step's output while it runs (the agents whose++ * steps the harness runs itself: the API loop)* { type: 'result', subtype: 'success' | 'error', is_error, result, session_id, model, duration_ms, num_turns,* total_cost_usd, usage: { input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens } }* the end: exactly one per agent run@@@ -39,6 +41,7 @@ function scrub(s) {const init = (sessionId, agent, model) => ({ type: 'system', subtype: 'init', session_id: sessionId || null, agent, model: model || null });const text = (t, model, usage) => ({ type: 'assistant', message: { model: model || null, content: [{ type: 'text', text: scrub(t) }], ...(usage ? { usage } : {}) } });const toolUse = (id, name, input, model) => ({ type: 'assistant', message: { model: model || null, content: [{ type: 'tool_use', id: String(id), name, input: input || {} }] } });++const toolProgress = (id, chunk) => ({ type: 'system', subtype: 'tool_progress', tool_use_id: String(id), text: clip(scrub(chunk), 4000) });const toolResult = (id, content, isError) => ({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: String(id), content: clip(scrub(content), 4000), is_error: !!isError }] } });const usageEvent = (agent, usage) => ({ type: 'system', subtype: 'usage', agent, usage });const warning = (t) => ({ type: 'system', subtype: 'warning', text: clip(scrub(t), 500) });@@@ -85,4 +88,4 @@ function createTally() {return { add, get: () => t };}--module.exports = { init, text, toolUse, toolResult, usageEvent, warning, error, stderr, usage, result, createTally, clip, scrub };++module.exports = { init, text, toolUse, toolProgress, toolResult, usageEvent, warning, error, stderr, usage, result, createTally, clip, scrub };@@@ -18,10 +18,13 @@ const os = require('os');const path = require('path');const { execFile } = require('child_process');--const JOB_PROTOCOL = '1.0.0';++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 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;const MAX_TEXT = 2_000;++const MAX_OUTPUT = 4_000; // a step's output on a line (live)++const MAX_LOG = 240_000; // the session log kept in the result (its outputs cut shorter when it would not fit)const BRANCH = /^[A-Za-z0-9._/-]{1,100}$/;/** git with no prompt and no credential helper; rejects with the command's stderr (tokens scrubbed). */@@@ -88,30 +91,95 @@ function parseArgs(raw, env = process.env) {};}--/** One harness event as a short line the page can show (long text cut, tool inputs reduced to what they touch). */++/** A step's input as a reader needs it: what it ran or touched, an edit's change, the plan; every string cut and++ * scrubbed of credentials, everything else left out. */++function stepInput(input) {++ const i = input && typeof input === 'object' ? input : {};++ const str = (v, n) => (typeof v === 'string' ? clean(cutTo(v, n)) : undefined);++ const out = {};++ const put = (k, v) => { if (v !== undefined && v !== '') out[k] = v; };++ put('command', str(i.command || i.cmd, 2000));++ put('description', str(i.description, 300));++ put('file_path', str(i.file_path || i.path || i.notebook_path, 300));++ put('pattern', str(i.pattern, 300));++ put('glob', str(i.glob, 200));++ put('query', str(i.query, 300));++ put('url', str(i.url, 500));++ put('old_string', str(i.old_string, MAX_OUTPUT));++ put('new_string', str(i.new_string, MAX_OUTPUT));++ put('content', str(i.content, MAX_OUTPUT));++ put('prompt', str(i.prompt, 1600));++ put('subagent_type', str(i.subagent_type, 60));++ if (Array.isArray(i.edits)) out.edits = i.edits.slice(0, 20).map((e) => ({ old_string: str(e && e.old_string, 2000) || '', new_string: str(e && e.new_string, 2000) || '' }));++ if (Array.isArray(i.todos)) out.todos = i.todos.slice(0, 30).map((t) => ({ content: str(t && t.content, 200) || '', status: str(t && t.status, 20) || 'pending', activeForm: str(t && t.activeForm, 200) }));++ return out;++}++// Every string a line carries goes through both scrubbers: the events one (provider keys, tokens, JWTs, private keys,++// key=… pairs) and this file's (credentials in URLs, GitHub tokens): CLI agents' output reaches here unscrubbed.++const clean = (s) => scrub(require('./events').scrub(s));++const cutTo = (s, n) => { const t = String(s == null ? '' : s); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };++const resultText = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map((x) => (x && typeof x.text === 'string' ? x.text : '')).join('\n') : c == null ? '' : String(c));++++/**++ * One harness event as the lines a reader shows (job protocol 1.1, openvibe-shared/agent-session.js reads them):++ * agent, text, tool (with its id and input), tool_progress, tool_result (with its output), handoff, note, usage, done.++ * A line from a helper agent carries `parent`, the id of the Task step that started it. 1.0 readers keep working: the++ * fields they read are where they were.++ */function compact(e) {-- const cut = (s) => { const t = String(s || ''); return t.length > MAX_TEXT ? `${t.slice(0, MAX_TEXT)}…` : t; };++ const cut = (s) => cutTo(s, MAX_TEXT);++ const parent = e.parent_tool_use_id ? { parent: String(e.parent_tool_use_id) } : {};if (e.type === 'system' && e.subtype === 'init') return { t: 'agent', agent: e.agent, model: e.model || null };-- if (e.type === 'system' && e.subtype === 'handoff') return { t: 'handoff', from: e.from, reason: cut(e.reason) };-- if (e.type === 'system' && (e.subtype === 'error' || e.subtype === 'warning')) return { t: 'note', text: cut(e.text) };++ if (e.type === 'system' && e.subtype === 'handoff') return { t: 'handoff', from: e.from, ...(e.to ? { to: e.to } : {}), reason: cut(e.reason) };++ if (e.type === 'system' && (e.subtype === 'error' || e.subtype === 'warning')) return { t: 'note', level: e.subtype, text: cut(e.text) };++ if (e.type === 'system' && e.subtype === 'tool_progress') return { t: 'tool_progress', id: String(e.tool_use_id || ''), chunk: clean(cutTo(e.text, MAX_OUTPUT)) };++ if (e.type === 'system' && e.subtype === 'usage' && e.usage) return { t: 'usage', input_tokens: e.usage.input_tokens || 0, output_tokens: e.usage.output_tokens || 0 };if (e.type === 'assistant') {const out = [];for (const c of (e.message && e.message.content) || []) {-- if (c.type === 'text' && c.text) out.push({ t: 'text', text: cut(c.text) });++ if (c.type === 'text' && c.text) out.push({ t: 'text', text: clean(cut(c.text)), ...parent });if (c.type === 'tool_use') {const i = c.input || {};const what = i.command || i.file_path || i.path || i.pattern || i.query || i.description || '';-- out.push({ t: 'tool', name: String(c.name || ''), what: cut(String(what).split('\n')[0]).slice(0, 200) });++ out.push({ t: 'tool', id: String(c.id || ''), name: String(c.name || ''), what: clean(cut(String(what).split('\n')[0]).slice(0, 200)), input: stepInput(i), ...parent });}}return out;}++ if (e.type === 'user' && e.message && Array.isArray(e.message.content)) {++ return e.message.content.filter((c) => c && c.type === 'tool_result')++ .map((c) => ({ t: 'tool_result', id: String(c.tool_use_id || ''), ok: !c.is_error, output: clean(cutTo(resultText(c.content), MAX_OUTPUT)), ...parent }));++ }if (e.type === 'result') {-- return { t: 'done', ok: !e.is_error, text: cut(e.result), duration_ms: e.duration_ms || 0, cost_usd: e.total_cost_usd || 0 };++ return { t: 'done', ok: !e.is_error, text: cut(e.result), duration_ms: e.duration_ms || 0, cost_usd: e.total_cost_usd || 0, turns: e.num_turns || 0 };}return null;}++/**++ * The session log a result keeps: every line, in order, up to MAX_LOG bytes. When it would not fit, outputs and++ * progress are cut shorter first (progress is dropped where its step's result has the output), so the steps and what++ * the agents said survive whole.++ */++function sessionLog(lines) {++ const size = (l) => JSON.stringify(l).length + 1;++ let list = lines.slice();++ let total = list.reduce((n, l) => n + size(l), 0);++ if (total <= MAX_LOG) return list;++ const ended = new Set(list.filter((l) => l.t === 'tool_result').map((l) => l.id));++ list = list.filter((l) => !(l.t === 'tool_progress' && ended.has(l.id)));++ for (const n of [1500, 600, 200]) {++ total = list.reduce((m, l) => m + size(l), 0);++ if (total <= MAX_LOG) return list;++ list = list.map((l) => (l.t === 'tool_result' || l.t === 'tool_progress') ? { ...l, [l.t === 'tool_result' ? 'output' : 'chunk']: cutTo(l.output || l.chunk, n) } : l.t === 'tool' && l.input && (l.input.content || l.input.old_string) ? { ...l, input: { ...l.input, content: l.input.content && cutTo(l.input.content, n), old_string: l.input.old_string && cutTo(l.input.old_string, n), new_string: l.input.new_string && cutTo(l.input.new_string, n) } } : l);++ }++ const kept = [];++ total = 0;++ for (const l of list) { total += size(l); if (total > MAX_LOG) break; kept.push(l); }++ kept.push({ t: 'note', level: 'warning', text: 'The rest of this session was too long to keep.' });++ return kept;++}++/** What changed: per-file counts and the patch (new files included), cut to fit the result channel. */async function changes(dir) {await git(['add', '-A'], { cwd: dir });@@@ -175,7 +243,8 @@ async function pushBranch(dir, job, { env, fetchImpl, base }) {*/async function runJob({ input, harness, emit, writeResult, env = process.env, tmp = os.tmpdir(), fetchImpl = globalThis.fetch }) {const started = Date.now();-- const say = (doc) => emit(JSON.stringify(doc));++ const log = [];++ const say = (doc) => { if (doc && doc.t && doc.t !== 'result_doc') log.push(doc); emit(JSON.stringify(doc)); };let job;try {job = parseArgs(input, env);@@@ -226,6 +295,8 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tmresult.duration_ms = Date.now() - started;fs.rmSync(dir, { recursive: true, force: true });}++ result.lines = LINES;++ result.log = sessionLog(log);writeResult(result);return result;}@@@ -242,4 +313,4 @@ function resultWriter(emit) {};}--module.exports = { runJob, parseArgs, compact, resultWriter, scrub, githubRepo, pushTarget, JOB_PROTOCOL };++module.exports = { runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, JOB_PROTOCOL };@@@ -17,7 +17,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.23.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.24.0","pg": "^8.23.0"},"bin": {@@@ -918,9 +918,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.23.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.23.0",-- "integrity": "sha512-oLcUXmHOOC2CCpAuwCN95TIoadukLG5lC6YHPhPbYKcS/rZh0iwLbEA0aAzYHjalKGrqu9mw896LcFc3cd5xWg==",++ "version": "4.24.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.24.0",++ "integrity": "sha512-EJyEPg21pRGmHyMmIlpDb2tvgHwF2eAuEtKTj/eOPj0/LvZLvdQAVS5ge3wBsJgt/Ttz1rhobsTBY0ByTEi8zA==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -1,6 +1,6 @@{"name": "openvibe-codes",-- "version": "0.3.1",++ "version": "0.4.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",@@@ -32,7 +32,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.23.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.24.0","pg": "^8.23.0"},"engines": {@@@ -1,5 +1,8 @@/** Run on your machine (server/http/run.js): the live lines of a running task and the colours of a finished patch.++ * With openvibe-shared/agent-session.js on the page (the task page loads it), the lines become the agents' session:++ * steps grouped, each step's command, change and output (live), helpers, hand-offs and the plan; a finished task's++ * page shows the session its result kept. Without it, the plain list below.* The lines come from the task's own event stream (/run/tasks/:id/events, Run's job stream through Codes): `output`* events carry the job's stdout, one JSON object per line (harness/job.js), `state` the job's state, `end` its end.* Everything is put on the page as text, never as HTML.@@@ -37,6 +40,13 @@var pill = document.querySelector('[data-state]');var buffer = '';if (!log || !window.EventSource) return;++ var view = null;++ if (window.OpenVibeAgentSession) {++ var host = document.createElement('div');++ log.parentNode.replaceChild(host, log);++ view = window.OpenVibeAgentSession.mount(host, { title: section.getAttribute('data-title') || 'Task', status: 'queued', empty: 'Waiting for your machine to take the task…' });++ log = null;++ }var source = new EventSource(section.getAttribute('data-events'));source.addEventListener('output', function (e) {var doc;@@@ -46,13 +56,17 @@buffer = parts.pop();parts.forEach(function (raw) {if (!raw.trim()) return;-- try { show(log, JSON.parse(raw)); } catch (err) { line(log, 'tool', raw); }++ var doc;++ try { doc = JSON.parse(raw); } catch (err) { doc = null; }++ if (view) { if (doc) view.push(doc); return; }++ if (doc) show(log, doc); else line(log, 'tool', raw);});});source.addEventListener('state', function (e) {try {var s = JSON.parse(e.data).state;if (pill && LABEL[s]) { pill.textContent = LABEL[s]; pill.className = 'run-pill run-pill-' + s; }++ if (view) view.status(s, LABEL[s]);} catch (err) { /* ignore */ }});source.addEventListener('end', function () {@@@ -79,6 +93,20 @@});}++ /** A finished task: the session its result kept (lines 1.1), read whole. */++ function replay(section) {++ var data = section.querySelector('[data-session-log]');++ var into = section.querySelector('[data-session-view]');++ if (!data || !into || !window.OpenVibeAgentSession) { section.hidden = true; return; }++ var lines;++ try { lines = JSON.parse(data.textContent); } catch (err) { section.hidden = true; return; }++ var state = section.getAttribute('data-state');++ var view = window.OpenVibeAgentSession.mount(into, { title: section.getAttribute('data-title') || 'Task', status: 'running', follow: false });++ view.pushAll(lines);++ view.status(state, LABEL[state]);++ }++document.querySelectorAll('[data-events]').forEach(follow);++ document.querySelectorAll('[data-session]').forEach(replay);document.querySelectorAll('[data-patch]').forEach(colour);})();@@@ -55,6 +55,20 @@ function presentResult(d) {});}++// The job lines a session log may hold (harness/job.js, lines 1.1; openvibe-shared/agent-session.js reads them).++const LINE_KINDS = new Set(['agent', 'text', 'tool', 'tool_progress', 'tool_result', 'handoff', 'note', 'usage', 'done', 'step']);++/**++ * The session a finished task's result keeps (`log`), for its page: the lines whose kind is known, at most 4000, each a++ * plain object no larger than 20 KB. Not part of codes.task@1 (the API passes the result's contract fields only).++ */++function sessionLogOf(row) {++ let d = row && row.result;++ if (typeof d === 'string') { try { d = JSON.parse(d); } catch { return null; } }++ if (!d || !Array.isArray(d.log)) return null;++ const lines = d.log.filter((l) => l && typeof l === 'object' && !Array.isArray(l) && LINE_KINDS.has(l.t) && JSON.stringify(l).length <= 20000).slice(0, 4000);++ return lines.length ? lines : null;++}++function createRunTasks({ store, run, config, log = console }) {const db = store.db;const who = (row) => ({ subject: row.subject, project: row.project_id });@@@ -103,4 +117,4 @@ function createRunTasks({ store, run, config, log = console }) {return { start, refresh, latestProject, present, who };}--module.exports = { createRunTasks, presentResult, TERMINAL, STATE_LABEL };++module.exports = { createRunTasks, presentResult, TERMINAL, STATE_LABEL, sessionLogOf };@@@ -18,7 +18,7 @@ const { send } = require('../render/layout');const { html, raw, esc } = require('../render/html');const { parseArgs } = require('../../harness/job');--const { createRunTasks, TERMINAL, STATE_LABEL } = require('../domain/run-tasks');++const { createRunTasks, TERMINAL, STATE_LABEL, sessionLogOf } = require('../domain/run-tasks');const DELEGABLE = new Set(['owner', 'admin']);const MACHINES_URL = 'https://openvibe.bot/machines';@@@ -158,7 +158,9 @@ ${tasksHtml(recent)}`;return res.redirect(303, `/run/tasks/${row.id}`);});-- function resultHtml(row) {++ /** The end of a task: the verdict, what the agents said, the files and the patch. With the session on the page (`session`),++ * its closing card already says how it ended and the agents' last words, so only a failure keeps its notice. */++ function resultHtml(row, { session = false } = {}) {const d = row.result || {};const files = Array.isArray(d.files) ? d.files : [];const push = d.push || null;@@@ -166,8 +168,9 @@ ${tasksHtml(recent)}`;? html`<p class="run-notice run-notice-error">${row.state === 'expired' ? 'No machine of yours took the task in time. Is it online on openvibe.bot/machines, with the Codes job runner?' : row.error || STATE_LABEL[row.state]}</p>`: d.ok ? html`<p class="run-notice run-notice-ok">The agents finished the task${d.agents && d.agents.length ? ` (${d.agents.join(' → ')})` : ''}.</p>`: html`<p class="run-notice run-notice-error">The task ended without success${d.error ? `: ${d.error}` : ''}.</p>`;-- return html`<section class="run-result">${verdict}--${d.summary ? html`<h2>What the agent said</h2><pre tabindex="0" class="run-summary">${d.summary}</pre>` : ''}++ const ended = !(session && row.state === 'succeeded' && d.ok);++ return html`<section class="run-result">${ended ? verdict : ''}++${d.summary && !session ? html`<h2>What the agent said</h2><pre tabindex="0" class="run-summary">${d.summary}</pre>` : ''}${push ? (push.pushed ? html`<p>Pushed <a href="${push.branch_url}">${push.branch}</a>${push.pull_request ? html` · <a href="${push.pull_request}">pull request</a>` : ''}${push.pull_request_error ? html` · the pull request was not opened (${push.pull_request_error})` : ''}</p>` : html`<p class="muted">Not pushed: ${push.reason}</p>`) : ''}${files.length ? html`<h2>Changed files</h2><table class="run-files"><tbody>${files.map((f) => html`<tr><td><code>${f.path}</code></td><td class="add">+${f.added == null ? '·' : f.added}</td><td class="del">−${f.removed == null ? '·' : f.removed}</td></tr>`)}</tbody></table>` : (row.state === 'succeeded' ? html`<p class="muted">No files changed.</p>` : '')}${d.patch ? html`<h2>The patch${d.patch_truncated ? ' (cut)' : ''}</h2><pre tabindex="0" class="run-patch" data-patch>${d.patch}</pre>` : ''}@@@ -181,13 +184,18 @@ ${typeof d.duration_ms === 'number' ? html`<p class="muted">${Math.round(d.duratif (!row) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>No such task of yours. <a href="/run">Your tasks</a></p>` }, 404);row = await refresh(row);const live = !TERMINAL.has(row.state);++ // The agents' session (openvibe-shared/agent-session.js, js/run.js mounts it): live from the job's stream, and++ // after the end from the log the result keeps (a machine on an older Codes keeps none: the summary stands).++ const lines = live ? null : sessionLogOf(row);++ const title = String(row.prompt || '').split('\n')[0].slice(0, 90);++ const session = lines ? html`<section class="run-session" data-session data-title="${title}" data-state="${row.state}"><h2>The session</h2><div data-session-view></div><script type="application/json" data-session-log>${raw(JSON.stringify(lines).replace(/</g, '\\u003c'))}</script></section>` : '';const body = html`<p class="crumbs"><a href="/run">Run on your machine</a></p><h1 class="run-repo">${repoName(row.repo)}${row.ref ? html` <small>${row.ref}</small>` : ''}</h1><p><span class="run-pill run-pill-${row.state}" data-state>${STATE_LABEL[row.state] || row.state}</span>${row.agent ? html` <span class="run-pill">${row.agent}</span>` : ''}</p><pre tabindex="0" class="run-prompt">${row.prompt}</pre>--${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events" aria-live="polite"><h2>Live</h2><ol class="run-log" data-log></ol></section>--<form method="post" action="/run/tasks/${row.id}/cancel"><button class="run-btn" type="submit">Cancel</button></form>` : resultHtml(row)}`;-- return page(req, res, { title: 'Task', body });++${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events" data-title="${title}" aria-live="polite"><h2>Live</h2><ol class="run-log" data-log></ol></section>++<form method="post" action="/run/tasks/${row.id}/cancel"><button class="run-btn" type="submit">Cancel</button></form>` : html`${session}${resultHtml(row, { session: !!lines })}`}`;++ return page(req, res, { title: 'Task', body, sharedScripts: ['agent-session.js'] });});r.get('/run/tasks/:id/events', async (req, res) => {@@@ -45,7 +45,7 @@ let RELEASE = 'dev';function setRelease(id) { if (id) RELEASE = String(id); }/**-- * o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],++ * o: title, description, body (html), viewer, config, path, index (default false), scripts [rel], sharedScripts [openvibe-shared file names],* crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names],* css [this site's own extra stylesheets, rel to public/, for the pages that need them]*/@@@ -77,7 +77,7 @@ function renderPage(o) {const crumbs = (o.crumbs || []).length? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`: '';-- const scripts = (o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`);++ const scripts = (o.sharedScripts || []).map((name) => html`<script src="${ovServe.url(name)}" defer></script>`).concat((o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`));return shell.page({name: SITE_NAME,lang: 'en',@@@ -161,6 +161,21 @@ const toolResults = (events) => events.filter((e) => e.type === 'user').flatMap(assert.ok(toolResults(events).some((c) => c.content.trim() === fs.realpathSync(work)));});++ // A command's output reaches the stream while it runs (tool_progress), before its result: a long test run is seen++ // moving (the owner, 2026-10-28: the Claude app said "Output unavailable" for a running command).++ await check('a running command\'s output streams before its result; edits carry their change', async () => {++ api.set([{ calls: [['run_command', { command: 'echo first; sleep 0.4; echo second' }], ['write_file', { path: 'note.txt', content: 'hello' }]] }, { content: 'ok' }]);++ const events = await collect(h.run({ prompt: 'run it', agent: 'openai-compatible', cwd: work, permission: 'full', handoff: false }));++ const progress = events.filter((e) => e.type === 'system' && e.subtype === 'tool_progress');++ const resultAt = events.findIndex((e) => e.type === 'user' && e.message.content.some((c) => /second/.test(c.content)));++ assert.ok(progress.length >= 1 && /first/.test(progress[0].text), JSON.stringify(progress));++ assert.ok(events.indexOf(progress[0]) < resultAt, 'the first output came before the command ended');++ assert.ok(progress.every((p) => p.tool_use_id === progress[0].tool_use_id), 'every chunk names its step');++ const write = events.find((e) => e.type === 'assistant' && e.message.content.some((c) => c.type === 'tool_use' && c.name !== 'Bash' && /note\.txt/.test(JSON.stringify(c.input))));++ const input = write.message.content.find((c) => c.type === 'tool_use').input;++ assert.deepStrictEqual([input.file_path, input.content], ['note.txt', 'hello'], 'a new file shows what it holds');++ });++await check('an HTTP error ends the run with the server\'s message', async () => {api.set([{ status: 429, error: 'rate limited, slow down' }]);const events = await collect(h.run({ prompt: 'x', agent: 'openai-compatible', cwd: work, handoff: false }));@@@ -11,7 +11,7 @@ const fs = require('fs');const os = require('os');const path = require('path');const { execFileSync, spawnSync } = require('child_process');--const { runJob, parseArgs, compact, scrub, githubRepo, JOB_PROTOCOL } = require('../harness/job');++const { runJob, parseArgs, compact, sessionLog, scrub, githubRepo, JOB_PROTOCOL } = require('../harness/job');const { check, done } = require('./helpers/boot');const sh = (cwd, ...args) => execFileSync('git', args, { cwd, env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' } });@@@ -125,7 +125,35 @@ function fakeHarness({ fail = false } = {}) {assert.strictEqual(scrub('token ghs_abcdefghijklmnopqrstuvwxyz0123 leaked'), 'token *** leaked');assert.deepStrictEqual(githubRepo('https://github.com/OpenVibers/OpenVibe.Codes.git'), { owner: 'OpenVibers', name: 'OpenVibe.Codes' });assert.strictEqual(githubRepo('https://gitlab.com/a/b'), null);-- assert.deepStrictEqual(compact({ type: 'result', is_error: false, result: 'ok', duration_ms: 5 }), { t: 'done', ok: true, text: 'ok', duration_ms: 5, cost_usd: 0 });++ assert.deepStrictEqual(compact({ type: 'result', is_error: false, result: 'ok', duration_ms: 5 }), { t: 'done', ok: true, text: 'ok', duration_ms: 5, cost_usd: 0, turns: 0 });++ });++++ // Lines 1.1 (openvibe-shared/agent-session.js reads them): a step keeps its id and what a reader needs of its input,++ // a result its output, a helper's line its parent; secrets never pass, in any string.++ await check('lines 1.1: steps with their input, results with their output, helpers nested, secrets scrubbed', () => {++ const [said, step] = compact({ type: 'assistant', parent_tool_use_id: 'T1', message: { content: [{ type: 'text', text: 'key sk-abcdefghijklmnopqrstuv' }, { type: 'tool_use', id: 'b1', name: 'Bash', input: { command: 'curl -H "Authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz0123" https://x', description: 'Call the API', timeout: 5 } }] } });++ assert.deepStrictEqual(said, { t: 'text', text: 'key [redacted]', parent: 'T1' });++ assert.deepStrictEqual([step.t, step.id, step.name, step.parent, Object.keys(step.input).sort()], ['tool', 'b1', 'Bash', 'T1', ['command', 'description']], 'only what a reader needs of the input');++ assert.ok(!/ghp_/.test(JSON.stringify(step)), 'no token in the command');++ const [edit] = compact({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'e1', name: 'Edit', input: { file_path: 'a.js', old_string: 'x', new_string: 'y' } }] } });++ assert.deepStrictEqual(edit.input, { file_path: 'a.js', old_string: 'x', new_string: 'y' }, 'an edit carries its change');++ assert.deepStrictEqual(compact({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'b1', is_error: true, content: [{ type: 'text', text: 'boom ghs_abcdefghijklmnopqrstuvwxyz0123' }] }] } }),++ [{ t: 'tool_result', id: 'b1', ok: false, output: 'boom [redacted]' }], 'a result with its output, scrubbed');++ assert.deepStrictEqual(compact({ type: 'system', subtype: 'tool_progress', tool_use_id: 'b1', text: 'line\n' }), { t: 'tool_progress', id: 'b1', chunk: 'line\n' });++ assert.deepStrictEqual(compact({ type: 'system', subtype: 'handoff', from: 'claude', to: 'codex', reason: 'limit' }), { t: 'handoff', from: 'claude', to: 'codex', reason: 'limit' });++ const todos = compact({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'p', name: 'TodoWrite', input: { todos: [{ content: 'A', status: 'in_progress', activeForm: 'Doing A' }] } }] } })[0];++ assert.deepStrictEqual(todos.input.todos, [{ content: 'A', status: 'in_progress', activeForm: 'Doing A' }], 'the plan travels');++ });++++ await check('the session log a result keeps fits: outputs are cut shorter first, the steps survive', () => {++ const lines = [];++ for (let i = 0; i < 120; i++) { lines.push({ t: 'tool', id: `s${i}`, name: 'Bash', what: 'npm test', input: { command: 'npm test' } }); lines.push({ t: 'tool_progress', id: `s${i}`, chunk: 'p'.repeat(3000) }); lines.push({ t: 'tool_result', id: `s${i}`, ok: true, output: 'o'.repeat(4000) }); }++ const kept = sessionLog(lines);++ assert.ok(JSON.stringify(kept).length <= 250000, 'within the result\'s room');++ assert.strictEqual(kept.filter((l) => l.t === 'tool').length, 120, 'every step is still there');++ assert.ok(!kept.some((l) => l.t === 'tool_progress'), 'progress whose step ended goes first');++ assert.ok(kept.every((l) => l.t !== 'tool_result' || l.output.length <= 1500), 'outputs cut shorter');++ assert.deepStrictEqual(sessionLog([{ t: 'text', text: 'hi' }]), [{ t: 'text', text: 'hi' }], 'a short session is kept as it is');});await check('the command reads its input on stdin and prints its result when fd 3 is not open', () => {@@@ -84,10 +84,20 @@ const { boot, check, done } = require('./helpers/boot');const stream = calls.find((c) => c[0] === 'stream');assert.deepStrictEqual(stream[2], { subject: ada.subject, project });state = 'succeeded';-- result = { protocol: '1.0.0', ok: true, agents: ['claude-code'], summary: 'Fixed <the> typo', files: [{ path: 'README.md', added: 1, removed: 1 }], patch: '@@ -1 +1 @@\n-teh\n+the\n', patch_truncated: false, push: { pushed: true, branch: 'openvibe-codes/x', branch_url: 'https://github.com/OpenVibers/OpenVibe.Codes/tree/openvibe-codes%2Fx', pull_request: 'https://github.com/OpenVibers/OpenVibe.Codes/pull/9' }, duration_ms: 42000 };++ result = { protocol: '1.0.0', lines: '1.1', log: [{ t: 'agent', agent: 'claude-code' }, { t: 'text', text: 'I fix <b>it</b></script>' }, { t: 'tool', id: 'e1', name: 'Edit', input: { file_path: 'README.md', old_string: 'teh', new_string: 'the' } }, { t: 'tool_result', id: 'e1', ok: true, output: 'ok' }, { t: 'mystery', x: 1 }, { t: 'done', ok: true, text: 'Fixed', duration_ms: 900 }], ok: true, agents: ['claude-code'], summary: 'Fixed <the> typo', files: [{ path: 'README.md', added: 1, removed: 1 }], patch: '@@ -1 +1 @@\n-teh\n+the\n', patch_truncated: false, push: { pushed: true, branch: 'openvibe-codes/x', branch_url: 'https://github.com/OpenVibers/OpenVibe.Codes/tree/openvibe-codes%2Fx', pull_request: 'https://github.com/OpenVibers/OpenVibe.Codes/pull/9' }, duration_ms: 42000 };const done_ = await t.get(`/run/tasks/${taskId}`, { as: ada });-- assert.ok(done_.text.includes('The agents finished the task') && done_.text.includes('Fixed <the> typo') && done_.text.includes('README.md'));++ // With the session on the page, its closing card says how it ended and what the agents said: the page does not repeat it.++ assert.ok(!done_.text.includes('The agents finished the task') && !done_.text.includes('What the agent said') && done_.text.includes('README.md'));assert.ok(done_.text.includes('pull/9') && done_.text.includes('data-patch') && !done_.text.includes('data-events'));++ // The session the result kept, for openvibe-shared/agent-session.js: known lines only, and nothing in them can end++ // the page's script early.++ assert.ok(done_.text.includes('data-session') && /<script src="[^"]*agent-session\.js[^"]*" defer>/.test(done_.text), 'the session and the component');++ const kept = JSON.parse(/<script type="application\/json" data-session-log>([\s\S]*?)<\/script>/.exec(done_.text)[1]);++ assert.deepStrictEqual(kept.map((l) => l.t), ['agent', 'text', 'tool', 'tool_result', 'done'], 'lines of an unknown kind are left out');++ assert.ok(!/<\/script>I|<b>it/.test(/data-session-log>([\s\S]*?)<\/script>/.exec(done_.text)[1]), 'no markup inside the data');++ assert.strictEqual(kept[1].text, 'I fix <b>it</b></script>', 'and the text reads back as it was');++ const api = await t.get(`/api/v1/tasks/${taskId}`, { as: ada });++ if (api.status === 200) assert.ok(!('log' in ((api.json && api.json.result) || {})), 'the task API passes codes.task@1\'s fields only');const row = await t.ctx.store.db.one('SELECT state, result FROM run_tasks WHERE id = $1', [taskId]);assert.strictEqual(row.state, 'succeeded');assert.strictEqual(row.result.summary, 'Fixed <the> typo');
@@ -20,7 +20,7 @@ One place to send a coding task to the agent that fits it — by what the task n
@@ -20,7 +20,7 @@ One place to send a coding task to the agent that fits it — by what the task n
| The harness | `openvibe-codes` ([bin/openvibe-codes.js](bin/openvibe-codes.js), [harness/](harness/)) | Runs on a person's own machine with their CLIs and keys; nothing goes through OpenVibe. See **Use the harness** below. |
| The harness | `openvibe-codes` ([bin/openvibe-codes.js](bin/openvibe-codes.js), [harness/](harness/)) | Runs on a person's own machine with their CLIs and keys; nothing goes through OpenVibe. See **Use the harness** below. |
| Home | `/` | What the harness is, the agents in the catalog, how a task finds its agent, Improve OpenVibe, and an honest table of what works today and what comes next. |
| Home | `/` | What the harness is, the agents in the catalog, how a task finds its agent, Improve OpenVibe, and an honest table of what works today and what comes next. |
| Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. |
| Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. |
-| Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). |
+| Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). The task page shows the agents' **session** with openvibe-shared/agent-session.js: steps grouped, each step's command, change and output (live while it runs), helpers nested, hand-offs and the plan; after the end, the session the result kept (job lines 1.1, `log`). |
| The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. |
| The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. |
| Task API (services) | `POST /api/v1/tasks`, `GET /api/v1/tasks/:id`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404). The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. |
| Task API (services) | `POST /api/v1/tasks`, `GET /api/v1/tasks/:id`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404). The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. |
| Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). |
| Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). |
@@ -35,7 +35,7 @@ Everything is server-rendered and usable without JavaScript.
@@ -35,7 +35,7 @@ Everything is server-rendered and usable without JavaScript.
## Use the harness
## Use the harness
```bash
```bash
-npm install -g https://codeload.github.com/OpenVibers/OpenVibe.Codes/tar.gz/refs/tags/v0.3.0 # Node.js 22+
+npm install -g https://codeload.github.com/OpenVibers/OpenVibe.Codes/tar.gz/refs/tags/v0.4.0 # Node.js 22+
openvibe-codes agents # what this machine can run, and why not the rest
openvibe-codes agents # what this machine can run, and why not the rest
openvibe-codes run "rename getUser to findUser" # routed, handed off on failure, kept
openvibe-codes run "rename getUser to findUser" # routed, handed off on failure, kept
openvibe-codes run --agent codex --permission read "where is the session cookie set?"
openvibe-codes run --agent codex --permission read "where is the session cookie set?"
@@ -9,11 +9,11 @@
@@ -9,11 +9,11 @@
"domain": "openvibe.codes",
"domain": "openvibe.codes",
"contracts": "openvibe-contracts v0.222.0",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.67.0",
"sdk": "openvibe-sdk v0.67.0",
- "shared": "openvibe-shared v4.23.0",
+ "shared": "openvibe-shared v4.24.0",
"packages": [
"packages": [
"openvibe-contracts v0.222.0",
"openvibe-contracts v0.222.0",
"openvibe-sdk v0.67.0",
"openvibe-sdk v0.67.0",
- "openvibe-shared v4.23.0"
+ "openvibe-shared v4.24.0"
],
],
"summary": "Public at https://openvibe.codes (sudo ovhost deploy codes; unit openvibe-codes on 127.0.0.1:4900). Since 2026-10-08 Codes is the open coding-agent harness: openvibe-codes (a command-line tool and Node.js module, v0.2.0) runs a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or a person's own OpenAI-compatible model on their machine, hands it to the next agent when one fails or goes quiet, and keeps every run; the site carries the agent catalog with a public routing API, getting started, Improve OpenVibe and the community documents. The developer console that used to live here moved to OpenVibe.Services (https://openvibe.services) and every one of its addresses answers a permanent redirect there.",
"summary": "Public at https://openvibe.codes (sudo ovhost deploy codes; unit openvibe-codes on 127.0.0.1:4900). Since 2026-10-08 Codes is the open coding-agent harness: openvibe-codes (a command-line tool and Node.js module, v0.2.0) runs a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or a person's own OpenAI-compatible model on their machine, hands it to the next agent when one fails or goes quiet, and keeps every run; the site carries the agent catalog with a public routing API, getting started, Improve OpenVibe and the community documents. The developer console that used to live here moved to OpenVibe.Services (https://openvibe.services) and every one of its addresses answers a permanent redirect there.",
"works": [
"works": [
@@ -125,12 +125,12 @@ function runTool(name, args, cwd) {
@@ -125,12 +125,12 @@ function runTool(name, args, cwd) {
return [`unknown tool ${name}`, true];
return [`unknown tool ${name}`, true];
}
}
-/** run_command, asynchronously (the only tool that waits on something else). */
-function runCommand(command, cwd, signal) {
+/** run_command, asynchronously (the only tool that waits on something else); onChunk(text) hears its output as it comes. */
+function runCommand(command, cwd, signal, onChunk) {
return new Promise((resolve) => {
return new Promise((resolve) => {
const child = spawn('bash', ['-lc', String(command)], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
const child = spawn('bash', ['-lc', String(command)], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
let out = '';
let out = '';
- const add = (d) => { out = (out + d.toString()).slice(-16_000); };
+ const add = (d) => { out = (out + d.toString()).slice(-16_000); if (onChunk) { try { onChunk(d.toString()); } catch { /* a listener never stops the command */ } } };
child.stdout.on('data', add); child.stderr.on('data', add);
child.stdout.on('data', add); child.stderr.on('data', add);
const timer = setTimeout(() => child.kill('SIGKILL'), 120_000);
const timer = setTimeout(() => child.kill('SIGKILL'), 120_000);
const abort = () => child.kill('SIGKILL');
const abort = () => child.kill('SIGKILL');
@@ -140,6 +140,16 @@ function runCommand(command, cwd, signal) {
@@ -140,6 +140,16 @@ function runCommand(command, cwd, signal) {
});
});
}
}
+/** What a step shows of its input: an edit its change and a new file its start (clipped, secrets scrubbed), so a
+ * reader sees the diff; everything else as the agent gave it. */
+function shownInput(name, args) {
+ const cut = (s, n) => ev.clip(ev.scrub(s == null ? '' : s), n);
+ if (name === 'edit_file') return { file_path: args.path, old_string: cut(args.old_string, 4000), new_string: cut(args.new_string, 4000) };
+ if (name === 'write_file') return { file_path: args.path, content: cut(args.content, 4000) };
+ if (name === 'run_command') return { command: cut(args.command, 2000) };
+ return args;
+}
+
/**
/**
* apiAgent({ id, name, baseUrl, keyEnv, model, priceIn, priceOut, token }) → an adapter.
* apiAgent({ id, name, baseUrl, keyEnv, model, priceIn, priceOut, token }) → an adapter.
* extra(env) → fields added to every request body (OpenVibe.AI's x_openvibe.provider: the project's own provider).
* extra(env) → fields added to every request body (OpenVibe.AI's x_openvibe.provider: the project's own provider).
@@ -206,12 +216,25 @@ function apiAgent(spec) {
@@ -206,12 +216,25 @@ function apiAgent(spec) {
const name = call.function && call.function.name;
const name = call.function && call.function.name;
let args = {};
let args = {};
try { args = JSON.parse((call.function && call.function.arguments) || '{}'); } catch { /* answered as an error below */ }
try { args = JSON.parse((call.function && call.function.arguments) || '{}'); } catch { /* answered as an error below */ }
- yield ev.toolUse(call.id, SHOWN_AS[name] || name, name === 'edit_file' || name === 'write_file' ? { file_path: args.path } : args, model);
+ yield ev.toolUse(call.id, SHOWN_AS[name] || name, shownInput(name, args), model);
let out;
let out;
const def = TOOL_DEFS[name];
const def = TOOL_DEFS[name];
if (!def) out = [`unknown tool ${name}`, true];
if (!def) out = [`unknown tool ${name}`, true];
else if (LEVELS[def.level] > level) out = [`${name} needs the ${def.level} permission level`, true];
else if (LEVELS[def.level] > level) out = [`${name} needs the ${def.level} permission level`, true];
- else if (name === 'run_command') out = await runCommand(args.command, t.cwd, controller.signal);
+ else if (name === 'run_command') {
+ // The command's output reaches the stream as it comes (batched every 150 ms), so a
+ // reader sees a long test run move instead of waiting for its end.
+ const chunks = []; let wake = null; let finished = null;
+ const nudge = () => { if (wake) { const w = wake; wake = null; w(); } };
+ runCommand(args.command, t.cwd, controller.signal, (d) => { chunks.push(d); nudge(); }).then((r) => { finished = r; nudge(); });
+ while (!finished) {
+ await new Promise((r) => { wake = r; if (finished || chunks.length) nudge(); });
+ if (!finished) await new Promise((r) => setTimeout(r, 150));
+ if (chunks.length) yield ev.toolProgress(call.id, chunks.splice(0).join(''));
+ }
+ if (chunks.length) yield ev.toolProgress(call.id, chunks.splice(0).join(''));
+ out = finished;
+ }
else { try { out = runTool(name, args, t.cwd); } catch (err) { out = [err.message, true]; } }
else { try { out = runTool(name, args, t.cwd); } catch (err) { out = [err.message, true]; } }
yield ev.toolResult(call.id, out[0], out[1]);
yield ev.toolResult(call.id, out[0], out[1]);
messages.push({ role: 'tool', tool_call_id: call.id, content: String(out[0]).slice(0, 16_000) });
messages.push({ role: 'tool', tool_call_id: call.id, content: String(out[0]).slice(0, 16_000) });
@@ -10,6 +10,8 @@
@@ -10,6 +10,8 @@
* usage? } } what the agent said or called
* usage? } } what the agent said or called
* { type: 'user', message: { content: [ { type: 'tool_result', tool_use_id, content, is_error } ] } } a tool's answer
* { type: 'user', message: { content: [ { type: 'tool_result', tool_use_id, content, is_error } ] } } a tool's answer
* { type: 'system', subtype: 'usage' | 'warning' | 'error' | 'stderr' | 'handoff', ... } along the way
* { type: 'system', subtype: 'usage' | 'warning' | 'error' | 'stderr' | 'handoff', ... } along the way
+ * { type: 'system', subtype: 'tool_progress', tool_use_id, text } a step's output while it runs (the agents whose
+ * steps the harness runs itself: the API loop)
* { type: 'result', subtype: 'success' | 'error', is_error, result, session_id, model, duration_ms, num_turns,
* { type: 'result', subtype: 'success' | 'error', is_error, result, session_id, model, duration_ms, num_turns,
* total_cost_usd, usage: { input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens } }
* total_cost_usd, usage: { input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens } }
* the end: exactly one per agent run
* the end: exactly one per agent run
@@ -39,6 +41,7 @@ function scrub(s) {
@@ -39,6 +41,7 @@ function scrub(s) {
const init = (sessionId, agent, model) => ({ type: 'system', subtype: 'init', session_id: sessionId || null, agent, model: model || null });
const init = (sessionId, agent, model) => ({ type: 'system', subtype: 'init', session_id: sessionId || null, agent, model: model || null });
const text = (t, model, usage) => ({ type: 'assistant', message: { model: model || null, content: [{ type: 'text', text: scrub(t) }], ...(usage ? { usage } : {}) } });
const text = (t, model, usage) => ({ type: 'assistant', message: { model: model || null, content: [{ type: 'text', text: scrub(t) }], ...(usage ? { usage } : {}) } });
const toolUse = (id, name, input, model) => ({ type: 'assistant', message: { model: model || null, content: [{ type: 'tool_use', id: String(id), name, input: input || {} }] } });
const toolUse = (id, name, input, model) => ({ type: 'assistant', message: { model: model || null, content: [{ type: 'tool_use', id: String(id), name, input: input || {} }] } });
+const toolProgress = (id, chunk) => ({ type: 'system', subtype: 'tool_progress', tool_use_id: String(id), text: clip(scrub(chunk), 4000) });
const toolResult = (id, content, isError) => ({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: String(id), content: clip(scrub(content), 4000), is_error: !!isError }] } });
const toolResult = (id, content, isError) => ({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: String(id), content: clip(scrub(content), 4000), is_error: !!isError }] } });
const usageEvent = (agent, usage) => ({ type: 'system', subtype: 'usage', agent, usage });
const usageEvent = (agent, usage) => ({ type: 'system', subtype: 'usage', agent, usage });
const warning = (t) => ({ type: 'system', subtype: 'warning', text: clip(scrub(t), 500) });
const warning = (t) => ({ type: 'system', subtype: 'warning', text: clip(scrub(t), 500) });
@@ -85,4 +88,4 @@ function createTally() {
@@ -85,4 +88,4 @@ function createTally() {
return { add, get: () => t };
return { add, get: () => t };
}
}
-module.exports = { init, text, toolUse, toolResult, usageEvent, warning, error, stderr, usage, result, createTally, clip, scrub };
+module.exports = { init, text, toolUse, toolProgress, toolResult, usageEvent, warning, error, stderr, usage, result, createTally, clip, scrub };
@@ -18,10 +18,13 @@ const os = require('os');
@@ -18,10 +18,13 @@ const os = require('os');
const path = require('path');
const path = require('path');
const { execFile } = require('child_process');
const { execFile } = require('child_process');
-const JOB_PROTOCOL = '1.0.0';
+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 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;
const MAX_TEXT = 2_000;
const MAX_TEXT = 2_000;
+const MAX_OUTPUT = 4_000; // a step's output on a line (live)
+const MAX_LOG = 240_000; // the session log kept in the result (its outputs cut shorter when it would not fit)
const BRANCH = /^[A-Za-z0-9._/-]{1,100}$/;
const BRANCH = /^[A-Za-z0-9._/-]{1,100}$/;
/** git with no prompt and no credential helper; rejects with the command's stderr (tokens scrubbed). */
/** git with no prompt and no credential helper; rejects with the command's stderr (tokens scrubbed). */
@@ -88,30 +91,95 @@ function parseArgs(raw, env = process.env) {
@@ -88,30 +91,95 @@ function parseArgs(raw, env = process.env) {
};
};
}
}
-/** One harness event as a short line the page can show (long text cut, tool inputs reduced to what they touch). */
+/** A step's input as a reader needs it: what it ran or touched, an edit's change, the plan; every string cut and
+ * scrubbed of credentials, everything else left out. */
+function stepInput(input) {
+ const i = input && typeof input === 'object' ? input : {};
+ const str = (v, n) => (typeof v === 'string' ? clean(cutTo(v, n)) : undefined);
+ const out = {};
+ const put = (k, v) => { if (v !== undefined && v !== '') out[k] = v; };
+ put('command', str(i.command || i.cmd, 2000));
+ put('description', str(i.description, 300));
+ put('file_path', str(i.file_path || i.path || i.notebook_path, 300));
+ put('pattern', str(i.pattern, 300));
+ put('glob', str(i.glob, 200));
+ put('query', str(i.query, 300));
+ put('url', str(i.url, 500));
+ put('old_string', str(i.old_string, MAX_OUTPUT));
+ put('new_string', str(i.new_string, MAX_OUTPUT));
+ put('content', str(i.content, MAX_OUTPUT));
+ put('prompt', str(i.prompt, 1600));
+ put('subagent_type', str(i.subagent_type, 60));
+ if (Array.isArray(i.edits)) out.edits = i.edits.slice(0, 20).map((e) => ({ old_string: str(e && e.old_string, 2000) || '', new_string: str(e && e.new_string, 2000) || '' }));
+ if (Array.isArray(i.todos)) out.todos = i.todos.slice(0, 30).map((t) => ({ content: str(t && t.content, 200) || '', status: str(t && t.status, 20) || 'pending', activeForm: str(t && t.activeForm, 200) }));
+ return out;
+}
+// Every string a line carries goes through both scrubbers: the events one (provider keys, tokens, JWTs, private keys,
+// key=… pairs) and this file's (credentials in URLs, GitHub tokens): CLI agents' output reaches here unscrubbed.
+const clean = (s) => scrub(require('./events').scrub(s));
+const cutTo = (s, n) => { const t = String(s == null ? '' : s); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
+const resultText = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map((x) => (x && typeof x.text === 'string' ? x.text : '')).join('\n') : c == null ? '' : String(c));
+
+/**
+ * One harness event as the lines a reader shows (job protocol 1.1, openvibe-shared/agent-session.js reads them):
+ * agent, text, tool (with its id and input), tool_progress, tool_result (with its output), handoff, note, usage, done.
+ * A line from a helper agent carries `parent`, the id of the Task step that started it. 1.0 readers keep working: the
+ * fields they read are where they were.
+ */
function compact(e) {
function compact(e) {
- const cut = (s) => { const t = String(s || ''); return t.length > MAX_TEXT ? `${t.slice(0, MAX_TEXT)}…` : t; };
+ const cut = (s) => cutTo(s, MAX_TEXT);
+ const parent = e.parent_tool_use_id ? { parent: String(e.parent_tool_use_id) } : {};
if (e.type === 'system' && e.subtype === 'init') return { t: 'agent', agent: e.agent, model: e.model || null };
if (e.type === 'system' && e.subtype === 'init') return { t: 'agent', agent: e.agent, model: e.model || null };
- if (e.type === 'system' && e.subtype === 'handoff') return { t: 'handoff', from: e.from, reason: cut(e.reason) };
- if (e.type === 'system' && (e.subtype === 'error' || e.subtype === 'warning')) return { t: 'note', text: cut(e.text) };
+ if (e.type === 'system' && e.subtype === 'handoff') return { t: 'handoff', from: e.from, ...(e.to ? { to: e.to } : {}), reason: cut(e.reason) };
+ if (e.type === 'system' && (e.subtype === 'error' || e.subtype === 'warning')) return { t: 'note', level: e.subtype, text: cut(e.text) };
+ if (e.type === 'system' && e.subtype === 'tool_progress') return { t: 'tool_progress', id: String(e.tool_use_id || ''), chunk: clean(cutTo(e.text, MAX_OUTPUT)) };
+ if (e.type === 'system' && e.subtype === 'usage' && e.usage) return { t: 'usage', input_tokens: e.usage.input_tokens || 0, output_tokens: e.usage.output_tokens || 0 };
if (e.type === 'assistant') {
if (e.type === 'assistant') {
const out = [];
const out = [];
for (const c of (e.message && e.message.content) || []) {
for (const c of (e.message && e.message.content) || []) {
- if (c.type === 'text' && c.text) out.push({ t: 'text', text: cut(c.text) });
+ if (c.type === 'text' && c.text) out.push({ t: 'text', text: clean(cut(c.text)), ...parent });
if (c.type === 'tool_use') {
if (c.type === 'tool_use') {
const i = c.input || {};
const i = c.input || {};
const what = i.command || i.file_path || i.path || i.pattern || i.query || i.description || '';
const what = i.command || i.file_path || i.path || i.pattern || i.query || i.description || '';
- out.push({ t: 'tool', name: String(c.name || ''), what: cut(String(what).split('\n')[0]).slice(0, 200) });
+ out.push({ t: 'tool', id: String(c.id || ''), name: String(c.name || ''), what: clean(cut(String(what).split('\n')[0]).slice(0, 200)), input: stepInput(i), ...parent });
}
}
}
}
return out;
return out;
}
}
+ if (e.type === 'user' && e.message && Array.isArray(e.message.content)) {
+ return e.message.content.filter((c) => c && c.type === 'tool_result')
+ .map((c) => ({ t: 'tool_result', id: String(c.tool_use_id || ''), ok: !c.is_error, output: clean(cutTo(resultText(c.content), MAX_OUTPUT)), ...parent }));
+ }
if (e.type === 'result') {
if (e.type === 'result') {
- return { t: 'done', ok: !e.is_error, text: cut(e.result), duration_ms: e.duration_ms || 0, cost_usd: e.total_cost_usd || 0 };
+ return { t: 'done', ok: !e.is_error, text: cut(e.result), duration_ms: e.duration_ms || 0, cost_usd: e.total_cost_usd || 0, turns: e.num_turns || 0 };
}
}
return null;
return null;
}
}
+/**
+ * The session log a result keeps: every line, in order, up to MAX_LOG bytes. When it would not fit, outputs and
+ * progress are cut shorter first (progress is dropped where its step's result has the output), so the steps and what
+ * the agents said survive whole.
+ */
+function sessionLog(lines) {
+ const size = (l) => JSON.stringify(l).length + 1;
+ let list = lines.slice();
+ let total = list.reduce((n, l) => n + size(l), 0);
+ if (total <= MAX_LOG) return list;
+ const ended = new Set(list.filter((l) => l.t === 'tool_result').map((l) => l.id));
+ list = list.filter((l) => !(l.t === 'tool_progress' && ended.has(l.id)));
+ for (const n of [1500, 600, 200]) {
+ total = list.reduce((m, l) => m + size(l), 0);
+ if (total <= MAX_LOG) return list;
+ list = list.map((l) => (l.t === 'tool_result' || l.t === 'tool_progress') ? { ...l, [l.t === 'tool_result' ? 'output' : 'chunk']: cutTo(l.output || l.chunk, n) } : l.t === 'tool' && l.input && (l.input.content || l.input.old_string) ? { ...l, input: { ...l.input, content: l.input.content && cutTo(l.input.content, n), old_string: l.input.old_string && cutTo(l.input.old_string, n), new_string: l.input.new_string && cutTo(l.input.new_string, n) } } : l);
+ }
+ const kept = [];
+ total = 0;
+ for (const l of list) { total += size(l); if (total > MAX_LOG) break; kept.push(l); }
+ kept.push({ t: 'note', level: 'warning', text: 'The rest of this session was too long to keep.' });
+ return kept;
+}
+
/** What changed: per-file counts and the patch (new files included), cut to fit the result channel. */
/** What changed: per-file counts and the patch (new files included), cut to fit the result channel. */
async function changes(dir) {
async function changes(dir) {
await git(['add', '-A'], { cwd: dir });
await git(['add', '-A'], { cwd: dir });
@@ -175,7 +243,8 @@ async function pushBranch(dir, job, { env, fetchImpl, base }) {
@@ -175,7 +243,8 @@ async function pushBranch(dir, job, { env, fetchImpl, base }) {
*/
*/
async function runJob({ input, harness, emit, writeResult, env = process.env, tmp = os.tmpdir(), fetchImpl = globalThis.fetch }) {
async function runJob({ input, harness, emit, writeResult, env = process.env, tmp = os.tmpdir(), fetchImpl = globalThis.fetch }) {
const started = Date.now();
const started = Date.now();
- const say = (doc) => emit(JSON.stringify(doc));
+ const log = [];
+ const say = (doc) => { if (doc && doc.t && doc.t !== 'result_doc') log.push(doc); emit(JSON.stringify(doc)); };
let job;
let job;
try {
try {
job = parseArgs(input, env);
job = parseArgs(input, env);
@@ -226,6 +295,8 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
@@ -226,6 +295,8 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
result.duration_ms = Date.now() - started;
result.duration_ms = Date.now() - started;
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
}
}
+ result.lines = LINES;
+ result.log = sessionLog(log);
writeResult(result);
writeResult(result);
return result;
return result;
}
}
@@ -242,4 +313,4 @@ function resultWriter(emit) {
@@ -242,4 +313,4 @@ function resultWriter(emit) {
};
};
}
}
-module.exports = { runJob, parseArgs, compact, resultWriter, scrub, githubRepo, pushTarget, JOB_PROTOCOL };
+module.exports = { runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, JOB_PROTOCOL };
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.23.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.24.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"bin": {
"bin": {
@@ -918,9 +918,9 @@
@@ -918,9 +918,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.23.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.23.0",
- "integrity": "sha512-oLcUXmHOOC2CCpAuwCN95TIoadukLG5lC6YHPhPbYKcS/rZh0iwLbEA0aAzYHjalKGrqu9mw896LcFc3cd5xWg==",
+ "version": "4.24.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.24.0",
+ "integrity": "sha512-EJyEPg21pRGmHyMmIlpDb2tvgHwF2eAuEtKTj/eOPj0/LvZLvdQAVS5ge3wBsJgt/Ttz1rhobsTBY0ByTEi8zA==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-codes",
"name": "openvibe-codes",
- "version": "0.3.1",
+ "version": "0.4.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",
@@ -32,7 +32,7 @@
@@ -32,7 +32,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.23.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.24.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -1,5 +1,8 @@
@@ -1,5 +1,8 @@
/*
/*
* Run on your machine (server/http/run.js): the live lines of a running task and the colours of a finished patch.
* Run on your machine (server/http/run.js): the live lines of a running task and the colours of a finished patch.
+ * With openvibe-shared/agent-session.js on the page (the task page loads it), the lines become the agents' session:
+ * steps grouped, each step's command, change and output (live), helpers, hand-offs and the plan; a finished task's
+ * page shows the session its result kept. Without it, the plain list below.
* The lines come from the task's own event stream (/run/tasks/:id/events, Run's job stream through Codes): `output`
* The lines come from the task's own event stream (/run/tasks/:id/events, Run's job stream through Codes): `output`
* events carry the job's stdout, one JSON object per line (harness/job.js), `state` the job's state, `end` its end.
* events carry the job's stdout, one JSON object per line (harness/job.js), `state` the job's state, `end` its end.
* Everything is put on the page as text, never as HTML.
* Everything is put on the page as text, never as HTML.
@@ -37,6 +40,13 @@
@@ -37,6 +40,13 @@
var pill = document.querySelector('[data-state]');
var pill = document.querySelector('[data-state]');
var buffer = '';
var buffer = '';
if (!log || !window.EventSource) return;
if (!log || !window.EventSource) return;
+ var view = null;
+ if (window.OpenVibeAgentSession) {
+ var host = document.createElement('div');
+ log.parentNode.replaceChild(host, log);
+ view = window.OpenVibeAgentSession.mount(host, { title: section.getAttribute('data-title') || 'Task', status: 'queued', empty: 'Waiting for your machine to take the task…' });
+ log = null;
+ }
var source = new EventSource(section.getAttribute('data-events'));
var source = new EventSource(section.getAttribute('data-events'));
source.addEventListener('output', function (e) {
source.addEventListener('output', function (e) {
var doc;
var doc;
@@ -46,13 +56,17 @@
@@ -46,13 +56,17 @@
buffer = parts.pop();
buffer = parts.pop();
parts.forEach(function (raw) {
parts.forEach(function (raw) {
if (!raw.trim()) return;
if (!raw.trim()) return;
- try { show(log, JSON.parse(raw)); } catch (err) { line(log, 'tool', raw); }
+ var doc;
+ try { doc = JSON.parse(raw); } catch (err) { doc = null; }
+ if (view) { if (doc) view.push(doc); return; }
+ if (doc) show(log, doc); else line(log, 'tool', raw);
});
});
});
});
source.addEventListener('state', function (e) {
source.addEventListener('state', function (e) {
try {
try {
var s = JSON.parse(e.data).state;
var s = JSON.parse(e.data).state;
if (pill && LABEL[s]) { pill.textContent = LABEL[s]; pill.className = 'run-pill run-pill-' + s; }
if (pill && LABEL[s]) { pill.textContent = LABEL[s]; pill.className = 'run-pill run-pill-' + s; }
+ if (view) view.status(s, LABEL[s]);
} catch (err) { /* ignore */ }
} catch (err) { /* ignore */ }
});
});
source.addEventListener('end', function () {
source.addEventListener('end', function () {
@@ -79,6 +93,20 @@
@@ -79,6 +93,20 @@
});
});
}
}
+ /** A finished task: the session its result kept (lines 1.1), read whole. */
+ function replay(section) {
+ var data = section.querySelector('[data-session-log]');
+ var into = section.querySelector('[data-session-view]');
+ if (!data || !into || !window.OpenVibeAgentSession) { section.hidden = true; return; }
+ var lines;
+ try { lines = JSON.parse(data.textContent); } catch (err) { section.hidden = true; return; }
+ var state = section.getAttribute('data-state');
+ var view = window.OpenVibeAgentSession.mount(into, { title: section.getAttribute('data-title') || 'Task', status: 'running', follow: false });
+ view.pushAll(lines);
+ view.status(state, LABEL[state]);
+ }
+
document.querySelectorAll('[data-events]').forEach(follow);
document.querySelectorAll('[data-events]').forEach(follow);
+ document.querySelectorAll('[data-session]').forEach(replay);
document.querySelectorAll('[data-patch]').forEach(colour);
document.querySelectorAll('[data-patch]').forEach(colour);
})();
})();
@@ -55,6 +55,20 @@ function presentResult(d) {
@@ -55,6 +55,20 @@ function presentResult(d) {
});
});
}
}
+// The job lines a session log may hold (harness/job.js, lines 1.1; openvibe-shared/agent-session.js reads them).
+const LINE_KINDS = new Set(['agent', 'text', 'tool', 'tool_progress', 'tool_result', 'handoff', 'note', 'usage', 'done', 'step']);
+/**
+ * The session a finished task's result keeps (`log`), for its page: the lines whose kind is known, at most 4000, each a
+ * plain object no larger than 20 KB. Not part of codes.task@1 (the API passes the result's contract fields only).
+ */
+function sessionLogOf(row) {
+ let d = row && row.result;
+ if (typeof d === 'string') { try { d = JSON.parse(d); } catch { return null; } }
+ if (!d || !Array.isArray(d.log)) return null;
+ const lines = d.log.filter((l) => l && typeof l === 'object' && !Array.isArray(l) && LINE_KINDS.has(l.t) && JSON.stringify(l).length <= 20000).slice(0, 4000);
+ return lines.length ? lines : null;
+}
+
function createRunTasks({ store, run, config, log = console }) {
function createRunTasks({ store, run, config, log = console }) {
const db = store.db;
const db = store.db;
const who = (row) => ({ subject: row.subject, project: row.project_id });
const who = (row) => ({ subject: row.subject, project: row.project_id });
@@ -103,4 +117,4 @@ function createRunTasks({ store, run, config, log = console }) {
@@ -103,4 +117,4 @@ function createRunTasks({ store, run, config, log = console }) {
return { start, refresh, latestProject, present, who };
return { start, refresh, latestProject, present, who };
}
}
-module.exports = { createRunTasks, presentResult, TERMINAL, STATE_LABEL };
+module.exports = { createRunTasks, presentResult, TERMINAL, STATE_LABEL, sessionLogOf };
@@ -18,7 +18,7 @@ const { send } = require('../render/layout');
@@ -18,7 +18,7 @@ const { send } = require('../render/layout');
const { html, raw, esc } = require('../render/html');
const { html, raw, esc } = require('../render/html');
const { parseArgs } = require('../../harness/job');
const { parseArgs } = require('../../harness/job');
-const { createRunTasks, TERMINAL, STATE_LABEL } = require('../domain/run-tasks');
+const { createRunTasks, TERMINAL, STATE_LABEL, sessionLogOf } = require('../domain/run-tasks');
const DELEGABLE = new Set(['owner', 'admin']);
const DELEGABLE = new Set(['owner', 'admin']);
const MACHINES_URL = 'https://openvibe.bot/machines';
const MACHINES_URL = 'https://openvibe.bot/machines';
@@ -158,7 +158,9 @@ ${tasksHtml(recent)}`;
@@ -158,7 +158,9 @@ ${tasksHtml(recent)}`;
return res.redirect(303, `/run/tasks/${row.id}`);
return res.redirect(303, `/run/tasks/${row.id}`);
});
});
- function resultHtml(row) {
+ /** The end of a task: the verdict, what the agents said, the files and the patch. With the session on the page (`session`),
+ * its closing card already says how it ended and the agents' last words, so only a failure keeps its notice. */
+ function resultHtml(row, { session = false } = {}) {
const d = row.result || {};
const d = row.result || {};
const files = Array.isArray(d.files) ? d.files : [];
const files = Array.isArray(d.files) ? d.files : [];
const push = d.push || null;
const push = d.push || null;
@@ -166,8 +168,9 @@ ${tasksHtml(recent)}`;
@@ -166,8 +168,9 @@ ${tasksHtml(recent)}`;
? html`<p class="run-notice run-notice-error">${row.state === 'expired' ? 'No machine of yours took the task in time. Is it online on openvibe.bot/machines, with the Codes job runner?' : row.error || STATE_LABEL[row.state]}</p>`
? html`<p class="run-notice run-notice-error">${row.state === 'expired' ? 'No machine of yours took the task in time. Is it online on openvibe.bot/machines, with the Codes job runner?' : row.error || STATE_LABEL[row.state]}</p>`
: d.ok ? html`<p class="run-notice run-notice-ok">The agents finished the task${d.agents && d.agents.length ? ` (${d.agents.join(' → ')})` : ''}.</p>`
: d.ok ? html`<p class="run-notice run-notice-ok">The agents finished the task${d.agents && d.agents.length ? ` (${d.agents.join(' → ')})` : ''}.</p>`
: html`<p class="run-notice run-notice-error">The task ended without success${d.error ? `: ${d.error}` : ''}.</p>`;
: html`<p class="run-notice run-notice-error">The task ended without success${d.error ? `: ${d.error}` : ''}.</p>`;
- return html`<section class="run-result">${verdict}
-${d.summary ? html`<h2>What the agent said</h2><pre tabindex="0" class="run-summary">${d.summary}</pre>` : ''}
+ const ended = !(session && row.state === 'succeeded' && d.ok);
+ return html`<section class="run-result">${ended ? verdict : ''}
+${d.summary && !session ? html`<h2>What the agent said</h2><pre tabindex="0" class="run-summary">${d.summary}</pre>` : ''}
${push ? (push.pushed ? html`<p>Pushed <a href="${push.branch_url}">${push.branch}</a>${push.pull_request ? html` · <a href="${push.pull_request}">pull request</a>` : ''}${push.pull_request_error ? html` · the pull request was not opened (${push.pull_request_error})` : ''}</p>` : html`<p class="muted">Not pushed: ${push.reason}</p>`) : ''}
${push ? (push.pushed ? html`<p>Pushed <a href="${push.branch_url}">${push.branch}</a>${push.pull_request ? html` · <a href="${push.pull_request}">pull request</a>` : ''}${push.pull_request_error ? html` · the pull request was not opened (${push.pull_request_error})` : ''}</p>` : html`<p class="muted">Not pushed: ${push.reason}</p>`) : ''}
${files.length ? html`<h2>Changed files</h2><table class="run-files"><tbody>${files.map((f) => html`<tr><td><code>${f.path}</code></td><td class="add">+${f.added == null ? '·' : f.added}</td><td class="del">−${f.removed == null ? '·' : f.removed}</td></tr>`)}</tbody></table>` : (row.state === 'succeeded' ? html`<p class="muted">No files changed.</p>` : '')}
${files.length ? html`<h2>Changed files</h2><table class="run-files"><tbody>${files.map((f) => html`<tr><td><code>${f.path}</code></td><td class="add">+${f.added == null ? '·' : f.added}</td><td class="del">−${f.removed == null ? '·' : f.removed}</td></tr>`)}</tbody></table>` : (row.state === 'succeeded' ? html`<p class="muted">No files changed.</p>` : '')}
${d.patch ? html`<h2>The patch${d.patch_truncated ? ' (cut)' : ''}</h2><pre tabindex="0" class="run-patch" data-patch>${d.patch}</pre>` : ''}
${d.patch ? html`<h2>The patch${d.patch_truncated ? ' (cut)' : ''}</h2><pre tabindex="0" class="run-patch" data-patch>${d.patch}</pre>` : ''}
@@ -181,13 +184,18 @@ ${typeof d.duration_ms === 'number' ? html`<p class="muted">${Math.round(d.durat
@@ -181,13 +184,18 @@ ${typeof d.duration_ms === 'number' ? html`<p class="muted">${Math.round(d.durat
if (!row) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>No such task of yours. <a href="/run">Your tasks</a></p>` }, 404);
if (!row) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>No such task of yours. <a href="/run">Your tasks</a></p>` }, 404);
row = await refresh(row);
row = await refresh(row);
const live = !TERMINAL.has(row.state);
const live = !TERMINAL.has(row.state);
+ // The agents' session (openvibe-shared/agent-session.js, js/run.js mounts it): live from the job's stream, and
+ // after the end from the log the result keeps (a machine on an older Codes keeps none: the summary stands).
+ const lines = live ? null : sessionLogOf(row);
+ const title = String(row.prompt || '').split('\n')[0].slice(0, 90);
+ const session = lines ? html`<section class="run-session" data-session data-title="${title}" data-state="${row.state}"><h2>The session</h2><div data-session-view></div><script type="application/json" data-session-log>${raw(JSON.stringify(lines).replace(/</g, '\\u003c'))}</script></section>` : '';
const body = html`<p class="crumbs"><a href="/run">Run on your machine</a></p>
const body = html`<p class="crumbs"><a href="/run">Run on your machine</a></p>
<h1 class="run-repo">${repoName(row.repo)}${row.ref ? html` <small>${row.ref}</small>` : ''}</h1>
<h1 class="run-repo">${repoName(row.repo)}${row.ref ? html` <small>${row.ref}</small>` : ''}</h1>
<p><span class="run-pill run-pill-${row.state}" data-state>${STATE_LABEL[row.state] || row.state}</span>${row.agent ? html` <span class="run-pill">${row.agent}</span>` : ''}</p>
<p><span class="run-pill run-pill-${row.state}" data-state>${STATE_LABEL[row.state] || row.state}</span>${row.agent ? html` <span class="run-pill">${row.agent}</span>` : ''}</p>
<pre tabindex="0" class="run-prompt">${row.prompt}</pre>
<pre tabindex="0" class="run-prompt">${row.prompt}</pre>
-${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events" aria-live="polite"><h2>Live</h2><ol class="run-log" data-log></ol></section>
-<form method="post" action="/run/tasks/${row.id}/cancel"><button class="run-btn" type="submit">Cancel</button></form>` : resultHtml(row)}`;
- return page(req, res, { title: 'Task', body });
+${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events" data-title="${title}" aria-live="polite"><h2>Live</h2><ol class="run-log" data-log></ol></section>
+<form method="post" action="/run/tasks/${row.id}/cancel"><button class="run-btn" type="submit">Cancel</button></form>` : html`${session}${resultHtml(row, { session: !!lines })}`}`;
+ return page(req, res, { title: 'Task', body, sharedScripts: ['agent-session.js'] });
});
});
r.get('/run/tasks/:id/events', async (req, res) => {
r.get('/run/tasks/:id/events', async (req, res) => {
@@ -45,7 +45,7 @@ let RELEASE = 'dev';
@@ -45,7 +45,7 @@ let RELEASE = 'dev';
function setRelease(id) { if (id) RELEASE = String(id); }
function setRelease(id) { if (id) RELEASE = String(id); }
/**
/**
- * o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],
+ * o: title, description, body (html), viewer, config, path, index (default false), scripts [rel], sharedScripts [openvibe-shared file names],
* crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names],
* crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names],
* css [this site's own extra stylesheets, rel to public/, for the pages that need them]
* css [this site's own extra stylesheets, rel to public/, for the pages that need them]
*/
*/
@@ -77,7 +77,7 @@ function renderPage(o) {
@@ -77,7 +77,7 @@ function renderPage(o) {
const crumbs = (o.crumbs || []).length
const crumbs = (o.crumbs || []).length
? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`
? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`
: '';
: '';
- const scripts = (o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`);
+ const scripts = (o.sharedScripts || []).map((name) => html`<script src="${ovServe.url(name)}" defer></script>`).concat((o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`));
return shell.page({
return shell.page({
name: SITE_NAME,
name: SITE_NAME,
lang: 'en',
lang: 'en',
@@ -161,6 +161,21 @@ const toolResults = (events) => events.filter((e) => e.type === 'user').flatMap(
@@ -161,6 +161,21 @@ const toolResults = (events) => events.filter((e) => e.type === 'user').flatMap(
assert.ok(toolResults(events).some((c) => c.content.trim() === fs.realpathSync(work)));
assert.ok(toolResults(events).some((c) => c.content.trim() === fs.realpathSync(work)));
});
});
+ // A command's output reaches the stream while it runs (tool_progress), before its result: a long test run is seen
+ // moving (the owner, 2026-10-28: the Claude app said "Output unavailable" for a running command).
+ await check('a running command\'s output streams before its result; edits carry their change', async () => {
+ api.set([{ calls: [['run_command', { command: 'echo first; sleep 0.4; echo second' }], ['write_file', { path: 'note.txt', content: 'hello' }]] }, { content: 'ok' }]);
+ const events = await collect(h.run({ prompt: 'run it', agent: 'openai-compatible', cwd: work, permission: 'full', handoff: false }));
+ const progress = events.filter((e) => e.type === 'system' && e.subtype === 'tool_progress');
+ const resultAt = events.findIndex((e) => e.type === 'user' && e.message.content.some((c) => /second/.test(c.content)));
+ assert.ok(progress.length >= 1 && /first/.test(progress[0].text), JSON.stringify(progress));
+ assert.ok(events.indexOf(progress[0]) < resultAt, 'the first output came before the command ended');
+ assert.ok(progress.every((p) => p.tool_use_id === progress[0].tool_use_id), 'every chunk names its step');
+ const write = events.find((e) => e.type === 'assistant' && e.message.content.some((c) => c.type === 'tool_use' && c.name !== 'Bash' && /note\.txt/.test(JSON.stringify(c.input))));
+ const input = write.message.content.find((c) => c.type === 'tool_use').input;
+ assert.deepStrictEqual([input.file_path, input.content], ['note.txt', 'hello'], 'a new file shows what it holds');
+ });
+
await check('an HTTP error ends the run with the server\'s message', async () => {
await check('an HTTP error ends the run with the server\'s message', async () => {
api.set([{ status: 429, error: 'rate limited, slow down' }]);
api.set([{ status: 429, error: 'rate limited, slow down' }]);
const events = await collect(h.run({ prompt: 'x', agent: 'openai-compatible', cwd: work, handoff: false }));
const events = await collect(h.run({ prompt: 'x', agent: 'openai-compatible', cwd: work, handoff: false }));
@@ -11,7 +11,7 @@ const fs = require('fs');
@@ -11,7 +11,7 @@ const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
const { execFileSync, spawnSync } = require('child_process');
-const { runJob, parseArgs, compact, scrub, githubRepo, JOB_PROTOCOL } = require('../harness/job');
+const { runJob, parseArgs, compact, sessionLog, scrub, githubRepo, JOB_PROTOCOL } = require('../harness/job');
const { check, done } = require('./helpers/boot');
const { check, done } = require('./helpers/boot');
const sh = (cwd, ...args) => execFileSync('git', args, { cwd, env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' } });
const sh = (cwd, ...args) => execFileSync('git', args, { cwd, env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' } });
@@ -125,7 +125,35 @@ function fakeHarness({ fail = false } = {}) {
@@ -125,7 +125,35 @@ function fakeHarness({ fail = false } = {}) {
assert.strictEqual(scrub('token ghs_abcdefghijklmnopqrstuvwxyz0123 leaked'), 'token *** leaked');
assert.strictEqual(scrub('token ghs_abcdefghijklmnopqrstuvwxyz0123 leaked'), 'token *** leaked');
assert.deepStrictEqual(githubRepo('https://github.com/OpenVibers/OpenVibe.Codes.git'), { owner: 'OpenVibers', name: 'OpenVibe.Codes' });
assert.deepStrictEqual(githubRepo('https://github.com/OpenVibers/OpenVibe.Codes.git'), { owner: 'OpenVibers', name: 'OpenVibe.Codes' });
assert.strictEqual(githubRepo('https://gitlab.com/a/b'), null);
assert.strictEqual(githubRepo('https://gitlab.com/a/b'), null);
- assert.deepStrictEqual(compact({ type: 'result', is_error: false, result: 'ok', duration_ms: 5 }), { t: 'done', ok: true, text: 'ok', duration_ms: 5, cost_usd: 0 });
+ assert.deepStrictEqual(compact({ type: 'result', is_error: false, result: 'ok', duration_ms: 5 }), { t: 'done', ok: true, text: 'ok', duration_ms: 5, cost_usd: 0, turns: 0 });
+ });
+
+ // Lines 1.1 (openvibe-shared/agent-session.js reads them): a step keeps its id and what a reader needs of its input,
+ // a result its output, a helper's line its parent; secrets never pass, in any string.
+ await check('lines 1.1: steps with their input, results with their output, helpers nested, secrets scrubbed', () => {
+ const [said, step] = compact({ type: 'assistant', parent_tool_use_id: 'T1', message: { content: [{ type: 'text', text: 'key sk-abcdefghijklmnopqrstuv' }, { type: 'tool_use', id: 'b1', name: 'Bash', input: { command: 'curl -H "Authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz0123" https://x', description: 'Call the API', timeout: 5 } }] } });
+ assert.deepStrictEqual(said, { t: 'text', text: 'key [redacted]', parent: 'T1' });
+ assert.deepStrictEqual([step.t, step.id, step.name, step.parent, Object.keys(step.input).sort()], ['tool', 'b1', 'Bash', 'T1', ['command', 'description']], 'only what a reader needs of the input');
+ assert.ok(!/ghp_/.test(JSON.stringify(step)), 'no token in the command');
+ const [edit] = compact({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'e1', name: 'Edit', input: { file_path: 'a.js', old_string: 'x', new_string: 'y' } }] } });
+ assert.deepStrictEqual(edit.input, { file_path: 'a.js', old_string: 'x', new_string: 'y' }, 'an edit carries its change');
+ assert.deepStrictEqual(compact({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'b1', is_error: true, content: [{ type: 'text', text: 'boom ghs_abcdefghijklmnopqrstuvwxyz0123' }] }] } }),
+ [{ t: 'tool_result', id: 'b1', ok: false, output: 'boom [redacted]' }], 'a result with its output, scrubbed');
+ assert.deepStrictEqual(compact({ type: 'system', subtype: 'tool_progress', tool_use_id: 'b1', text: 'line\n' }), { t: 'tool_progress', id: 'b1', chunk: 'line\n' });
+ assert.deepStrictEqual(compact({ type: 'system', subtype: 'handoff', from: 'claude', to: 'codex', reason: 'limit' }), { t: 'handoff', from: 'claude', to: 'codex', reason: 'limit' });
+ const todos = compact({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'p', name: 'TodoWrite', input: { todos: [{ content: 'A', status: 'in_progress', activeForm: 'Doing A' }] } }] } })[0];
+ assert.deepStrictEqual(todos.input.todos, [{ content: 'A', status: 'in_progress', activeForm: 'Doing A' }], 'the plan travels');
+ });
+
+ await check('the session log a result keeps fits: outputs are cut shorter first, the steps survive', () => {
+ const lines = [];
+ for (let i = 0; i < 120; i++) { lines.push({ t: 'tool', id: `s${i}`, name: 'Bash', what: 'npm test', input: { command: 'npm test' } }); lines.push({ t: 'tool_progress', id: `s${i}`, chunk: 'p'.repeat(3000) }); lines.push({ t: 'tool_result', id: `s${i}`, ok: true, output: 'o'.repeat(4000) }); }
+ const kept = sessionLog(lines);
+ assert.ok(JSON.stringify(kept).length <= 250000, 'within the result\'s room');
+ assert.strictEqual(kept.filter((l) => l.t === 'tool').length, 120, 'every step is still there');
+ assert.ok(!kept.some((l) => l.t === 'tool_progress'), 'progress whose step ended goes first');
+ assert.ok(kept.every((l) => l.t !== 'tool_result' || l.output.length <= 1500), 'outputs cut shorter');
+ assert.deepStrictEqual(sessionLog([{ t: 'text', text: 'hi' }]), [{ t: 'text', text: 'hi' }], 'a short session is kept as it is');
});
});
await check('the command reads its input on stdin and prints its result when fd 3 is not open', () => {
await check('the command reads its input on stdin and prints its result when fd 3 is not open', () => {
@@ -84,10 +84,20 @@ const { boot, check, done } = require('./helpers/boot');
@@ -84,10 +84,20 @@ const { boot, check, done } = require('./helpers/boot');
const stream = calls.find((c) => c[0] === 'stream');
const stream = calls.find((c) => c[0] === 'stream');
assert.deepStrictEqual(stream[2], { subject: ada.subject, project });
assert.deepStrictEqual(stream[2], { subject: ada.subject, project });
state = 'succeeded';
state = 'succeeded';
- result = { protocol: '1.0.0', ok: true, agents: ['claude-code'], summary: 'Fixed <the> typo', files: [{ path: 'README.md', added: 1, removed: 1 }], patch: '@@ -1 +1 @@\n-teh\n+the\n', patch_truncated: false, push: { pushed: true, branch: 'openvibe-codes/x', branch_url: 'https://github.com/OpenVibers/OpenVibe.Codes/tree/openvibe-codes%2Fx', pull_request: 'https://github.com/OpenVibers/OpenVibe.Codes/pull/9' }, duration_ms: 42000 };
+ result = { protocol: '1.0.0', lines: '1.1', log: [{ t: 'agent', agent: 'claude-code' }, { t: 'text', text: 'I fix <b>it</b></script>' }, { t: 'tool', id: 'e1', name: 'Edit', input: { file_path: 'README.md', old_string: 'teh', new_string: 'the' } }, { t: 'tool_result', id: 'e1', ok: true, output: 'ok' }, { t: 'mystery', x: 1 }, { t: 'done', ok: true, text: 'Fixed', duration_ms: 900 }], ok: true, agents: ['claude-code'], summary: 'Fixed <the> typo', files: [{ path: 'README.md', added: 1, removed: 1 }], patch: '@@ -1 +1 @@\n-teh\n+the\n', patch_truncated: false, push: { pushed: true, branch: 'openvibe-codes/x', branch_url: 'https://github.com/OpenVibers/OpenVibe.Codes/tree/openvibe-codes%2Fx', pull_request: 'https://github.com/OpenVibers/OpenVibe.Codes/pull/9' }, duration_ms: 42000 };
const done_ = await t.get(`/run/tasks/${taskId}`, { as: ada });
const done_ = await t.get(`/run/tasks/${taskId}`, { as: ada });
- assert.ok(done_.text.includes('The agents finished the task') && done_.text.includes('Fixed <the> typo') && done_.text.includes('README.md'));
+ // With the session on the page, its closing card says how it ended and what the agents said: the page does not repeat it.
+ assert.ok(!done_.text.includes('The agents finished the task') && !done_.text.includes('What the agent said') && done_.text.includes('README.md'));
assert.ok(done_.text.includes('pull/9') && done_.text.includes('data-patch') && !done_.text.includes('data-events'));
assert.ok(done_.text.includes('pull/9') && done_.text.includes('data-patch') && !done_.text.includes('data-events'));
+ // The session the result kept, for openvibe-shared/agent-session.js: known lines only, and nothing in them can end
+ // the page's script early.
+ assert.ok(done_.text.includes('data-session') && /<script src="[^"]*agent-session\.js[^"]*" defer>/.test(done_.text), 'the session and the component');
+ const kept = JSON.parse(/<script type="application\/json" data-session-log>([\s\S]*?)<\/script>/.exec(done_.text)[1]);
+ assert.deepStrictEqual(kept.map((l) => l.t), ['agent', 'text', 'tool', 'tool_result', 'done'], 'lines of an unknown kind are left out');
+ assert.ok(!/<\/script>I|<b>it/.test(/data-session-log>([\s\S]*?)<\/script>/.exec(done_.text)[1]), 'no markup inside the data');
+ assert.strictEqual(kept[1].text, 'I fix <b>it</b></script>', 'and the text reads back as it was');
+ const api = await t.get(`/api/v1/tasks/${taskId}`, { as: ada });
+ if (api.status === 200) assert.ok(!('log' in ((api.json && api.json.result) || {})), 'the task API passes codes.task@1\'s fields only');
const row = await t.ctx.store.db.one('SELECT state, result FROM run_tasks WHERE id = $1', [taskId]);
const row = await t.ctx.store.db.one('SELECT state, result FROM run_tasks WHERE id = $1', [taskId]);
assert.strictEqual(row.state, 'succeeded');
assert.strictEqual(row.state, 'succeeded');
assert.strictEqual(row.result.summary, 'Fixed <the> typo');
assert.strictEqual(row.result.summary, 'Fixed <the> typo');