openvibers/openvibe.codes Commits
openvibe-codes 0.2.0: the harness runs coding tasks on any agent from your machine, with hand-offs between them. (#26)
Changes
@@@ -15,7 +15,9 @@ One place to send a coding task to the agent that fits it — by what the task n| Surface | Path | Notes ||---|---|---|++| 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. || 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. || 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`). || Improve OpenVibe | `/improve` | Every OpenVibe repository, from the pinned contracts' service manifests (the services that run, then the shared libraries), and the path from a change to a reviewed pull request. |@@@ -26,21 +28,46 @@ One place to send a coding task to the agent that fits it — by what the task nEverything is server-rendered and usable without JavaScript.--## What comes next++## Use the harness++++```bash++npm install -g https://codeload.github.com/OpenVibers/OpenVibe.Codes/tar.gz/refs/tags/v0.2.0 # Node.js 22+++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 --agent codex --permission read "where is the session cookie set?"++openvibe-codes runs # the latest runs++openvibe-codes resume <run id> "now add a test"++```--The harness grows out of the agent pool that already builds OpenVibe (`~/openvibe/agents` on the owner's machines: Claude Code, Codex, OpenCode Go, Command Code and the DeepSeek API, each with launch, streaming, usage, resume and pooling, plus routing and hand-offs). The order, adapter interface first:++| Agent | Adapter | Needs |++|---|---|---|++| Claude Code | `claude -p --output-format stream-json` | `claude` on PATH |++| Codex | `codex exec --json` | `codex` on PATH |++| OpenCode | `opencode run --format json` | `opencode` on PATH |++| Command Code | `cmd --print --output-format json` | `cmd` on PATH |++| Aider | `aider --message` | `aider` on PATH |++| DeepSeek | the harness's own agent loop ([harness/adapters/api.js](harness/adapters/api.js)) | `DEEPSEEK_API_KEY` |++| Your own model | the same loop against any OpenAI-compatible server | `OPENVIBE_CODES_BASE_URL`, `OPENVIBE_CODES_MODEL`, optional `OPENVIBE_CODES_API_KEY` |++++- **Events.** Every adapter turns its agent's output into one stream in Claude Code's `stream-json` shape (system/init, assistant text and tool_use, user tool_result, system warning/error/handoff, exactly one result), [harness/events.js](harness/events.js). Keys, tokens and private keys are redacted before an event is printed or kept.++- **Permission levels.** `read` (Claude Code plan mode, Codex read-only sandbox, read tools only), `edit` (the default: edits in the working directory, Claude Code refused `git push`, the API loop without commands), `full` (anything, unattended). The API loop never leaves the working directory, never reads `.env` and never writes under `.git`.++- **Hand-offs.** A failed, rate-limited, crashed or silent attempt (`stallMs`) is stopped and the task goes to the next agent the router picks, leaving out the ones tried, with a note: the task, why the last agent stopped, its tools, the files it changed, its last message and `git status`.++- **Runs** are kept in `$OPENVIBE_CODES_HOME` (else `$XDG_STATE_HOME/openvibe-codes`, else `~/.local/state/openvibe-codes`), one 0700 directory per run.++- **From code:** `const { createHarness } = require('openvibe-codes')`, then `for await (const e of createHarness().run({ prompt, cwd }))`.++++## What comes next--1. **Adapters** — one interface per harness (launch, stream, usage, resume, pool, capabilities), the personal paths and credentials replaced by tenant configuration and keys kept in OpenVibe.AI.--2. **Runs** — `POST /api/v1/jobs`, a stream, cancel; budgets per person and per project; the run log in Codes' own database.--3. **Hand-offs and sessions** — a stalled run moves to the next candidate with its context.--4. **A runner on your own machine** — your CLIs and your keys, through an OpenVibe Node.--5. **Improve OpenVibe as a flow** — repository → change → tests → independent review → pull request, never straight to `main`.++1. **A hosted runner** — the same harness on OpenVibe.Run workers for people who would rather not run agents themselves: `POST /api/v1/jobs`, a stream, cancel, budgets per person and per project, metered through Billing, the run log in Codes' own database.++2. **OpenVibe.Actor uses Codes** — Actor is OpenVibe's general agent (owner, 2026-10-08); its coding steps run through this harness.++3. **Improve OpenVibe as a flow** — repository → change → tests → independent review → pull request, never straight to `main`.++4. **The OpenVibe agent pool on this package** — `~/openvibe/agents` (the pool that builds OpenVibe) replaces its own adapters with these.## Owns- The harness catalog and the routing policy over it (placement itself is `openvibe-sdk/placement`, the one implementation every service uses).- Its pages and the community documents in this repository.--- Nothing in its database yet: migration 0002 dropped the console's tables when the console moved; the harness's runs will live here.++- The harness package (`harness/`, `bin/`): adapters, the event format, hand-offs and the local run store.++- Nothing in its database yet: migration 0002 dropped the console's tables when the console moved; hosted runs will live here.## Does not own@@@ -76,7 +103,7 @@ fnm exec --using=22.22.1 npm test # every test/*.test.js on temp PGlitefnm exec --using=22.22.1 npm run dev # http://localhost:4900```--Tests: the catalog (validation at boot, the API and the router: capability first, the reasons, refusals as problems); the redirects (every console address 301 or 308 to the same path on openvibe.services, what stays here answering 200); sign-in (PKCE, state, next=, an assertion or app token is not a session, cross-site sign-out refused); the community documents (drafts marked, not indexed, every link resolving); the crawl artifacts; per-actor limits; no internal key anywhere; no secret in any response, the database or the logs; no URL a caller typed ever fetched; readiness, release.json and loopback metrics; the released codes manifest matching the code; and the home page's size budgets (`test/perf-budget.test.js`, openvibe-shared/perf-budget).++Tests: the harness against stand-in agents (`test/helpers/fake-agents.js`: every CLI adapter's events, hand-offs on failure and on silence, redaction, resume, the API loop against a stand-in server with its path rules, the command line and its exit codes); the catalog (validation at boot, the API and the router: capability first, the reasons, refusals as problems); the redirects (every console address 301 or 308 to the same path on openvibe.services, what stays here answering 200); sign-in (PKCE, state, next=, an assertion or app token is not a session, cross-site sign-out refused); the community documents (drafts marked, not indexed, every link resolving); the crawl artifacts; per-actor limits; no internal key anywhere; no secret in any response, the database or the logs; no URL a caller typed ever fetched; readiness, release.json and loopback metrics; the released codes manifest matching the code; and the home page's size budgets (`test/perf-budget.test.js`, openvibe-shared/perf-budget).## Security (threat notes)@@@ -15,21 +15,28 @@"openvibe-sdk v0.35.2","openvibe-shared v2.13.3"],-- "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: the agent catalog with a public routing API, Improve OpenVibe (every repository and the path to a reviewed pull request) 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": [++ "openvibe-codes on a person's machine (bin/openvibe-codes.js, harness/): agents, route, run, runs, show, resume; installs from the release tag",++ "Adapters for Claude Code, Codex, OpenCode, Command Code and Aider (their CLIs) and for the DeepSeek API and any OpenAI-compatible server (the harness's own agent loop with list/read/grep/write/edit/run tools, confined to the working directory)",++ "One event stream for every agent, in Claude Code's stream-json shape, ending with exactly one result; keys and tokens redacted",++ "Hand-offs: a failed, rate-limited, crashed or silent attempt hands the task to the next routed agent with a note (task, tools, files changed, last message, git status)",++ "Permission levels read / edit / full mapped onto each agent's own sandbox or tool set",++ "Runs kept on the person's machine (OPENVIBE_CODES_HOME, files 0600) for show and resume","Sign-in with OpenVibe.Network (authorization code + PKCE S256), session tokens in httpOnly cookies","The agent catalog (server/data/harness-offers.json, validated at boot as platform.harness-offer@1 / platform.agent-offer@1): /harnesses and GET /api/v1/harnesses",-- "Routing a task over the catalog with openvibe-sdk/placement: POST /api/v1/harnesses/route { task, requirements? } → { selected, reasons, candidates }",++ "Routing a task over the catalog with openvibe-sdk/placement: POST /api/v1/harnesses/route { task, requirements? } → { selected, reasons, candidates } (the same router the command line uses)",++ "Getting started (/start): install, the agents and what each needs, commands, permission levels, hand-offs, the event format, using it from code","Improve OpenVibe (/improve): every repository from the pinned contracts' service manifests, and the contributor path","The community documents (code of conduct, contributing, contributor ladder, moderation) rendered from this repository","Permanent redirects for every former console address (301 for reads, 308 for writes) to openvibe.services","robots.txt, sitemap.xml, llms.txt and llms-full.txt; per-actor and per-address limits; truthful readiness"],"notYet": [-- "Running a task on an agent, streaming its output, budgets (the agents run today on OpenVibe's own machines through ~/openvibe/agents)",-- "Hand-offs between agents and resumable sessions",-- "A runner on a person's own machine (their CLIs, their keys)",-- "Improve OpenVibe as a flow on this site (repository → change → tests → review → pull request); today it is the contributor path on GitHub",++ "A hosted runner (tasks on OpenVibe.Run workers for people who do not run agents themselves) with budgets and metering",++ "OpenVibe.Actor using Codes for its coding work",++ "Improve OpenVibe as a guided run on this site (repository → change → tests → review → pull request); today it is the harness plus the contributor path on GitHub",++ "The OpenVibe agent pool (~/openvibe/agents) running on this package instead of its own adapters","The community documents are drafts pending the owner's review"],"capabilitiesRegistered": true,@@@ -37,6 +44,8 @@"deployed": true,"placeholderInSites": false,"features": [++ "coding-agent harness (command line and Node.js module)",++ "hand-offs between agents","coding-agent catalog and router","Improve OpenVibe","community documents",nnew file mode 100755@@@ -0,0 +1,158 @@++#!/usr/bin/env node++'use strict';++++/**++ * openvibe-codes — code with any agent, from your terminal. The harness runs on your machine with your agents and your++ * keys; nothing is sent to OpenVibe. The commands and options are in USAGE below (`openvibe-codes --help`); the guide is++ * https://openvibe.codes/start.++ */++const path = require('path');++const { createHarness } = require('../harness');++const { version: VERSION } = require('../package.json');++++const USAGE = `openvibe-codes ${VERSION} — code with any agent, from your terminal++++ openvibe-codes agents which agents this machine can run, and why not the others++ openvibe-codes route [--task edit] [--need harness:resume,…] which agent a task would go to, and why++ openvibe-codes run [options] "what to do" run a task ("-" reads it from stdin)++ --task edit|review|long|resume what kind of task (default edit)++ --agent <id> skip routing: claude-code, codex, opencode, command-code, aider,++ deepseek, openai-compatible++ --model <name> the first agent's model++ --permission read|edit|full read: look and answer; edit: change files (default);++ full: anything, unattended++ --cwd <dir> where the agent works (default: here)++ --no-handoff stop at the first agent's result++ --attempts <n> at most this many agents in one run (default 3)++ --json the events as JSON lines instead of a readable log++ openvibe-codes runs the latest runs on this machine++ openvibe-codes show <run id> [--json] one run's attempts and result (--json: every event)++ openvibe-codes resume <run id> "follow-up" continue a run's last session with its agent++++Environment: DEEPSEEK_API_KEY · OPENVIBE_CODES_BASE_URL, OPENVIBE_CODES_MODEL, OPENVIBE_CODES_API_KEY++(your own OpenAI-compatible server) · OPENVIBE_CODES_HOME (where runs are kept).++Guide: https://openvibe.codes/start`;++++function parse(argv) {++ const opts = { _: [] };++ for (let i = 0; i < argv.length; i++) {++ const a = argv[i];++ if (a === '--json') opts.json = true;++ else if (a === '--no-handoff') opts.handoff = false;++ else if (a === '--help' || a === '-h') opts.help = true;++ else if (a.startsWith('--')) { const k = a.slice(2); opts[k] = argv[++i]; }++ else opts._.push(a);++ }++ return opts;++}++++const out = (s) => process.stdout.write(`${s}\n`);++const dim = (s) => (process.stdout.isTTY ? `\x1b[2m${s}\x1b[0m` : s);++const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : String(n));++++function printEvent(e) {++ if (e.type === 'system' && e.subtype === 'init') return out(dim(`▶ ${e.agent}${e.model ? ` (${e.model})` : ''}${e.session_id ? ` · session ${e.session_id}` : ''}`));++ if (e.type === 'system' && e.subtype === 'handoff') return out(`⇢ handing over from ${e.from}: ${e.reason}`);++ if (e.type === 'system' && (e.subtype === 'error' || e.subtype === 'warning')) return out(dim(`! ${e.text}`));++ if (e.type === 'assistant') {++ for (const c of (e.message && e.message.content) || []) {++ if (c.type === 'text') out(c.text);++ 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(dim(` → ${c.name}${what ? ` ${String(what).split('\n')[0].slice(0, 120)}` : ''}`));++ }++ }++ return undefined;++ }++ if (e.type === 'user') {++ for (const c of (e.message && e.message.content) || []) if (c.type === 'tool_result' && c.is_error) out(dim(` ✗ ${String(c.content).split('\n')[0].slice(0, 160)}`));++ return undefined;++ }++ if (e.type === 'result') {++ const u = e.usage || {};++ const facts = [`${Math.round((e.duration_ms || 0) / 1000)} s`, e.total_cost_usd ? `$${e.total_cost_usd.toFixed(4)}` : null, `${k((u.input_tokens || 0) + (u.cache_read_input_tokens || 0))} in / ${k(u.output_tokens || 0)} out`].filter(Boolean).join(' · ');++ return out(e.is_error ? `✗ ${e.result} ${dim(`(${facts})`)}` : dim(`✔ done (${facts})`));++ }++ return undefined;++}++++async function readStdin() {++ let s = '';++ for await (const chunk of process.stdin) s += chunk;++ return s;++}++++async function main() {++ const [cmd, ...rest] = process.argv.slice(2);++ const o = parse(rest);++ if (!cmd || cmd === 'help' || o.help || cmd === '--help' || cmd === '-h') { out(USAGE); return 0; }++ if (cmd === '--version' || cmd === '-v' || cmd === 'version') { out(VERSION); return 0; }++ const harness = createHarness();++++ if (cmd === 'agents') {++ const list = harness.agents();++ if (o.json) { out(JSON.stringify(list, null, 2)); return 0; }++ for (const a of list) out(`${a.available ? '✔' : '·'} ${a.id.padEnd(18)} ${a.name.padEnd(16)} ${a.available ? dim(a.where || '') : dim(a.reason)}`);++ return 0;++ }++ if (cmd === 'route') {++ const requirements = o.need ? { capabilities: String(o.need).split(',').map((s) => s.trim()).filter(Boolean) } : {};++ const d = harness.route({ task: o.task || 'edit', requirements });++ if (o.json) { out(JSON.stringify(d, null, 2)); return 0; }++ out(d.selected ? `→ ${d.selected}` : '→ no agent here can take it');++ for (const r of d.reasons || []) out(dim(` ${r}`));++ for (const c of d.candidates || []) out(`${c.included ? ' ✔' : ' ·'} ${c.id}${c.reason ? dim(` — ${c.reason}`) : ''}`);++ return d.selected ? 0 : 1;++ }++ if (cmd === 'run' || cmd === 'resume') {++ let prompt;++ let resume = null;++ let agent = o.agent || null;++ let cwd = path.resolve(o.cwd || process.cwd());++ if (cmd === 'resume') {++ const prev = harness.store.loadRun(o._[0]);++ if (!prev) { out(`no run ${o._[0]} on this machine (openvibe-codes runs)`); return 1; }++ const last = [...(prev.attempts || [])].reverse().find((a) => a.session_id);++ if (!last) { out('that run has no session to resume'); return 1; }++ resume = last.session_id; agent = last.agent; cwd = prev.cwd || cwd;++ prompt = o._.slice(1).join(' ');++ } else {++ prompt = o._.join(' ');++ }++ if (prompt === '-') prompt = await readStdin();++ if (!prompt || !prompt.trim()) { out('what should the agent do? openvibe-codes run "…"'); return 1; }++ const controller = new AbortController();++ process.on('SIGINT', () => { controller.abort(); });++ const run = harness.run({++ prompt, task: o.task || (resume ? 'resume' : 'edit'), agent, model: o.model || null, cwd, resume,++ permission: o.permission || 'edit', handoff: o.handoff !== false && !resume, maxAttempts: Number(o.attempts) || 3, signal: controller.signal,++ });++ if (!o.json) out(dim(`run ${run.runId} · ${cwd}`));++ let last = null;++ for await (const e of run) { last = e; if (o.json) out(JSON.stringify(e)); else printEvent(e); }++ return last && last.type === 'result' && !last.is_error ? 0 : 1;++ }++ if (cmd === 'runs') {++ const runs = harness.store.listRuns(Number(o.limit) || 20);++ if (o.json) { out(JSON.stringify(runs, null, 2)); return 0; }++ for (const r of runs) {++ const ok = r.result && !r.result.is_error;++ out(`${ok ? '✔' : '✗'} ${r.run_id} ${(r.attempts || []).map((a) => a.agent).join(' ⇢ ').padEnd(28)} ${dim(String(r.prompt || '').split('\n')[0].slice(0, 70))}`);++ }++ return 0;++ }++ if (cmd === 'show') {++ const r = harness.store.loadRun(o._[0]);++ if (!r) { out(`no run ${o._[0]} on this machine`); return 1; }++ if (o.json) { for (const e of harness.store.events(r.run_id)) out(JSON.stringify(e)); return 0; }++ out(`${r.run_id} · ${r.cwd} · ${r.permission}`);++ for (const a of r.attempts || []) out(` ${a.ok ? '✔' : '✗'} ${a.agent}${a.session_id ? dim(` session ${a.session_id}`) : ''}${a.error ? ` — ${a.error}` : ''}`);++ if (r.result) out(r.result.result || '');++ return r.result && !r.result.is_error ? 0 : 1;++ }++ out(USAGE);++ return 1;++}++++main().then((code) => process.exit(code), (err) => { process.stderr.write(`openvibe-codes: ${err.message}\n`); process.exit(1); });@@@ -1,6 +1,6 @@# ADR-050: The Codes harness fabric and its boundaries--**Status:** Proposed 2026-10-05. Governs the Codes product code that has not been written yet; the catalog, the++**Status:** Proposed 2026-10-05; decision 8 added by the owner's decision of 2026-10-08. Governs the Codes product code that has not been written yet; the catalog, theplacement adapter and the read API recorded under **Context and current evidence** are the seed thisrecord is written against, as they stand on `main` (most recently `af3aab3`, `a1097e7`). Written in Codes'first `docs/adr/` — the plan (§8) lists **ADR-050 Codes Harness Fabric** among the ADRs "to write before their code", and no earlier record covers it.@@@ -101,6 +101,18 @@ first `docs/adr/` — the plan (§8) lists **ADR-050 Codes Harness Fabric** amonto `trust: 'external'` (`harness-placement.js:44`, asserted at `test/harness-placement.test.js:56`) — whetherfirst-party harnesses should advertise a different class is **open**.++8. **Owner decision, 2026-10-08: the harness runs on the person's machine first.** Codes became the open, modular++ coding-agent harness and the developer console moved to OpenVibe.Services. The write half that decision 5 left open++ starts where it needs no other track: `openvibe-codes` (`harness/`, `bin/`) launches the agents a person already++ has (Claude Code, Codex, OpenCode, Command Code, Aider as CLIs; DeepSeek and any OpenAI-compatible server through++ the harness's own tool loop), turns every agent's output into one event stream in Claude Code's `stream-json` shape,++ hands a failed or silent attempt to the next agent `plan()` picks with a note on what was done, and keeps runs in a++ local store. Routing still goes through `openvibe-sdk/placement` (decision 1): an agent that is not installed or has++ no key is passed to `plan()` as `health: down` with the reason. Nothing is sent to OpenVibe and no key leaves the++ machine, so T2/T5/T14 are not prerequisites for this half. A hosted runner (OpenVibe.Run workers, budgets, metering)++ still waits on T14 and T5, and OpenVibe.Actor (T17) uses Codes for its coding work rather than owning a second++ coding harness.++## Alternatives considered- **A second coding-harness router inside Codes (or reusing `openvibe-agents`' scored router as the optimizer):**nnew file mode 100644@@@ -0,0 +1,206 @@++'use strict';++++/**++ * A coding agent for any model behind an OpenAI-compatible chat API: DeepSeek, OpenAI, or a model you host (llama.cpp,++ * Ollama, vLLM, LM Studio). The model gets a small set of tools that work inside the working directory, and the loop++ * runs until it answers without calling one (or reaches its turn limit):++ *++ * list_dir, read_file, grep every permission level++ * write_file, edit_file edit and full++ * run_command full only (bash -lc in the working directory, two minutes, output clipped)++ *++ * Every path is resolved inside the working directory: `..` out of it, an absolute path elsewhere, `.git/` writes and++ * `.env` files are refused. The conversation is kept in the session store so `resume` continues it.++ */++const fs = require('fs');++const path = require('path');++const { spawn } = require('child_process');++const ev = require('../events');++++const SYSTEM = `You are a careful coding agent working in a repository on the user's machine. Use the tools to look before++you change anything: list and read the files involved, make the smallest change that does what was asked, and keep the++code's own style. When you are done, answer with a short summary of what you changed and anything left to do. Never++invent a file's contents: read it first.`;++++const TOOL_DEFS = {++ list_dir: { level: 'read', description: 'List the entries of a directory (relative to the working directory).', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: [] } },++ read_file: { level: 'read', description: 'Read a text file. Optional 1-based line offset and line limit.', parameters: { type: 'object', properties: { path: { type: 'string' }, offset: { type: 'integer' }, limit: { type: 'integer' } }, required: ['path'] } },++ grep: { level: 'read', description: 'Search files for a regular expression; returns path:line: text matches.', parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' } }, required: ['pattern'] } },++ write_file: { level: 'edit', description: 'Create or replace a file with the given content.', parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] } },++ edit_file: { level: 'edit', description: 'Replace one exact, unique occurrence of old_string with new_string in a file.', parameters: { type: 'object', properties: { path: { type: 'string' }, old_string: { type: 'string' }, new_string: { type: 'string' } }, required: ['path', 'old_string', 'new_string'] } },++ run_command: { level: 'full', description: 'Run a shell command in the working directory (two-minute limit).', parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] } },++};++const LEVELS = { read: 1, edit: 2, full: 3 };++const SHOWN_AS = { list_dir: 'LS', read_file: 'Read', grep: 'Grep', write_file: 'Write', edit_file: 'Edit', run_command: 'Bash' };++const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', 'build', '.next', 'coverage', '.cache']);++++/** A path inside the working directory, or an Error that says why not. */++function inside(cwd, p, { write = false } = {}) {++ const root = fs.realpathSync(cwd);++ const abs = path.resolve(root, String(p || '.'));++ if (abs !== root && !abs.startsWith(`${root}${path.sep}`)) return new Error(`${p} is outside the working directory`);++ const rel = path.relative(root, abs);++ if (/(^|[\\/])\.env(\.|$)/.test(rel)) return new Error('.env files are not read or written');++ if (write && /(^|[\\/])\.git([\\/]|$)/.test(rel)) return new Error('.git is not written');++ return abs;++}++++function runTool(name, args, cwd) {++ const a = args || {};++ if (name === 'list_dir') {++ const dir = inside(cwd, a.path);++ if (dir instanceof Error) return [dir.message, true];++ const items = fs.readdirSync(dir, { withFileTypes: true }).slice(0, 300).map((d) => `${d.name}${d.isDirectory() ? '/' : ''}`);++ return [items.join('\n') || '(empty)', false];++ }++ if (name === 'read_file') {++ const f = inside(cwd, a.path);++ if (f instanceof Error) return [f.message, true];++ const text = fs.readFileSync(f, 'utf8');++ const lines = text.split('\n');++ const from = Math.max(1, Number(a.offset) || 1);++ const to = Math.min(lines.length, from - 1 + Math.max(1, Number(a.limit) || 2000));++ const body = lines.slice(from - 1, to).map((l, i) => `${from + i}\t${l}`).join('\n');++ return [body.length > 64_000 ? `${body.slice(0, 64_000)}\n… (cut: read with offset/limit)` : body, false];++ }++ if (name === 'grep') {++ const base = inside(cwd, a.path);++ if (base instanceof Error) return [base.message, true];++ let re;++ try { re = new RegExp(String(a.pattern)); } catch (err) { return [`bad pattern: ${err.message}`, true]; }++ const hits = [];++ const walk = (p) => {++ if (hits.length >= 200) return;++ const st = fs.statSync(p);++ if (st.isDirectory()) { for (const n of fs.readdirSync(p)) if (!SKIP_DIRS.has(n) && !/^\.env/.test(n)) walk(path.join(p, n)); return; }++ if (st.size > 1_000_000) return;++ const text = fs.readFileSync(p, 'utf8');++ if (text.includes('\u0000')) return;++ text.split('\n').forEach((l, i) => { if (hits.length < 200 && re.test(l)) hits.push(`${path.relative(cwd, p)}:${i + 1}: ${l.slice(0, 300)}`); });++ };++ walk(base);++ return [hits.join('\n') || 'no matches', false];++ }++ if (name === 'write_file') {++ const f = inside(cwd, a.path, { write: true });++ if (f instanceof Error) return [f.message, true];++ fs.mkdirSync(path.dirname(f), { recursive: true });++ fs.writeFileSync(f, String(a.content == null ? '' : a.content));++ return [`wrote ${path.relative(cwd, f)}`, false];++ }++ if (name === 'edit_file') {++ const f = inside(cwd, a.path, { write: true });++ if (f instanceof Error) return [f.message, true];++ const text = fs.readFileSync(f, 'utf8');++ const old = String(a.old_string || '');++ const count = old ? text.split(old).length - 1 : 0;++ if (count !== 1) return [count ? `old_string occurs ${count} times: add context so it is unique` : 'old_string not found: read the file first', true];++ fs.writeFileSync(f, text.replace(old, String(a.new_string == null ? '' : a.new_string)));++ return [`edited ${path.relative(cwd, f)}`, false];++ }++ return [`unknown tool ${name}`, true];++}++++/** run_command, asynchronously (the only tool that waits on something else). */++function runCommand(command, cwd, signal) {++ 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); };++ child.stdout.on('data', add); child.stderr.on('data', add);++ const timer = setTimeout(() => child.kill('SIGKILL'), 120_000);++ const abort = () => child.kill('SIGKILL');++ if (signal) signal.addEventListener('abort', abort, { once: true });++ child.on('close', (code) => { clearTimeout(timer); resolve([`${out.trim()}${code ? `\n(exit ${code})` : ''}` || '(no output)', code !== 0]); });++ child.on('error', (err) => { clearTimeout(timer); resolve([err.message, true]); });++ });++}++++/**++ * apiAgent({ id, name, baseUrl, keyEnv, model, priceIn, priceOut }) → an adapter.++ * ctx (from harness/index.js): { env, fetch, sessions } — sessions keeps the conversation for resume.++ */++function apiAgent(spec) {++ const adapter = {++ id: spec.id, name: spec.name, kind: 'api', baseUrl: spec.baseUrl, keyEnv: spec.keyEnv || null, defaultModel: spec.model,++ start(t, ctx = {}) {++ const env = ctx.env || process.env;++ const doFetch = ctx.fetch || globalThis.fetch;++ const base = String(t.baseUrl || spec.baseUrl).replace(/\/+$/, '');++ const key = spec.keyEnv ? env[spec.keyEnv] : (t.apiKey || '');++ const model = t.model || spec.model;++ const level = LEVELS[t.permission] || 1;++ const tools = Object.entries(TOOL_DEFS).filter(([, d]) => LEVELS[d.level] <= level)++ .map(([name, d]) => ({ type: 'function', function: { name, description: d.description, parameters: d.parameters } }));++ const sessionId = t.resume || `api_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;++ const startedAt = Date.now();++ const maxTurns = Math.max(1, Number(t.maxTurns) || 40);++ let cancelled = false;++ const controller = new AbortController();++ if (t.signal) t.signal.addEventListener('abort', () => { cancelled = true; controller.abort(); }, { once: true });++++ const iterator = (async function* run() {++ const prior = t.resume && ctx.sessions ? ctx.sessions.load(sessionId) : null;++ const messages = prior && Array.isArray(prior.messages) ? prior.messages : [{ role: 'system', content: SYSTEM }];++ messages.push({ role: 'user', content: t.prompt });++ yield ev.init(sessionId, spec.id, model);++ const u = { input: 0, output: 0, cacheRead: 0 };++ let lastText = '';++ let turns = 0;++ let failure = null;++ try {++ while (turns < maxTurns && !cancelled) {++ turns++;++ const res = await doFetch(`${base}/chat/completions`, {++ method: 'POST',++ headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}` } : {}) },++ body: JSON.stringify({ model, messages, tools, tool_choice: 'auto' }),++ signal: controller.signal,++ });++ const body = await res.json().catch(() => null);++ if (!res.ok || !body || !Array.isArray(body.choices) || !body.choices[0]) {++ failure = `${spec.name} answered HTTP ${res.status}${body && body.error ? `: ${String(body.error.message || body.error).slice(0, 300)}` : ''}`;++ yield ev.error(failure);++ break;++ }++ const usage = body.usage || {};++ u.input += +usage.prompt_tokens || 0; u.output += +usage.completion_tokens || 0;++ u.cacheRead += +(usage.prompt_cache_hit_tokens || (usage.prompt_tokens_details && usage.prompt_tokens_details.cached_tokens)) || 0;++ const msg = body.choices[0].message || {};++ messages.push({ role: 'assistant', content: msg.content || '', ...(msg.tool_calls ? { tool_calls: msg.tool_calls } : {}) });++ if (String(msg.content || '').trim()) { lastText = msg.content; yield ev.text(msg.content, model); }++ const calls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];++ if (!calls.length) break;++ for (const call of calls) {++ 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);++ 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 { 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) });++ }++ }++ if (!failure && !cancelled && turns >= maxTurns && !lastText) failure = `${spec.name} reached its turn limit (${maxTurns}) without an answer`;++ } catch (err) {++ failure = cancelled ? `${spec.name} was stopped` : `${spec.name} could not be reached: ${err.message}`;++ yield ev.error(failure);++ }++ if (ctx.sessions) ctx.sessions.save(sessionId, { agent: spec.id, model, messages: messages.slice(-200) });++ const cost = (u.input * (spec.priceIn || 0) + u.output * (spec.priceOut || 0)) / 1000;++ yield ev.result({ ok: !failure && !cancelled, text: failure || lastText, sessionId, model, startedAt, turns, costUsd: cost,++ usage: ev.usage({ input: u.input - u.cacheRead, output: u.output, cacheRead: u.cacheRead }) });++ }());++ iterator.cancel = () => { cancelled = true; controller.abort(); };++ return iterator;++ },++ };++ return adapter;++}++++module.exports = { apiAgent, runTool, inside, TOOL_DEFS, SYSTEM };nnew file mode 100644@@@ -0,0 +1,218 @@++'use strict';++++/**++ * The coding agents that run as a command on the person's machine. Each adapter says how to start its CLI for a task++ * and how to read what it prints into the shared events (harness/events.js):++ *++ * { id, name, kind: 'cli', bin, start(task, ctx) } ctx: { env } (the harness's environment: PATH finds the CLI)++ * task: { prompt, cwd, model, resume, permission: 'read' | 'edit' | 'full', maxTurns, timeoutMs, signal }++ *++ * Permission levels mean the same for every agent, mapped onto what each CLI offers:++ * read look and answer: no file is changed and no command that changes anything runs++ * edit change files in the working directory; shell commands only as the agent's own rules allow++ * full anything the agent can do, unattended (its own guardrails only) — for a sandbox or a throwaway checkout++ * Nothing here pushes, commits or opens a pull request: that is the person's (or a later Improve OpenVibe step's) call.++ */++const ev = require('../events');++const { spawnAgent, exitReason } = require('../process');++++/** A parser that reads JSON lines, hands each object to onEvent, and ends the run with exactly one result. */++function jsonLines(agent, onEvent, onEnd) {++ let sawResult = false;++ const state = {};++ return {++ line(raw, emit) {++ let o;++ try { o = JSON.parse(raw); } catch { if (raw.trim()) emit(ev.stderr(raw)); return; }++ onEvent(o, (e) => { if (e && e.type === 'result') sawResult = true; emit(e); }, state);++ },++ end(info, emit) {++ if (onEnd) onEnd(info, (e) => { if (e && e.type === 'result') sawResult = true; emit(e); }, state);++ if (!sawResult) emit(ev.result({ ok: false, text: exitReason(info, agent.name), sessionId: state.sessionId, model: state.model, startedAt: state.startedAt }));++ },++ };++}++++// ── Claude Code: `claude -p --output-format stream-json` already prints the shared events ──++const claudeCode = {++ id: 'claude-code', name: 'Claude Code', kind: 'cli', bin: 'claude',++ start(t, ctx = {}) {++ const mode = { read: 'plan', edit: 'acceptEdits', full: 'bypassPermissions' }[t.permission] || 'plan';++ const args = ['-p', t.prompt, '--output-format', 'stream-json', '--verbose', '--permission-mode', mode];++ if (t.model) args.push('--model', t.model);++ if (t.resume) args.push('--resume', t.resume);++ if (t.maxTurns) args.push('--max-turns', String(t.maxTurns));++ // Even unattended, a run never pushes: publishing is the person's step.++ args.push('--disallowedTools', 'Bash(git push:*)');++ const parser = jsonLines(claudeCode, (o, emit, st) => {++ if (o.type === 'system' && o.subtype === 'init') { st.sessionId = o.session_id; st.model = o.model; emit(ev.init(o.session_id, 'claude-code', o.model)); return; }++ if (o.type === 'result') { emit({ ...o, result: ev.scrub(o.result || '') }); return; }++ if (o.type === 'assistant' || o.type === 'user') emit(o);++ });++ return spawnAgent({ cmd: this.bin, args, cwd: t.cwd, env: ctx.env, timeoutMs: t.timeoutMs, signal: t.signal, parser });++ },++};++++// ── Codex: `codex exec --json` (thread/turn/item events) ──++const CODEX_TOOL = (it) => {++ if (it.type === 'command_execution') return ['Bash', { command: it.command || '' }];++ if (it.type === 'mcp_tool_call') return [`mcp__${it.server || 'mcp'}__${it.tool || 'tool'}`, it.arguments && typeof it.arguments === 'object' ? it.arguments : {}];++ if (it.type === 'web_search') return ['WebSearch', { query: it.query || '' }];++ return [`codex:${it.type || 'item'}`, {}];++};++const codex = {++ id: 'codex', name: 'Codex', kind: 'cli', bin: 'codex',++ start(t, ctx = {}) {++ const sandbox = { read: 'read-only', edit: 'workspace-write', full: 'danger-full-access' }[t.permission] || 'read-only';++ const args = t.resume++ ? ['exec', 'resume', t.resume, '--json', '--skip-git-repo-check']++ : ['exec', '--json', '--skip-git-repo-check', '-C', t.cwd, '-s', sandbox];++ if (t.model) args.push('-m', t.model);++ args.push(t.prompt);++ const startedAt = Date.now();++ const parser = jsonLines(codex, (e, emit, st) => {++ st.startedAt = startedAt;++ st.usage = st.usage || { input: 0, cached: 0, output: 0 };++ st.started = st.started || new Set();++ const item = e.item || {};++ const id = String(item.id || `${item.type}-${st.started.size}`);++ if (e.type === 'thread.started') { st.sessionId = e.thread_id; emit(ev.init(e.thread_id, 'codex', t.model)); }++ else if (e.type === 'turn.started') st.turns = (st.turns || 0) + 1;++ else if (e.type === 'item.completed' && item.type === 'agent_message' && String(item.text || '').trim()) { st.lastText = item.text; emit(ev.text(item.text, t.model)); }++ else if (e.type === 'item.completed' && item.type === 'file_change') {++ (item.changes || [{}]).forEach((ch, i) => {++ const kind = ch.kind && typeof ch.kind === 'object' ? ch.kind.type : ch.kind;++ emit(ev.toolUse(`${id}:${i}`, kind === 'add' ? 'Write' : kind === 'delete' ? 'Delete' : 'Edit', { file_path: ch.path || '' }, t.model));++ emit(ev.toolResult(`${id}:${i}`, item.status === 'failed' ? 'patch failed' : `${kind || 'update'} applied`, item.status === 'failed'));++ });++ } else if ((e.type === 'item.started' || e.type === 'item.completed') && ['command_execution', 'mcp_tool_call', 'web_search'].includes(item.type)) {++ if (!st.started.has(id)) { st.started.add(id); const [name, input] = CODEX_TOOL(item); emit(ev.toolUse(id, name, input, t.model)); }++ if (e.type === 'item.completed') {++ const failed = item.status === 'failed' || (item.exit_code != null && item.exit_code !== 0);++ const out = item.type === 'command_execution' ? `${item.aggregated_output || ''}${item.exit_code ? `\n(exit ${item.exit_code})` : ''}` : (item.error ? String(item.error.message || item.error) : 'done');++ emit(ev.toolResult(id, out, failed));++ }++ } else if (e.type === 'turn.completed') {++ const u = e.usage || {};++ st.usage.input += +u.input_tokens || 0; st.usage.cached += +u.cached_input_tokens || 0; st.usage.output += +u.output_tokens || 0;++ } else if (e.type === 'turn.failed' || e.type === 'error') {++ const why = String((e.error && (e.error.message || e.error)) || e.message || 'turn failed');++ if (why !== st.failed) emit(ev.error(why)); // Codex reports one failure as an error and a failed turn++ st.failed = why;++ }++ }, (info, emit, st) => {++ if (!st.sessionId && !st.lastText) return; // nothing ran: the generic exit reason says why++ const u = st.usage || { input: 0, cached: 0, output: 0 };++ const ok = !st.failed && !!st.lastText && info.code === 0;++ emit(ev.result({ ok, text: ok ? st.lastText : (st.failed || exitReason(info, 'Codex')), sessionId: st.sessionId, model: t.model, startedAt, turns: st.turns || 0,++ usage: ev.usage({ input: u.input - u.cached, cacheRead: u.cached, output: u.output }) }));++ });++ return spawnAgent({ cmd: this.bin, args, cwd: t.cwd, env: ctx.env, timeoutMs: t.timeoutMs, signal: t.signal, parser });++ },++};++++// ── OpenCode: `opencode run --format json` (text, tool_use, step_finish, error parts) ──++const OC_NAMES = { shell: 'Bash', bash: 'Bash', read: 'Read', edit: 'Edit', write: 'Write', patch: 'Edit', multiedit: 'Edit', grep: 'Grep', glob: 'Glob', list: 'LS', webfetch: 'WebFetch', websearch: 'WebSearch', task: 'Task', todowrite: 'TodoWrite' };++const opencode = {++ id: 'opencode', name: 'OpenCode', kind: 'cli', bin: 'opencode',++ start(t, ctx = {}) {++ const args = ['run', '--format', 'json'];++ if (t.permission !== 'read') args.push('--auto');++ if (t.model) args.push('-m', t.model);++ if (t.resume) args.push('-s', t.resume);++ args.push('--', t.prompt);++ const startedAt = Date.now();++ const parser = jsonLines(opencode, (e, emit, st) => {++ st.startedAt = startedAt;++ st.u = st.u || { input: 0, output: 0, read: 0, write: 0, cost: 0 };++ if (e.sessionID && !st.sessionId) { st.sessionId = e.sessionID; emit(ev.init(e.sessionID, 'opencode', t.model)); }++ const p = e.part || {};++ if (e.type === 'tool_use') {++ const s = p.state || {};++ const input = { ...(s.input || {}) };++ if (input.path && !input.file_path) input.file_path = input.path;++ emit(ev.toolUse(p.id || p.partID, OC_NAMES[p.tool] || p.tool, input, t.model));++ emit(ev.toolResult(p.id || p.partID, s.status === 'error' ? String(s.error || 'the tool failed') : typeof s.output === 'string' ? s.output : JSON.stringify(s.output || ''), s.status === 'error'));++ } else if (e.type === 'text' && String(p.text || '').trim()) {++ st.lastText = (st.lastMsg === p.messageID ? `${st.lastText || ''}` : '') + p.text;++ st.lastMsg = p.messageID;++ emit(ev.text(p.text, t.model));++ } else if (e.type === 'step_finish') {++ const k = p.tokens || {};++ st.turns = (st.turns || 0) + 1;++ st.u.input += k.input || 0; st.u.output += (k.output || 0) + (k.reasoning || 0); st.u.read += (k.cache && k.cache.read) || 0; st.u.write += (k.cache && k.cache.write) || 0; st.u.cost += +p.cost || 0;++ } else if (e.type === 'error') {++ st.failed = String((e.error && (e.error.message || e.error.type)) || 'unknown error');++ emit(ev.error(st.failed));++ }++ }, (info, emit, st) => {++ if (!st.sessionId && !st.lastText) return;++ const u = st.u || { input: 0, output: 0, read: 0, write: 0, cost: 0 };++ const final = String(st.lastText || '').trim();++ emit(ev.result({ ok: !!final && !st.failed, text: final || st.failed || exitReason(info, 'OpenCode'), sessionId: st.sessionId, model: t.model, startedAt, turns: st.turns || 0, costUsd: u.cost,++ usage: ev.usage({ input: u.input, output: u.output, cacheRead: u.read, cacheWrite: u.write }) }));++ });++ return spawnAgent({ cmd: this.bin, args, cwd: t.cwd, env: ctx.env, timeoutMs: t.timeoutMs, signal: t.signal, parser });++ },++};++++// ── Command Code: `cmd --print --output-format json` (run_start, message_end, tool_* events, result) ──++const CC_NAMES = { read_file: 'Read', shell: 'Bash', run_shell: 'Bash', shell_command: 'Bash', read_directory: 'LS', grep: 'Grep', glob: 'Glob', edit_file: 'Edit', write_file: 'Write', list_directory: 'LS', web_fetch: 'WebFetch', task: 'Task' };++const commandCode = {++ id: 'command-code', name: 'Command Code', kind: 'cli', bin: 'cmd',++ start(t, ctx = {}) {++ const args = [`--print=${t.prompt}`, '--output-format', 'json', '--skip-onboarding', '--no-auto-update', '-t'];++ if (t.permission !== 'read') args.push('--yolo');++ if (t.model) args.push('-m', t.model);++ if (t.resume) args.push('--resume', t.resume);++ if (t.maxTurns) args.push('--max-turns', String(t.maxTurns));++ const startedAt = Date.now();++ const parser = jsonLines(commandCode, (f, emit, st) => {++ st.startedAt = startedAt;++ if (f.type === 'result') {++ const err = f.subtype === 'error' ? String((f.error && (f.error.message || f.error)) || 'unknown error') : null;++ const u = f.usage || {};++ emit(ev.result({ ok: f.subtype === 'success', text: f.finalText || err || '', sessionId: f.sessionId || st.sessionId, model: st.model, startedAt,++ usage: ev.usage({ input: u.inputTokens, output: u.outputTokens, cacheRead: u.cacheReadTokens, cacheWrite: u.cacheWriteTokens }) }));++ return;++ }++ const e = f.type === 'event' && f.event;++ if (!e) return;++ if (e.type === 'run_start') { st.sessionId = e.sessionId; emit(ev.init(e.sessionId, 'command-code', t.model)); }++ else if (e.type === 'model_request_start') st.model = e.model || st.model;++ else if (e.type === 'message_end') {++ for (const c of e.content || []) {++ if (c.type === 'text' && String(c.text || '').trim()) emit(ev.text(c.text, st.model));++ else if (c.type === 'tool_use') emit(ev.toolUse(c.id, CC_NAMES[c.name] || c.name, c.input || {}, st.model));++ }++ } else if (e.type === 'tool_completed') emit(ev.toolResult(e.toolCallId, (e.result || []).map((r) => r.text || '').join('\n'), !!e.isError));++ else if (['tool_denied', 'tool_failed', 'tool_error', 'tool_cancelled'].includes(e.type)) emit(ev.toolResult(e.toolCallId, e.type === 'tool_denied' ? 'denied by the permission level' : String(e.error || e.message || e.type), true));++ else if (e.type === 'error' || e.type === 'run_error') emit(ev.error(typeof (e.message || e.error) === 'object' ? JSON.stringify(e.message || e.error) : String(e.message || e.error || 'unknown')));++ });++ return spawnAgent({ cmd: this.bin, args, cwd: t.cwd, env: ctx.env, timeoutMs: t.timeoutMs, signal: t.signal, parser });++ },++};++++// ── Aider: plain text on stdout; the answer is what it printed ──++const aider = {++ id: 'aider', name: 'Aider', kind: 'cli', bin: 'aider',++ start(t, ctx = {}) {++ const args = ['--message', t.prompt, '--yes-always', '--no-pretty', '--no-stream', '--no-check-update', '--no-show-release-notes'];++ if (t.permission === 'read') args.push('--dry-run');++ if (t.model) args.push('--model', t.model);++ const startedAt = Date.now();++ const lines = [];++ let started = false;++ const parser = {++ line(raw, emit) { if (!started) { started = true; emit(ev.init(null, 'aider', t.model)); } lines.push(raw); },++ end(info, emit) {++ const out = lines.join('\n').trim();++ if (out) emit(ev.text(out.slice(-8000), t.model));++ emit(ev.result({ ok: info.code === 0 && !!out, text: info.code === 0 && out ? out.slice(-2000) : exitReason(info, 'Aider'), model: t.model, startedAt }));++ },++ };++ return spawnAgent({ cmd: this.bin, args, cwd: t.cwd, env: ctx.env, timeoutMs: t.timeoutMs, signal: t.signal, parser });++ },++};++++module.exports = { claudeCode, codex, opencode, commandCode, aider, jsonLines };nnew file mode 100644@@@ -0,0 +1,88 @@++'use strict';++++/**++ * One event shape for every coding agent: Claude Code's stream-json (one JSON object per line), the format its own++ * `--output-format stream-json` prints and the one tools already read. Every adapter turns its agent's output into++ * these, so a run reads the same whichever agent did the work:++ *++ * { type: 'system', subtype: 'init', session_id, agent, model } the run started (session_id resumes it)++ * { type: 'assistant', message: { model, content: [ { type: 'text', text } | { type: 'tool_use', id, name, input } ],++ * 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: '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++ *++ * Tool names are Claude Code's (Bash, Read, Edit, Write, Grep, Glob, LS, WebFetch, WebSearch, Task, TodoWrite) when++ * the agent's tool is one of them, so a reader needs one vocabulary.++ */++++const clip = (s, n) => { s = String(s == null ? '' : s); return s.length > n ? `${s.slice(0, n - 1)}…` : s; };++++// Credentials an agent prints or reads (a key in a config file, a token in an error) never reach the event stream:++// provider keys, GitHub tokens, JWTs, private-key blocks and `key=…` / `"token": "…"` pairs become [redacted].++const SECRET_PATTERNS = [++ /\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}/g,++ /\bgh[pousr]_[A-Za-z0-9]{20,}/g,++ /\bgithub_pat_[A-Za-z0-9_]{20,}/g,++ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,++ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,++ /\b((?:api[_-]?key|access[_-]?token|secret|password|client[_-]?secret)["']?\s*[:=]\s*["']?)[^\s"']{8,}/gi,++];++function scrub(s) {++ let out = String(s == null ? '' : s);++ for (const re of SECRET_PATTERNS) out = out.replace(re, (m, keep) => (typeof keep === 'string' && m.startsWith(keep) ? `${keep}[redacted]` : '[redacted]'));++ return out;++}++++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 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) });++const error = (t) => ({ type: 'system', subtype: 'error', text: clip(scrub(t), 500) });++const stderr = (t) => ({ type: 'system', subtype: 'stderr', text: clip(scrub(t), 500) });++++/** Token usage in the result's shape, from any counts (missing ones are 0). */++function usage({ input = 0, output = 0, cacheRead = 0, cacheWrite = 0 } = {}) {++ const n = (x) => (Number.isFinite(+x) ? Math.max(0, +x) : 0);++ return { input_tokens: n(input), output_tokens: n(output), cache_read_input_tokens: n(cacheRead), cache_creation_input_tokens: n(cacheWrite) };++}++++/** The one result line an agent run ends with. */++function result({ ok, text: final, sessionId, model, startedAt, turns = 0, costUsd = 0, usage: u = usage() }) {++ return {++ type: 'result', subtype: ok ? 'success' : 'error', is_error: !ok, result: scrub(final || ''),++ session_id: sessionId || null, model: model || null, duration_ms: startedAt ? Date.now() - startedAt : 0,++ num_turns: turns, total_cost_usd: Math.round((Number(costUsd) || 0) * 1e5) / 1e5, usage: u,++ };++}++++/**++ * Follows a stream of events and keeps what a summary needs: the session, the last text, the tools called, the files++ * touched, errors, and the result line. Used by the CLI's printer, the run log and the hand-off note.++ */++function createTally() {++ const t = { sessionId: null, model: null, lastText: '', tools: 0, files: new Set(), errors: [], result: null };++ function add(e) {++ if (!e || typeof e !== 'object') return;++ if (e.type === 'system' && e.subtype === 'init') { t.sessionId = e.session_id || t.sessionId; t.model = e.model || t.model; }++ if (e.type === 'system' && e.subtype === 'error') t.errors.push(e.text);++ if (e.type === 'assistant') {++ for (const c of (e.message && e.message.content) || []) {++ if (c.type === 'text' && String(c.text).trim()) t.lastText = c.text;++ if (c.type === 'tool_use') {++ t.tools++;++ const f = c.input && (c.input.file_path || c.input.path);++ if (f && ['Edit', 'Write', 'Delete'].includes(c.name)) t.files.add(String(f));++ }++ }++ }++ if (e.type === 'result') { t.result = e; t.sessionId = e.session_id || t.sessionId; }++ }++ return { add, get: () => t };++}++++module.exports = { init, text, toolUse, toolResult, usageEvent, warning, error, stderr, usage, result, createTally, clip, scrub };nnew file mode 100644@@@ -0,0 +1,206 @@++'use strict';++++/**++ * The OpenVibe.Codes harness: every coding agent behind one interface, routed by what the task needs, with hand-offs.++ *++ * const harness = createHarness({ env, fetch, store });++ * harness.agents() the catalog with what is available on this machine, and why not++ * harness.route({ task, requirements }) the placement decision (openvibe-sdk/placement over the catalog's offers)++ * const run = harness.run({ prompt, task, agent?, model?, cwd, permission, resume?, handoff, maxAttempts })++ * for await (const event of run) … the shared events (harness/events.js), every attempt in turn++ * run.summary() { run_id, attempts: [{ agent, ok, session_id, … }], result }++ *++ * Routing is the site's own (server/domain/harness-placement.js over server/data/harness-offers.json): capability++ * first, then cost; an agent that is not installed or has no key here is offered to the planner as down, so the++ * decision names it and says why. A hand-off happens when an attempt ends in error or goes quiet for `stallMs`: the++ * next agent the planner picks (the ones that already failed are left out) gets the task with a note on what the++ * previous one did and what the working tree looks like now.++ */++const fs = require('fs');++const path = require('path');++const { execFileSync } = require('child_process');++const { createHarnesses } = require('../server/domain/harnesses');++const placement = require('../server/domain/harness-placement');++const cli = require('./adapters/cli');++const { apiAgent } = require('./adapters/api');++const ev = require('./events');++const { createStore } = require('./sessions');++++/** The adapter for each catalog harness (the catalog row's id). */++function adaptersFor(env) {++ return {++ 'claude-code': cli.claudeCode,++ codex: cli.codex,++ opencode: cli.opencode,++ 'command-code': cli.commandCode,++ aider: cli.aider,++ deepseek: apiAgent({ id: 'deepseek', name: 'DeepSeek', baseUrl: env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com', keyEnv: 'DEEPSEEK_API_KEY', model: 'deepseek-chat', priceIn: 0.00027, priceOut: 0.0011 }),++ 'openai-compatible': apiAgent({ id: 'openai-compatible', name: 'Your own model', baseUrl: env.OPENVIBE_CODES_BASE_URL || 'http://127.0.0.1:8080/v1', keyEnv: 'OPENVIBE_CODES_API_KEY', model: env.OPENVIBE_CODES_MODEL || 'local' }),++ };++}++++/** Is the executable on PATH? (No shell, no `which`.) */++function onPath(bin, env) {++ for (const dir of String(env.PATH || '').split(path.delimiter).filter(Boolean)) {++ try { fs.accessSync(path.join(dir, bin), fs.constants.X_OK); return path.join(dir, bin); } catch { /* next */ }++ }++ return null;++}++++function availability(adapter, env) {++ if (!adapter) return { available: false, reason: 'no adapter for this harness yet' };++ if (adapter.kind === 'cli') {++ const where = onPath(adapter.bin, env);++ return where ? { available: true, where } : { available: false, reason: `\`${adapter.bin}\` is not on PATH (install ${adapter.name})` };++ }++ if (adapter.id === 'openai-compatible') return env.OPENVIBE_CODES_BASE_URL ? { available: true, where: env.OPENVIBE_CODES_BASE_URL } : { available: false, reason: 'set OPENVIBE_CODES_BASE_URL (and OPENVIBE_CODES_MODEL) to your server' };++ return env[adapter.keyEnv] ? { available: true, where: adapter.baseUrl } : { available: false, reason: `set ${adapter.keyEnv}` };++}++++/** What changed in the working tree, for a hand-off note (empty outside a git checkout). */++function treeState(cwd) {++ try {++ const git = (args) => execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();++ const status = git(['status', '--short']);++ const stat = git(['diff', '--stat']);++ return [status && `git status --short:\n${status.split('\n').slice(0, 40).join('\n')}`, stat && `git diff --stat:\n${stat.split('\n').slice(-20).join('\n')}`].filter(Boolean).join('\n\n');++ } catch { return ''; }++}++++function handoffNote({ prompt, from, why, tally, cwd }) {++ const t = tally;++ const files = [...t.files].slice(0, 30);++ return [++ `You are taking over a coding task that ${from} started and could not finish (${why}).`,++ '',++ 'The task:',++ prompt,++ '',++ `What ${from} did: ${t.tools} tool call(s)${files.length ? `, files it edited: ${files.join(', ')}` : ''}.`,++ t.lastText ? `Its last message:\n${ev.clip(t.lastText, 2000)}` : '',++ treeState(cwd) ? `The working tree now:\n${treeState(cwd)}` : '',++ '',++ 'Look at the current state before you change anything, finish the task, and end with a short summary of what you did.',++ ].filter((l) => l !== '').join('\n');++}++++function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch, store = createStore({ env }), catalog = createHarnesses() } = {}) {++ const adapters = adaptersFor(env);++++ function agents() {++ return catalog.list().map((h) => ({ ...h, agents: catalog.agents(h.id), adapter: adapters[h.id] ? adapters[h.id].kind : null, ...availability(adapters[h.id], env) }));++ }++++ /** The placement decision among the agents available here; `exclude` leaves harnesses out (a hand-off's earlier attempts). */++ function route({ task = 'edit', requirements = {}, exclude = [] } = {}) {++ const opts = {};++ const why = {};++ for (const h of catalog.list()) {++ const a = availability(adapters[h.id], env);++ const down = !a.available || exclude.includes(h.id);++ for (const agent of catalog.agents(h.id)) {++ const id = `${h.id}:${agent.id || agent.model}`;++ if (down) { opts[id] = { health: 'down' }; why[id] = exclude.includes(h.id) ? 'already tried in this run' : a.reason; }++ }++ }++ const decision = placement.route({ task, extra: requirements, harnesses: catalog, opts });++ decision.candidates = (decision.candidates || []).map((c) => (why[c.id] ? { ...c, included: false, reason: why[c.id] } : c));++ return decision;++ }++++ function run({ prompt, task = 'edit', agent = null, model = null, cwd = process.cwd(), permission = 'edit', resume = null, handoff = true, maxAttempts = 3, stallMs = 10 * 60_000, timeoutMs, maxTurns, requirements = {}, signal } = {}) {++ if (!prompt || !String(prompt).trim()) throw new Error('a run needs a prompt');++ if (!['read', 'edit', 'full'].includes(permission)) throw new Error('permission is read, edit or full');++ const runId = store.newRunId();++ const attempts = [];++ let final = null;++ let current = null;++++ const summary = () => ({ run_id: runId, prompt: ev.clip(prompt, 500), cwd, task, permission, attempts, result: final, started_at: attempts[0] ? attempts[0].started_at : null });++++ const iterator = (async function* events() {++ const tried = [];++ let nextPrompt = String(prompt);++ for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt++) {++ let harnessId;++ let modelFor = model;++ if (attempt === 1 && agent) {++ harnessId = agent;++ const a = availability(adapters[agent], env);++ if (!adapters[agent] || !a.available) {++ final = ev.result({ ok: false, text: `${agent} is not available here: ${a.reason || 'unknown agent'}` });++ yield final; break;++ }++ } else {++ const decision = route({ task, requirements, exclude: tried });++ if (!decision.selected) {++ const reason = attempt === 1 ? 'no available agent can take this task' : 'no other agent is available to take over';++ const text = `${reason}: ${(decision.candidates || []).map((c) => `${c.id} (${c.reason || 'excluded'})`).join('; ')}`;++ yield ev.error(text);++ // The run ends with the last attempt's own result (or this refusal when nothing ran).++ if (attempt === 1) final = ev.result({ ok: false, text });++ yield final;++ break;++ }++ harnessId = decision.selected.split(':')[0];++ if (attempt > 1) modelFor = null; // the model named for the first agent is that agent's++ }++ tried.push(harnessId);++ const adapter = adapters[harnessId];++ const started = Date.now();++ const rec = { attempt, agent: harnessId, started_at: new Date(started).toISOString(), ok: false, session_id: null };++ attempts.push(rec);++ const tally = ev.createTally();++ const controller = new AbortController();++ if (signal) signal.addEventListener('abort', () => controller.abort(), { once: true });++ current = adapter.start({ prompt: nextPrompt, cwd, model: modelFor, resume: attempt === 1 ? resume : null, permission, maxTurns, timeoutMs, signal: controller.signal }, { env, fetch: fetchImpl, sessions: store });++ let stalled = false;++ let res = null;++ let pending = null; // the next() still in flight when the stall timer won the race++ while (true) {++ let timer;++ const quiet = new Promise((r) => { timer = setTimeout(() => r({ stalled: true }), stallMs); timer.unref?.(); });++ if (!pending) pending = current.next();++ const next = await Promise.race([pending, quiet]);++ clearTimeout(timer);++ if (next.stalled) { if (!stalled) { stalled = true; current.cancel(); controller.abort(); } continue; }++ pending = null;++ if (next.done) break;++ const e = { ...next.value, _attempt: attempt, _agent: harnessId };++ tally.add(next.value);++ store.appendEvent(runId, e);++ if (next.value.type === 'result') { res = next.value; continue; }++ yield e;++ }++ const t = tally.get();++ rec.session_id = t.sessionId;++ rec.ok = !!(res && !res.is_error);++ rec.duration_ms = Date.now() - started;++ rec.cost_usd = res ? res.total_cost_usd : 0;++ if (stalled) {++ const quietFor = stallMs >= 60_000 ? `${Math.round(stallMs / 60_000)} min` : `${Math.round(stallMs / 1000)} s`;++ res = { ...(res || ev.result({ ok: false })), subtype: 'error', is_error: true, result: `went quiet for ${quietFor} and was stopped` };++ rec.ok = false;++ }++ rec.error = rec.ok ? null : (res ? ev.clip(res.result, 300) : 'no result');++ final = res ? { ...res, _attempt: attempt, _agent: harnessId } : ev.result({ ok: false, text: 'the agent ended without a result' });++ if (rec.ok || !handoff || (signal && signal.aborted)) { yield final; break; }++ if (attempt >= maxAttempts) { yield final; break; }++ const why = rec.error || 'it failed';++ nextPrompt = handoffNote({ prompt, from: adapter.name, why, tally: t, cwd });++ const note = { type: 'system', subtype: 'handoff', from: harnessId, reason: why, attempt: attempt + 1 };++ store.appendEvent(runId, note);++ yield note;++ }++ store.saveRun(runId, summary());++ }());++ iterator.runId = runId;++ iterator.summary = summary;++ iterator.cancel = () => { if (current && current.cancel) current.cancel(); };++ return iterator;++ }++++ return { agents, route, run, store, adapters };++}++++module.exports = { createHarness, adaptersFor, availability, handoffNote, onPath };nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++++/**++ * Runs one agent CLI and turns its output into events (harness/events.js) as they come.++ *++ * const run = spawnAgent({ cmd, args, cwd, env, timeoutMs, signal, parser });++ * for await (const event of run) …; // ends after the parser's end() and the process exit++ * run.cancel(); // SIGTERM, then SIGKILL after 5 s++ *++ * parser: { line(raw, emit), end({ code, signal, timedOut, stderr }, emit) } — line() sees every stdout line, end()++ * runs once when the process has exited and must emit the run's one `result` event if line() did not. stderr is kept++ * (the last 4 kB) for end(); whatever of it is shown goes through events.scrub() first.++ */++const { spawn } = require('child_process');++const readline = require('readline');++const { scrub } = require('./events');++++function spawnAgent({ cmd, args = [], cwd, env, timeoutMs = 45 * 60_000, signal, parser, stdin = null }) {++ const queue = [];++ let wake = null;++ let finished = false;++ const emit = (e) => { if (e) { queue.push(e); if (wake) { wake(); wake = null; } } };++ const finish = () => { finished = true; if (wake) { wake(); wake = null; } };++++ let child;++ try {++ child = spawn(cmd, args, { cwd, env: { ...process.env, ...(env || {}) }, stdio: [stdin == null ? 'ignore' : 'pipe', 'pipe', 'pipe'] });++ } catch (err) {++ parser.end({ code: null, signal: null, timedOut: false, stderr: '', spawnError: err.message }, emit);++ finish();++ }++ let stderrBuf = '';++ let timedOut = false;++ let killTimer = null;++ const cancel = () => {++ if (!child || child.exitCode !== null || child.signalCode) return;++ try { child.kill('SIGTERM'); } catch { /* gone */ }++ killTimer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 5000);++ killTimer.unref();++ };++ if (child) {++ if (stdin != null) { child.stdin.end(stdin); }++ const timer = setTimeout(() => { timedOut = true; cancel(); }, timeoutMs);++ timer.unref();++ if (signal) { if (signal.aborted) cancel(); else signal.addEventListener('abort', cancel, { once: true }); }++ readline.createInterface({ input: child.stdout }).on('line', (raw) => { try { parser.line(raw, emit); } catch (err) { emit({ type: 'system', subtype: 'warning', text: `unreadable line: ${err.message}` }); } });++ child.stderr.on('data', (d) => { stderrBuf = (stderrBuf + d.toString()).slice(-4096); });++ child.on('error', (err) => { stderrBuf += `\n${err.message}`; });++ child.on('close', (code, sig) => {++ clearTimeout(timer);++ if (killTimer) clearTimeout(killTimer);++ // Let readline drain the last stdout lines before the parser ends the run.++ setImmediate(() => {++ try { parser.end({ code, signal: sig, timedOut, stderr: stderrBuf }, emit); } catch (err) { emit({ type: 'system', subtype: 'error', text: err.message }); }++ finish();++ });++ });++ }++++ const iterator = (async function* events() {++ while (true) {++ if (queue.length) { yield queue.shift(); continue; }++ if (finished) return;++ await new Promise((r) => { wake = r; });++ }++ }());++ iterator.cancel = cancel;++ iterator.pid = child ? child.pid : null;++ return iterator;++}++++/** Why an agent's process ended without a result, in words a person can act on. */++function exitReason({ code, signal, timedOut, stderr, spawnError }, name) {++ if (spawnError) return `${name} could not start: ${spawnError}`;++ if (timedOut) return `${name} ran past its time limit and was stopped`;++ if (signal) return `${name} was stopped (${signal})`;++ const last = String(stderr || '').trim().split('\n').filter(Boolean).pop();++ return `${name} ended without a result (exit ${code})${last ? `: ${scrub(last).slice(0, 200)}` : ''}`;++}++++module.exports = { spawnAgent, exitReason };nnew file mode 100644@@@ -0,0 +1,48 @@++'use strict';++++/**++ * Where the harness keeps its runs on the person's machine (never on a server):++ *++ * $OPENVIBE_CODES_HOME, else $XDG_STATE_HOME/openvibe-codes, else ~/.local/state/openvibe-codes++ * runs/<run id>.jsonl every event of the run, each tagged with its attempt and agent++ * runs/<run id>.json the run's summary: prompt (first 500 characters), working directory, attempts, result++ * sessions/<id>.json an API agent's conversation, so `resume` continues it++ *++ * Files are created 0600 in 0700 directories: a run's events can quote the code it worked on.++ */++const fs = require('fs');++const os = require('os');++const path = require('path');++++function homeDir(env = process.env) {++ if (env.OPENVIBE_CODES_HOME) return path.resolve(env.OPENVIBE_CODES_HOME);++ const state = env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state');++ return path.join(state, 'openvibe-codes');++}++++function createStore({ env = process.env } = {}) {++ const root = homeDir(env);++ const dir = (name) => { const d = path.join(root, name); fs.mkdirSync(d, { recursive: true, mode: 0o700 }); return d; };++ const safe = (id) => String(id).replace(/[^A-Za-z0-9_.:-]/g, '_').slice(0, 120);++ const write = (file, text) => fs.writeFileSync(file, text, { mode: 0o600 });++++ return {++ root,++ newRunId: () => `run_${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)}_${Math.random().toString(36).slice(2, 8)}`,++ appendEvent(runId, event) { fs.appendFileSync(path.join(dir('runs'), `${safe(runId)}.jsonl`), `${JSON.stringify(event)}\n`, { mode: 0o600 }); },++ saveRun(runId, summary) { write(path.join(dir('runs'), `${safe(runId)}.json`), `${JSON.stringify(summary, null, 2)}\n`); },++ loadRun(runId) { try { return JSON.parse(fs.readFileSync(path.join(dir('runs'), `${safe(runId)}.json`), 'utf8')); } catch { return null; } },++ events(runId) {++ try { return fs.readFileSync(path.join(dir('runs'), `${safe(runId)}.jsonl`), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)); } catch { return []; }++ },++ listRuns(limit = 20) {++ const d = dir('runs');++ return fs.readdirSync(d).filter((f) => f.endsWith('.json')).sort().reverse().slice(0, limit)++ .map((f) => { try { return JSON.parse(fs.readFileSync(path.join(d, f), 'utf8')); } catch { return null; } }).filter(Boolean);++ },++ load(sessionId) { try { return JSON.parse(fs.readFileSync(path.join(dir('sessions'), `${safe(sessionId)}.json`), 'utf8')); } catch { return null; } },++ save(sessionId, data) { write(path.join(dir('sessions'), `${safe(sessionId)}.json`), JSON.stringify(data)); },++ };++}++++module.exports = { createStore, homeDir };@@@ -1,13 +1,16 @@{"name": "openvibe-codes",-- "version": "0.1.0",++ "version": "0.2.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-codes",-- "version": "0.1.0",++ "version": "0.2.0","license": "AGPL-3.0-only",++ "bin": {++ "openvibe-codes": "bin/openvibe-codes.js"++ },"dependencies": {"cookie-parser": "^1.4.7","dotenv": "^16.4.7",@@@ -1,9 +1,12 @@{"name": "openvibe-codes",-- "version": "0.1.0",++ "version": "0.2.0","private": true,-- "description": "OpenVibe.Codes: the open coding-agent harness. The agent catalog (Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API) with a public routing API, and the way to improve OpenVibe itself. Server-rendered and useful without JavaScript.",-- "main": "server/index.js",++ "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",++ "bin": {++ "openvibe-codes": "bin/openvibe-codes.js"++ },"scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",@@@ -14,7 +14,9 @@"vision": true,"browser": false,"computer_use": false,-- "runtimes": ["code"]++ "runtimes": [++ "code"++ ]},"address": {"kind": "cli",@@@ -64,7 +66,9 @@"vision": true,"browser": false,"computer_use": false,-- "runtimes": ["code"]++ "runtimes": [++ "code"++ ]},"address": {"kind": "cli",@@@ -114,7 +118,9 @@"vision": false,"browser": false,"computer_use": false,-- "runtimes": ["code"]++ "runtimes": [++ "code"++ ]},"address": {"kind": "cli",@@@ -164,7 +170,9 @@"vision": true,"browser": false,"computer_use": false,-- "runtimes": ["code"]++ "runtimes": [++ "code"++ ]},"address": {"kind": "cli",@@@ -214,7 +222,9 @@"vision": true,"browser": false,"computer_use": false,-- "runtimes": ["code"]++ "runtimes": [++ "code"++ ]},"address": {"kind": "cli",@@@ -254,13 +264,13 @@"name": "DeepSeek","provider": "deepseek","capabilities": {-- "host_access": false,++ "host_access": true,"mcp": false,"long_autonomy": false,-- "resume": false,-- "edit": false,-- "review": false,-- "tools": false,++ "resume": true,++ "edit": true,++ "review": true,++ "tools": true,"vision": false,"browser": false,"computer_use": false@@@ -289,6 +299,55 @@"output_tokens": 8192},"concurrency": 8,++ "price_per_1k_tokens": {++ "fresh_usd": 0.00027,++ "cached_usd": 0.00007,++ "output_usd": 0.0011++ },++ "availability_windows": []++ }++ ]++ },++ {++ "id": "openai-compatible",++ "name": "Your own model",++ "provider": "self-hosted",++ "capabilities": {++ "host_access": true,++ "mcp": false,++ "long_autonomy": false,++ "resume": true,++ "edit": true,++ "review": true,++ "tools": true,++ "vision": false,++ "browser": false,++ "computer_use": false++ },++ "address": {++ "kind": "api",++ "target": "http://127.0.0.1:8080/v1"++ },++ "price": {++ "amount_usd": 0,++ "unit": "hour"++ },++ "limits": {++ "max_duration_seconds": 3600,++ "max_concurrent_runs": 2,++ "max_context_tokens": 32000++ },++ "agents": [++ {++ "id": "openai-compatible-local",++ "harness": "openai-compatible",++ "provider": "self-hosted",++ "model": "local",++ "context_limits": {++ "input_tokens": 32000,++ "output_tokens": 4096++ },++ "concurrency": 2,"price_per_1k_tokens": {"fresh_usd": 0,"cached_usd": 0,@@@ -19,15 +19,19 @@ const { asyncRouter } = require('./router');const SITE_NAME = 'OpenVibe.Codes';const SERVICES_ORIGIN = 'https://openvibe.services';--const DESCRIPTION = 'The open coding-agent harness: send a coding task to Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or a model you host, with routing by capability and cost, hand-offs between agents, and the way to improve OpenVibe itself.';++const DESCRIPTION = 'The open coding-agent harness: run a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or a model you host, with routing by capability and cost, hand-offs between agents, and the way to improve OpenVibe itself.';++const { version: VERSION } = require('../../package.json');++const INSTALL_HINT = `npm install -g https://codeload.github.com/OpenVibers/OpenVibe.Codes/tar.gz/refs/tags/v${VERSION}`;++const HARNESS_PAGES = ['/', '/start', '/harnesses', '/improve'];// Sign-in and the API are not for crawlers. The console's old addresses redirect to openvibe.services.const DISALLOW = ['/auth/', '/api/'];// The one line /llms-full.txt says about each fixed public page: a title and what the page serves.// These are the pages publicPages() lists (and so the sitemap), so the two cannot name different sets.const PAGE_TEXT = {-- '/': ['OpenVibe.Codes home', 'Code with any agent: the coding-agent catalog, how a task is routed, what works today, and how to improve OpenVibe.'],-- '/harnesses': ['The coding agents', 'Every harness the router can pick (Claude Code, Codex, OpenCode, Command Code, Aider, DeepSeek) with its models, capabilities, prices and limits.'],++ '/': ['OpenVibe.Codes home', 'Code with any agent: the coding-agent harness, how a task is routed and handed off, what works today, and how to improve OpenVibe.'],++ '/start': ['Get started with the coding-agent harness', 'Install openvibe-codes; the agents it can use and what each needs; the commands, permission levels, hand-offs, the event format and using it from your own code.'],++ '/harnesses': ['The coding agents', 'Every harness the router can pick (Claude Code, Codex, OpenCode, Command Code, Aider, DeepSeek, your own model) with its models, capabilities, prices and limits.'],'/improve': ['Improve OpenVibe', 'How OpenVibe is built in the open: every repository, and the path from a change to a reviewed pull request.'],'/updates': ['What shipped on OpenVibe.Codes', 'This site\'s update log, from the network changelog feed.'],'/policy': ['Community', 'The code of conduct, the contributing guide, the contributor ladder and the moderation policy.'],@@@ -52,7 +56,7 @@ function homeJsonLd(config) {const site = String(config.baseUrl).replace(/\/+$/, '');return [seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),-- seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'DeveloperApplication', keywords: 'ai coding agent, coding agent router, claude code, codex, opencode, deepseek, aider, open source coding harness, contribute to open source' }),++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'DeveloperApplication', keywords: 'ai coding agent, coding agent harness, coding agent router, agent hand-off, claude code, codex, opencode, command code, deepseek, aider, self-hosted coding agent, open source coding harness, contribute to open source' }),seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),];}@@@ -61,6 +65,7 @@ function homeJsonLd(config) {function publicPages({ governance = [] }) {const pages = [{ path: '/', changefreq: 'weekly', priority: 1.0 },++ { path: '/start', changefreq: 'weekly', priority: 0.9 },{ path: '/harnesses', changefreq: 'weekly', priority: 0.9 },{ path: '/improve', changefreq: 'weekly', priority: 0.8 },{ path: '/updates', changefreq: 'daily', priority: 0.5 },@@@ -95,11 +100,12 @@ function createDiscoveryRoutes(ctx) {r.get('/llms.txt', (_req, res) => {res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({name: SITE_NAME,-- summary: 'OpenVibe.Codes: the open coding-agent harness. A catalog of coding agents (Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API) with a public routing API, and the way to improve OpenVibe itself.',-- details: `Every page is server-rendered and readable without JavaScript. The catalog and the router are public: GET /api/v1/harnesses lists every harness with its models, and POST /api/v1/harnesses/route picks one for a task by capability first, then cost. Running tasks, hand-offs and a self-hosted runner come next. The developer console that used to live here (projects, apps, keys, the API reference, OAuth and webhook tools, releases) is OpenVibe.Services at ${SERVICES_ORIGIN}.`,++ summary: 'OpenVibe.Codes: the open coding-agent harness. openvibe-codes runs a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or your own OpenAI-compatible model, hands it to the next agent when one fails, and keeps every run; plus a public catalog and routing API, and the way to improve OpenVibe itself.',++ details: `Every page is server-rendered and readable without JavaScript. The harness is a command-line tool and a Node.js module that runs on your machine with your agents and keys (install: ${INSTALL_HINT}). Every agent's output becomes one event stream in Claude Code's stream-json shape. The catalog and the router are public: GET /api/v1/harnesses lists every harness with its models, and POST /api/v1/harnesses/route picks one for a task by capability first, then cost. A hosted runner comes next. The developer console that used to live here (projects, apps, keys, the API reference, OAuth and webhook tools, releases) is OpenVibe.Services at ${SERVICES_ORIGIN}.`,sections: [{ title: 'Start here', links: [{ title: 'Code with any agent', url: abs('/'), note: 'what the harness is, how a task finds its agent, what works today' },++ { title: 'Get started', url: abs('/start'), note: 'install, agents, commands, permission levels, hand-offs, events, from your own code' },{ title: 'The coding agents', url: abs('/harnesses'), note: 'every harness with its models, capabilities, prices and limits' },{ title: 'Improve OpenVibe', url: abs('/improve'), note: 'every repository and the path to a reviewed pull request' },{ title: 'What shipped on OpenVibe.Codes', url: abs('/updates') },@@@ -127,12 +133,12 @@ function createDiscoveryRoutes(ctx) {const pages = publicPages({ governance }).map((p) => ({ url: p.path, ...describePage(p.path, { governance }) }));res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({site: SITE_NAME,-- summary: 'Every public page of OpenVibe.Codes, one line each: the harness, the agent catalog, Improve OpenVibe, the update log and the community documents.',++ summary: 'Every public page of OpenVibe.Codes, one line each: the harness, getting started, the agent catalog, Improve OpenVibe, the update log and the community documents.',base: site,maxBytes: 128 * 1024,sections: [-- { title: 'Harness', pages: pages.filter((p) => ['/', '/harnesses', '/improve'].includes(p.url)) },-- { title: 'Site', pages: pages.filter((p) => !['/', '/harnesses', '/improve'].includes(p.url)) },++ { title: 'Harness', pages: pages.filter((p) => HARNESS_PAGES.includes(p.url)) },++ { title: 'Site', pages: pages.filter((p) => !HARNESS_PAGES.includes(p.url)) },],}));});@@@ -1,8 +1,9 @@'use strict';/**-- * Public pages: the home page (the open coding-agent harness), the agent catalog, Improve OpenVibe (how OpenVibe-- * itself is built and how to help), the community documents, and the update log. Crawl artifacts (robots.txt,++ * Public pages: the home page (the open coding-agent harness), getting started with the harness on your own machine,++ * the agent catalog, Improve OpenVibe (how OpenVibe itself is built and how to help), the community documents, and the++ * update log. Crawl artifacts (robots.txt,* sitemap.xml, llms.txt, llms-full.txt, the home page's JSON-LD) live in http/discovery.js and are mounted here.** What is shown is read, never restated: the catalog is server/data/harness-offers.json as validated at boot, the@@@ -26,6 +27,9 @@ const placement = require('../domain/harness-placement');const ROOT = path.join(__dirname, '..', '..');const GITHUB = 'https://github.com/OpenVibers';++const { version: VERSION } = require('../../package.json');++// The harness installs from its release tag, the way every OpenVibe library is pinned.++const INSTALL = `npm install -g https://codeload.github.com/OpenVibers/OpenVibe.Codes/tar.gz/refs/tags/v${VERSION}`;/*** Community documents: Markdown in this repository (the same files GitHub shows), rendered as they are. A document@@@ -77,13 +81,16 @@ function createPageRoutes(ctx) {r.get('/', (req, res) => {const offers = harnesses.list();const tasks = Object.keys(placement.TASKS);-- const snippet = `# Which agent should take this task?--curl -s ${config.baseUrl}/api/v1/harnesses/route \\-- -H 'content-type: application/json' \\-- -d '{"task":"edit"}'----# → { "selected": "claude-code:…",--# "reasons": […], "candidates": […] }`;++ const snippet = `$ openvibe-codes run "add a test for the date parser"++run run_20261008… · ~/my-project++▶ codex · session 0199…++! You have hit your usage limit.++⇢ handing over from codex: You have hit your usage limit.++▶ claude-code · session 4f2e…++ → Read src/date.js++ → Edit test/date.test.js++Added test/date.test.js: three cases, all pass.++✔ done (48 s · $0.0400 · 31k in / 1.2k out)`;page(req, res, {index: true, cache: PUBLIC_CACHE,jsonLd: homeJsonLd(config),@@@ -92,8 +99,8 @@ curl -s ${config.baseUrl}/api/v1/harnesses/route \\eyebrow: 'OpenVibe.Codes · the open coding-agent harness · alpha',title: 'Code with', accent: 'any agent',lede: 'One open harness for Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API and models you host yourself: send a coding task to the agent that fits it, hand it over when one gets stuck, and keep the whole run in one place. Then point it at OpenVibe itself.',-- actions: [{ label: 'Meet the agents', href: '/harnesses', primary: true }, { label: 'Improve OpenVibe', href: '/improve' }],-- note: 'Open source (AGPL-3.0). Today: the agent catalog and a routing API anyone can call. Running jobs, hand-offs and a self-hosted runner come next; the list below says exactly what works.',++ actions: [{ label: 'Install the harness', href: '/start', primary: true }, { label: 'Meet the agents', href: '/harnesses' }],++ note: `Open source (AGPL-3.0). Version ${VERSION} runs on your machine with the agents and keys you already have; nothing goes through OpenVibe. A hosted runner comes next; the list below says exactly what works.`,aside: { html: `<pre class="codes-hero-code"><code>${showcase.esc(snippet)}</code></pre>` },}))}${raw(showcase.features({@@@ -106,7 +113,7 @@ ${raw(showcase.steps({{ title: 'Describe the task', text: `One of ${tasks.join(', ')}, plus anything it must have: a capability, a runtime, a ceiling on cost.` },{ title: 'Hard limits first', text: 'An agent that lacks a required capability is out, whatever it costs. Each refusal says why.' },{ title: 'Then the cheapest fit', text: 'Already-paid capacity and free allowances first, then price per token, through the same placement engine every OpenVibe service uses.' },-- { title: 'Hand-offs', text: 'When the first agent stalls, the run moves to the next candidate with its context (next: the runner).' },++ { title: 'Hand-offs', text: 'When an agent fails, hits its usage limit or goes quiet, the task moves to the next one with a note: the task, the files touched and the state of the tree.' },],}))}<section class="sc-sec" aria-labelledby="h-improve"><h2 id="h-improve">Improve OpenVibe</h2>@@@ -114,17 +121,97 @@ ${raw(showcase.steps({<p><a class="sc-btn sc-primary" href="/improve">Start contributing</a> <a class="sc-btn" href="/policy/contributing">Read the guide</a></p></section><section class="sc-sec" aria-labelledby="h-status"><h2 id="h-status">What works today</h2>${table(['Piece', 'State'], [++ [html`<a href="/start">The harness on your machine</a> (<code>openvibe-codes</code>)`, `Live, ${VERSION}: runs a task on any of the seven agents with your CLIs and keys, streams one event format, keeps every run to show or resume.`],++ ['Hand-offs between agents', 'Live: a failed, rate-limited or silent agent hands the task to the next one with a note on what was done.'],[html`<a href="/harnesses">The agent catalog</a>`, 'Live: every harness and model with its capabilities, prices and limits.'],[html`<code>GET /api/v1/harnesses</code>, <code>POST /api/v1/harnesses/route</code>`, 'Live: public, no key needed; per-address and per-caller limits.'],-- ['Running a task on an agent, streaming its output, budgets', 'Next. The agents run today on OpenVibe\'s own machines; opening that to everyone is the next step.'],-- ['Hand-offs between agents, resumable sessions', 'Next, with the runner.'],-- ['A runner on your own machine (your CLIs, your keys)', 'Planned.'],-- [html`Improve OpenVibe from here: repository → change → tests → review → pull request`, 'Planned. Today: the contributor path on GitHub, below.'],++ ['A hosted runner: tasks on OpenVibe\'s machines, with budgets', 'Next, on OpenVibe.Run workers.'],++ ['OpenVibe.Actor using Codes for its coding work', 'Next: Actor is OpenVibe\'s general agent; Codes is the part of it that writes code.'],++ [html`Improve OpenVibe from here: repository → change → tests → review → pull request`, 'Planned. Today: the harness plus the contributor path on GitHub, below.'],])}</section>${raw(showcase.cta({ title: 'Building an app instead?', text: 'Projects, keys, grants and the API docs of every OpenVibe service live on OpenVibe.Services.', actions: [{ label: 'Open the developer platform', href: SERVICES_ORIGIN, primary: true }, { label: 'Meet the agents', href: '/harnesses' }] }))}`,});});++ // ── Getting started: the harness on your own machine (bin/openvibe-codes.js, harness/) ──++ r.get('/start', (req, res) => {++ const example = `$ openvibe-codes agents++✔ claude-code Claude Code ~/.local/bin/claude++✔ codex Codex ~/.local/bin/codex++· aider Aider \`aider\` is not on PATH (install Aider)++· deepseek DeepSeek set DEEPSEEK_API_KEY++++$ cd my-project++$ openvibe-codes run "rename getUser to findUser everywhere"++$ openvibe-codes run --permission read "where is the session cookie set?"++$ openvibe-codes runs++$ openvibe-codes resume run_… "now add a test"`;++ const fromCode = `const { createHarness } = require('openvibe-codes');++++const harness = createHarness(); // your PATH, your keys++for await (const event of harness.run({ prompt: 'fix the failing test', cwd: '.' })) {++ if (event.type === 'result') console.log(event.is_error ? 'failed' : 'done', event.result);++}`;++ const events = `{"type":"system","subtype":"init","agent":"codex","session_id":"0199…","run_id":"run_…"}++{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"npm test"}}]}}++{"type":"user","message":{"content":[{"type":"tool_result","content":"…","is_error":false}]}}++{"type":"system","subtype":"handoff","from":"codex","reason":"You have hit your usage limit."}++{"type":"result","subtype":"success","is_error":false,"result":"…","total_cost_usd":0.04,"usage":{…}}`;++ page(req, res, {++ index: true, cache: PUBLIC_CACHE,++ title: 'Get started with the coding-agent harness',++ description: 'Install openvibe-codes and run coding tasks on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or your own model, with hand-offs between them, from your terminal or your own code.',++ crumbs: [{ label: 'Get started' }],++ body: html`<h1>Get started</h1>++<p><code>openvibe-codes</code> runs a coding task on whichever agent fits it, hands it to the next one when an agent fails, hits its limit or goes quiet, and keeps the whole run. It runs on your machine with the agents and keys you already have: nothing goes through OpenVibe, and it needs no account.</p>++<h2 id="install">Install</h2>++<p>Node.js 22 or newer, then:</p>++<pre><code>${INSTALL}</code></pre>++<p>Or from a clone: <code>git clone ${GITHUB}/OpenVibe.Codes</code>, <code>npm ci</code>, then <code>node bin/openvibe-codes.js</code>. Check what it found with <code>openvibe-codes agents</code>.</p>++<h2 id="agents">The agents it can use</h2>++<p>Install any of them; the harness uses what is there and says why the rest are not.</p>++${table(['Agent', 'What it needs'], [++ [html`<a href="/harnesses#claude-code">Claude Code</a>`, html`<code>claude</code> on PATH, signed in`],++ [html`<a href="/harnesses#codex">Codex</a>`, html`<code>codex</code> on PATH, signed in`],++ [html`<a href="/harnesses#opencode">OpenCode</a>`, html`<code>opencode</code> on PATH`],++ [html`<a href="/harnesses#command-code">Command Code</a>`, html`<code>cmd</code> on PATH`],++ [html`<a href="/harnesses#aider">Aider</a>`, html`<code>aider</code> on PATH`],++ [html`<a href="/harnesses#deepseek">DeepSeek</a>`, html`<code>DEEPSEEK_API_KEY</code>; the harness runs its own agent loop against the API`],++ [html`<a href="/harnesses#openai-compatible">Your own model</a>`, html`<code>OPENVIBE_CODES_BASE_URL</code> (an OpenAI-compatible server: llama.cpp, vLLM, Ollama, LM Studio…), <code>OPENVIBE_CODES_MODEL</code>, and <code>OPENVIBE_CODES_API_KEY</code> if it wants one`],++ ])}++<h2 id="use">Use it</h2>++<pre><code>${example}</code></pre>++${table(['Command', 'What it does'], [++ [html`<code>agents</code>`, 'Which agents this machine can run, and why not the others.'],++ [html`<code>route [--task edit] [--need harness:resume]</code>`, 'Which agent a task would go to, with the reason for every candidate.'],++ [html`<code>run [options] "task"</code>`, html`Run a task. <code>--agent</code> skips routing, <code>--model</code> picks the first agent's model, <code>--cwd</code> sets where it works, <code>--attempts</code> caps the hand-offs (default 3), <code>--no-handoff</code> stops at the first result, <code>--json</code> prints the events. A task of <code>-</code> is read from stdin.`],++ [html`<code>runs</code>`, 'The latest runs on this machine.'],++ [html`<code>show <run id> [--json]</code>`, 'One run: each attempt, its session and the result; every event with --json.'],++ [html`<code>resume <run id> "follow-up"</code>`, 'Continue the run\'s last session with the same agent.'],++ ])}++<p>The exit code follows the result: 0 when the last agent finished, 1 when it did not.</p>++<h2 id="permission">Permission levels</h2>++${table(['Level', 'What the agent may do'], [++ [html`<code>read</code>`, 'Look and answer. Claude Code runs in plan mode, Codex in its read-only sandbox, the API agents get only list, read and search tools.'],++ [html`<code>edit</code> (default)`, html`Change files in the working directory. Claude Code accepts edits and is refused <code>git push</code>; Codex runs in its workspace-write sandbox; the API agents can write and edit files but not run commands.`],++ [html`<code>full</code>`, 'Anything, unattended, including running commands. Use it in a container, a VM or a throwaway checkout.'],++ ])}++<p>The built-in API agent never leaves the working directory, never reads a <code>.env</code> file and never writes under <code>.git</code>, at any level.</p>++<h2 id="handoffs">Hand-offs</h2>++<p>When an attempt fails, hits a usage limit, crashes or prints nothing for a while, the harness stops it and gives the task to the next agent the router picks, leaving out the ones already tried. The next agent gets a note: the original task, which agent tried and why it stopped, the tools it used, the files it changed, its last message and <code>git status</code>. Every attempt is in the run.</p>++<h2 id="events">One event format</h2>++<p>Every agent's output becomes the same events: one JSON object per line with <code>--json</code>, the same shape as Claude Code's <code>stream-json</code>, ending with exactly one <code>result</code>. Keys and tokens in an agent's output are redacted before an event is printed or kept.</p>++<pre><code>${events}</code></pre>++<h2 id="runs">Where runs are kept</h2>++<p>In <code>$OPENVIBE_CODES_HOME</code>, else <code>$XDG_STATE_HOME/openvibe-codes</code>, else <code>~/.local/state/openvibe-codes</code>: one directory per run with its attempts and events, readable only by you.</p>++<h2 id="code">From your own code</h2>++<pre><code>${fromCode}</code></pre>++<p><code>harness.agents()</code>, <code>harness.route({ task })</code> and <code>harness.run({ prompt, cwd, agent, permission, handoff })</code> are what the command line uses. The router is the same one behind <a href="/api/v1/harnesses"><code>/api/v1/harnesses/route</code></a>.</p>++<h2 id="next">What comes next</h2>++<p>A hosted runner on OpenVibe.Run workers for people who would rather not run agents themselves, with budgets; OpenVibe.Actor using Codes for its coding work; and <a href="/improve">Improve OpenVibe</a> as a guided run: pick a repository, describe the change, get a pull request.</p>`,++ });++ });++// ── The agent catalog (server/data/harness-offers.json, validated at boot) ──// No address target that is a local path is ever shown — only the address kind.const isLocalPath = (t) => /^(?:[A-Za-z]:[\\/]|[~/]|\.\.?[\\/])/.test(String(t || ''));@@@ -22,6 +22,7 @@ const NETWORK_URL = 'https://openvibe.network';const SITE_NAME = 'OpenVibe.Codes';const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');const NAV = [++ { label: 'Get started', href: '/start' },{ label: 'Agents', href: '/harnesses' },{ label: 'Improve OpenVibe', href: '/improve' },{ label: 'Community', href: '/policy' },@@@ -87,7 +87,7 @@ const { boot, check, done } = require('./helpers/boot');});await check('public pages are complete without JavaScript (noscript nav, SSR footer)', async () => {-- for (const p of ['/', '/harnesses', '/improve', '/policy', '/policy/contributing']) {++ for (const p of ['/', '/start', '/harnesses', '/improve', '/policy', '/policy/contributing']) {const r = await t.get(p);assert.strictEqual(r.status, 200, p);assert.match(r.text, /<noscript><nav/);@@@ -32,7 +32,7 @@ const PRIVATE = ['/projects', '/staff', '/auth/', '/oauth/test-callback'];assert.strictEqual(r.status, 200);assert.match(r.headers.get('content-type'), /^application\/xml/);assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);-- for (const p of ['/', '/harnesses', '/improve', '/policy']) assert.ok(r.text.includes(`<loc>https://openvibe.codes${p}</loc>`), `no ${p} entry`);++ for (const p of ['/', '/start', '/harnesses', '/improve', '/policy']) assert.ok(r.text.includes(`<loc>https://openvibe.codes${p}</loc>`), `no ${p} entry`);for (const p of ['/docs', '/projects', '/oauth', '/tools/webhooks', '/policy/transparency']) assert.ok(!r.text.includes(`<loc>https://openvibe.codes${p}</loc>`), `${p} moved to openvibe.services`);const lastmods = [...r.text.matchAll(/<lastmod>([^<]+)<\/lastmod>/g)].map((m) => m[1]);assert.ok(lastmods.length > 0, 'no lastmod anywhere');nnew file mode 100644@@@ -0,0 +1,123 @@++'use strict';++/**++ * The API agent (harness/adapters/api.js) against a stand-in OpenAI-compatible server: the model's tool calls run inside++ * the working directory and the loop ends on an answer; usage and cost come from the API's own counts; the permission++ * level decides which tools exist; a path out of the working directory, a .env file or a .git write is refused; an HTTP++ * error ends the run with the server's message; resume continues the saved conversation.++ */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const { createHarness } = require('../harness');++const { createStore } = require('../harness/sessions');++const { check, done } = require('./helpers/boot');++++/** A chat-completions server that answers from a script: each request gets the next scripted message. */++async function mockApi() {++ const requests = [];++ let script = [];++ const server = http.createServer((req, res) => {++ let body = '';++ req.on('data', (d) => { body += d; });++ req.on('end', () => {++ const parsed = JSON.parse(body || '{}');++ requests.push({ auth: req.headers.authorization || null, body: parsed });++ const step = script.shift();++ if (!step) { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'done' } }], usage: { prompt_tokens: 1, completion_tokens: 1 } })); return; }++ if (step.status) { res.writeHead(step.status, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: step.error } })); return; }++ res.writeHead(200, { 'content-type': 'application/json' });++ res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: step.content || '', ...(step.calls ? { tool_calls: step.calls.map((c, i) => ({ id: `call_${requests.length}_${i}`, type: 'function', function: { name: c[0], arguments: JSON.stringify(c[1]) } })) } : {}) } }], usage: { prompt_tokens: 1000, completion_tokens: 100, prompt_cache_hit_tokens: 400 } }));++ });++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ return { url: `http://127.0.0.1:${server.address().port}`, requests, set: (s) => { script = s; requests.length = 0; }, close: () => new Promise((r) => server.close(r)) };++}++++const collect = async (run) => { const out = []; for await (const e of run) out.push(e); return out; };++const toolResults = (events) => events.filter((e) => e.type === 'user').flatMap((e) => e.message.content);++++(async () => {++ const api = await mockApi();++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-api-'));++ const work = path.join(dir, 'work');++ fs.mkdirSync(path.join(work, 'src'), { recursive: true });++ fs.writeFileSync(path.join(work, 'src', 'a.js'), 'const name = 1;\nmodule.exports = name;\n');++ fs.writeFileSync(path.join(work, '.env'), 'SECRET=1\n');++ fs.writeFileSync(path.join(dir, 'outside.txt'), 'outside');++ const env = { PATH: '/nonexistent', OPENVIBE_CODES_HOME: path.join(dir, 'home'), OPENVIBE_CODES_BASE_URL: api.url, OPENVIBE_CODES_MODEL: 'tiny', OPENVIBE_CODES_API_KEY: 'k-123' };++ const h = createHarness({ env, store: createStore({ env }) });++++ await check('the model\'s tool calls run in the working directory and the loop ends on its answer', async () => {++ api.set([++ { calls: [['list_dir', { path: 'src' }], ['read_file', { path: 'src/a.js' }]] },++ { calls: [['edit_file', { path: 'src/a.js', old_string: 'const name = 1;', new_string: 'const count = 1;' }]] },++ { content: 'Renamed name to count in src/a.js.' },++ ]);++ const events = await collect(h.run({ prompt: 'rename name to count', agent: 'openai-compatible', cwd: work, permission: 'edit', handoff: false }));++ assert.strictEqual(fs.readFileSync(path.join(work, 'src', 'a.js'), 'utf8'), 'const count = 1;\nmodule.exports = name;\n');++ const r = events.find((e) => e.type === 'result');++ assert.strictEqual(r.is_error, false, r.result);++ assert.strictEqual(r.result, 'Renamed name to count in src/a.js.');++ assert.strictEqual(r.num_turns, 3);++ assert.deepStrictEqual([r.usage.input_tokens, r.usage.cache_read_input_tokens, r.usage.output_tokens], [1800, 1200, 300]);++ assert.ok(toolResults(events).some((c) => c.content.includes('a.js')), 'the listing came back');++ assert.ok(toolResults(events).some((c) => c.content.includes('1\tconst name = 1;')), 'the file came back with line numbers');++ assert.strictEqual(api.requests[0].auth, 'Bearer k-123');++ assert.strictEqual(api.requests[0].body.model, 'tiny');++ assert.deepStrictEqual(api.requests[0].body.tools.map((t) => t.function.name).sort(), ['edit_file', 'grep', 'list_dir', 'read_file', 'write_file']);++ assert.strictEqual(api.requests[2].body.messages.filter((m) => m.role === 'tool').length, 3, 'every tool result went back to the model');++ });++++ await check('read permission offers no writing tool, and a forced write is refused', async () => {++ api.set([{ calls: [['write_file', { path: 'src/new.js', content: 'x' }]] }, { content: 'ok' }]);++ const events = await collect(h.run({ prompt: 'look', agent: 'openai-compatible', cwd: work, permission: 'read', handoff: false }));++ assert.deepStrictEqual(api.requests[0].body.tools.map((t) => t.function.name).sort(), ['grep', 'list_dir', 'read_file']);++ assert.ok(!fs.existsSync(path.join(work, 'src', 'new.js')));++ assert.ok(toolResults(events).some((c) => c.is_error && /needs the edit permission level/.test(c.content)));++ });++++ await check('a path out of the working directory, a .env file and a .git write are refused', async () => {++ api.set([{ calls: [['read_file', { path: '../outside.txt' }], ['read_file', { path: '/etc/passwd' }], ['read_file', { path: '.env' }], ['write_file', { path: '.git/config', content: 'x' }], ['grep', { pattern: 'SECRET' }]] }, { content: 'ok' }]);++ const events = await collect(h.run({ prompt: 'try', agent: 'openai-compatible', cwd: work, permission: 'edit', handoff: false }));++ const res = toolResults(events);++ assert.strictEqual(res.filter((c) => c.is_error).length, 4, JSON.stringify(res.map((c) => c.content)));++ assert.ok(res.some((c) => /outside the working directory/.test(c.content)));++ assert.ok(res.some((c) => /\.env files are not read/.test(c.content)));++ assert.ok(res.some((c) => /\.git is not written/.test(c.content)));++ assert.ok(!res.some((c) => /SECRET=1/.test(c.content)), 'grep skips .env files');++ assert.ok(!fs.existsSync(path.join(work, '.git')));++ });++++ await check('run_command exists only at the full level, and runs in the working directory', async () => {++ api.set([{ calls: [['run_command', { command: 'pwd' }]] }, { content: 'ok' }]);++ const events = await collect(h.run({ prompt: 'where', agent: 'openai-compatible', cwd: work, permission: 'full', handoff: false }));++ assert.ok(api.requests[0].body.tools.some((t) => t.function.name === 'run_command'));++ assert.ok(toolResults(events).some((c) => c.content.trim() === fs.realpathSync(work)));++ });++++ 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 }));++ const r = events.find((e) => e.type === 'result');++ assert.strictEqual(r.is_error, true);++ assert.match(r.result, /HTTP 429: rate limited/);++ });++++ await check('resume continues the saved conversation', async () => {++ api.set([{ content: 'first answer' }]);++ const run1 = h.run({ prompt: 'remember the word kiwi', agent: 'openai-compatible', cwd: work, handoff: false });++ const e1 = await collect(run1);++ const session = e1.find((e) => e.type === 'result').session_id;++ api.set([{ content: 'kiwi' }]);++ await collect(h.run({ prompt: 'which word?', agent: 'openai-compatible', cwd: work, resume: session, handoff: false }));++ const msgs = api.requests[0].body.messages;++ assert.ok(msgs.some((m) => m.role === 'user' && m.content === 'remember the word kiwi'), 'the earlier turn came back');++ assert.strictEqual(msgs[msgs.length - 1].content, 'which word?');++ });++++ await api.close();++ fs.rmSync(dir, { recursive: true, force: true });++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++/**++ * The `openvibe-codes` command (bin/openvibe-codes.js) with stand-in agents on PATH: agents, route, run (a readable log++ * and --json), runs, show and resume, and the exit code following the result.++ */++const assert = require('assert');++const path = require('path');++const { spawnSync } = require('child_process');++const { fakeAgents } = require('./helpers/fake-agents');++const { check, done } = require('./helpers/boot');++++const BIN = path.join(__dirname, '..', 'bin', 'openvibe-codes.js');++++(async () => {++ const f = fakeAgents({ codex: 'fail' });++ const cli = (...args) => {++ const r = spawnSync(process.execPath, [BIN, ...args], { cwd: f.work, env: { ...process.env, ...f.env }, encoding: 'utf8', timeout: 60_000 });++ return { code: r.status, out: r.stdout, err: r.stderr };++ };++++ await check('agents: what is here and why not the rest', () => {++ const r = cli('agents');++ assert.strictEqual(r.code, 0, r.err);++ assert.match(r.out, /✔ claude-code/);++ assert.match(r.out, /· aider .*not on PATH/);++ const json = JSON.parse(cli('agents', '--json').out);++ assert.ok(json.find((a) => a.id === 'codex').available);++ });++++ await check('route: the decision, its reasons and every candidate', () => {++ const r = cli('route', '--task', 'review');++ assert.strictEqual(r.code, 0, r.err);++ assert.match(r.out, /^→ \S+:/m);++ assert.match(r.out, /aider:aider-default — `aider` is not on PATH/);++ });++++ let runId;++ await check('run: a readable log, a hand-off, and exit 0 when the last agent succeeded', () => {++ const r = cli('run', '--agent', 'codex', 'rename', 'the', 'variable');++ assert.strictEqual(r.code, 0, r.out + r.err);++ runId = (r.out.match(/^run (run_\S+)/m) || [])[1];++ assert.ok(runId, r.out);++ assert.match(r.out, /▶ codex/);++ assert.match(r.out, /⇢ handing over from codex: .*usage limit/);++ assert.match(r.out, /✔ done/);++ });++++ await check('run --json: one event per line, ending with the result', () => {++ const r = cli('run', '--agent', 'claude-code', '--no-handoff', '--json', 'x');++ const lines = r.out.trim().split('\n').map((l) => JSON.parse(l));++ assert.strictEqual(lines[0].subtype, 'init');++ assert.strictEqual(lines[lines.length - 1].type, 'result');++ assert.strictEqual(r.code, 0);++ });++++ await check('runs, show and resume', () => {++ const list = cli('runs');++ assert.ok(list.out.includes(runId), list.out);++ assert.match(list.out, /codex ⇢ /);++ const show = cli('show', runId);++ assert.strictEqual(show.code, 0);++ assert.match(show.out, /✗ codex/);++ const events = cli('show', runId, '--json').out.trim().split('\n').map((l) => JSON.parse(l));++ assert.ok(events.some((e) => e.subtype === 'handoff'));++ const resumed = cli('resume', runId, 'and', 'add', 'a', 'test');++ assert.strictEqual(resumed.code, 0, resumed.out + resumed.err);++ assert.match(resumed.out, /did: and add a test/);++ assert.strictEqual(cli('resume', 'run_nope', 'x').code, 1);++ });++++ await check('a failed run exits 1', () => {++ assert.strictEqual(cli('run', '--agent', 'codex', '--no-handoff', 'x').code, 1);++ assert.strictEqual(cli('run').code, 1, 'no prompt');++ });++++ f.cleanup();++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -52,7 +52,7 @@ function priced(row, freshUsd) {const deepseek = rows.find((row) => row.id === 'deepseek');const offer = toOffer(deepseek, deepseek.agents[0]);assert.strictEqual(offer.offer_id, 'deepseek:deepseek-chat');-- assert.deepStrictEqual(offer.capabilities, []);++ assert.deepStrictEqual(offer.capabilities, ['task:edit', 'task:review', 'harness:host-access', 'harness:resume', 'harness:edit', 'harness:review', 'harness:tools']);assert.strictEqual(offer.trust, 'external');assert.deepStrictEqual(offer.health, { status: 'up' });assert.strictEqual('latency_ms' in offer, false);@@@ -65,7 +65,7 @@ function priced(row, freshUsd) {await check('every seed offer validates as platform.resource-offer@1 of kind harness', async () => {const harnesses = createHarnesses({ catalogPath });const offers = harnesses.list().flatMap((harness) => harnesses.agents(harness.id).map((agent) => [harness, toOffer(harness, agent, { now })]));-- assert.strictEqual(offers.length, 6);++ assert.strictEqual(offers.length, 7);for (const [harness, offer] of offers) {const result = contracts.validate('platform.resource-offer@1', JSON.parse(JSON.stringify(offer)));assert.ok(result.valid, `${offer.offer_id}: ${JSON.stringify(result.errors)}`);@@@ -92,26 +92,29 @@ function priced(row, freshUsd) {assert.deepStrictEqual(tasksOf('claude-code'), ['edit', 'review']);assert.deepStrictEqual(tasksOf('command-code'), ['edit']);assert.deepStrictEqual(tasksOf('aider'), ['edit']);-- assert.deepStrictEqual(tasksOf('deepseek'), []);++ // The API models edit and review through the harness's own agent loop (harness/adapters/api.js).++ assert.deepStrictEqual(tasksOf('deepseek'), ['edit', 'review']);++ assert.deepStrictEqual(tasksOf('openai-compatible'), ['edit', 'review']);// An explicit task_capabilities list still overrides the booleans.const claude = byId.get('claude-code');assert.deepStrictEqual(toOffer({ ...claude, task_capabilities: ['edit', 'browse'] }, claude.agents[0]).detail.task_capabilities, ['edit', 'browse']);const harnesses = createHarnesses({ catalogPath });const review = route({ task: 'review', harnesses, now });-- for (const id of ['command-code:command-code-default', 'aider:aider-default', 'deepseek:deepseek-chat']) {++ for (const id of ['command-code:command-code-default', 'aider:aider-default']) {assert.deepStrictEqual(review.candidates.find((c) => c.id === id), { id, included: false, reason: 'lacks task:review' });}-- assert.ok(!['command-code', 'aider', 'deepseek'].includes(review.selected.split(':')[0]));++ assert.ok(!['command-code', 'aider'].includes(review.selected.split(':')[0]));++ assert.ok(review.candidates.some((c) => c.id === 'deepseek:deepseek-chat' && c.included), 'DeepSeek reviews through the agent loop');const edit = route({ task: 'edit', harnesses, now });-- assert.deepStrictEqual(edit.candidates.find((c) => c.id === 'deepseek:deepseek-chat'), { id: 'deepseek:deepseek-chat', included: false, reason: 'lacks task:edit' });++ assert.ok(edit.candidates.some((c) => c.id === 'deepseek:deepseek-chat' && c.included));assert.ok(edit.candidates.some((c) => c.id === 'command-code:command-code-default' && c.included));});await check('deepseek advertises no runtime class', async () => {const deepseek = rows.find((row) => row.id === 'deepseek');const offer = toOffer(deepseek, deepseek.agents[0]);-- assert.deepStrictEqual(offer.capabilities, []);++ assert.ok(!offer.capabilities.some((c) => c.startsWith('runtime:')), offer.capabilities.join(', '));assert.strictEqual('runtimes' in offer.detail.capabilities, false);// A bare runtime:code requirement no longer reaches the host-less API, but still reaches a code CLI.const req = { kind: 'harness.edit', mobility: 'job', latency_class: 'interactive', objective: 'balanced', capabilities: ['runtime:code'] };@@@ -127,7 +130,7 @@ function priced(row, freshUsd) {const [harnessId] = r.selected.split(':');assert.ok(toOffer(harnesses.get(harnessId), harnesses.agents(harnessId)[0]).capabilities.includes('task:review'));assert.ok(r.reasons.length);-- assert.deepStrictEqual(r.candidates.map((c) => c.id), ['claude-code:claude-code-sonnet', 'codex:codex-default', 'command-code:command-code-default', 'opencode:opencode-default', 'aider:aider-default', 'deepseek:deepseek-chat']);++ assert.deepStrictEqual(r.candidates.map((c) => c.id), ['claude-code:claude-code-sonnet', 'codex:codex-default', 'command-code:command-code-default', 'opencode:opencode-default', 'aider:aider-default', 'deepseek:deepseek-chat', 'openai-compatible:openai-compatible-local']);});await check('an offer whose health is down is excluded with a reason', async () => {@@@ -141,11 +144,9 @@ function priced(row, freshUsd) {const harnesses = createHarnesses({ catalogPath });const r = route({ task: 'edit', extra: { trust: 'first-party' }, harnesses, now, opts: { 'codex:codex-default': { trust: 'first-party' } } });assert.strictEqual(r.selected, 'codex:codex-default');-- for (const c of r.candidates.filter((c) => c.id !== 'codex:codex-default' && c.id !== 'deepseek:deepseek-chat')) {++ for (const c of r.candidates.filter((c) => c.id !== 'codex:codex-default')) {assert.deepStrictEqual(c, { id: c.id, included: false, reason: 'workload requires first-party trust' });}-- // Deepseek cannot edit, so it drops out on capabilities before trust is considered.-- assert.deepStrictEqual(r.candidates.find((c) => c.id === 'deepseek:deepseek-chat'), { id: 'deepseek:deepseek-chat', included: false, reason: 'lacks task:edit' });const none = route({ task: 'edit', extra: { trust: ['first-party'] }, harnesses, now });assert.strictEqual(none.selected, null);assert.ok(none.candidates.every((c) => !c.included));@@@ -170,11 +171,7 @@ function priced(row, freshUsd) {assert.deepStrictEqual(r.candidates.find((c) => c.id === 'codex:codex-default'), { id: 'codex:codex-default', included: false, reason: 'lacks task:browse' });const none = route({ task: 'edit', extra: { capabilities: ['task:browse'] }, harnesses: createHarnesses({ catalogPath }), now });assert.strictEqual(none.selected, null);-- for (const c of none.candidates.filter((c) => c.id !== 'deepseek:deepseek-chat')) {-- assert.strictEqual(c.reason, 'lacks task:browse');-- }-- // Deepseek drops on capabilities it lacks (edit) before task:browse.-- assert.deepStrictEqual(none.candidates.find((c) => c.id === 'deepseek:deepseek-chat'), { id: 'deepseek:deepseek-chat', included: false, reason: 'lacks task:edit' });++ for (const c of none.candidates) assert.strictEqual(c.reason, 'lacks task:browse', c.id);});await check('a harness:vision requirement drops the vision:false harnesses', async () => {nnew file mode 100644@@@ -0,0 +1,135 @@++'use strict';++/**++ * The harness (harness/index.js) against stand-in agents (test/helpers/fake-agents.js): every CLI adapter turns its++ * agent's output into the shared events with one result; availability comes from PATH and keys; routing leaves out++ * what is not here and says why; a failed or silent attempt hands the task to the next agent with a note on what was++ * done; a crash ends with a reason whose secrets are redacted; runs and their events are kept for `show` and `resume`.++ */++const assert = require('assert');++const { createHarness } = require('../harness');++const { createStore } = require('../harness/sessions');++const { fakeAgents } = require('./helpers/fake-agents');++const { check, done } = require('./helpers/boot');++++const collect = async (run) => { const out = []; for await (const e of run) out.push(e); return out; };++const texts = (events) => events.filter((e) => e.type === 'assistant').flatMap((e) => e.message.content.filter((c) => c.type === 'text').map((c) => c.text));++const results = (events) => events.filter((e) => e.type === 'result');++++(async () => {++ const f = fakeAgents();++ const harness = (env = f.env) => createHarness({ env, store: createStore({ env }) });++++ await check('availability: the CLIs on PATH are here; an API agent needs its key; the reason names what to do', () => {++ const list = harness().agents();++ const by = Object.fromEntries(list.map((a) => [a.id, a]));++ for (const id of ['claude-code', 'codex', 'opencode', 'command-code']) assert.strictEqual(by[id].available, true, id);++ assert.strictEqual(by.aider.available, false);++ assert.match(by.aider.reason, /not on PATH/);++ assert.strictEqual(by.deepseek.available, false);++ assert.match(by.deepseek.reason, /DEEPSEEK_API_KEY/);++ assert.strictEqual(harness({ ...f.env, DEEPSEEK_API_KEY: 'x' }).agents().find((a) => a.id === 'deepseek').available, true);++ });++++ await check('routing goes to an available agent; the rest are listed with why', () => {++ const d = harness().route({ task: 'edit' });++ assert.ok(d.selected, JSON.stringify(d));++ assert.ok(['claude-code', 'codex', 'opencode', 'command-code'].includes(d.selected.split(':')[0]), d.selected);++ const aider = d.candidates.find((c) => c.id.startsWith('aider:'));++ assert.strictEqual(aider.included, false);++ assert.match(aider.reason, /not on PATH/);++ const none = createHarness({ env: { PATH: '/nonexistent', OPENVIBE_CODES_HOME: f.env.OPENVIBE_CODES_HOME } }).route({ task: 'edit' });++ assert.strictEqual(none.selected, null);++ });++++ for (const [agent, word] of [['claude-code', 'claude'], ['codex', 'codex'], ['opencode', 'opencode'], ['command-code', 'cmd']]) {++ await check(`${agent}: the agent's output becomes the shared events, with one result`, async () => {++ const events = await collect(harness().run({ prompt: 'fix a.js', agent, cwd: f.work, handoff: false }));++ assert.strictEqual(events[0].type, 'system');++ assert.strictEqual(events[0].subtype, 'init');++ assert.ok(events[0].session_id, 'a session id to resume');++ assert.ok(events.some((e) => e.type === 'assistant' && e.message.content.some((c) => c.type === 'tool_use')), 'a tool call');++ assert.ok(events.some((e) => e.type === 'user' && e.message.content.some((c) => c.type === 'tool_result')), 'its result');++ const r = results(events);++ assert.strictEqual(r.length, 1, 'exactly one result');++ assert.strictEqual(r[0].is_error, false, r[0].result);++ assert.match(r[0].result, new RegExp(`${word} did: fix a\\.js`));++ assert.ok(r[0].usage && Number.isFinite(r[0].usage.input_tokens));++ });++ }++++ await check('codex: cached input is counted as cache reads, not twice', async () => {++ const events = await collect(harness().run({ prompt: 'x', agent: 'codex', cwd: f.work, handoff: false }));++ const u = results(events)[0].usage;++ assert.deepStrictEqual([u.input_tokens, u.cache_read_input_tokens, u.output_tokens], [60, 40, 7]);++ assert.ok(events.some((e) => e.type === 'assistant' && e.message.content.some((c) => c.type === 'tool_use' && c.name === 'Edit' && c.input.file_path === 'a.js')), 'a file change is an Edit');++ });++++ await check('a failed attempt hands the task to the next agent, with the task and what was done', async () => {++ const g = fakeAgents({ codex: 'fail' });++ const h = createHarness({ env: g.env, store: createStore({ env: g.env }) });++ const run = h.run({ prompt: 'rename the variable', agent: 'codex', cwd: g.work, maxAttempts: 3 });++ const events = await collect(run);++ const handoff = events.find((e) => e.type === 'system' && e.subtype === 'handoff');++ assert.ok(handoff, 'a hand-off event');++ assert.strictEqual(handoff.from, 'codex');++ assert.match(handoff.reason, /usage limit/);++ assert.strictEqual(events.filter((e) => e.type === 'system' && e.subtype === 'error').length, 1, 'one failure, reported once');++ const r = results(events);++ assert.strictEqual(r.length, 1, 'the run ends with one result: the last attempt\'s');++ assert.strictEqual(r[0].is_error, false);++ const answer = texts(events).pop();++ assert.match(answer, /taking over a coding task that Codex started/, 'the next agent got the hand-off note');++ assert.match(answer, /rename the variable/, 'with the original task');++ const s = run.summary();++ assert.deepStrictEqual(s.attempts.map((a) => [a.agent, a.ok]).slice(0, 1), [['codex', false]]);++ assert.strictEqual(s.attempts.length, 2);++ assert.ok(s.attempts[1].ok);++ assert.deepStrictEqual(h.store.loadRun(run.runId).attempts.length, 2, 'the run is kept');++ assert.ok(h.store.events(run.runId).some((e) => e.type === 'system' && e.subtype === 'handoff'));++ g.cleanup();++ });++++ await check('--no-handoff stops at the first result', async () => {++ const g = fakeAgents({ codex: 'fail' });++ const events = await collect(createHarness({ env: g.env, store: createStore({ env: g.env }) }).run({ prompt: 'x', agent: 'codex', cwd: g.work, handoff: false }));++ assert.ok(!events.some((e) => e.subtype === 'handoff'));++ assert.strictEqual(results(events)[0].is_error, true);++ g.cleanup();++ });++++ await check('an agent that goes quiet is stopped and the task handed on', async () => {++ const g = fakeAgents({ claude: 'hang' });++ const run = createHarness({ env: g.env, store: createStore({ env: g.env }) }).run({ prompt: 'x', agent: 'claude-code', cwd: g.work, stallMs: 400, maxAttempts: 2 });++ const events = await collect(run);++ const handoff = events.find((e) => e.subtype === 'handoff');++ assert.ok(handoff, 'handed on');++ assert.match(handoff.reason, /went quiet/);++ assert.strictEqual(results(events)[0].is_error, false, 'the next agent finished');++ g.cleanup();++ });++++ await check('a crash ends with a reason a person can act on, and a key in it is redacted', async () => {++ const g = fakeAgents({ claude: 'crash' });++ const events = await collect(createHarness({ env: g.env, store: createStore({ env: g.env }) }).run({ prompt: 'x', agent: 'claude-code', cwd: g.work, handoff: false }));++ const r = results(events)[0];++ assert.strictEqual(r.is_error, true);++ assert.match(r.result, /ended without a result \(exit 3\)/);++ assert.ok(!r.result.includes('sk-test0123456789'), r.result);++ assert.match(r.result, /\[redacted\]/);++ g.cleanup();++ });++++ await check('an agent that is not here is refused at once, with why', async () => {++ const events = await collect(harness().run({ prompt: 'x', agent: 'aider', cwd: f.work }));++ assert.strictEqual(results(events)[0].is_error, true);++ assert.match(results(events)[0].result, /not available here.*not on PATH/);++ });++++ await check('a run needs a prompt and a known permission level', () => {++ assert.throws(() => harness().run({ prompt: ' ', cwd: f.work }), /needs a prompt/);++ assert.throws(() => harness().run({ prompt: 'x', permission: 'root', cwd: f.work }), /read, edit or full/);++ });++++ f.cleanup();++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -13,7 +13,7 @@ const rows = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));(async () => {await check('every seed row matches the pinned harness and agent contracts', async () => {-- assert.strictEqual(rows.length, 6);++ assert.strictEqual(rows.length, 7);for (const { agents, ...offer } of rows) {const harnessCheck = contracts.validate('platform.harness-offer@1', offer);assert.strictEqual(harnessCheck.valid, true, JSON.stringify(harnessCheck.errors));nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++/**++ * Stand-in coding agents for the harness tests: a temporary bin directory with `claude`, `codex`, `opencode` and `cmd`++ * scripts that print what the real CLIs print (recorded shapes) and an isolated OPENVIBE_CODES_HOME. Each script reads++ * FAKE_<NAME> from its environment:++ *++ * ok a short run: a tool call, its result, a final answer that quotes the prompt it was given++ * fail the agent's own error, then exit 1 (no result line from the CLI)++ * crash exit 3 with a line on stderr that carries a key (it must come back redacted)++ * hang prints its start, then sleeps (a stall)++ *++ * const f = fakeAgents({ claude: 'ok', codex: 'fail' }); f.env → PATH and OPENVIBE_CODES_HOME; f.cleanup()++ */++const fs = require('fs');++const os = require('os');++const path = require('path');++++const SCRIPTS = {++ claude: `++const prompt = process.argv[process.argv.indexOf('-p') + 1];++const mode = process.env.FAKE_CLAUDE || 'ok';++const out = (o) => console.log(JSON.stringify(o));++if (mode === 'hang') { out({ type: 'system', subtype: 'init', session_id: 'cl-hang', model: 'fake-claude' }); setInterval(() => {}, 1000); return; }++if (mode === 'crash') { process.stderr.write('boom: api_key=sk-test0123456789abcdefghij\\n'); process.exit(3); }++out({ type: 'system', subtype: 'init', session_id: 'cl-1', model: 'fake-claude' });++out({ type: 'assistant', message: { model: 'fake-claude', content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: 'a.js' } }] } });++out({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't1', content: 'const a = 1;', is_error: false }] } });++if (mode === 'fail') { out({ type: 'result', subtype: 'error_during_execution', is_error: true, result: 'claude could not finish', session_id: 'cl-1' }); process.exit(1); }++out({ type: 'assistant', message: { model: 'fake-claude', content: [{ type: 'text', text: 'claude did: ' + prompt }] } });++out({ type: 'result', subtype: 'success', is_error: false, result: 'claude did: ' + prompt, session_id: 'cl-1', duration_ms: 5, num_turns: 2, total_cost_usd: 0.01, usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } });++`,++ codex: `++const prompt = process.argv[process.argv.length - 1];++const mode = process.env.FAKE_CODEX || 'ok';++const out = (o) => console.log(JSON.stringify(o));++out({ type: 'thread.started', thread_id: 'cx-1' });++out({ type: 'turn.started' });++if (mode === 'fail') { out({ type: 'error', message: 'You have hit your usage limit.' }); out({ type: 'turn.failed', error: { message: 'You have hit your usage limit.' } }); process.exit(1); }++out({ type: 'item.started', item: { id: 'i1', type: 'command_execution', command: 'ls', status: 'in_progress' } });++out({ type: 'item.completed', item: { id: 'i1', type: 'command_execution', command: 'ls', aggregated_output: 'a.js', exit_code: 0, status: 'completed' } });++out({ type: 'item.completed', item: { id: 'i2', type: 'file_change', changes: [{ path: 'a.js', kind: 'update' }], status: 'completed' } });++out({ type: 'item.completed', item: { id: 'i3', type: 'agent_message', text: 'codex did: ' + prompt } });++out({ type: 'turn.completed', usage: { input_tokens: 100, cached_input_tokens: 40, output_tokens: 7 } });++`,++ opencode: `++const prompt = process.argv[process.argv.length - 1];++const out = (o) => console.log(JSON.stringify(o));++out({ type: 'step_start', sessionID: 'oc-1', part: {} });++out({ type: 'tool_use', sessionID: 'oc-1', part: { id: 'p1', tool: 'read', state: { status: 'completed', input: { path: 'a.js' }, output: 'const a = 1;' } } });++out({ type: 'text', sessionID: 'oc-1', part: { messageID: 'm1', text: 'opencode did: ' + prompt } });++out({ type: 'step_finish', sessionID: 'oc-1', part: { tokens: { input: 20, output: 3, reasoning: 1, cache: { read: 5, write: 0 } }, cost: 0.002 } });++`,++ cmd: `++const arg = process.argv.find((a) => a.startsWith('--print=')) || '';++const prompt = arg.slice(8);++const out = (o) => console.log(JSON.stringify(o));++out({ type: 'event', event: { type: 'run_start', sessionId: 'cc-1' } });++out({ type: 'event', event: { type: 'model_request_start', model: 'fake-cmd' } });++out({ type: 'event', event: { type: 'message_end', content: [{ type: 'tool_use', id: 'u1', name: 'edit_file', input: { path: 'a.js' } }] } });++out({ type: 'event', event: { type: 'tool_completed', toolCallId: 'u1', result: [{ text: 'edited' }] } });++out({ type: 'event', event: { type: 'message_end', content: [{ type: 'text', text: 'cmd did: ' + prompt }] } });++out({ type: 'result', subtype: 'success', finalText: 'cmd did: ' + prompt, sessionId: 'cc-1', usage: { inputTokens: 9, outputTokens: 4 } });++`,++};++++function fakeAgents(modes = {}, { include = Object.keys(SCRIPTS) } = {}) {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-fake-'));++ const bin = path.join(dir, 'bin');++ fs.mkdirSync(bin);++ for (const name of include) {++ const f = path.join(bin, name);++ fs.writeFileSync(f, `#!${process.execPath}\n'use strict';\n(function () {${SCRIPTS[name]}})();\n`, { mode: 0o755 });++ }++ const work = path.join(dir, 'work');++ fs.mkdirSync(work);++ fs.writeFileSync(path.join(work, 'a.js'), 'const a = 1;\n');++ const env = {++ PATH: bin, OPENVIBE_CODES_HOME: path.join(dir, 'home'),++ FAKE_CLAUDE: modes.claude || 'ok', FAKE_CODEX: modes.codex || 'ok',++ };++ return { dir, bin, work, env, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) };++}++++module.exports = { fakeAgents };@@@ -20,7 +20,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#maiconst release = (await t.get('/release.json')).json().release;await check('every rendered page carries the ov-boost marker (the app\'s release) and the data-main boost script', async () => {-- for (const p of ['/', '/harnesses', '/improve', '/policy']) {++ for (const p of ['/', '/start', '/harnesses', '/improve', '/policy']) {const r = await t.get(p);assert.strictEqual(r.status, 200, p);const m = r.text.match(MARKER);@@@ -40,7 +40,7 @@ const { movedTo } = require('../server/http/moved');});await check('what Codes serves stays here', async () => {-- for (const p of ['/', '/harnesses', '/improve', '/policy', '/policy/contributing', '/updates', '/api/v1/harnesses', '/robots.txt']) {++ for (const p of ['/', '/start', '/harnesses', '/improve', '/policy', '/policy/contributing', '/updates', '/api/v1/harnesses', '/robots.txt']) {const r = await t.get(p);assert.strictEqual(r.status, 200, `${p}: ${r.status}`);}nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++/**++ * Getting started (/start) says how to install the harness from this release's tag and names every agent the harness++ * has an adapter for, with what it needs; the home page leads there and lists what works today; the command line++ * prints the same version and points back at the page.++ */++const assert = require('assert');++const path = require('path');++const { spawnSync } = require('child_process');++const { boot, check, done } = require('./helpers/boot');++const { adaptersFor } = require('../harness');++const { version } = require('../package.json');++++(async () => {++ const t = await boot();++++ await check('/start installs from this release\'s tag and names every agent with what it needs', async () => {++ const r = await t.get('/start');++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes(`npm install -g https://codeload.github.com/OpenVibers/OpenVibe.Codes/tar.gz/refs/tags/v${version}`), 'no install line for this version');++ for (const id of Object.keys(adaptersFor({}))) assert.ok(r.text.includes(`href="/harnesses#${id}"`), `no row for ${id}`);++ for (const env of ['DEEPSEEK_API_KEY', 'OPENVIBE_CODES_BASE_URL', 'OPENVIBE_CODES_MODEL']) assert.ok(r.text.includes(env), env);++ for (const level of ['read', 'edit', 'full']) assert.ok(r.text.includes(`<code>${level}</code>`), level);++ assert.match(r.text, /<link rel="canonical" href="https:\/\/openvibe\.codes\/start">/);++ });++++ await check('the home page leads to /start and says the harness is live', async () => {++ const r = await t.get('/');++ assert.ok(r.text.includes('href="/start"'));++ assert.ok(r.text.includes(`Live, ${version}`));++ assert.ok(!/Hand-offs between agents, resumable sessions<\/td><td>Next/.test(r.text), 'the old "next" row is gone');++ });++++ await check('the command line prints this version and points at the guide', () => {++ const bin = path.join(__dirname, '..', 'bin', 'openvibe-codes.js');++ const v = spawnSync(process.execPath, [bin, '--version'], { encoding: 'utf8' });++ assert.strictEqual(v.stdout.trim(), version);++ const help = spawnSync(process.execPath, [bin, 'run', '--help'], { encoding: 'utf8' });++ assert.strictEqual(help.status, 0);++ assert.match(help.stdout, /https:\/\/openvibe\.codes\/start/);++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -15,7 +15,9 @@ One place to send a coding task to the agent that fits it — by what the task n
@@ -15,7 +15,9 @@ One place to send a coding task to the agent that fits it — by what the task n
| Surface | Path | Notes |
| Surface | Path | Notes |
|---|---|---|
|---|---|---|
+| 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. |
| 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. |
| 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`). |
| Improve OpenVibe | `/improve` | Every OpenVibe repository, from the pinned contracts' service manifests (the services that run, then the shared libraries), and the path from a change to a reviewed pull request. |
| Improve OpenVibe | `/improve` | Every OpenVibe repository, from the pinned contracts' service manifests (the services that run, then the shared libraries), and the path from a change to a reviewed pull request. |
@@ -26,21 +28,46 @@ One place to send a coding task to the agent that fits it — by what the task n
@@ -26,21 +28,46 @@ One place to send a coding task to the agent that fits it — by what the task n
Everything is server-rendered and usable without JavaScript.
Everything is server-rendered and usable without JavaScript.
-## What comes next
+## Use the harness
+
+```bash
+npm install -g https://codeload.github.com/OpenVibers/OpenVibe.Codes/tar.gz/refs/tags/v0.2.0 # Node.js 22+
+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 --agent codex --permission read "where is the session cookie set?"
+openvibe-codes runs # the latest runs
+openvibe-codes resume <run id> "now add a test"
+```
-The harness grows out of the agent pool that already builds OpenVibe (`~/openvibe/agents` on the owner's machines: Claude Code, Codex, OpenCode Go, Command Code and the DeepSeek API, each with launch, streaming, usage, resume and pooling, plus routing and hand-offs). The order, adapter interface first:
+| Agent | Adapter | Needs |
+|---|---|---|
+| Claude Code | `claude -p --output-format stream-json` | `claude` on PATH |
+| Codex | `codex exec --json` | `codex` on PATH |
+| OpenCode | `opencode run --format json` | `opencode` on PATH |
+| Command Code | `cmd --print --output-format json` | `cmd` on PATH |
+| Aider | `aider --message` | `aider` on PATH |
+| DeepSeek | the harness's own agent loop ([harness/adapters/api.js](harness/adapters/api.js)) | `DEEPSEEK_API_KEY` |
+| Your own model | the same loop against any OpenAI-compatible server | `OPENVIBE_CODES_BASE_URL`, `OPENVIBE_CODES_MODEL`, optional `OPENVIBE_CODES_API_KEY` |
+
+- **Events.** Every adapter turns its agent's output into one stream in Claude Code's `stream-json` shape (system/init, assistant text and tool_use, user tool_result, system warning/error/handoff, exactly one result), [harness/events.js](harness/events.js). Keys, tokens and private keys are redacted before an event is printed or kept.
+- **Permission levels.** `read` (Claude Code plan mode, Codex read-only sandbox, read tools only), `edit` (the default: edits in the working directory, Claude Code refused `git push`, the API loop without commands), `full` (anything, unattended). The API loop never leaves the working directory, never reads `.env` and never writes under `.git`.
+- **Hand-offs.** A failed, rate-limited, crashed or silent attempt (`stallMs`) is stopped and the task goes to the next agent the router picks, leaving out the ones tried, with a note: the task, why the last agent stopped, its tools, the files it changed, its last message and `git status`.
+- **Runs** are kept in `$OPENVIBE_CODES_HOME` (else `$XDG_STATE_HOME/openvibe-codes`, else `~/.local/state/openvibe-codes`), one 0700 directory per run.
+- **From code:** `const { createHarness } = require('openvibe-codes')`, then `for await (const e of createHarness().run({ prompt, cwd }))`.
+
+## What comes next
-1. **Adapters** — one interface per harness (launch, stream, usage, resume, pool, capabilities), the personal paths and credentials replaced by tenant configuration and keys kept in OpenVibe.AI.
-2. **Runs** — `POST /api/v1/jobs`, a stream, cancel; budgets per person and per project; the run log in Codes' own database.
-3. **Hand-offs and sessions** — a stalled run moves to the next candidate with its context.
-4. **A runner on your own machine** — your CLIs and your keys, through an OpenVibe Node.
-5. **Improve OpenVibe as a flow** — repository → change → tests → independent review → pull request, never straight to `main`.
+1. **A hosted runner** — the same harness on OpenVibe.Run workers for people who would rather not run agents themselves: `POST /api/v1/jobs`, a stream, cancel, budgets per person and per project, metered through Billing, the run log in Codes' own database.
+2. **OpenVibe.Actor uses Codes** — Actor is OpenVibe's general agent (owner, 2026-10-08); its coding steps run through this harness.
+3. **Improve OpenVibe as a flow** — repository → change → tests → independent review → pull request, never straight to `main`.
+4. **The OpenVibe agent pool on this package** — `~/openvibe/agents` (the pool that builds OpenVibe) replaces its own adapters with these.
## Owns
## Owns
- The harness catalog and the routing policy over it (placement itself is `openvibe-sdk/placement`, the one implementation every service uses).
- The harness catalog and the routing policy over it (placement itself is `openvibe-sdk/placement`, the one implementation every service uses).
- Its pages and the community documents in this repository.
- Its pages and the community documents in this repository.
-- Nothing in its database yet: migration 0002 dropped the console's tables when the console moved; the harness's runs will live here.
+- The harness package (`harness/`, `bin/`): adapters, the event format, hand-offs and the local run store.
+- Nothing in its database yet: migration 0002 dropped the console's tables when the console moved; hosted runs will live here.
## Does not own
## Does not own
@@ -76,7 +103,7 @@ fnm exec --using=22.22.1 npm test # every test/*.test.js on temp PGlite
@@ -76,7 +103,7 @@ fnm exec --using=22.22.1 npm test # every test/*.test.js on temp PGlite
fnm exec --using=22.22.1 npm run dev # http://localhost:4900
fnm exec --using=22.22.1 npm run dev # http://localhost:4900
```
```
-Tests: the catalog (validation at boot, the API and the router: capability first, the reasons, refusals as problems); the redirects (every console address 301 or 308 to the same path on openvibe.services, what stays here answering 200); sign-in (PKCE, state, next=, an assertion or app token is not a session, cross-site sign-out refused); the community documents (drafts marked, not indexed, every link resolving); the crawl artifacts; per-actor limits; no internal key anywhere; no secret in any response, the database or the logs; no URL a caller typed ever fetched; readiness, release.json and loopback metrics; the released codes manifest matching the code; and the home page's size budgets (`test/perf-budget.test.js`, openvibe-shared/perf-budget).
+Tests: the harness against stand-in agents (`test/helpers/fake-agents.js`: every CLI adapter's events, hand-offs on failure and on silence, redaction, resume, the API loop against a stand-in server with its path rules, the command line and its exit codes); the catalog (validation at boot, the API and the router: capability first, the reasons, refusals as problems); the redirects (every console address 301 or 308 to the same path on openvibe.services, what stays here answering 200); sign-in (PKCE, state, next=, an assertion or app token is not a session, cross-site sign-out refused); the community documents (drafts marked, not indexed, every link resolving); the crawl artifacts; per-actor limits; no internal key anywhere; no secret in any response, the database or the logs; no URL a caller typed ever fetched; readiness, release.json and loopback metrics; the released codes manifest matching the code; and the home page's size budgets (`test/perf-budget.test.js`, openvibe-shared/perf-budget).
## Security (threat notes)
## Security (threat notes)
@@ -15,21 +15,28 @@
@@ -15,21 +15,28 @@
"openvibe-sdk v0.35.2",
"openvibe-sdk v0.35.2",
"openvibe-shared v2.13.3"
"openvibe-shared v2.13.3"
],
],
- "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: the agent catalog with a public routing API, Improve OpenVibe (every repository and the path to a reviewed pull request) 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": [
+ "openvibe-codes on a person's machine (bin/openvibe-codes.js, harness/): agents, route, run, runs, show, resume; installs from the release tag",
+ "Adapters for Claude Code, Codex, OpenCode, Command Code and Aider (their CLIs) and for the DeepSeek API and any OpenAI-compatible server (the harness's own agent loop with list/read/grep/write/edit/run tools, confined to the working directory)",
+ "One event stream for every agent, in Claude Code's stream-json shape, ending with exactly one result; keys and tokens redacted",
+ "Hand-offs: a failed, rate-limited, crashed or silent attempt hands the task to the next routed agent with a note (task, tools, files changed, last message, git status)",
+ "Permission levels read / edit / full mapped onto each agent's own sandbox or tool set",
+ "Runs kept on the person's machine (OPENVIBE_CODES_HOME, files 0600) for show and resume",
"Sign-in with OpenVibe.Network (authorization code + PKCE S256), session tokens in httpOnly cookies",
"Sign-in with OpenVibe.Network (authorization code + PKCE S256), session tokens in httpOnly cookies",
"The agent catalog (server/data/harness-offers.json, validated at boot as platform.harness-offer@1 / platform.agent-offer@1): /harnesses and GET /api/v1/harnesses",
"The agent catalog (server/data/harness-offers.json, validated at boot as platform.harness-offer@1 / platform.agent-offer@1): /harnesses and GET /api/v1/harnesses",
- "Routing a task over the catalog with openvibe-sdk/placement: POST /api/v1/harnesses/route { task, requirements? } → { selected, reasons, candidates }",
+ "Routing a task over the catalog with openvibe-sdk/placement: POST /api/v1/harnesses/route { task, requirements? } → { selected, reasons, candidates } (the same router the command line uses)",
+ "Getting started (/start): install, the agents and what each needs, commands, permission levels, hand-offs, the event format, using it from code",
"Improve OpenVibe (/improve): every repository from the pinned contracts' service manifests, and the contributor path",
"Improve OpenVibe (/improve): every repository from the pinned contracts' service manifests, and the contributor path",
"The community documents (code of conduct, contributing, contributor ladder, moderation) rendered from this repository",
"The community documents (code of conduct, contributing, contributor ladder, moderation) rendered from this repository",
"Permanent redirects for every former console address (301 for reads, 308 for writes) to openvibe.services",
"Permanent redirects for every former console address (301 for reads, 308 for writes) to openvibe.services",
"robots.txt, sitemap.xml, llms.txt and llms-full.txt; per-actor and per-address limits; truthful readiness"
"robots.txt, sitemap.xml, llms.txt and llms-full.txt; per-actor and per-address limits; truthful readiness"
],
],
"notYet": [
"notYet": [
- "Running a task on an agent, streaming its output, budgets (the agents run today on OpenVibe's own machines through ~/openvibe/agents)",
- "Hand-offs between agents and resumable sessions",
- "A runner on a person's own machine (their CLIs, their keys)",
- "Improve OpenVibe as a flow on this site (repository → change → tests → review → pull request); today it is the contributor path on GitHub",
+ "A hosted runner (tasks on OpenVibe.Run workers for people who do not run agents themselves) with budgets and metering",
+ "OpenVibe.Actor using Codes for its coding work",
+ "Improve OpenVibe as a guided run on this site (repository → change → tests → review → pull request); today it is the harness plus the contributor path on GitHub",
+ "The OpenVibe agent pool (~/openvibe/agents) running on this package instead of its own adapters",
"The community documents are drafts pending the owner's review"
"The community documents are drafts pending the owner's review"
],
],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
@@ -37,6 +44,8 @@
@@ -37,6 +44,8 @@
"deployed": true,
"deployed": true,
"placeholderInSites": false,
"placeholderInSites": false,
"features": [
"features": [
+ "coding-agent harness (command line and Node.js module)",
+ "hand-offs between agents",
"coding-agent catalog and router",
"coding-agent catalog and router",
"Improve OpenVibe",
"Improve OpenVibe",
"community documents",
"community documents",
new file mode 100755
new file mode 100755
@@ -0,0 +1,158 @@
@@ -0,0 +1,158 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * openvibe-codes — code with any agent, from your terminal. The harness runs on your machine with your agents and your
+ * keys; nothing is sent to OpenVibe. The commands and options are in USAGE below (`openvibe-codes --help`); the guide is
+ * https://openvibe.codes/start.
+ */
+const path = require('path');
+const { createHarness } = require('../harness');
+const { version: VERSION } = require('../package.json');
+
+const USAGE = `openvibe-codes ${VERSION} — code with any agent, from your terminal
+
+ openvibe-codes agents which agents this machine can run, and why not the others
+ openvibe-codes route [--task edit] [--need harness:resume,…] which agent a task would go to, and why
+ openvibe-codes run [options] "what to do" run a task ("-" reads it from stdin)
+ --task edit|review|long|resume what kind of task (default edit)
+ --agent <id> skip routing: claude-code, codex, opencode, command-code, aider,
+ deepseek, openai-compatible
+ --model <name> the first agent's model
+ --permission read|edit|full read: look and answer; edit: change files (default);
+ full: anything, unattended
+ --cwd <dir> where the agent works (default: here)
+ --no-handoff stop at the first agent's result
+ --attempts <n> at most this many agents in one run (default 3)
+ --json the events as JSON lines instead of a readable log
+ openvibe-codes runs the latest runs on this machine
+ openvibe-codes show <run id> [--json] one run's attempts and result (--json: every event)
+ openvibe-codes resume <run id> "follow-up" continue a run's last session with its agent
+
+Environment: DEEPSEEK_API_KEY · OPENVIBE_CODES_BASE_URL, OPENVIBE_CODES_MODEL, OPENVIBE_CODES_API_KEY
+(your own OpenAI-compatible server) · OPENVIBE_CODES_HOME (where runs are kept).
+Guide: https://openvibe.codes/start`;
+
+function parse(argv) {
+ const opts = { _: [] };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--json') opts.json = true;
+ else if (a === '--no-handoff') opts.handoff = false;
+ else if (a === '--help' || a === '-h') opts.help = true;
+ else if (a.startsWith('--')) { const k = a.slice(2); opts[k] = argv[++i]; }
+ else opts._.push(a);
+ }
+ return opts;
+}
+
+const out = (s) => process.stdout.write(`${s}\n`);
+const dim = (s) => (process.stdout.isTTY ? `\x1b[2m${s}\x1b[0m` : s);
+const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : String(n));
+
+function printEvent(e) {
+ if (e.type === 'system' && e.subtype === 'init') return out(dim(`▶ ${e.agent}${e.model ? ` (${e.model})` : ''}${e.session_id ? ` · session ${e.session_id}` : ''}`));
+ if (e.type === 'system' && e.subtype === 'handoff') return out(`⇢ handing over from ${e.from}: ${e.reason}`);
+ if (e.type === 'system' && (e.subtype === 'error' || e.subtype === 'warning')) return out(dim(`! ${e.text}`));
+ if (e.type === 'assistant') {
+ for (const c of (e.message && e.message.content) || []) {
+ if (c.type === 'text') out(c.text);
+ 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(dim(` → ${c.name}${what ? ` ${String(what).split('\n')[0].slice(0, 120)}` : ''}`));
+ }
+ }
+ return undefined;
+ }
+ if (e.type === 'user') {
+ for (const c of (e.message && e.message.content) || []) if (c.type === 'tool_result' && c.is_error) out(dim(` ✗ ${String(c.content).split('\n')[0].slice(0, 160)}`));
+ return undefined;
+ }
+ if (e.type === 'result') {
+ const u = e.usage || {};
+ const facts = [`${Math.round((e.duration_ms || 0) / 1000)} s`, e.total_cost_usd ? `$${e.total_cost_usd.toFixed(4)}` : null, `${k((u.input_tokens || 0) + (u.cache_read_input_tokens || 0))} in / ${k(u.output_tokens || 0)} out`].filter(Boolean).join(' · ');
+ return out(e.is_error ? `✗ ${e.result} ${dim(`(${facts})`)}` : dim(`✔ done (${facts})`));
+ }
+ return undefined;
+}
+
+async function readStdin() {
+ let s = '';
+ for await (const chunk of process.stdin) s += chunk;
+ return s;
+}
+
+async function main() {
+ const [cmd, ...rest] = process.argv.slice(2);
+ const o = parse(rest);
+ if (!cmd || cmd === 'help' || o.help || cmd === '--help' || cmd === '-h') { out(USAGE); return 0; }
+ if (cmd === '--version' || cmd === '-v' || cmd === 'version') { out(VERSION); return 0; }
+ const harness = createHarness();
+
+ if (cmd === 'agents') {
+ const list = harness.agents();
+ if (o.json) { out(JSON.stringify(list, null, 2)); return 0; }
+ for (const a of list) out(`${a.available ? '✔' : '·'} ${a.id.padEnd(18)} ${a.name.padEnd(16)} ${a.available ? dim(a.where || '') : dim(a.reason)}`);
+ return 0;
+ }
+ if (cmd === 'route') {
+ const requirements = o.need ? { capabilities: String(o.need).split(',').map((s) => s.trim()).filter(Boolean) } : {};
+ const d = harness.route({ task: o.task || 'edit', requirements });
+ if (o.json) { out(JSON.stringify(d, null, 2)); return 0; }
+ out(d.selected ? `→ ${d.selected}` : '→ no agent here can take it');
+ for (const r of d.reasons || []) out(dim(` ${r}`));
+ for (const c of d.candidates || []) out(`${c.included ? ' ✔' : ' ·'} ${c.id}${c.reason ? dim(` — ${c.reason}`) : ''}`);
+ return d.selected ? 0 : 1;
+ }
+ if (cmd === 'run' || cmd === 'resume') {
+ let prompt;
+ let resume = null;
+ let agent = o.agent || null;
+ let cwd = path.resolve(o.cwd || process.cwd());
+ if (cmd === 'resume') {
+ const prev = harness.store.loadRun(o._[0]);
+ if (!prev) { out(`no run ${o._[0]} on this machine (openvibe-codes runs)`); return 1; }
+ const last = [...(prev.attempts || [])].reverse().find((a) => a.session_id);
+ if (!last) { out('that run has no session to resume'); return 1; }
+ resume = last.session_id; agent = last.agent; cwd = prev.cwd || cwd;
+ prompt = o._.slice(1).join(' ');
+ } else {
+ prompt = o._.join(' ');
+ }
+ if (prompt === '-') prompt = await readStdin();
+ if (!prompt || !prompt.trim()) { out('what should the agent do? openvibe-codes run "…"'); return 1; }
+ const controller = new AbortController();
+ process.on('SIGINT', () => { controller.abort(); });
+ const run = harness.run({
+ prompt, task: o.task || (resume ? 'resume' : 'edit'), agent, model: o.model || null, cwd, resume,
+ permission: o.permission || 'edit', handoff: o.handoff !== false && !resume, maxAttempts: Number(o.attempts) || 3, signal: controller.signal,
+ });
+ if (!o.json) out(dim(`run ${run.runId} · ${cwd}`));
+ let last = null;
+ for await (const e of run) { last = e; if (o.json) out(JSON.stringify(e)); else printEvent(e); }
+ return last && last.type === 'result' && !last.is_error ? 0 : 1;
+ }
+ if (cmd === 'runs') {
+ const runs = harness.store.listRuns(Number(o.limit) || 20);
+ if (o.json) { out(JSON.stringify(runs, null, 2)); return 0; }
+ for (const r of runs) {
+ const ok = r.result && !r.result.is_error;
+ out(`${ok ? '✔' : '✗'} ${r.run_id} ${(r.attempts || []).map((a) => a.agent).join(' ⇢ ').padEnd(28)} ${dim(String(r.prompt || '').split('\n')[0].slice(0, 70))}`);
+ }
+ return 0;
+ }
+ if (cmd === 'show') {
+ const r = harness.store.loadRun(o._[0]);
+ if (!r) { out(`no run ${o._[0]} on this machine`); return 1; }
+ if (o.json) { for (const e of harness.store.events(r.run_id)) out(JSON.stringify(e)); return 0; }
+ out(`${r.run_id} · ${r.cwd} · ${r.permission}`);
+ for (const a of r.attempts || []) out(` ${a.ok ? '✔' : '✗'} ${a.agent}${a.session_id ? dim(` session ${a.session_id}`) : ''}${a.error ? ` — ${a.error}` : ''}`);
+ if (r.result) out(r.result.result || '');
+ return r.result && !r.result.is_error ? 0 : 1;
+ }
+ out(USAGE);
+ return 1;
+}
+
+main().then((code) => process.exit(code), (err) => { process.stderr.write(`openvibe-codes: ${err.message}\n`); process.exit(1); });
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
# ADR-050: The Codes harness fabric and its boundaries
# ADR-050: The Codes harness fabric and its boundaries
-**Status:** Proposed 2026-10-05. Governs the Codes product code that has not been written yet; the catalog, the
+**Status:** Proposed 2026-10-05; decision 8 added by the owner's decision of 2026-10-08. Governs the Codes product code that has not been written yet; the catalog, the
placement adapter and the read API recorded under **Context and current evidence** are the seed this
placement adapter and the read API recorded under **Context and current evidence** are the seed this
record is written against, as they stand on `main` (most recently `af3aab3`, `a1097e7`). Written in Codes'
record is written against, as they stand on `main` (most recently `af3aab3`, `a1097e7`). Written in Codes'
first `docs/adr/` — the plan (§8) lists **ADR-050 Codes Harness Fabric** among the ADRs "to write before their code", and no earlier record covers it.
first `docs/adr/` — the plan (§8) lists **ADR-050 Codes Harness Fabric** among the ADRs "to write before their code", and no earlier record covers it.
@@ -101,6 +101,18 @@ first `docs/adr/` — the plan (§8) lists **ADR-050 Codes Harness Fabric** amon
@@ -101,6 +101,18 @@ first `docs/adr/` — the plan (§8) lists **ADR-050 Codes Harness Fabric** amon
to `trust: 'external'` (`harness-placement.js:44`, asserted at `test/harness-placement.test.js:56`) — whether
to `trust: 'external'` (`harness-placement.js:44`, asserted at `test/harness-placement.test.js:56`) — whether
first-party harnesses should advertise a different class is **open**.
first-party harnesses should advertise a different class is **open**.
+8. **Owner decision, 2026-10-08: the harness runs on the person's machine first.** Codes became the open, modular
+ coding-agent harness and the developer console moved to OpenVibe.Services. The write half that decision 5 left open
+ starts where it needs no other track: `openvibe-codes` (`harness/`, `bin/`) launches the agents a person already
+ has (Claude Code, Codex, OpenCode, Command Code, Aider as CLIs; DeepSeek and any OpenAI-compatible server through
+ the harness's own tool loop), turns every agent's output into one event stream in Claude Code's `stream-json` shape,
+ hands a failed or silent attempt to the next agent `plan()` picks with a note on what was done, and keeps runs in a
+ local store. Routing still goes through `openvibe-sdk/placement` (decision 1): an agent that is not installed or has
+ no key is passed to `plan()` as `health: down` with the reason. Nothing is sent to OpenVibe and no key leaves the
+ machine, so T2/T5/T14 are not prerequisites for this half. A hosted runner (OpenVibe.Run workers, budgets, metering)
+ still waits on T14 and T5, and OpenVibe.Actor (T17) uses Codes for its coding work rather than owning a second
+ coding harness.
+
## Alternatives considered
## Alternatives considered
- **A second coding-harness router inside Codes (or reusing `openvibe-agents`' scored router as the optimizer):**
- **A second coding-harness router inside Codes (or reusing `openvibe-agents`' scored router as the optimizer):**
new file mode 100644
new file mode 100644
@@ -0,0 +1,206 @@
@@ -0,0 +1,206 @@
+'use strict';
+
+/**
+ * A coding agent for any model behind an OpenAI-compatible chat API: DeepSeek, OpenAI, or a model you host (llama.cpp,
+ * Ollama, vLLM, LM Studio). The model gets a small set of tools that work inside the working directory, and the loop
+ * runs until it answers without calling one (or reaches its turn limit):
+ *
+ * list_dir, read_file, grep every permission level
+ * write_file, edit_file edit and full
+ * run_command full only (bash -lc in the working directory, two minutes, output clipped)
+ *
+ * Every path is resolved inside the working directory: `..` out of it, an absolute path elsewhere, `.git/` writes and
+ * `.env` files are refused. The conversation is kept in the session store so `resume` continues it.
+ */
+const fs = require('fs');
+const path = require('path');
+const { spawn } = require('child_process');
+const ev = require('../events');
+
+const SYSTEM = `You are a careful coding agent working in a repository on the user's machine. Use the tools to look before
+you change anything: list and read the files involved, make the smallest change that does what was asked, and keep the
+code's own style. When you are done, answer with a short summary of what you changed and anything left to do. Never
+invent a file's contents: read it first.`;
+
+const TOOL_DEFS = {
+ list_dir: { level: 'read', description: 'List the entries of a directory (relative to the working directory).', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: [] } },
+ read_file: { level: 'read', description: 'Read a text file. Optional 1-based line offset and line limit.', parameters: { type: 'object', properties: { path: { type: 'string' }, offset: { type: 'integer' }, limit: { type: 'integer' } }, required: ['path'] } },
+ grep: { level: 'read', description: 'Search files for a regular expression; returns path:line: text matches.', parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' } }, required: ['pattern'] } },
+ write_file: { level: 'edit', description: 'Create or replace a file with the given content.', parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] } },
+ edit_file: { level: 'edit', description: 'Replace one exact, unique occurrence of old_string with new_string in a file.', parameters: { type: 'object', properties: { path: { type: 'string' }, old_string: { type: 'string' }, new_string: { type: 'string' } }, required: ['path', 'old_string', 'new_string'] } },
+ run_command: { level: 'full', description: 'Run a shell command in the working directory (two-minute limit).', parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] } },
+};
+const LEVELS = { read: 1, edit: 2, full: 3 };
+const SHOWN_AS = { list_dir: 'LS', read_file: 'Read', grep: 'Grep', write_file: 'Write', edit_file: 'Edit', run_command: 'Bash' };
+const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', 'build', '.next', 'coverage', '.cache']);
+
+/** A path inside the working directory, or an Error that says why not. */
+function inside(cwd, p, { write = false } = {}) {
+ const root = fs.realpathSync(cwd);
+ const abs = path.resolve(root, String(p || '.'));
+ if (abs !== root && !abs.startsWith(`${root}${path.sep}`)) return new Error(`${p} is outside the working directory`);
+ const rel = path.relative(root, abs);
+ if (/(^|[\\/])\.env(\.|$)/.test(rel)) return new Error('.env files are not read or written');
+ if (write && /(^|[\\/])\.git([\\/]|$)/.test(rel)) return new Error('.git is not written');
+ return abs;
+}
+
+function runTool(name, args, cwd) {
+ const a = args || {};
+ if (name === 'list_dir') {
+ const dir = inside(cwd, a.path);
+ if (dir instanceof Error) return [dir.message, true];
+ const items = fs.readdirSync(dir, { withFileTypes: true }).slice(0, 300).map((d) => `${d.name}${d.isDirectory() ? '/' : ''}`);
+ return [items.join('\n') || '(empty)', false];
+ }
+ if (name === 'read_file') {
+ const f = inside(cwd, a.path);
+ if (f instanceof Error) return [f.message, true];
+ const text = fs.readFileSync(f, 'utf8');
+ const lines = text.split('\n');
+ const from = Math.max(1, Number(a.offset) || 1);
+ const to = Math.min(lines.length, from - 1 + Math.max(1, Number(a.limit) || 2000));
+ const body = lines.slice(from - 1, to).map((l, i) => `${from + i}\t${l}`).join('\n');
+ return [body.length > 64_000 ? `${body.slice(0, 64_000)}\n… (cut: read with offset/limit)` : body, false];
+ }
+ if (name === 'grep') {
+ const base = inside(cwd, a.path);
+ if (base instanceof Error) return [base.message, true];
+ let re;
+ try { re = new RegExp(String(a.pattern)); } catch (err) { return [`bad pattern: ${err.message}`, true]; }
+ const hits = [];
+ const walk = (p) => {
+ if (hits.length >= 200) return;
+ const st = fs.statSync(p);
+ if (st.isDirectory()) { for (const n of fs.readdirSync(p)) if (!SKIP_DIRS.has(n) && !/^\.env/.test(n)) walk(path.join(p, n)); return; }
+ if (st.size > 1_000_000) return;
+ const text = fs.readFileSync(p, 'utf8');
+ if (text.includes('\u0000')) return;
+ text.split('\n').forEach((l, i) => { if (hits.length < 200 && re.test(l)) hits.push(`${path.relative(cwd, p)}:${i + 1}: ${l.slice(0, 300)}`); });
+ };
+ walk(base);
+ return [hits.join('\n') || 'no matches', false];
+ }
+ if (name === 'write_file') {
+ const f = inside(cwd, a.path, { write: true });
+ if (f instanceof Error) return [f.message, true];
+ fs.mkdirSync(path.dirname(f), { recursive: true });
+ fs.writeFileSync(f, String(a.content == null ? '' : a.content));
+ return [`wrote ${path.relative(cwd, f)}`, false];
+ }
+ if (name === 'edit_file') {
+ const f = inside(cwd, a.path, { write: true });
+ if (f instanceof Error) return [f.message, true];
+ const text = fs.readFileSync(f, 'utf8');
+ const old = String(a.old_string || '');
+ const count = old ? text.split(old).length - 1 : 0;
+ if (count !== 1) return [count ? `old_string occurs ${count} times: add context so it is unique` : 'old_string not found: read the file first', true];
+ fs.writeFileSync(f, text.replace(old, String(a.new_string == null ? '' : a.new_string)));
+ return [`edited ${path.relative(cwd, f)}`, false];
+ }
+ return [`unknown tool ${name}`, true];
+}
+
+/** run_command, asynchronously (the only tool that waits on something else). */
+function runCommand(command, cwd, signal) {
+ 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); };
+ child.stdout.on('data', add); child.stderr.on('data', add);
+ const timer = setTimeout(() => child.kill('SIGKILL'), 120_000);
+ const abort = () => child.kill('SIGKILL');
+ if (signal) signal.addEventListener('abort', abort, { once: true });
+ child.on('close', (code) => { clearTimeout(timer); resolve([`${out.trim()}${code ? `\n(exit ${code})` : ''}` || '(no output)', code !== 0]); });
+ child.on('error', (err) => { clearTimeout(timer); resolve([err.message, true]); });
+ });
+}
+
+/**
+ * apiAgent({ id, name, baseUrl, keyEnv, model, priceIn, priceOut }) → an adapter.
+ * ctx (from harness/index.js): { env, fetch, sessions } — sessions keeps the conversation for resume.
+ */
+function apiAgent(spec) {
+ const adapter = {
+ id: spec.id, name: spec.name, kind: 'api', baseUrl: spec.baseUrl, keyEnv: spec.keyEnv || null, defaultModel: spec.model,
+ start(t, ctx = {}) {
+ const env = ctx.env || process.env;
+ const doFetch = ctx.fetch || globalThis.fetch;
+ const base = String(t.baseUrl || spec.baseUrl).replace(/\/+$/, '');
+ const key = spec.keyEnv ? env[spec.keyEnv] : (t.apiKey || '');
+ const model = t.model || spec.model;
+ const level = LEVELS[t.permission] || 1;
+ const tools = Object.entries(TOOL_DEFS).filter(([, d]) => LEVELS[d.level] <= level)
+ .map(([name, d]) => ({ type: 'function', function: { name, description: d.description, parameters: d.parameters } }));
+ const sessionId = t.resume || `api_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
+ const startedAt = Date.now();
+ const maxTurns = Math.max(1, Number(t.maxTurns) || 40);
+ let cancelled = false;
+ const controller = new AbortController();
+ if (t.signal) t.signal.addEventListener('abort', () => { cancelled = true; controller.abort(); }, { once: true });
+
+ const iterator = (async function* run() {
+ const prior = t.resume && ctx.sessions ? ctx.sessions.load(sessionId) : null;
+ const messages = prior && Array.isArray(prior.messages) ? prior.messages : [{ role: 'system', content: SYSTEM }];
+ messages.push({ role: 'user', content: t.prompt });
+ yield ev.init(sessionId, spec.id, model);
+ const u = { input: 0, output: 0, cacheRead: 0 };
+ let lastText = '';
+ let turns = 0;
+ let failure = null;
+ try {
+ while (turns < maxTurns && !cancelled) {
+ turns++;
+ const res = await doFetch(`${base}/chat/completions`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}` } : {}) },
+ body: JSON.stringify({ model, messages, tools, tool_choice: 'auto' }),
+ signal: controller.signal,
+ });
+ const body = await res.json().catch(() => null);
+ if (!res.ok || !body || !Array.isArray(body.choices) || !body.choices[0]) {
+ failure = `${spec.name} answered HTTP ${res.status}${body && body.error ? `: ${String(body.error.message || body.error).slice(0, 300)}` : ''}`;
+ yield ev.error(failure);
+ break;
+ }
+ const usage = body.usage || {};
+ u.input += +usage.prompt_tokens || 0; u.output += +usage.completion_tokens || 0;
+ u.cacheRead += +(usage.prompt_cache_hit_tokens || (usage.prompt_tokens_details && usage.prompt_tokens_details.cached_tokens)) || 0;
+ const msg = body.choices[0].message || {};
+ messages.push({ role: 'assistant', content: msg.content || '', ...(msg.tool_calls ? { tool_calls: msg.tool_calls } : {}) });
+ if (String(msg.content || '').trim()) { lastText = msg.content; yield ev.text(msg.content, model); }
+ const calls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
+ if (!calls.length) break;
+ for (const call of calls) {
+ 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);
+ 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 { 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) });
+ }
+ }
+ if (!failure && !cancelled && turns >= maxTurns && !lastText) failure = `${spec.name} reached its turn limit (${maxTurns}) without an answer`;
+ } catch (err) {
+ failure = cancelled ? `${spec.name} was stopped` : `${spec.name} could not be reached: ${err.message}`;
+ yield ev.error(failure);
+ }
+ if (ctx.sessions) ctx.sessions.save(sessionId, { agent: spec.id, model, messages: messages.slice(-200) });
+ const cost = (u.input * (spec.priceIn || 0) + u.output * (spec.priceOut || 0)) / 1000;
+ yield ev.result({ ok: !failure && !cancelled, text: failure || lastText, sessionId, model, startedAt, turns, costUsd: cost,
+ usage: ev.usage({ input: u.input - u.cacheRead, output: u.output, cacheRead: u.cacheRead }) });
+ }());
+ iterator.cancel = () => { cancelled = true; controller.abort(); };
+ return iterator;
+ },
+ };
+ return adapter;
+}
+
+module.exports = { apiAgent, runTool, inside, TOOL_DEFS, SYSTEM };
new file mode 100644
new file mode 100644
@@ -0,0 +1,218 @@
@@ -0,0 +1,218 @@
+'use strict';
+
+/**
+ * The coding agents that run as a command on the person's machine. Each adapter says how to start its CLI for a task
+ * and how to read what it prints into the shared events (harness/events.js):
+ *
+ * { id, name, kind: 'cli', bin, start(task, ctx) } ctx: { env } (the harness's environment: PATH finds the CLI)
+ * task: { prompt, cwd, model, resume, permission: 'read' | 'edit' | 'full', maxTurns, timeoutMs, signal }
+ *
+ * Permission levels mean the same for every agent, mapped onto what each CLI offers:
+ * read look and answer: no file is changed and no command that changes anything runs
+ * edit change files in the working directory; shell commands only as the agent's own rules allow
+ * full anything the agent can do, unattended (its own guardrails only) — for a sandbox or a throwaway checkout
+ * Nothing here pushes, commits or opens a pull request: that is the person's (or a later Improve OpenVibe step's) call.
+ */
+const ev = require('../events');
+const { spawnAgent, exitReason } = require('../process');
+
+/** A parser that reads JSON lines, hands each object to onEvent, and ends the run with exactly one result. */
+function jsonLines(agent, onEvent, onEnd) {
+ let sawResult = false;
+ const state = {};
+ return {
+ line(raw, emit) {
+ let o;
+ try { o = JSON.parse(raw); } catch { if (raw.trim()) emit(ev.stderr(raw)); return; }
+ onEvent(o, (e) => { if (e && e.type === 'result') sawResult = true; emit(e); }, state);
+ },
+ end(info, emit) {
+ if (onEnd) onEnd(info, (e) => { if (e && e.type === 'result') sawResult = true; emit(e); }, state);
+ if (!sawResult) emit(ev.result({ ok: false, text: exitReason(info, agent.name), sessionId: state.sessionId, model: state.model, startedAt: state.startedAt }));
+ },
+ };
+}
+
+// ── Claude Code: `claude -p --output-format stream-json` already prints the shared events ──
+const claudeCode = {
+ id: 'claude-code', name: 'Claude Code', kind: 'cli', bin: 'claude',
+ start(t, ctx = {}) {
+ const mode = { read: 'plan', edit: 'acceptEdits', full: 'bypassPermissions' }[t.permission] || 'plan';
+ const args = ['-p', t.prompt, '--output-format', 'stream-json', '--verbose', '--permission-mode', mode];
+ if (t.model) args.push('--model', t.model);
+ if (t.resume) args.push('--resume', t.resume);
+ if (t.maxTurns) args.push('--max-turns', String(t.maxTurns));
+ // Even unattended, a run never pushes: publishing is the person's step.
+ args.push('--disallowedTools', 'Bash(git push:*)');
+ const parser = jsonLines(claudeCode, (o, emit, st) => {
+ if (o.type === 'system' && o.subtype === 'init') { st.sessionId = o.session_id; st.model = o.model; emit(ev.init(o.session_id, 'claude-code', o.model)); return; }
+ if (o.type === 'result') { emit({ ...o, result: ev.scrub(o.result || '') }); return; }
+ if (o.type === 'assistant' || o.type === 'user') emit(o);
+ });
+ return spawnAgent({ cmd: this.bin, args, cwd: t.cwd, env: ctx.env, timeoutMs: t.timeoutMs, signal: t.signal, parser });
+ },
+};
+
+// ── Codex: `codex exec --json` (thread/turn/item events) ──
+const CODEX_TOOL = (it) => {
+ if (it.type === 'command_execution') return ['Bash', { command: it.command || '' }];
+ if (it.type === 'mcp_tool_call') return [`mcp__${it.server || 'mcp'}__${it.tool || 'tool'}`, it.arguments && typeof it.arguments === 'object' ? it.arguments : {}];
+ if (it.type === 'web_search') return ['WebSearch', { query: it.query || '' }];
+ return [`codex:${it.type || 'item'}`, {}];
+};
+const codex = {
+ id: 'codex', name: 'Codex', kind: 'cli', bin: 'codex',
+ start(t, ctx = {}) {
+ const sandbox = { read: 'read-only', edit: 'workspace-write', full: 'danger-full-access' }[t.permission] || 'read-only';
+ const args = t.resume
+ ? ['exec', 'resume', t.resume, '--json', '--skip-git-repo-check']
+ : ['exec', '--json', '--skip-git-repo-check', '-C', t.cwd, '-s', sandbox];
+ if (t.model) args.push('-m', t.model);
+ args.push(t.prompt);
+ const startedAt = Date.now();
+ const parser = jsonLines(codex, (e, emit, st) => {
+ st.startedAt = startedAt;
+ st.usage = st.usage || { input: 0, cached: 0, output: 0 };
+ st.started = st.started || new Set();
+ const item = e.item || {};
+ const id = String(item.id || `${item.type}-${st.started.size}`);
+ if (e.type === 'thread.started') { st.sessionId = e.thread_id; emit(ev.init(e.thread_id, 'codex', t.model)); }
+ else if (e.type === 'turn.started') st.turns = (st.turns || 0) + 1;
+ else if (e.type === 'item.completed' && item.type === 'agent_message' && String(item.text || '').trim()) { st.lastText = item.text; emit(ev.text(item.text, t.model)); }
+ else if (e.type === 'item.completed' && item.type === 'file_change') {
+ (item.changes || [{}]).forEach((ch, i) => {
+ const kind = ch.kind && typeof ch.kind === 'object' ? ch.kind.type : ch.kind;
+ emit(ev.toolUse(`${id}:${i}`, kind === 'add' ? 'Write' : kind === 'delete' ? 'Delete' : 'Edit', { file_path: ch.path || '' }, t.model));
+ emit(ev.toolResult(`${id}:${i}`, item.status === 'failed' ? 'patch failed' : `${kind || 'update'} applied`, item.status === 'failed'));
+ });
+ } else if ((e.type === 'item.started' || e.type === 'item.completed') && ['command_execution', 'mcp_tool_call', 'web_search'].includes(item.type)) {
+ if (!st.started.has(id)) { st.started.add(id); const [name, input] = CODEX_TOOL(item); emit(ev.toolUse(id, name, input, t.model)); }
+ if (e.type === 'item.completed') {
+ const failed = item.status === 'failed' || (item.exit_code != null && item.exit_code !== 0);
+ const out = item.type === 'command_execution' ? `${item.aggregated_output || ''}${item.exit_code ? `\n(exit ${item.exit_code})` : ''}` : (item.error ? String(item.error.message || item.error) : 'done');
+ emit(ev.toolResult(id, out, failed));
+ }
+ } else if (e.type === 'turn.completed') {
+ const u = e.usage || {};
+ st.usage.input += +u.input_tokens || 0; st.usage.cached += +u.cached_input_tokens || 0; st.usage.output += +u.output_tokens || 0;
+ } else if (e.type === 'turn.failed' || e.type === 'error') {
+ const why = String((e.error && (e.error.message || e.error)) || e.message || 'turn failed');
+ if (why !== st.failed) emit(ev.error(why)); // Codex reports one failure as an error and a failed turn
+ st.failed = why;
+ }
+ }, (info, emit, st) => {
+ if (!st.sessionId && !st.lastText) return; // nothing ran: the generic exit reason says why
+ const u = st.usage || { input: 0, cached: 0, output: 0 };
+ const ok = !st.failed && !!st.lastText && info.code === 0;
+ emit(ev.result({ ok, text: ok ? st.lastText : (st.failed || exitReason(info, 'Codex')), sessionId: st.sessionId, model: t.model, startedAt, turns: st.turns || 0,
+ usage: ev.usage({ input: u.input - u.cached, cacheRead: u.cached, output: u.output }) }));
+ });
+ return spawnAgent({ cmd: this.bin, args, cwd: t.cwd, env: ctx.env, timeoutMs: t.timeoutMs, signal: t.signal, parser });
+ },
+};
+
+// ── OpenCode: `opencode run --format json` (text, tool_use, step_finish, error parts) ──
+const OC_NAMES = { shell: 'Bash', bash: 'Bash', read: 'Read', edit: 'Edit', write: 'Write', patch: 'Edit', multiedit: 'Edit', grep: 'Grep', glob: 'Glob', list: 'LS', webfetch: 'WebFetch', websearch: 'WebSearch', task: 'Task', todowrite: 'TodoWrite' };
+const opencode = {
+ id: 'opencode', name: 'OpenCode', kind: 'cli', bin: 'opencode',
+ start(t, ctx = {}) {
+ const args = ['run', '--format', 'json'];
+ if (t.permission !== 'read') args.push('--auto');
+ if (t.model) args.push('-m', t.model);
+ if (t.resume) args.push('-s', t.resume);
+ args.push('--', t.prompt);
+ const startedAt = Date.now();
+ const parser = jsonLines(opencode, (e, emit, st) => {
+ st.startedAt = startedAt;
+ st.u = st.u || { input: 0, output: 0, read: 0, write: 0, cost: 0 };
+ if (e.sessionID && !st.sessionId) { st.sessionId = e.sessionID; emit(ev.init(e.sessionID, 'opencode', t.model)); }
+ const p = e.part || {};
+ if (e.type === 'tool_use') {
+ const s = p.state || {};
+ const input = { ...(s.input || {}) };
+ if (input.path && !input.file_path) input.file_path = input.path;
+ emit(ev.toolUse(p.id || p.partID, OC_NAMES[p.tool] || p.tool, input, t.model));
+ emit(ev.toolResult(p.id || p.partID, s.status === 'error' ? String(s.error || 'the tool failed') : typeof s.output === 'string' ? s.output : JSON.stringify(s.output || ''), s.status === 'error'));
+ } else if (e.type === 'text' && String(p.text || '').trim()) {
+ st.lastText = (st.lastMsg === p.messageID ? `${st.lastText || ''}` : '') + p.text;
+ st.lastMsg = p.messageID;
+ emit(ev.text(p.text, t.model));
+ } else if (e.type === 'step_finish') {
+ const k = p.tokens || {};
+ st.turns = (st.turns || 0) + 1;
+ st.u.input += k.input || 0; st.u.output += (k.output || 0) + (k.reasoning || 0); st.u.read += (k.cache && k.cache.read) || 0; st.u.write += (k.cache && k.cache.write) || 0; st.u.cost += +p.cost || 0;
+ } else if (e.type === 'error') {
+ st.failed = String((e.error && (e.error.message || e.error.type)) || 'unknown error');
+ emit(ev.error(st.failed));
+ }
+ }, (info, emit, st) => {
+ if (!st.sessionId && !st.lastText) return;
+ const u = st.u || { input: 0, output: 0, read: 0, write: 0, cost: 0 };
+ const final = String(st.lastText || '').trim();
+ emit(ev.result({ ok: !!final && !st.failed, text: final || st.failed || exitReason(info, 'OpenCode'), sessionId: st.sessionId, model: t.model, startedAt, turns: st.turns || 0, costUsd: u.cost,
+ usage: ev.usage({ input: u.input, output: u.output, cacheRead: u.read, cacheWrite: u.write }) }));
+ });
+ return spawnAgent({ cmd: this.bin, args, cwd: t.cwd, env: ctx.env, timeoutMs: t.timeoutMs, signal: t.signal, parser });
+ },
+};
+
+// ── Command Code: `cmd --print --output-format json` (run_start, message_end, tool_* events, result) ──
+const CC_NAMES = { read_file: 'Read', shell: 'Bash', run_shell: 'Bash', shell_command: 'Bash', read_directory: 'LS', grep: 'Grep', glob: 'Glob', edit_file: 'Edit', write_file: 'Write', list_directory: 'LS', web_fetch: 'WebFetch', task: 'Task' };
+const commandCode = {
+ id: 'command-code', name: 'Command Code', kind: 'cli', bin: 'cmd',
+ start(t, ctx = {}) {
+ const args = [`--print=${t.prompt}`, '--output-format', 'json', '--skip-onboarding', '--no-auto-update', '-t'];
+ if (t.permission !== 'read') args.push('--yolo');
+ if (t.model) args.push('-m', t.model);
+ if (t.resume) args.push('--resume', t.resume);
+ if (t.maxTurns) args.push('--max-turns', String(t.maxTurns));
+ const startedAt = Date.now();
+ const parser = jsonLines(commandCode, (f, emit, st) => {
+ st.startedAt = startedAt;
+ if (f.type === 'result') {
+ const err = f.subtype === 'error' ? String((f.error && (f.error.message || f.error)) || 'unknown error') : null;
+ const u = f.usage || {};
+ emit(ev.result({ ok: f.subtype === 'success', text: f.finalText || err || '', sessionId: f.sessionId || st.sessionId, model: st.model, startedAt,
+ usage: ev.usage({ input: u.inputTokens, output: u.outputTokens, cacheRead: u.cacheReadTokens, cacheWrite: u.cacheWriteTokens }) }));
+ return;
+ }
+ const e = f.type === 'event' && f.event;
+ if (!e) return;
+ if (e.type === 'run_start') { st.sessionId = e.sessionId; emit(ev.init(e.sessionId, 'command-code', t.model)); }
+ else if (e.type === 'model_request_start') st.model = e.model || st.model;
+ else if (e.type === 'message_end') {
+ for (const c of e.content || []) {
+ if (c.type === 'text' && String(c.text || '').trim()) emit(ev.text(c.text, st.model));
+ else if (c.type === 'tool_use') emit(ev.toolUse(c.id, CC_NAMES[c.name] || c.name, c.input || {}, st.model));
+ }
+ } else if (e.type === 'tool_completed') emit(ev.toolResult(e.toolCallId, (e.result || []).map((r) => r.text || '').join('\n'), !!e.isError));
+ else if (['tool_denied', 'tool_failed', 'tool_error', 'tool_cancelled'].includes(e.type)) emit(ev.toolResult(e.toolCallId, e.type === 'tool_denied' ? 'denied by the permission level' : String(e.error || e.message || e.type), true));
+ else if (e.type === 'error' || e.type === 'run_error') emit(ev.error(typeof (e.message || e.error) === 'object' ? JSON.stringify(e.message || e.error) : String(e.message || e.error || 'unknown')));
+ });
+ return spawnAgent({ cmd: this.bin, args, cwd: t.cwd, env: ctx.env, timeoutMs: t.timeoutMs, signal: t.signal, parser });
+ },
+};
+
+// ── Aider: plain text on stdout; the answer is what it printed ──
+const aider = {
+ id: 'aider', name: 'Aider', kind: 'cli', bin: 'aider',
+ start(t, ctx = {}) {
+ const args = ['--message', t.prompt, '--yes-always', '--no-pretty', '--no-stream', '--no-check-update', '--no-show-release-notes'];
+ if (t.permission === 'read') args.push('--dry-run');
+ if (t.model) args.push('--model', t.model);
+ const startedAt = Date.now();
+ const lines = [];
+ let started = false;
+ const parser = {
+ line(raw, emit) { if (!started) { started = true; emit(ev.init(null, 'aider', t.model)); } lines.push(raw); },
+ end(info, emit) {
+ const out = lines.join('\n').trim();
+ if (out) emit(ev.text(out.slice(-8000), t.model));
+ emit(ev.result({ ok: info.code === 0 && !!out, text: info.code === 0 && out ? out.slice(-2000) : exitReason(info, 'Aider'), model: t.model, startedAt }));
+ },
+ };
+ return spawnAgent({ cmd: this.bin, args, cwd: t.cwd, env: ctx.env, timeoutMs: t.timeoutMs, signal: t.signal, parser });
+ },
+};
+
+module.exports = { claudeCode, codex, opencode, commandCode, aider, jsonLines };
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+'use strict';
+
+/**
+ * One event shape for every coding agent: Claude Code's stream-json (one JSON object per line), the format its own
+ * `--output-format stream-json` prints and the one tools already read. Every adapter turns its agent's output into
+ * these, so a run reads the same whichever agent did the work:
+ *
+ * { type: 'system', subtype: 'init', session_id, agent, model } the run started (session_id resumes it)
+ * { type: 'assistant', message: { model, content: [ { type: 'text', text } | { type: 'tool_use', id, name, input } ],
+ * 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: '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
+ *
+ * Tool names are Claude Code's (Bash, Read, Edit, Write, Grep, Glob, LS, WebFetch, WebSearch, Task, TodoWrite) when
+ * the agent's tool is one of them, so a reader needs one vocabulary.
+ */
+
+const clip = (s, n) => { s = String(s == null ? '' : s); return s.length > n ? `${s.slice(0, n - 1)}…` : s; };
+
+// Credentials an agent prints or reads (a key in a config file, a token in an error) never reach the event stream:
+// provider keys, GitHub tokens, JWTs, private-key blocks and `key=…` / `"token": "…"` pairs become [redacted].
+const SECRET_PATTERNS = [
+ /\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}/g,
+ /\bgh[pousr]_[A-Za-z0-9]{20,}/g,
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
+ /\b((?:api[_-]?key|access[_-]?token|secret|password|client[_-]?secret)["']?\s*[:=]\s*["']?)[^\s"']{8,}/gi,
+];
+function scrub(s) {
+ let out = String(s == null ? '' : s);
+ for (const re of SECRET_PATTERNS) out = out.replace(re, (m, keep) => (typeof keep === 'string' && m.startsWith(keep) ? `${keep}[redacted]` : '[redacted]'));
+ return out;
+}
+
+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 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) });
+const error = (t) => ({ type: 'system', subtype: 'error', text: clip(scrub(t), 500) });
+const stderr = (t) => ({ type: 'system', subtype: 'stderr', text: clip(scrub(t), 500) });
+
+/** Token usage in the result's shape, from any counts (missing ones are 0). */
+function usage({ input = 0, output = 0, cacheRead = 0, cacheWrite = 0 } = {}) {
+ const n = (x) => (Number.isFinite(+x) ? Math.max(0, +x) : 0);
+ return { input_tokens: n(input), output_tokens: n(output), cache_read_input_tokens: n(cacheRead), cache_creation_input_tokens: n(cacheWrite) };
+}
+
+/** The one result line an agent run ends with. */
+function result({ ok, text: final, sessionId, model, startedAt, turns = 0, costUsd = 0, usage: u = usage() }) {
+ return {
+ type: 'result', subtype: ok ? 'success' : 'error', is_error: !ok, result: scrub(final || ''),
+ session_id: sessionId || null, model: model || null, duration_ms: startedAt ? Date.now() - startedAt : 0,
+ num_turns: turns, total_cost_usd: Math.round((Number(costUsd) || 0) * 1e5) / 1e5, usage: u,
+ };
+}
+
+/**
+ * Follows a stream of events and keeps what a summary needs: the session, the last text, the tools called, the files
+ * touched, errors, and the result line. Used by the CLI's printer, the run log and the hand-off note.
+ */
+function createTally() {
+ const t = { sessionId: null, model: null, lastText: '', tools: 0, files: new Set(), errors: [], result: null };
+ function add(e) {
+ if (!e || typeof e !== 'object') return;
+ if (e.type === 'system' && e.subtype === 'init') { t.sessionId = e.session_id || t.sessionId; t.model = e.model || t.model; }
+ if (e.type === 'system' && e.subtype === 'error') t.errors.push(e.text);
+ if (e.type === 'assistant') {
+ for (const c of (e.message && e.message.content) || []) {
+ if (c.type === 'text' && String(c.text).trim()) t.lastText = c.text;
+ if (c.type === 'tool_use') {
+ t.tools++;
+ const f = c.input && (c.input.file_path || c.input.path);
+ if (f && ['Edit', 'Write', 'Delete'].includes(c.name)) t.files.add(String(f));
+ }
+ }
+ }
+ if (e.type === 'result') { t.result = e; t.sessionId = e.session_id || t.sessionId; }
+ }
+ return { add, get: () => t };
+}
+
+module.exports = { init, text, toolUse, toolResult, usageEvent, warning, error, stderr, usage, result, createTally, clip, scrub };
new file mode 100644
new file mode 100644
@@ -0,0 +1,206 @@
@@ -0,0 +1,206 @@
+'use strict';
+
+/**
+ * The OpenVibe.Codes harness: every coding agent behind one interface, routed by what the task needs, with hand-offs.
+ *
+ * const harness = createHarness({ env, fetch, store });
+ * harness.agents() the catalog with what is available on this machine, and why not
+ * harness.route({ task, requirements }) the placement decision (openvibe-sdk/placement over the catalog's offers)
+ * const run = harness.run({ prompt, task, agent?, model?, cwd, permission, resume?, handoff, maxAttempts })
+ * for await (const event of run) … the shared events (harness/events.js), every attempt in turn
+ * run.summary() { run_id, attempts: [{ agent, ok, session_id, … }], result }
+ *
+ * Routing is the site's own (server/domain/harness-placement.js over server/data/harness-offers.json): capability
+ * first, then cost; an agent that is not installed or has no key here is offered to the planner as down, so the
+ * decision names it and says why. A hand-off happens when an attempt ends in error or goes quiet for `stallMs`: the
+ * next agent the planner picks (the ones that already failed are left out) gets the task with a note on what the
+ * previous one did and what the working tree looks like now.
+ */
+const fs = require('fs');
+const path = require('path');
+const { execFileSync } = require('child_process');
+const { createHarnesses } = require('../server/domain/harnesses');
+const placement = require('../server/domain/harness-placement');
+const cli = require('./adapters/cli');
+const { apiAgent } = require('./adapters/api');
+const ev = require('./events');
+const { createStore } = require('./sessions');
+
+/** The adapter for each catalog harness (the catalog row's id). */
+function adaptersFor(env) {
+ return {
+ 'claude-code': cli.claudeCode,
+ codex: cli.codex,
+ opencode: cli.opencode,
+ 'command-code': cli.commandCode,
+ aider: cli.aider,
+ deepseek: apiAgent({ id: 'deepseek', name: 'DeepSeek', baseUrl: env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com', keyEnv: 'DEEPSEEK_API_KEY', model: 'deepseek-chat', priceIn: 0.00027, priceOut: 0.0011 }),
+ 'openai-compatible': apiAgent({ id: 'openai-compatible', name: 'Your own model', baseUrl: env.OPENVIBE_CODES_BASE_URL || 'http://127.0.0.1:8080/v1', keyEnv: 'OPENVIBE_CODES_API_KEY', model: env.OPENVIBE_CODES_MODEL || 'local' }),
+ };
+}
+
+/** Is the executable on PATH? (No shell, no `which`.) */
+function onPath(bin, env) {
+ for (const dir of String(env.PATH || '').split(path.delimiter).filter(Boolean)) {
+ try { fs.accessSync(path.join(dir, bin), fs.constants.X_OK); return path.join(dir, bin); } catch { /* next */ }
+ }
+ return null;
+}
+
+function availability(adapter, env) {
+ if (!adapter) return { available: false, reason: 'no adapter for this harness yet' };
+ if (adapter.kind === 'cli') {
+ const where = onPath(adapter.bin, env);
+ return where ? { available: true, where } : { available: false, reason: `\`${adapter.bin}\` is not on PATH (install ${adapter.name})` };
+ }
+ if (adapter.id === 'openai-compatible') return env.OPENVIBE_CODES_BASE_URL ? { available: true, where: env.OPENVIBE_CODES_BASE_URL } : { available: false, reason: 'set OPENVIBE_CODES_BASE_URL (and OPENVIBE_CODES_MODEL) to your server' };
+ return env[adapter.keyEnv] ? { available: true, where: adapter.baseUrl } : { available: false, reason: `set ${adapter.keyEnv}` };
+}
+
+/** What changed in the working tree, for a hand-off note (empty outside a git checkout). */
+function treeState(cwd) {
+ try {
+ const git = (args) => execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
+ const status = git(['status', '--short']);
+ const stat = git(['diff', '--stat']);
+ return [status && `git status --short:\n${status.split('\n').slice(0, 40).join('\n')}`, stat && `git diff --stat:\n${stat.split('\n').slice(-20).join('\n')}`].filter(Boolean).join('\n\n');
+ } catch { return ''; }
+}
+
+function handoffNote({ prompt, from, why, tally, cwd }) {
+ const t = tally;
+ const files = [...t.files].slice(0, 30);
+ return [
+ `You are taking over a coding task that ${from} started and could not finish (${why}).`,
+ '',
+ 'The task:',
+ prompt,
+ '',
+ `What ${from} did: ${t.tools} tool call(s)${files.length ? `, files it edited: ${files.join(', ')}` : ''}.`,
+ t.lastText ? `Its last message:\n${ev.clip(t.lastText, 2000)}` : '',
+ treeState(cwd) ? `The working tree now:\n${treeState(cwd)}` : '',
+ '',
+ 'Look at the current state before you change anything, finish the task, and end with a short summary of what you did.',
+ ].filter((l) => l !== '').join('\n');
+}
+
+function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch, store = createStore({ env }), catalog = createHarnesses() } = {}) {
+ const adapters = adaptersFor(env);
+
+ function agents() {
+ return catalog.list().map((h) => ({ ...h, agents: catalog.agents(h.id), adapter: adapters[h.id] ? adapters[h.id].kind : null, ...availability(adapters[h.id], env) }));
+ }
+
+ /** The placement decision among the agents available here; `exclude` leaves harnesses out (a hand-off's earlier attempts). */
+ function route({ task = 'edit', requirements = {}, exclude = [] } = {}) {
+ const opts = {};
+ const why = {};
+ for (const h of catalog.list()) {
+ const a = availability(adapters[h.id], env);
+ const down = !a.available || exclude.includes(h.id);
+ for (const agent of catalog.agents(h.id)) {
+ const id = `${h.id}:${agent.id || agent.model}`;
+ if (down) { opts[id] = { health: 'down' }; why[id] = exclude.includes(h.id) ? 'already tried in this run' : a.reason; }
+ }
+ }
+ const decision = placement.route({ task, extra: requirements, harnesses: catalog, opts });
+ decision.candidates = (decision.candidates || []).map((c) => (why[c.id] ? { ...c, included: false, reason: why[c.id] } : c));
+ return decision;
+ }
+
+ function run({ prompt, task = 'edit', agent = null, model = null, cwd = process.cwd(), permission = 'edit', resume = null, handoff = true, maxAttempts = 3, stallMs = 10 * 60_000, timeoutMs, maxTurns, requirements = {}, signal } = {}) {
+ if (!prompt || !String(prompt).trim()) throw new Error('a run needs a prompt');
+ if (!['read', 'edit', 'full'].includes(permission)) throw new Error('permission is read, edit or full');
+ const runId = store.newRunId();
+ const attempts = [];
+ let final = null;
+ let current = null;
+
+ const summary = () => ({ run_id: runId, prompt: ev.clip(prompt, 500), cwd, task, permission, attempts, result: final, started_at: attempts[0] ? attempts[0].started_at : null });
+
+ const iterator = (async function* events() {
+ const tried = [];
+ let nextPrompt = String(prompt);
+ for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt++) {
+ let harnessId;
+ let modelFor = model;
+ if (attempt === 1 && agent) {
+ harnessId = agent;
+ const a = availability(adapters[agent], env);
+ if (!adapters[agent] || !a.available) {
+ final = ev.result({ ok: false, text: `${agent} is not available here: ${a.reason || 'unknown agent'}` });
+ yield final; break;
+ }
+ } else {
+ const decision = route({ task, requirements, exclude: tried });
+ if (!decision.selected) {
+ const reason = attempt === 1 ? 'no available agent can take this task' : 'no other agent is available to take over';
+ const text = `${reason}: ${(decision.candidates || []).map((c) => `${c.id} (${c.reason || 'excluded'})`).join('; ')}`;
+ yield ev.error(text);
+ // The run ends with the last attempt's own result (or this refusal when nothing ran).
+ if (attempt === 1) final = ev.result({ ok: false, text });
+ yield final;
+ break;
+ }
+ harnessId = decision.selected.split(':')[0];
+ if (attempt > 1) modelFor = null; // the model named for the first agent is that agent's
+ }
+ tried.push(harnessId);
+ const adapter = adapters[harnessId];
+ const started = Date.now();
+ const rec = { attempt, agent: harnessId, started_at: new Date(started).toISOString(), ok: false, session_id: null };
+ attempts.push(rec);
+ const tally = ev.createTally();
+ const controller = new AbortController();
+ if (signal) signal.addEventListener('abort', () => controller.abort(), { once: true });
+ current = adapter.start({ prompt: nextPrompt, cwd, model: modelFor, resume: attempt === 1 ? resume : null, permission, maxTurns, timeoutMs, signal: controller.signal }, { env, fetch: fetchImpl, sessions: store });
+ let stalled = false;
+ let res = null;
+ let pending = null; // the next() still in flight when the stall timer won the race
+ while (true) {
+ let timer;
+ const quiet = new Promise((r) => { timer = setTimeout(() => r({ stalled: true }), stallMs); timer.unref?.(); });
+ if (!pending) pending = current.next();
+ const next = await Promise.race([pending, quiet]);
+ clearTimeout(timer);
+ if (next.stalled) { if (!stalled) { stalled = true; current.cancel(); controller.abort(); } continue; }
+ pending = null;
+ if (next.done) break;
+ const e = { ...next.value, _attempt: attempt, _agent: harnessId };
+ tally.add(next.value);
+ store.appendEvent(runId, e);
+ if (next.value.type === 'result') { res = next.value; continue; }
+ yield e;
+ }
+ const t = tally.get();
+ rec.session_id = t.sessionId;
+ rec.ok = !!(res && !res.is_error);
+ rec.duration_ms = Date.now() - started;
+ rec.cost_usd = res ? res.total_cost_usd : 0;
+ if (stalled) {
+ const quietFor = stallMs >= 60_000 ? `${Math.round(stallMs / 60_000)} min` : `${Math.round(stallMs / 1000)} s`;
+ res = { ...(res || ev.result({ ok: false })), subtype: 'error', is_error: true, result: `went quiet for ${quietFor} and was stopped` };
+ rec.ok = false;
+ }
+ rec.error = rec.ok ? null : (res ? ev.clip(res.result, 300) : 'no result');
+ final = res ? { ...res, _attempt: attempt, _agent: harnessId } : ev.result({ ok: false, text: 'the agent ended without a result' });
+ if (rec.ok || !handoff || (signal && signal.aborted)) { yield final; break; }
+ if (attempt >= maxAttempts) { yield final; break; }
+ const why = rec.error || 'it failed';
+ nextPrompt = handoffNote({ prompt, from: adapter.name, why, tally: t, cwd });
+ const note = { type: 'system', subtype: 'handoff', from: harnessId, reason: why, attempt: attempt + 1 };
+ store.appendEvent(runId, note);
+ yield note;
+ }
+ store.saveRun(runId, summary());
+ }());
+ iterator.runId = runId;
+ iterator.summary = summary;
+ iterator.cancel = () => { if (current && current.cancel) current.cancel(); };
+ return iterator;
+ }
+
+ return { agents, route, run, store, adapters };
+}
+
+module.exports = { createHarness, adaptersFor, availability, handoffNote, onPath };
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+
+/**
+ * Runs one agent CLI and turns its output into events (harness/events.js) as they come.
+ *
+ * const run = spawnAgent({ cmd, args, cwd, env, timeoutMs, signal, parser });
+ * for await (const event of run) …; // ends after the parser's end() and the process exit
+ * run.cancel(); // SIGTERM, then SIGKILL after 5 s
+ *
+ * parser: { line(raw, emit), end({ code, signal, timedOut, stderr }, emit) } — line() sees every stdout line, end()
+ * runs once when the process has exited and must emit the run's one `result` event if line() did not. stderr is kept
+ * (the last 4 kB) for end(); whatever of it is shown goes through events.scrub() first.
+ */
+const { spawn } = require('child_process');
+const readline = require('readline');
+const { scrub } = require('./events');
+
+function spawnAgent({ cmd, args = [], cwd, env, timeoutMs = 45 * 60_000, signal, parser, stdin = null }) {
+ const queue = [];
+ let wake = null;
+ let finished = false;
+ const emit = (e) => { if (e) { queue.push(e); if (wake) { wake(); wake = null; } } };
+ const finish = () => { finished = true; if (wake) { wake(); wake = null; } };
+
+ let child;
+ try {
+ child = spawn(cmd, args, { cwd, env: { ...process.env, ...(env || {}) }, stdio: [stdin == null ? 'ignore' : 'pipe', 'pipe', 'pipe'] });
+ } catch (err) {
+ parser.end({ code: null, signal: null, timedOut: false, stderr: '', spawnError: err.message }, emit);
+ finish();
+ }
+ let stderrBuf = '';
+ let timedOut = false;
+ let killTimer = null;
+ const cancel = () => {
+ if (!child || child.exitCode !== null || child.signalCode) return;
+ try { child.kill('SIGTERM'); } catch { /* gone */ }
+ killTimer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 5000);
+ killTimer.unref();
+ };
+ if (child) {
+ if (stdin != null) { child.stdin.end(stdin); }
+ const timer = setTimeout(() => { timedOut = true; cancel(); }, timeoutMs);
+ timer.unref();
+ if (signal) { if (signal.aborted) cancel(); else signal.addEventListener('abort', cancel, { once: true }); }
+ readline.createInterface({ input: child.stdout }).on('line', (raw) => { try { parser.line(raw, emit); } catch (err) { emit({ type: 'system', subtype: 'warning', text: `unreadable line: ${err.message}` }); } });
+ child.stderr.on('data', (d) => { stderrBuf = (stderrBuf + d.toString()).slice(-4096); });
+ child.on('error', (err) => { stderrBuf += `\n${err.message}`; });
+ child.on('close', (code, sig) => {
+ clearTimeout(timer);
+ if (killTimer) clearTimeout(killTimer);
+ // Let readline drain the last stdout lines before the parser ends the run.
+ setImmediate(() => {
+ try { parser.end({ code, signal: sig, timedOut, stderr: stderrBuf }, emit); } catch (err) { emit({ type: 'system', subtype: 'error', text: err.message }); }
+ finish();
+ });
+ });
+ }
+
+ const iterator = (async function* events() {
+ while (true) {
+ if (queue.length) { yield queue.shift(); continue; }
+ if (finished) return;
+ await new Promise((r) => { wake = r; });
+ }
+ }());
+ iterator.cancel = cancel;
+ iterator.pid = child ? child.pid : null;
+ return iterator;
+}
+
+/** Why an agent's process ended without a result, in words a person can act on. */
+function exitReason({ code, signal, timedOut, stderr, spawnError }, name) {
+ if (spawnError) return `${name} could not start: ${spawnError}`;
+ if (timedOut) return `${name} ran past its time limit and was stopped`;
+ if (signal) return `${name} was stopped (${signal})`;
+ const last = String(stderr || '').trim().split('\n').filter(Boolean).pop();
+ return `${name} ended without a result (exit ${code})${last ? `: ${scrub(last).slice(0, 200)}` : ''}`;
+}
+
+module.exports = { spawnAgent, exitReason };
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+'use strict';
+
+/**
+ * Where the harness keeps its runs on the person's machine (never on a server):
+ *
+ * $OPENVIBE_CODES_HOME, else $XDG_STATE_HOME/openvibe-codes, else ~/.local/state/openvibe-codes
+ * runs/<run id>.jsonl every event of the run, each tagged with its attempt and agent
+ * runs/<run id>.json the run's summary: prompt (first 500 characters), working directory, attempts, result
+ * sessions/<id>.json an API agent's conversation, so `resume` continues it
+ *
+ * Files are created 0600 in 0700 directories: a run's events can quote the code it worked on.
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+function homeDir(env = process.env) {
+ if (env.OPENVIBE_CODES_HOME) return path.resolve(env.OPENVIBE_CODES_HOME);
+ const state = env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state');
+ return path.join(state, 'openvibe-codes');
+}
+
+function createStore({ env = process.env } = {}) {
+ const root = homeDir(env);
+ const dir = (name) => { const d = path.join(root, name); fs.mkdirSync(d, { recursive: true, mode: 0o700 }); return d; };
+ const safe = (id) => String(id).replace(/[^A-Za-z0-9_.:-]/g, '_').slice(0, 120);
+ const write = (file, text) => fs.writeFileSync(file, text, { mode: 0o600 });
+
+ return {
+ root,
+ newRunId: () => `run_${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)}_${Math.random().toString(36).slice(2, 8)}`,
+ appendEvent(runId, event) { fs.appendFileSync(path.join(dir('runs'), `${safe(runId)}.jsonl`), `${JSON.stringify(event)}\n`, { mode: 0o600 }); },
+ saveRun(runId, summary) { write(path.join(dir('runs'), `${safe(runId)}.json`), `${JSON.stringify(summary, null, 2)}\n`); },
+ loadRun(runId) { try { return JSON.parse(fs.readFileSync(path.join(dir('runs'), `${safe(runId)}.json`), 'utf8')); } catch { return null; } },
+ events(runId) {
+ try { return fs.readFileSync(path.join(dir('runs'), `${safe(runId)}.jsonl`), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)); } catch { return []; }
+ },
+ listRuns(limit = 20) {
+ const d = dir('runs');
+ return fs.readdirSync(d).filter((f) => f.endsWith('.json')).sort().reverse().slice(0, limit)
+ .map((f) => { try { return JSON.parse(fs.readFileSync(path.join(d, f), 'utf8')); } catch { return null; } }).filter(Boolean);
+ },
+ load(sessionId) { try { return JSON.parse(fs.readFileSync(path.join(dir('sessions'), `${safe(sessionId)}.json`), 'utf8')); } catch { return null; } },
+ save(sessionId, data) { write(path.join(dir('sessions'), `${safe(sessionId)}.json`), JSON.stringify(data)); },
+ };
+}
+
+module.exports = { createStore, homeDir };
@@ -1,13 +1,16 @@
@@ -1,13 +1,16 @@
{
{
"name": "openvibe-codes",
"name": "openvibe-codes",
- "version": "0.1.0",
+ "version": "0.2.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-codes",
"name": "openvibe-codes",
- "version": "0.1.0",
+ "version": "0.2.0",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
+ "bin": {
+ "openvibe-codes": "bin/openvibe-codes.js"
+ },
"dependencies": {
"dependencies": {
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
@@ -1,9 +1,12 @@
@@ -1,9 +1,12 @@
{
{
"name": "openvibe-codes",
"name": "openvibe-codes",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"private": true,
- "description": "OpenVibe.Codes: the open coding-agent harness. The agent catalog (Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API) with a public routing API, and the way to improve OpenVibe itself. Server-rendered and useful without JavaScript.",
- "main": "server/index.js",
+ "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",
+ "bin": {
+ "openvibe-codes": "bin/openvibe-codes.js"
+ },
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
@@ -14,7 +14,9 @@
@@ -14,7 +14,9 @@
"vision": true,
"vision": true,
"browser": false,
"browser": false,
"computer_use": false,
"computer_use": false,
- "runtimes": ["code"]
+ "runtimes": [
+ "code"
+ ]
},
},
"address": {
"address": {
"kind": "cli",
"kind": "cli",
@@ -64,7 +66,9 @@
@@ -64,7 +66,9 @@
"vision": true,
"vision": true,
"browser": false,
"browser": false,
"computer_use": false,
"computer_use": false,
- "runtimes": ["code"]
+ "runtimes": [
+ "code"
+ ]
},
},
"address": {
"address": {
"kind": "cli",
"kind": "cli",
@@ -114,7 +118,9 @@
@@ -114,7 +118,9 @@
"vision": false,
"vision": false,
"browser": false,
"browser": false,
"computer_use": false,
"computer_use": false,
- "runtimes": ["code"]
+ "runtimes": [
+ "code"
+ ]
},
},
"address": {
"address": {
"kind": "cli",
"kind": "cli",
@@ -164,7 +170,9 @@
@@ -164,7 +170,9 @@
"vision": true,
"vision": true,
"browser": false,
"browser": false,
"computer_use": false,
"computer_use": false,
- "runtimes": ["code"]
+ "runtimes": [
+ "code"
+ ]
},
},
"address": {
"address": {
"kind": "cli",
"kind": "cli",
@@ -214,7 +222,9 @@
@@ -214,7 +222,9 @@
"vision": true,
"vision": true,
"browser": false,
"browser": false,
"computer_use": false,
"computer_use": false,
- "runtimes": ["code"]
+ "runtimes": [
+ "code"
+ ]
},
},
"address": {
"address": {
"kind": "cli",
"kind": "cli",
@@ -254,13 +264,13 @@
@@ -254,13 +264,13 @@
"name": "DeepSeek",
"name": "DeepSeek",
"provider": "deepseek",
"provider": "deepseek",
"capabilities": {
"capabilities": {
- "host_access": false,
+ "host_access": true,
"mcp": false,
"mcp": false,
"long_autonomy": false,
"long_autonomy": false,
- "resume": false,
- "edit": false,
- "review": false,
- "tools": false,
+ "resume": true,
+ "edit": true,
+ "review": true,
+ "tools": true,
"vision": false,
"vision": false,
"browser": false,
"browser": false,
"computer_use": false
"computer_use": false
@@ -289,6 +299,55 @@
@@ -289,6 +299,55 @@
"output_tokens": 8192
"output_tokens": 8192
},
},
"concurrency": 8,
"concurrency": 8,
+ "price_per_1k_tokens": {
+ "fresh_usd": 0.00027,
+ "cached_usd": 0.00007,
+ "output_usd": 0.0011
+ },
+ "availability_windows": []
+ }
+ ]
+ },
+ {
+ "id": "openai-compatible",
+ "name": "Your own model",
+ "provider": "self-hosted",
+ "capabilities": {
+ "host_access": true,
+ "mcp": false,
+ "long_autonomy": false,
+ "resume": true,
+ "edit": true,
+ "review": true,
+ "tools": true,
+ "vision": false,
+ "browser": false,
+ "computer_use": false
+ },
+ "address": {
+ "kind": "api",
+ "target": "http://127.0.0.1:8080/v1"
+ },
+ "price": {
+ "amount_usd": 0,
+ "unit": "hour"
+ },
+ "limits": {
+ "max_duration_seconds": 3600,
+ "max_concurrent_runs": 2,
+ "max_context_tokens": 32000
+ },
+ "agents": [
+ {
+ "id": "openai-compatible-local",
+ "harness": "openai-compatible",
+ "provider": "self-hosted",
+ "model": "local",
+ "context_limits": {
+ "input_tokens": 32000,
+ "output_tokens": 4096
+ },
+ "concurrency": 2,
"price_per_1k_tokens": {
"price_per_1k_tokens": {
"fresh_usd": 0,
"fresh_usd": 0,
"cached_usd": 0,
"cached_usd": 0,
@@ -19,15 +19,19 @@ const { asyncRouter } = require('./router');
@@ -19,15 +19,19 @@ const { asyncRouter } = require('./router');
const SITE_NAME = 'OpenVibe.Codes';
const SITE_NAME = 'OpenVibe.Codes';
const SERVICES_ORIGIN = 'https://openvibe.services';
const SERVICES_ORIGIN = 'https://openvibe.services';
-const DESCRIPTION = 'The open coding-agent harness: send a coding task to Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or a model you host, with routing by capability and cost, hand-offs between agents, and the way to improve OpenVibe itself.';
+const DESCRIPTION = 'The open coding-agent harness: run a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or a model you host, with routing by capability and cost, hand-offs between agents, and the way to improve OpenVibe itself.';
+const { version: VERSION } = require('../../package.json');
+const INSTALL_HINT = `npm install -g https://codeload.github.com/OpenVibers/OpenVibe.Codes/tar.gz/refs/tags/v${VERSION}`;
+const HARNESS_PAGES = ['/', '/start', '/harnesses', '/improve'];
// Sign-in and the API are not for crawlers. The console's old addresses redirect to openvibe.services.
// Sign-in and the API are not for crawlers. The console's old addresses redirect to openvibe.services.
const DISALLOW = ['/auth/', '/api/'];
const DISALLOW = ['/auth/', '/api/'];
// The one line /llms-full.txt says about each fixed public page: a title and what the page serves.
// The one line /llms-full.txt says about each fixed public page: a title and what the page serves.
// These are the pages publicPages() lists (and so the sitemap), so the two cannot name different sets.
// These are the pages publicPages() lists (and so the sitemap), so the two cannot name different sets.
const PAGE_TEXT = {
const PAGE_TEXT = {
- '/': ['OpenVibe.Codes home', 'Code with any agent: the coding-agent catalog, how a task is routed, what works today, and how to improve OpenVibe.'],
- '/harnesses': ['The coding agents', 'Every harness the router can pick (Claude Code, Codex, OpenCode, Command Code, Aider, DeepSeek) with its models, capabilities, prices and limits.'],
+ '/': ['OpenVibe.Codes home', 'Code with any agent: the coding-agent harness, how a task is routed and handed off, what works today, and how to improve OpenVibe.'],
+ '/start': ['Get started with the coding-agent harness', 'Install openvibe-codes; the agents it can use and what each needs; the commands, permission levels, hand-offs, the event format and using it from your own code.'],
+ '/harnesses': ['The coding agents', 'Every harness the router can pick (Claude Code, Codex, OpenCode, Command Code, Aider, DeepSeek, your own model) with its models, capabilities, prices and limits.'],
'/improve': ['Improve OpenVibe', 'How OpenVibe is built in the open: every repository, and the path from a change to a reviewed pull request.'],
'/improve': ['Improve OpenVibe', 'How OpenVibe is built in the open: every repository, and the path from a change to a reviewed pull request.'],
'/updates': ['What shipped on OpenVibe.Codes', 'This site\'s update log, from the network changelog feed.'],
'/updates': ['What shipped on OpenVibe.Codes', 'This site\'s update log, from the network changelog feed.'],
'/policy': ['Community', 'The code of conduct, the contributing guide, the contributor ladder and the moderation policy.'],
'/policy': ['Community', 'The code of conduct, the contributing guide, the contributor ladder and the moderation policy.'],
@@ -52,7 +56,7 @@ function homeJsonLd(config) {
@@ -52,7 +56,7 @@ function homeJsonLd(config) {
const site = String(config.baseUrl).replace(/\/+$/, '');
const site = String(config.baseUrl).replace(/\/+$/, '');
return [
return [
seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
- seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'DeveloperApplication', keywords: 'ai coding agent, coding agent router, claude code, codex, opencode, deepseek, aider, open source coding harness, contribute to open source' }),
+ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'DeveloperApplication', keywords: 'ai coding agent, coding agent harness, coding agent router, agent hand-off, claude code, codex, opencode, command code, deepseek, aider, self-hosted coding agent, open source coding harness, contribute to open source' }),
seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
];
];
}
}
@@ -61,6 +65,7 @@ function homeJsonLd(config) {
@@ -61,6 +65,7 @@ function homeJsonLd(config) {
function publicPages({ governance = [] }) {
function publicPages({ governance = [] }) {
const pages = [
const pages = [
{ path: '/', changefreq: 'weekly', priority: 1.0 },
{ path: '/', changefreq: 'weekly', priority: 1.0 },
+ { path: '/start', changefreq: 'weekly', priority: 0.9 },
{ path: '/harnesses', changefreq: 'weekly', priority: 0.9 },
{ path: '/harnesses', changefreq: 'weekly', priority: 0.9 },
{ path: '/improve', changefreq: 'weekly', priority: 0.8 },
{ path: '/improve', changefreq: 'weekly', priority: 0.8 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
@@ -95,11 +100,12 @@ function createDiscoveryRoutes(ctx) {
@@ -95,11 +100,12 @@ function createDiscoveryRoutes(ctx) {
r.get('/llms.txt', (_req, res) => {
r.get('/llms.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
name: SITE_NAME,
name: SITE_NAME,
- summary: 'OpenVibe.Codes: the open coding-agent harness. A catalog of coding agents (Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API) with a public routing API, and the way to improve OpenVibe itself.',
- details: `Every page is server-rendered and readable without JavaScript. The catalog and the router are public: GET /api/v1/harnesses lists every harness with its models, and POST /api/v1/harnesses/route picks one for a task by capability first, then cost. Running tasks, hand-offs and a self-hosted runner come next. The developer console that used to live here (projects, apps, keys, the API reference, OAuth and webhook tools, releases) is OpenVibe.Services at ${SERVICES_ORIGIN}.`,
+ summary: 'OpenVibe.Codes: the open coding-agent harness. openvibe-codes runs a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or your own OpenAI-compatible model, hands it to the next agent when one fails, and keeps every run; plus a public catalog and routing API, and the way to improve OpenVibe itself.',
+ details: `Every page is server-rendered and readable without JavaScript. The harness is a command-line tool and a Node.js module that runs on your machine with your agents and keys (install: ${INSTALL_HINT}). Every agent's output becomes one event stream in Claude Code's stream-json shape. The catalog and the router are public: GET /api/v1/harnesses lists every harness with its models, and POST /api/v1/harnesses/route picks one for a task by capability first, then cost. A hosted runner comes next. The developer console that used to live here (projects, apps, keys, the API reference, OAuth and webhook tools, releases) is OpenVibe.Services at ${SERVICES_ORIGIN}.`,
sections: [
sections: [
{ title: 'Start here', links: [
{ title: 'Start here', links: [
{ title: 'Code with any agent', url: abs('/'), note: 'what the harness is, how a task finds its agent, what works today' },
{ title: 'Code with any agent', url: abs('/'), note: 'what the harness is, how a task finds its agent, what works today' },
+ { title: 'Get started', url: abs('/start'), note: 'install, agents, commands, permission levels, hand-offs, events, from your own code' },
{ title: 'The coding agents', url: abs('/harnesses'), note: 'every harness with its models, capabilities, prices and limits' },
{ title: 'The coding agents', url: abs('/harnesses'), note: 'every harness with its models, capabilities, prices and limits' },
{ title: 'Improve OpenVibe', url: abs('/improve'), note: 'every repository and the path to a reviewed pull request' },
{ title: 'Improve OpenVibe', url: abs('/improve'), note: 'every repository and the path to a reviewed pull request' },
{ title: 'What shipped on OpenVibe.Codes', url: abs('/updates') },
{ title: 'What shipped on OpenVibe.Codes', url: abs('/updates') },
@@ -127,12 +133,12 @@ function createDiscoveryRoutes(ctx) {
@@ -127,12 +133,12 @@ function createDiscoveryRoutes(ctx) {
const pages = publicPages({ governance }).map((p) => ({ url: p.path, ...describePage(p.path, { governance }) }));
const pages = publicPages({ governance }).map((p) => ({ url: p.path, ...describePage(p.path, { governance }) }));
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
site: SITE_NAME,
site: SITE_NAME,
- summary: 'Every public page of OpenVibe.Codes, one line each: the harness, the agent catalog, Improve OpenVibe, the update log and the community documents.',
+ summary: 'Every public page of OpenVibe.Codes, one line each: the harness, getting started, the agent catalog, Improve OpenVibe, the update log and the community documents.',
base: site,
base: site,
maxBytes: 128 * 1024,
maxBytes: 128 * 1024,
sections: [
sections: [
- { title: 'Harness', pages: pages.filter((p) => ['/', '/harnesses', '/improve'].includes(p.url)) },
- { title: 'Site', pages: pages.filter((p) => !['/', '/harnesses', '/improve'].includes(p.url)) },
+ { title: 'Harness', pages: pages.filter((p) => HARNESS_PAGES.includes(p.url)) },
+ { title: 'Site', pages: pages.filter((p) => !HARNESS_PAGES.includes(p.url)) },
],
],
}));
}));
});
});
@@ -1,8 +1,9 @@
@@ -1,8 +1,9 @@
'use strict';
'use strict';
/**
/**
- * Public pages: the home page (the open coding-agent harness), the agent catalog, Improve OpenVibe (how OpenVibe
- * itself is built and how to help), the community documents, and the update log. Crawl artifacts (robots.txt,
+ * Public pages: the home page (the open coding-agent harness), getting started with the harness on your own machine,
+ * the agent catalog, Improve OpenVibe (how OpenVibe itself is built and how to help), the community documents, and the
+ * update log. Crawl artifacts (robots.txt,
* sitemap.xml, llms.txt, llms-full.txt, the home page's JSON-LD) live in http/discovery.js and are mounted here.
* sitemap.xml, llms.txt, llms-full.txt, the home page's JSON-LD) live in http/discovery.js and are mounted here.
*
*
* What is shown is read, never restated: the catalog is server/data/harness-offers.json as validated at boot, the
* What is shown is read, never restated: the catalog is server/data/harness-offers.json as validated at boot, the
@@ -26,6 +27,9 @@ const placement = require('../domain/harness-placement');
@@ -26,6 +27,9 @@ const placement = require('../domain/harness-placement');
const ROOT = path.join(__dirname, '..', '..');
const ROOT = path.join(__dirname, '..', '..');
const GITHUB = 'https://github.com/OpenVibers';
const GITHUB = 'https://github.com/OpenVibers';
+const { version: VERSION } = require('../../package.json');
+// The harness installs from its release tag, the way every OpenVibe library is pinned.
+const INSTALL = `npm install -g https://codeload.github.com/OpenVibers/OpenVibe.Codes/tar.gz/refs/tags/v${VERSION}`;
/**
/**
* Community documents: Markdown in this repository (the same files GitHub shows), rendered as they are. A document
* Community documents: Markdown in this repository (the same files GitHub shows), rendered as they are. A document
@@ -77,13 +81,16 @@ function createPageRoutes(ctx) {
@@ -77,13 +81,16 @@ function createPageRoutes(ctx) {
r.get('/', (req, res) => {
r.get('/', (req, res) => {
const offers = harnesses.list();
const offers = harnesses.list();
const tasks = Object.keys(placement.TASKS);
const tasks = Object.keys(placement.TASKS);
- const snippet = `# Which agent should take this task?
-curl -s ${config.baseUrl}/api/v1/harnesses/route \\
- -H 'content-type: application/json' \\
- -d '{"task":"edit"}'
-
-# → { "selected": "claude-code:…",
-# "reasons": […], "candidates": […] }`;
+ const snippet = `$ openvibe-codes run "add a test for the date parser"
+run run_20261008… · ~/my-project
+▶ codex · session 0199…
+! You have hit your usage limit.
+⇢ handing over from codex: You have hit your usage limit.
+▶ claude-code · session 4f2e…
+ → Read src/date.js
+ → Edit test/date.test.js
+Added test/date.test.js: three cases, all pass.
+✔ done (48 s · $0.0400 · 31k in / 1.2k out)`;
page(req, res, {
page(req, res, {
index: true, cache: PUBLIC_CACHE,
index: true, cache: PUBLIC_CACHE,
jsonLd: homeJsonLd(config),
jsonLd: homeJsonLd(config),
@@ -92,8 +99,8 @@ curl -s ${config.baseUrl}/api/v1/harnesses/route \\
@@ -92,8 +99,8 @@ curl -s ${config.baseUrl}/api/v1/harnesses/route \\
eyebrow: 'OpenVibe.Codes · the open coding-agent harness · alpha',
eyebrow: 'OpenVibe.Codes · the open coding-agent harness · alpha',
title: 'Code with', accent: 'any agent',
title: 'Code with', accent: 'any agent',
lede: 'One open harness for Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API and models you host yourself: send a coding task to the agent that fits it, hand it over when one gets stuck, and keep the whole run in one place. Then point it at OpenVibe itself.',
lede: 'One open harness for Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API and models you host yourself: send a coding task to the agent that fits it, hand it over when one gets stuck, and keep the whole run in one place. Then point it at OpenVibe itself.',
- actions: [{ label: 'Meet the agents', href: '/harnesses', primary: true }, { label: 'Improve OpenVibe', href: '/improve' }],
- note: 'Open source (AGPL-3.0). Today: the agent catalog and a routing API anyone can call. Running jobs, hand-offs and a self-hosted runner come next; the list below says exactly what works.',
+ actions: [{ label: 'Install the harness', href: '/start', primary: true }, { label: 'Meet the agents', href: '/harnesses' }],
+ note: `Open source (AGPL-3.0). Version ${VERSION} runs on your machine with the agents and keys you already have; nothing goes through OpenVibe. A hosted runner comes next; the list below says exactly what works.`,
aside: { html: `<pre class="codes-hero-code"><code>${showcase.esc(snippet)}</code></pre>` },
aside: { html: `<pre class="codes-hero-code"><code>${showcase.esc(snippet)}</code></pre>` },
}))}
}))}
${raw(showcase.features({
${raw(showcase.features({
@@ -106,7 +113,7 @@ ${raw(showcase.steps({
@@ -106,7 +113,7 @@ ${raw(showcase.steps({
{ title: 'Describe the task', text: `One of ${tasks.join(', ')}, plus anything it must have: a capability, a runtime, a ceiling on cost.` },
{ title: 'Describe the task', text: `One of ${tasks.join(', ')}, plus anything it must have: a capability, a runtime, a ceiling on cost.` },
{ title: 'Hard limits first', text: 'An agent that lacks a required capability is out, whatever it costs. Each refusal says why.' },
{ title: 'Hard limits first', text: 'An agent that lacks a required capability is out, whatever it costs. Each refusal says why.' },
{ title: 'Then the cheapest fit', text: 'Already-paid capacity and free allowances first, then price per token, through the same placement engine every OpenVibe service uses.' },
{ title: 'Then the cheapest fit', text: 'Already-paid capacity and free allowances first, then price per token, through the same placement engine every OpenVibe service uses.' },
- { title: 'Hand-offs', text: 'When the first agent stalls, the run moves to the next candidate with its context (next: the runner).' },
+ { title: 'Hand-offs', text: 'When an agent fails, hits its usage limit or goes quiet, the task moves to the next one with a note: the task, the files touched and the state of the tree.' },
],
],
}))}
}))}
<section class="sc-sec" aria-labelledby="h-improve"><h2 id="h-improve">Improve OpenVibe</h2>
<section class="sc-sec" aria-labelledby="h-improve"><h2 id="h-improve">Improve OpenVibe</h2>
@@ -114,17 +121,97 @@ ${raw(showcase.steps({
@@ -114,17 +121,97 @@ ${raw(showcase.steps({
<p><a class="sc-btn sc-primary" href="/improve">Start contributing</a> <a class="sc-btn" href="/policy/contributing">Read the guide</a></p></section>
<p><a class="sc-btn sc-primary" href="/improve">Start contributing</a> <a class="sc-btn" href="/policy/contributing">Read the guide</a></p></section>
<section class="sc-sec" aria-labelledby="h-status"><h2 id="h-status">What works today</h2>
<section class="sc-sec" aria-labelledby="h-status"><h2 id="h-status">What works today</h2>
${table(['Piece', 'State'], [
${table(['Piece', 'State'], [
+ [html`<a href="/start">The harness on your machine</a> (<code>openvibe-codes</code>)`, `Live, ${VERSION}: runs a task on any of the seven agents with your CLIs and keys, streams one event format, keeps every run to show or resume.`],
+ ['Hand-offs between agents', 'Live: a failed, rate-limited or silent agent hands the task to the next one with a note on what was done.'],
[html`<a href="/harnesses">The agent catalog</a>`, 'Live: every harness and model with its capabilities, prices and limits.'],
[html`<a href="/harnesses">The agent catalog</a>`, 'Live: every harness and model with its capabilities, prices and limits.'],
[html`<code>GET /api/v1/harnesses</code>, <code>POST /api/v1/harnesses/route</code>`, 'Live: public, no key needed; per-address and per-caller limits.'],
[html`<code>GET /api/v1/harnesses</code>, <code>POST /api/v1/harnesses/route</code>`, 'Live: public, no key needed; per-address and per-caller limits.'],
- ['Running a task on an agent, streaming its output, budgets', 'Next. The agents run today on OpenVibe\'s own machines; opening that to everyone is the next step.'],
- ['Hand-offs between agents, resumable sessions', 'Next, with the runner.'],
- ['A runner on your own machine (your CLIs, your keys)', 'Planned.'],
- [html`Improve OpenVibe from here: repository → change → tests → review → pull request`, 'Planned. Today: the contributor path on GitHub, below.'],
+ ['A hosted runner: tasks on OpenVibe\'s machines, with budgets', 'Next, on OpenVibe.Run workers.'],
+ ['OpenVibe.Actor using Codes for its coding work', 'Next: Actor is OpenVibe\'s general agent; Codes is the part of it that writes code.'],
+ [html`Improve OpenVibe from here: repository → change → tests → review → pull request`, 'Planned. Today: the harness plus the contributor path on GitHub, below.'],
])}</section>
])}</section>
${raw(showcase.cta({ title: 'Building an app instead?', text: 'Projects, keys, grants and the API docs of every OpenVibe service live on OpenVibe.Services.', actions: [{ label: 'Open the developer platform', href: SERVICES_ORIGIN, primary: true }, { label: 'Meet the agents', href: '/harnesses' }] }))}`,
${raw(showcase.cta({ title: 'Building an app instead?', text: 'Projects, keys, grants and the API docs of every OpenVibe service live on OpenVibe.Services.', actions: [{ label: 'Open the developer platform', href: SERVICES_ORIGIN, primary: true }, { label: 'Meet the agents', href: '/harnesses' }] }))}`,
});
});
});
});
+ // ── Getting started: the harness on your own machine (bin/openvibe-codes.js, harness/) ──
+ r.get('/start', (req, res) => {
+ const example = `$ openvibe-codes agents
+✔ claude-code Claude Code ~/.local/bin/claude
+✔ codex Codex ~/.local/bin/codex
+· aider Aider \`aider\` is not on PATH (install Aider)
+· deepseek DeepSeek set DEEPSEEK_API_KEY
+
+$ cd my-project
+$ openvibe-codes run "rename getUser to findUser everywhere"
+$ openvibe-codes run --permission read "where is the session cookie set?"
+$ openvibe-codes runs
+$ openvibe-codes resume run_… "now add a test"`;
+ const fromCode = `const { createHarness } = require('openvibe-codes');
+
+const harness = createHarness(); // your PATH, your keys
+for await (const event of harness.run({ prompt: 'fix the failing test', cwd: '.' })) {
+ if (event.type === 'result') console.log(event.is_error ? 'failed' : 'done', event.result);
+}`;
+ const events = `{"type":"system","subtype":"init","agent":"codex","session_id":"0199…","run_id":"run_…"}
+{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"npm test"}}]}}
+{"type":"user","message":{"content":[{"type":"tool_result","content":"…","is_error":false}]}}
+{"type":"system","subtype":"handoff","from":"codex","reason":"You have hit your usage limit."}
+{"type":"result","subtype":"success","is_error":false,"result":"…","total_cost_usd":0.04,"usage":{…}}`;
+ page(req, res, {
+ index: true, cache: PUBLIC_CACHE,
+ title: 'Get started with the coding-agent harness',
+ description: 'Install openvibe-codes and run coding tasks on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or your own model, with hand-offs between them, from your terminal or your own code.',
+ crumbs: [{ label: 'Get started' }],
+ body: html`<h1>Get started</h1>
+<p><code>openvibe-codes</code> runs a coding task on whichever agent fits it, hands it to the next one when an agent fails, hits its limit or goes quiet, and keeps the whole run. It runs on your machine with the agents and keys you already have: nothing goes through OpenVibe, and it needs no account.</p>
+<h2 id="install">Install</h2>
+<p>Node.js 22 or newer, then:</p>
+<pre><code>${INSTALL}</code></pre>
+<p>Or from a clone: <code>git clone ${GITHUB}/OpenVibe.Codes</code>, <code>npm ci</code>, then <code>node bin/openvibe-codes.js</code>. Check what it found with <code>openvibe-codes agents</code>.</p>
+<h2 id="agents">The agents it can use</h2>
+<p>Install any of them; the harness uses what is there and says why the rest are not.</p>
+${table(['Agent', 'What it needs'], [
+ [html`<a href="/harnesses#claude-code">Claude Code</a>`, html`<code>claude</code> on PATH, signed in`],
+ [html`<a href="/harnesses#codex">Codex</a>`, html`<code>codex</code> on PATH, signed in`],
+ [html`<a href="/harnesses#opencode">OpenCode</a>`, html`<code>opencode</code> on PATH`],
+ [html`<a href="/harnesses#command-code">Command Code</a>`, html`<code>cmd</code> on PATH`],
+ [html`<a href="/harnesses#aider">Aider</a>`, html`<code>aider</code> on PATH`],
+ [html`<a href="/harnesses#deepseek">DeepSeek</a>`, html`<code>DEEPSEEK_API_KEY</code>; the harness runs its own agent loop against the API`],
+ [html`<a href="/harnesses#openai-compatible">Your own model</a>`, html`<code>OPENVIBE_CODES_BASE_URL</code> (an OpenAI-compatible server: llama.cpp, vLLM, Ollama, LM Studio…), <code>OPENVIBE_CODES_MODEL</code>, and <code>OPENVIBE_CODES_API_KEY</code> if it wants one`],
+ ])}
+<h2 id="use">Use it</h2>
+<pre><code>${example}</code></pre>
+${table(['Command', 'What it does'], [
+ [html`<code>agents</code>`, 'Which agents this machine can run, and why not the others.'],
+ [html`<code>route [--task edit] [--need harness:resume]</code>`, 'Which agent a task would go to, with the reason for every candidate.'],
+ [html`<code>run [options] "task"</code>`, html`Run a task. <code>--agent</code> skips routing, <code>--model</code> picks the first agent's model, <code>--cwd</code> sets where it works, <code>--attempts</code> caps the hand-offs (default 3), <code>--no-handoff</code> stops at the first result, <code>--json</code> prints the events. A task of <code>-</code> is read from stdin.`],
+ [html`<code>runs</code>`, 'The latest runs on this machine.'],
+ [html`<code>show <run id> [--json]</code>`, 'One run: each attempt, its session and the result; every event with --json.'],
+ [html`<code>resume <run id> "follow-up"</code>`, 'Continue the run\'s last session with the same agent.'],
+ ])}
+<p>The exit code follows the result: 0 when the last agent finished, 1 when it did not.</p>
+<h2 id="permission">Permission levels</h2>
+${table(['Level', 'What the agent may do'], [
+ [html`<code>read</code>`, 'Look and answer. Claude Code runs in plan mode, Codex in its read-only sandbox, the API agents get only list, read and search tools.'],
+ [html`<code>edit</code> (default)`, html`Change files in the working directory. Claude Code accepts edits and is refused <code>git push</code>; Codex runs in its workspace-write sandbox; the API agents can write and edit files but not run commands.`],
+ [html`<code>full</code>`, 'Anything, unattended, including running commands. Use it in a container, a VM or a throwaway checkout.'],
+ ])}
+<p>The built-in API agent never leaves the working directory, never reads a <code>.env</code> file and never writes under <code>.git</code>, at any level.</p>
+<h2 id="handoffs">Hand-offs</h2>
+<p>When an attempt fails, hits a usage limit, crashes or prints nothing for a while, the harness stops it and gives the task to the next agent the router picks, leaving out the ones already tried. The next agent gets a note: the original task, which agent tried and why it stopped, the tools it used, the files it changed, its last message and <code>git status</code>. Every attempt is in the run.</p>
+<h2 id="events">One event format</h2>
+<p>Every agent's output becomes the same events: one JSON object per line with <code>--json</code>, the same shape as Claude Code's <code>stream-json</code>, ending with exactly one <code>result</code>. Keys and tokens in an agent's output are redacted before an event is printed or kept.</p>
+<pre><code>${events}</code></pre>
+<h2 id="runs">Where runs are kept</h2>
+<p>In <code>$OPENVIBE_CODES_HOME</code>, else <code>$XDG_STATE_HOME/openvibe-codes</code>, else <code>~/.local/state/openvibe-codes</code>: one directory per run with its attempts and events, readable only by you.</p>
+<h2 id="code">From your own code</h2>
+<pre><code>${fromCode}</code></pre>
+<p><code>harness.agents()</code>, <code>harness.route({ task })</code> and <code>harness.run({ prompt, cwd, agent, permission, handoff })</code> are what the command line uses. The router is the same one behind <a href="/api/v1/harnesses"><code>/api/v1/harnesses/route</code></a>.</p>
+<h2 id="next">What comes next</h2>
+<p>A hosted runner on OpenVibe.Run workers for people who would rather not run agents themselves, with budgets; OpenVibe.Actor using Codes for its coding work; and <a href="/improve">Improve OpenVibe</a> as a guided run: pick a repository, describe the change, get a pull request.</p>`,
+ });
+ });
+
// ── The agent catalog (server/data/harness-offers.json, validated at boot) ──
// ── The agent catalog (server/data/harness-offers.json, validated at boot) ──
// No address target that is a local path is ever shown — only the address kind.
// No address target that is a local path is ever shown — only the address kind.
const isLocalPath = (t) => /^(?:[A-Za-z]:[\\/]|[~/]|\.\.?[\\/])/.test(String(t || ''));
const isLocalPath = (t) => /^(?:[A-Za-z]:[\\/]|[~/]|\.\.?[\\/])/.test(String(t || ''));
@@ -22,6 +22,7 @@ const NETWORK_URL = 'https://openvibe.network';
@@ -22,6 +22,7 @@ const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Codes';
const SITE_NAME = 'OpenVibe.Codes';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const NAV = [
const NAV = [
+ { label: 'Get started', href: '/start' },
{ label: 'Agents', href: '/harnesses' },
{ label: 'Agents', href: '/harnesses' },
{ label: 'Improve OpenVibe', href: '/improve' },
{ label: 'Improve OpenVibe', href: '/improve' },
{ label: 'Community', href: '/policy' },
{ label: 'Community', href: '/policy' },
@@ -87,7 +87,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -87,7 +87,7 @@ const { boot, check, done } = require('./helpers/boot');
});
});
await check('public pages are complete without JavaScript (noscript nav, SSR footer)', async () => {
await check('public pages are complete without JavaScript (noscript nav, SSR footer)', async () => {
- for (const p of ['/', '/harnesses', '/improve', '/policy', '/policy/contributing']) {
+ for (const p of ['/', '/start', '/harnesses', '/improve', '/policy', '/policy/contributing']) {
const r = await t.get(p);
const r = await t.get(p);
assert.strictEqual(r.status, 200, p);
assert.strictEqual(r.status, 200, p);
assert.match(r.text, /<noscript><nav/);
assert.match(r.text, /<noscript><nav/);
@@ -32,7 +32,7 @@ const PRIVATE = ['/projects', '/staff', '/auth/', '/oauth/test-callback'];
@@ -32,7 +32,7 @@ const PRIVATE = ['/projects', '/staff', '/auth/', '/oauth/test-callback'];
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.headers.get('content-type'), /^application\/xml/);
assert.match(r.headers.get('content-type'), /^application\/xml/);
assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
- for (const p of ['/', '/harnesses', '/improve', '/policy']) assert.ok(r.text.includes(`<loc>https://openvibe.codes${p}</loc>`), `no ${p} entry`);
+ for (const p of ['/', '/start', '/harnesses', '/improve', '/policy']) assert.ok(r.text.includes(`<loc>https://openvibe.codes${p}</loc>`), `no ${p} entry`);
for (const p of ['/docs', '/projects', '/oauth', '/tools/webhooks', '/policy/transparency']) assert.ok(!r.text.includes(`<loc>https://openvibe.codes${p}</loc>`), `${p} moved to openvibe.services`);
for (const p of ['/docs', '/projects', '/oauth', '/tools/webhooks', '/policy/transparency']) assert.ok(!r.text.includes(`<loc>https://openvibe.codes${p}</loc>`), `${p} moved to openvibe.services`);
const lastmods = [...r.text.matchAll(/<lastmod>([^<]+)<\/lastmod>/g)].map((m) => m[1]);
const lastmods = [...r.text.matchAll(/<lastmod>([^<]+)<\/lastmod>/g)].map((m) => m[1]);
assert.ok(lastmods.length > 0, 'no lastmod anywhere');
assert.ok(lastmods.length > 0, 'no lastmod anywhere');
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -0,0 +1,123 @@
+'use strict';
+/**
+ * The API agent (harness/adapters/api.js) against a stand-in OpenAI-compatible server: the model's tool calls run inside
+ * the working directory and the loop ends on an answer; usage and cost come from the API's own counts; the permission
+ * level decides which tools exist; a path out of the working directory, a .env file or a .git write is refused; an HTTP
+ * error ends the run with the server's message; resume continues the saved conversation.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const { createHarness } = require('../harness');
+const { createStore } = require('../harness/sessions');
+const { check, done } = require('./helpers/boot');
+
+/** A chat-completions server that answers from a script: each request gets the next scripted message. */
+async function mockApi() {
+ const requests = [];
+ let script = [];
+ const server = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (d) => { body += d; });
+ req.on('end', () => {
+ const parsed = JSON.parse(body || '{}');
+ requests.push({ auth: req.headers.authorization || null, body: parsed });
+ const step = script.shift();
+ if (!step) { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'done' } }], usage: { prompt_tokens: 1, completion_tokens: 1 } })); return; }
+ if (step.status) { res.writeHead(step.status, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: step.error } })); return; }
+ res.writeHead(200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: step.content || '', ...(step.calls ? { tool_calls: step.calls.map((c, i) => ({ id: `call_${requests.length}_${i}`, type: 'function', function: { name: c[0], arguments: JSON.stringify(c[1]) } })) } : {}) } }], usage: { prompt_tokens: 1000, completion_tokens: 100, prompt_cache_hit_tokens: 400 } }));
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ return { url: `http://127.0.0.1:${server.address().port}`, requests, set: (s) => { script = s; requests.length = 0; }, close: () => new Promise((r) => server.close(r)) };
+}
+
+const collect = async (run) => { const out = []; for await (const e of run) out.push(e); return out; };
+const toolResults = (events) => events.filter((e) => e.type === 'user').flatMap((e) => e.message.content);
+
+(async () => {
+ const api = await mockApi();
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-api-'));
+ const work = path.join(dir, 'work');
+ fs.mkdirSync(path.join(work, 'src'), { recursive: true });
+ fs.writeFileSync(path.join(work, 'src', 'a.js'), 'const name = 1;\nmodule.exports = name;\n');
+ fs.writeFileSync(path.join(work, '.env'), 'SECRET=1\n');
+ fs.writeFileSync(path.join(dir, 'outside.txt'), 'outside');
+ const env = { PATH: '/nonexistent', OPENVIBE_CODES_HOME: path.join(dir, 'home'), OPENVIBE_CODES_BASE_URL: api.url, OPENVIBE_CODES_MODEL: 'tiny', OPENVIBE_CODES_API_KEY: 'k-123' };
+ const h = createHarness({ env, store: createStore({ env }) });
+
+ await check('the model\'s tool calls run in the working directory and the loop ends on its answer', async () => {
+ api.set([
+ { calls: [['list_dir', { path: 'src' }], ['read_file', { path: 'src/a.js' }]] },
+ { calls: [['edit_file', { path: 'src/a.js', old_string: 'const name = 1;', new_string: 'const count = 1;' }]] },
+ { content: 'Renamed name to count in src/a.js.' },
+ ]);
+ const events = await collect(h.run({ prompt: 'rename name to count', agent: 'openai-compatible', cwd: work, permission: 'edit', handoff: false }));
+ assert.strictEqual(fs.readFileSync(path.join(work, 'src', 'a.js'), 'utf8'), 'const count = 1;\nmodule.exports = name;\n');
+ const r = events.find((e) => e.type === 'result');
+ assert.strictEqual(r.is_error, false, r.result);
+ assert.strictEqual(r.result, 'Renamed name to count in src/a.js.');
+ assert.strictEqual(r.num_turns, 3);
+ assert.deepStrictEqual([r.usage.input_tokens, r.usage.cache_read_input_tokens, r.usage.output_tokens], [1800, 1200, 300]);
+ assert.ok(toolResults(events).some((c) => c.content.includes('a.js')), 'the listing came back');
+ assert.ok(toolResults(events).some((c) => c.content.includes('1\tconst name = 1;')), 'the file came back with line numbers');
+ assert.strictEqual(api.requests[0].auth, 'Bearer k-123');
+ assert.strictEqual(api.requests[0].body.model, 'tiny');
+ assert.deepStrictEqual(api.requests[0].body.tools.map((t) => t.function.name).sort(), ['edit_file', 'grep', 'list_dir', 'read_file', 'write_file']);
+ assert.strictEqual(api.requests[2].body.messages.filter((m) => m.role === 'tool').length, 3, 'every tool result went back to the model');
+ });
+
+ await check('read permission offers no writing tool, and a forced write is refused', async () => {
+ api.set([{ calls: [['write_file', { path: 'src/new.js', content: 'x' }]] }, { content: 'ok' }]);
+ const events = await collect(h.run({ prompt: 'look', agent: 'openai-compatible', cwd: work, permission: 'read', handoff: false }));
+ assert.deepStrictEqual(api.requests[0].body.tools.map((t) => t.function.name).sort(), ['grep', 'list_dir', 'read_file']);
+ assert.ok(!fs.existsSync(path.join(work, 'src', 'new.js')));
+ assert.ok(toolResults(events).some((c) => c.is_error && /needs the edit permission level/.test(c.content)));
+ });
+
+ await check('a path out of the working directory, a .env file and a .git write are refused', async () => {
+ api.set([{ calls: [['read_file', { path: '../outside.txt' }], ['read_file', { path: '/etc/passwd' }], ['read_file', { path: '.env' }], ['write_file', { path: '.git/config', content: 'x' }], ['grep', { pattern: 'SECRET' }]] }, { content: 'ok' }]);
+ const events = await collect(h.run({ prompt: 'try', agent: 'openai-compatible', cwd: work, permission: 'edit', handoff: false }));
+ const res = toolResults(events);
+ assert.strictEqual(res.filter((c) => c.is_error).length, 4, JSON.stringify(res.map((c) => c.content)));
+ assert.ok(res.some((c) => /outside the working directory/.test(c.content)));
+ assert.ok(res.some((c) => /\.env files are not read/.test(c.content)));
+ assert.ok(res.some((c) => /\.git is not written/.test(c.content)));
+ assert.ok(!res.some((c) => /SECRET=1/.test(c.content)), 'grep skips .env files');
+ assert.ok(!fs.existsSync(path.join(work, '.git')));
+ });
+
+ await check('run_command exists only at the full level, and runs in the working directory', async () => {
+ api.set([{ calls: [['run_command', { command: 'pwd' }]] }, { content: 'ok' }]);
+ const events = await collect(h.run({ prompt: 'where', agent: 'openai-compatible', cwd: work, permission: 'full', handoff: false }));
+ assert.ok(api.requests[0].body.tools.some((t) => t.function.name === 'run_command'));
+ assert.ok(toolResults(events).some((c) => c.content.trim() === fs.realpathSync(work)));
+ });
+
+ 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 }));
+ const r = events.find((e) => e.type === 'result');
+ assert.strictEqual(r.is_error, true);
+ assert.match(r.result, /HTTP 429: rate limited/);
+ });
+
+ await check('resume continues the saved conversation', async () => {
+ api.set([{ content: 'first answer' }]);
+ const run1 = h.run({ prompt: 'remember the word kiwi', agent: 'openai-compatible', cwd: work, handoff: false });
+ const e1 = await collect(run1);
+ const session = e1.find((e) => e.type === 'result').session_id;
+ api.set([{ content: 'kiwi' }]);
+ await collect(h.run({ prompt: 'which word?', agent: 'openai-compatible', cwd: work, resume: session, handoff: false }));
+ const msgs = api.requests[0].body.messages;
+ assert.ok(msgs.some((m) => m.role === 'user' && m.content === 'remember the word kiwi'), 'the earlier turn came back');
+ assert.strictEqual(msgs[msgs.length - 1].content, 'which word?');
+ });
+
+ await api.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+/**
+ * The `openvibe-codes` command (bin/openvibe-codes.js) with stand-in agents on PATH: agents, route, run (a readable log
+ * and --json), runs, show and resume, and the exit code following the result.
+ */
+const assert = require('assert');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { fakeAgents } = require('./helpers/fake-agents');
+const { check, done } = require('./helpers/boot');
+
+const BIN = path.join(__dirname, '..', 'bin', 'openvibe-codes.js');
+
+(async () => {
+ const f = fakeAgents({ codex: 'fail' });
+ const cli = (...args) => {
+ const r = spawnSync(process.execPath, [BIN, ...args], { cwd: f.work, env: { ...process.env, ...f.env }, encoding: 'utf8', timeout: 60_000 });
+ return { code: r.status, out: r.stdout, err: r.stderr };
+ };
+
+ await check('agents: what is here and why not the rest', () => {
+ const r = cli('agents');
+ assert.strictEqual(r.code, 0, r.err);
+ assert.match(r.out, /✔ claude-code/);
+ assert.match(r.out, /· aider .*not on PATH/);
+ const json = JSON.parse(cli('agents', '--json').out);
+ assert.ok(json.find((a) => a.id === 'codex').available);
+ });
+
+ await check('route: the decision, its reasons and every candidate', () => {
+ const r = cli('route', '--task', 'review');
+ assert.strictEqual(r.code, 0, r.err);
+ assert.match(r.out, /^→ \S+:/m);
+ assert.match(r.out, /aider:aider-default — `aider` is not on PATH/);
+ });
+
+ let runId;
+ await check('run: a readable log, a hand-off, and exit 0 when the last agent succeeded', () => {
+ const r = cli('run', '--agent', 'codex', 'rename', 'the', 'variable');
+ assert.strictEqual(r.code, 0, r.out + r.err);
+ runId = (r.out.match(/^run (run_\S+)/m) || [])[1];
+ assert.ok(runId, r.out);
+ assert.match(r.out, /▶ codex/);
+ assert.match(r.out, /⇢ handing over from codex: .*usage limit/);
+ assert.match(r.out, /✔ done/);
+ });
+
+ await check('run --json: one event per line, ending with the result', () => {
+ const r = cli('run', '--agent', 'claude-code', '--no-handoff', '--json', 'x');
+ const lines = r.out.trim().split('\n').map((l) => JSON.parse(l));
+ assert.strictEqual(lines[0].subtype, 'init');
+ assert.strictEqual(lines[lines.length - 1].type, 'result');
+ assert.strictEqual(r.code, 0);
+ });
+
+ await check('runs, show and resume', () => {
+ const list = cli('runs');
+ assert.ok(list.out.includes(runId), list.out);
+ assert.match(list.out, /codex ⇢ /);
+ const show = cli('show', runId);
+ assert.strictEqual(show.code, 0);
+ assert.match(show.out, /✗ codex/);
+ const events = cli('show', runId, '--json').out.trim().split('\n').map((l) => JSON.parse(l));
+ assert.ok(events.some((e) => e.subtype === 'handoff'));
+ const resumed = cli('resume', runId, 'and', 'add', 'a', 'test');
+ assert.strictEqual(resumed.code, 0, resumed.out + resumed.err);
+ assert.match(resumed.out, /did: and add a test/);
+ assert.strictEqual(cli('resume', 'run_nope', 'x').code, 1);
+ });
+
+ await check('a failed run exits 1', () => {
+ assert.strictEqual(cli('run', '--agent', 'codex', '--no-handoff', 'x').code, 1);
+ assert.strictEqual(cli('run').code, 1, 'no prompt');
+ });
+
+ f.cleanup();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -52,7 +52,7 @@ function priced(row, freshUsd) {
@@ -52,7 +52,7 @@ function priced(row, freshUsd) {
const deepseek = rows.find((row) => row.id === 'deepseek');
const deepseek = rows.find((row) => row.id === 'deepseek');
const offer = toOffer(deepseek, deepseek.agents[0]);
const offer = toOffer(deepseek, deepseek.agents[0]);
assert.strictEqual(offer.offer_id, 'deepseek:deepseek-chat');
assert.strictEqual(offer.offer_id, 'deepseek:deepseek-chat');
- assert.deepStrictEqual(offer.capabilities, []);
+ assert.deepStrictEqual(offer.capabilities, ['task:edit', 'task:review', 'harness:host-access', 'harness:resume', 'harness:edit', 'harness:review', 'harness:tools']);
assert.strictEqual(offer.trust, 'external');
assert.strictEqual(offer.trust, 'external');
assert.deepStrictEqual(offer.health, { status: 'up' });
assert.deepStrictEqual(offer.health, { status: 'up' });
assert.strictEqual('latency_ms' in offer, false);
assert.strictEqual('latency_ms' in offer, false);
@@ -65,7 +65,7 @@ function priced(row, freshUsd) {
@@ -65,7 +65,7 @@ function priced(row, freshUsd) {
await check('every seed offer validates as platform.resource-offer@1 of kind harness', async () => {
await check('every seed offer validates as platform.resource-offer@1 of kind harness', async () => {
const harnesses = createHarnesses({ catalogPath });
const harnesses = createHarnesses({ catalogPath });
const offers = harnesses.list().flatMap((harness) => harnesses.agents(harness.id).map((agent) => [harness, toOffer(harness, agent, { now })]));
const offers = harnesses.list().flatMap((harness) => harnesses.agents(harness.id).map((agent) => [harness, toOffer(harness, agent, { now })]));
- assert.strictEqual(offers.length, 6);
+ assert.strictEqual(offers.length, 7);
for (const [harness, offer] of offers) {
for (const [harness, offer] of offers) {
const result = contracts.validate('platform.resource-offer@1', JSON.parse(JSON.stringify(offer)));
const result = contracts.validate('platform.resource-offer@1', JSON.parse(JSON.stringify(offer)));
assert.ok(result.valid, `${offer.offer_id}: ${JSON.stringify(result.errors)}`);
assert.ok(result.valid, `${offer.offer_id}: ${JSON.stringify(result.errors)}`);
@@ -92,26 +92,29 @@ function priced(row, freshUsd) {
@@ -92,26 +92,29 @@ function priced(row, freshUsd) {
assert.deepStrictEqual(tasksOf('claude-code'), ['edit', 'review']);
assert.deepStrictEqual(tasksOf('claude-code'), ['edit', 'review']);
assert.deepStrictEqual(tasksOf('command-code'), ['edit']);
assert.deepStrictEqual(tasksOf('command-code'), ['edit']);
assert.deepStrictEqual(tasksOf('aider'), ['edit']);
assert.deepStrictEqual(tasksOf('aider'), ['edit']);
- assert.deepStrictEqual(tasksOf('deepseek'), []);
+ // The API models edit and review through the harness's own agent loop (harness/adapters/api.js).
+ assert.deepStrictEqual(tasksOf('deepseek'), ['edit', 'review']);
+ assert.deepStrictEqual(tasksOf('openai-compatible'), ['edit', 'review']);
// An explicit task_capabilities list still overrides the booleans.
// An explicit task_capabilities list still overrides the booleans.
const claude = byId.get('claude-code');
const claude = byId.get('claude-code');
assert.deepStrictEqual(toOffer({ ...claude, task_capabilities: ['edit', 'browse'] }, claude.agents[0]).detail.task_capabilities, ['edit', 'browse']);
assert.deepStrictEqual(toOffer({ ...claude, task_capabilities: ['edit', 'browse'] }, claude.agents[0]).detail.task_capabilities, ['edit', 'browse']);
const harnesses = createHarnesses({ catalogPath });
const harnesses = createHarnesses({ catalogPath });
const review = route({ task: 'review', harnesses, now });
const review = route({ task: 'review', harnesses, now });
- for (const id of ['command-code:command-code-default', 'aider:aider-default', 'deepseek:deepseek-chat']) {
+ for (const id of ['command-code:command-code-default', 'aider:aider-default']) {
assert.deepStrictEqual(review.candidates.find((c) => c.id === id), { id, included: false, reason: 'lacks task:review' });
assert.deepStrictEqual(review.candidates.find((c) => c.id === id), { id, included: false, reason: 'lacks task:review' });
}
}
- assert.ok(!['command-code', 'aider', 'deepseek'].includes(review.selected.split(':')[0]));
+ assert.ok(!['command-code', 'aider'].includes(review.selected.split(':')[0]));
+ assert.ok(review.candidates.some((c) => c.id === 'deepseek:deepseek-chat' && c.included), 'DeepSeek reviews through the agent loop');
const edit = route({ task: 'edit', harnesses, now });
const edit = route({ task: 'edit', harnesses, now });
- assert.deepStrictEqual(edit.candidates.find((c) => c.id === 'deepseek:deepseek-chat'), { id: 'deepseek:deepseek-chat', included: false, reason: 'lacks task:edit' });
+ assert.ok(edit.candidates.some((c) => c.id === 'deepseek:deepseek-chat' && c.included));
assert.ok(edit.candidates.some((c) => c.id === 'command-code:command-code-default' && c.included));
assert.ok(edit.candidates.some((c) => c.id === 'command-code:command-code-default' && c.included));
});
});
await check('deepseek advertises no runtime class', async () => {
await check('deepseek advertises no runtime class', async () => {
const deepseek = rows.find((row) => row.id === 'deepseek');
const deepseek = rows.find((row) => row.id === 'deepseek');
const offer = toOffer(deepseek, deepseek.agents[0]);
const offer = toOffer(deepseek, deepseek.agents[0]);
- assert.deepStrictEqual(offer.capabilities, []);
+ assert.ok(!offer.capabilities.some((c) => c.startsWith('runtime:')), offer.capabilities.join(', '));
assert.strictEqual('runtimes' in offer.detail.capabilities, false);
assert.strictEqual('runtimes' in offer.detail.capabilities, false);
// A bare runtime:code requirement no longer reaches the host-less API, but still reaches a code CLI.
// A bare runtime:code requirement no longer reaches the host-less API, but still reaches a code CLI.
const req = { kind: 'harness.edit', mobility: 'job', latency_class: 'interactive', objective: 'balanced', capabilities: ['runtime:code'] };
const req = { kind: 'harness.edit', mobility: 'job', latency_class: 'interactive', objective: 'balanced', capabilities: ['runtime:code'] };
@@ -127,7 +130,7 @@ function priced(row, freshUsd) {
@@ -127,7 +130,7 @@ function priced(row, freshUsd) {
const [harnessId] = r.selected.split(':');
const [harnessId] = r.selected.split(':');
assert.ok(toOffer(harnesses.get(harnessId), harnesses.agents(harnessId)[0]).capabilities.includes('task:review'));
assert.ok(toOffer(harnesses.get(harnessId), harnesses.agents(harnessId)[0]).capabilities.includes('task:review'));
assert.ok(r.reasons.length);
assert.ok(r.reasons.length);
- assert.deepStrictEqual(r.candidates.map((c) => c.id), ['claude-code:claude-code-sonnet', 'codex:codex-default', 'command-code:command-code-default', 'opencode:opencode-default', 'aider:aider-default', 'deepseek:deepseek-chat']);
+ assert.deepStrictEqual(r.candidates.map((c) => c.id), ['claude-code:claude-code-sonnet', 'codex:codex-default', 'command-code:command-code-default', 'opencode:opencode-default', 'aider:aider-default', 'deepseek:deepseek-chat', 'openai-compatible:openai-compatible-local']);
});
});
await check('an offer whose health is down is excluded with a reason', async () => {
await check('an offer whose health is down is excluded with a reason', async () => {
@@ -141,11 +144,9 @@ function priced(row, freshUsd) {
@@ -141,11 +144,9 @@ function priced(row, freshUsd) {
const harnesses = createHarnesses({ catalogPath });
const harnesses = createHarnesses({ catalogPath });
const r = route({ task: 'edit', extra: { trust: 'first-party' }, harnesses, now, opts: { 'codex:codex-default': { trust: 'first-party' } } });
const r = route({ task: 'edit', extra: { trust: 'first-party' }, harnesses, now, opts: { 'codex:codex-default': { trust: 'first-party' } } });
assert.strictEqual(r.selected, 'codex:codex-default');
assert.strictEqual(r.selected, 'codex:codex-default');
- for (const c of r.candidates.filter((c) => c.id !== 'codex:codex-default' && c.id !== 'deepseek:deepseek-chat')) {
+ for (const c of r.candidates.filter((c) => c.id !== 'codex:codex-default')) {
assert.deepStrictEqual(c, { id: c.id, included: false, reason: 'workload requires first-party trust' });
assert.deepStrictEqual(c, { id: c.id, included: false, reason: 'workload requires first-party trust' });
}
}
- // Deepseek cannot edit, so it drops out on capabilities before trust is considered.
- assert.deepStrictEqual(r.candidates.find((c) => c.id === 'deepseek:deepseek-chat'), { id: 'deepseek:deepseek-chat', included: false, reason: 'lacks task:edit' });
const none = route({ task: 'edit', extra: { trust: ['first-party'] }, harnesses, now });
const none = route({ task: 'edit', extra: { trust: ['first-party'] }, harnesses, now });
assert.strictEqual(none.selected, null);
assert.strictEqual(none.selected, null);
assert.ok(none.candidates.every((c) => !c.included));
assert.ok(none.candidates.every((c) => !c.included));
@@ -170,11 +171,7 @@ function priced(row, freshUsd) {
@@ -170,11 +171,7 @@ function priced(row, freshUsd) {
assert.deepStrictEqual(r.candidates.find((c) => c.id === 'codex:codex-default'), { id: 'codex:codex-default', included: false, reason: 'lacks task:browse' });
assert.deepStrictEqual(r.candidates.find((c) => c.id === 'codex:codex-default'), { id: 'codex:codex-default', included: false, reason: 'lacks task:browse' });
const none = route({ task: 'edit', extra: { capabilities: ['task:browse'] }, harnesses: createHarnesses({ catalogPath }), now });
const none = route({ task: 'edit', extra: { capabilities: ['task:browse'] }, harnesses: createHarnesses({ catalogPath }), now });
assert.strictEqual(none.selected, null);
assert.strictEqual(none.selected, null);
- for (const c of none.candidates.filter((c) => c.id !== 'deepseek:deepseek-chat')) {
- assert.strictEqual(c.reason, 'lacks task:browse');
- }
- // Deepseek drops on capabilities it lacks (edit) before task:browse.
- assert.deepStrictEqual(none.candidates.find((c) => c.id === 'deepseek:deepseek-chat'), { id: 'deepseek:deepseek-chat', included: false, reason: 'lacks task:edit' });
+ for (const c of none.candidates) assert.strictEqual(c.reason, 'lacks task:browse', c.id);
});
});
await check('a harness:vision requirement drops the vision:false harnesses', async () => {
await check('a harness:vision requirement drops the vision:false harnesses', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
+'use strict';
+/**
+ * The harness (harness/index.js) against stand-in agents (test/helpers/fake-agents.js): every CLI adapter turns its
+ * agent's output into the shared events with one result; availability comes from PATH and keys; routing leaves out
+ * what is not here and says why; a failed or silent attempt hands the task to the next agent with a note on what was
+ * done; a crash ends with a reason whose secrets are redacted; runs and their events are kept for `show` and `resume`.
+ */
+const assert = require('assert');
+const { createHarness } = require('../harness');
+const { createStore } = require('../harness/sessions');
+const { fakeAgents } = require('./helpers/fake-agents');
+const { check, done } = require('./helpers/boot');
+
+const collect = async (run) => { const out = []; for await (const e of run) out.push(e); return out; };
+const texts = (events) => events.filter((e) => e.type === 'assistant').flatMap((e) => e.message.content.filter((c) => c.type === 'text').map((c) => c.text));
+const results = (events) => events.filter((e) => e.type === 'result');
+
+(async () => {
+ const f = fakeAgents();
+ const harness = (env = f.env) => createHarness({ env, store: createStore({ env }) });
+
+ await check('availability: the CLIs on PATH are here; an API agent needs its key; the reason names what to do', () => {
+ const list = harness().agents();
+ const by = Object.fromEntries(list.map((a) => [a.id, a]));
+ for (const id of ['claude-code', 'codex', 'opencode', 'command-code']) assert.strictEqual(by[id].available, true, id);
+ assert.strictEqual(by.aider.available, false);
+ assert.match(by.aider.reason, /not on PATH/);
+ assert.strictEqual(by.deepseek.available, false);
+ assert.match(by.deepseek.reason, /DEEPSEEK_API_KEY/);
+ assert.strictEqual(harness({ ...f.env, DEEPSEEK_API_KEY: 'x' }).agents().find((a) => a.id === 'deepseek').available, true);
+ });
+
+ await check('routing goes to an available agent; the rest are listed with why', () => {
+ const d = harness().route({ task: 'edit' });
+ assert.ok(d.selected, JSON.stringify(d));
+ assert.ok(['claude-code', 'codex', 'opencode', 'command-code'].includes(d.selected.split(':')[0]), d.selected);
+ const aider = d.candidates.find((c) => c.id.startsWith('aider:'));
+ assert.strictEqual(aider.included, false);
+ assert.match(aider.reason, /not on PATH/);
+ const none = createHarness({ env: { PATH: '/nonexistent', OPENVIBE_CODES_HOME: f.env.OPENVIBE_CODES_HOME } }).route({ task: 'edit' });
+ assert.strictEqual(none.selected, null);
+ });
+
+ for (const [agent, word] of [['claude-code', 'claude'], ['codex', 'codex'], ['opencode', 'opencode'], ['command-code', 'cmd']]) {
+ await check(`${agent}: the agent's output becomes the shared events, with one result`, async () => {
+ const events = await collect(harness().run({ prompt: 'fix a.js', agent, cwd: f.work, handoff: false }));
+ assert.strictEqual(events[0].type, 'system');
+ assert.strictEqual(events[0].subtype, 'init');
+ assert.ok(events[0].session_id, 'a session id to resume');
+ assert.ok(events.some((e) => e.type === 'assistant' && e.message.content.some((c) => c.type === 'tool_use')), 'a tool call');
+ assert.ok(events.some((e) => e.type === 'user' && e.message.content.some((c) => c.type === 'tool_result')), 'its result');
+ const r = results(events);
+ assert.strictEqual(r.length, 1, 'exactly one result');
+ assert.strictEqual(r[0].is_error, false, r[0].result);
+ assert.match(r[0].result, new RegExp(`${word} did: fix a\\.js`));
+ assert.ok(r[0].usage && Number.isFinite(r[0].usage.input_tokens));
+ });
+ }
+
+ await check('codex: cached input is counted as cache reads, not twice', async () => {
+ const events = await collect(harness().run({ prompt: 'x', agent: 'codex', cwd: f.work, handoff: false }));
+ const u = results(events)[0].usage;
+ assert.deepStrictEqual([u.input_tokens, u.cache_read_input_tokens, u.output_tokens], [60, 40, 7]);
+ assert.ok(events.some((e) => e.type === 'assistant' && e.message.content.some((c) => c.type === 'tool_use' && c.name === 'Edit' && c.input.file_path === 'a.js')), 'a file change is an Edit');
+ });
+
+ await check('a failed attempt hands the task to the next agent, with the task and what was done', async () => {
+ const g = fakeAgents({ codex: 'fail' });
+ const h = createHarness({ env: g.env, store: createStore({ env: g.env }) });
+ const run = h.run({ prompt: 'rename the variable', agent: 'codex', cwd: g.work, maxAttempts: 3 });
+ const events = await collect(run);
+ const handoff = events.find((e) => e.type === 'system' && e.subtype === 'handoff');
+ assert.ok(handoff, 'a hand-off event');
+ assert.strictEqual(handoff.from, 'codex');
+ assert.match(handoff.reason, /usage limit/);
+ assert.strictEqual(events.filter((e) => e.type === 'system' && e.subtype === 'error').length, 1, 'one failure, reported once');
+ const r = results(events);
+ assert.strictEqual(r.length, 1, 'the run ends with one result: the last attempt\'s');
+ assert.strictEqual(r[0].is_error, false);
+ const answer = texts(events).pop();
+ assert.match(answer, /taking over a coding task that Codex started/, 'the next agent got the hand-off note');
+ assert.match(answer, /rename the variable/, 'with the original task');
+ const s = run.summary();
+ assert.deepStrictEqual(s.attempts.map((a) => [a.agent, a.ok]).slice(0, 1), [['codex', false]]);
+ assert.strictEqual(s.attempts.length, 2);
+ assert.ok(s.attempts[1].ok);
+ assert.deepStrictEqual(h.store.loadRun(run.runId).attempts.length, 2, 'the run is kept');
+ assert.ok(h.store.events(run.runId).some((e) => e.type === 'system' && e.subtype === 'handoff'));
+ g.cleanup();
+ });
+
+ await check('--no-handoff stops at the first result', async () => {
+ const g = fakeAgents({ codex: 'fail' });
+ const events = await collect(createHarness({ env: g.env, store: createStore({ env: g.env }) }).run({ prompt: 'x', agent: 'codex', cwd: g.work, handoff: false }));
+ assert.ok(!events.some((e) => e.subtype === 'handoff'));
+ assert.strictEqual(results(events)[0].is_error, true);
+ g.cleanup();
+ });
+
+ await check('an agent that goes quiet is stopped and the task handed on', async () => {
+ const g = fakeAgents({ claude: 'hang' });
+ const run = createHarness({ env: g.env, store: createStore({ env: g.env }) }).run({ prompt: 'x', agent: 'claude-code', cwd: g.work, stallMs: 400, maxAttempts: 2 });
+ const events = await collect(run);
+ const handoff = events.find((e) => e.subtype === 'handoff');
+ assert.ok(handoff, 'handed on');
+ assert.match(handoff.reason, /went quiet/);
+ assert.strictEqual(results(events)[0].is_error, false, 'the next agent finished');
+ g.cleanup();
+ });
+
+ await check('a crash ends with a reason a person can act on, and a key in it is redacted', async () => {
+ const g = fakeAgents({ claude: 'crash' });
+ const events = await collect(createHarness({ env: g.env, store: createStore({ env: g.env }) }).run({ prompt: 'x', agent: 'claude-code', cwd: g.work, handoff: false }));
+ const r = results(events)[0];
+ assert.strictEqual(r.is_error, true);
+ assert.match(r.result, /ended without a result \(exit 3\)/);
+ assert.ok(!r.result.includes('sk-test0123456789'), r.result);
+ assert.match(r.result, /\[redacted\]/);
+ g.cleanup();
+ });
+
+ await check('an agent that is not here is refused at once, with why', async () => {
+ const events = await collect(harness().run({ prompt: 'x', agent: 'aider', cwd: f.work }));
+ assert.strictEqual(results(events)[0].is_error, true);
+ assert.match(results(events)[0].result, /not available here.*not on PATH/);
+ });
+
+ await check('a run needs a prompt and a known permission level', () => {
+ assert.throws(() => harness().run({ prompt: ' ', cwd: f.work }), /needs a prompt/);
+ assert.throws(() => harness().run({ prompt: 'x', permission: 'root', cwd: f.work }), /read, edit or full/);
+ });
+
+ f.cleanup();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -13,7 +13,7 @@ const rows = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
@@ -13,7 +13,7 @@ const rows = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
(async () => {
(async () => {
await check('every seed row matches the pinned harness and agent contracts', async () => {
await check('every seed row matches the pinned harness and agent contracts', async () => {
- assert.strictEqual(rows.length, 6);
+ assert.strictEqual(rows.length, 7);
for (const { agents, ...offer } of rows) {
for (const { agents, ...offer } of rows) {
const harnessCheck = contracts.validate('platform.harness-offer@1', offer);
const harnessCheck = contracts.validate('platform.harness-offer@1', offer);
assert.strictEqual(harnessCheck.valid, true, JSON.stringify(harnessCheck.errors));
assert.strictEqual(harnessCheck.valid, true, JSON.stringify(harnessCheck.errors));
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+/**
+ * Stand-in coding agents for the harness tests: a temporary bin directory with `claude`, `codex`, `opencode` and `cmd`
+ * scripts that print what the real CLIs print (recorded shapes) and an isolated OPENVIBE_CODES_HOME. Each script reads
+ * FAKE_<NAME> from its environment:
+ *
+ * ok a short run: a tool call, its result, a final answer that quotes the prompt it was given
+ * fail the agent's own error, then exit 1 (no result line from the CLI)
+ * crash exit 3 with a line on stderr that carries a key (it must come back redacted)
+ * hang prints its start, then sleeps (a stall)
+ *
+ * const f = fakeAgents({ claude: 'ok', codex: 'fail' }); f.env → PATH and OPENVIBE_CODES_HOME; f.cleanup()
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const SCRIPTS = {
+ claude: `
+const prompt = process.argv[process.argv.indexOf('-p') + 1];
+const mode = process.env.FAKE_CLAUDE || 'ok';
+const out = (o) => console.log(JSON.stringify(o));
+if (mode === 'hang') { out({ type: 'system', subtype: 'init', session_id: 'cl-hang', model: 'fake-claude' }); setInterval(() => {}, 1000); return; }
+if (mode === 'crash') { process.stderr.write('boom: api_key=sk-test0123456789abcdefghij\\n'); process.exit(3); }
+out({ type: 'system', subtype: 'init', session_id: 'cl-1', model: 'fake-claude' });
+out({ type: 'assistant', message: { model: 'fake-claude', content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: 'a.js' } }] } });
+out({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't1', content: 'const a = 1;', is_error: false }] } });
+if (mode === 'fail') { out({ type: 'result', subtype: 'error_during_execution', is_error: true, result: 'claude could not finish', session_id: 'cl-1' }); process.exit(1); }
+out({ type: 'assistant', message: { model: 'fake-claude', content: [{ type: 'text', text: 'claude did: ' + prompt }] } });
+out({ type: 'result', subtype: 'success', is_error: false, result: 'claude did: ' + prompt, session_id: 'cl-1', duration_ms: 5, num_turns: 2, total_cost_usd: 0.01, usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } });
+`,
+ codex: `
+const prompt = process.argv[process.argv.length - 1];
+const mode = process.env.FAKE_CODEX || 'ok';
+const out = (o) => console.log(JSON.stringify(o));
+out({ type: 'thread.started', thread_id: 'cx-1' });
+out({ type: 'turn.started' });
+if (mode === 'fail') { out({ type: 'error', message: 'You have hit your usage limit.' }); out({ type: 'turn.failed', error: { message: 'You have hit your usage limit.' } }); process.exit(1); }
+out({ type: 'item.started', item: { id: 'i1', type: 'command_execution', command: 'ls', status: 'in_progress' } });
+out({ type: 'item.completed', item: { id: 'i1', type: 'command_execution', command: 'ls', aggregated_output: 'a.js', exit_code: 0, status: 'completed' } });
+out({ type: 'item.completed', item: { id: 'i2', type: 'file_change', changes: [{ path: 'a.js', kind: 'update' }], status: 'completed' } });
+out({ type: 'item.completed', item: { id: 'i3', type: 'agent_message', text: 'codex did: ' + prompt } });
+out({ type: 'turn.completed', usage: { input_tokens: 100, cached_input_tokens: 40, output_tokens: 7 } });
+`,
+ opencode: `
+const prompt = process.argv[process.argv.length - 1];
+const out = (o) => console.log(JSON.stringify(o));
+out({ type: 'step_start', sessionID: 'oc-1', part: {} });
+out({ type: 'tool_use', sessionID: 'oc-1', part: { id: 'p1', tool: 'read', state: { status: 'completed', input: { path: 'a.js' }, output: 'const a = 1;' } } });
+out({ type: 'text', sessionID: 'oc-1', part: { messageID: 'm1', text: 'opencode did: ' + prompt } });
+out({ type: 'step_finish', sessionID: 'oc-1', part: { tokens: { input: 20, output: 3, reasoning: 1, cache: { read: 5, write: 0 } }, cost: 0.002 } });
+`,
+ cmd: `
+const arg = process.argv.find((a) => a.startsWith('--print=')) || '';
+const prompt = arg.slice(8);
+const out = (o) => console.log(JSON.stringify(o));
+out({ type: 'event', event: { type: 'run_start', sessionId: 'cc-1' } });
+out({ type: 'event', event: { type: 'model_request_start', model: 'fake-cmd' } });
+out({ type: 'event', event: { type: 'message_end', content: [{ type: 'tool_use', id: 'u1', name: 'edit_file', input: { path: 'a.js' } }] } });
+out({ type: 'event', event: { type: 'tool_completed', toolCallId: 'u1', result: [{ text: 'edited' }] } });
+out({ type: 'event', event: { type: 'message_end', content: [{ type: 'text', text: 'cmd did: ' + prompt }] } });
+out({ type: 'result', subtype: 'success', finalText: 'cmd did: ' + prompt, sessionId: 'cc-1', usage: { inputTokens: 9, outputTokens: 4 } });
+`,
+};
+
+function fakeAgents(modes = {}, { include = Object.keys(SCRIPTS) } = {}) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-fake-'));
+ const bin = path.join(dir, 'bin');
+ fs.mkdirSync(bin);
+ for (const name of include) {
+ const f = path.join(bin, name);
+ fs.writeFileSync(f, `#!${process.execPath}\n'use strict';\n(function () {${SCRIPTS[name]}})();\n`, { mode: 0o755 });
+ }
+ const work = path.join(dir, 'work');
+ fs.mkdirSync(work);
+ fs.writeFileSync(path.join(work, 'a.js'), 'const a = 1;\n');
+ const env = {
+ PATH: bin, OPENVIBE_CODES_HOME: path.join(dir, 'home'),
+ FAKE_CLAUDE: modes.claude || 'ok', FAKE_CODEX: modes.codex || 'ok',
+ };
+ return { dir, bin, work, env, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) };
+}
+
+module.exports = { fakeAgents };
@@ -20,7 +20,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
@@ -20,7 +20,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
const release = (await t.get('/release.json')).json().release;
const release = (await t.get('/release.json')).json().release;
await check('every rendered page carries the ov-boost marker (the app\'s release) and the data-main boost script', async () => {
await check('every rendered page carries the ov-boost marker (the app\'s release) and the data-main boost script', async () => {
- for (const p of ['/', '/harnesses', '/improve', '/policy']) {
+ for (const p of ['/', '/start', '/harnesses', '/improve', '/policy']) {
const r = await t.get(p);
const r = await t.get(p);
assert.strictEqual(r.status, 200, p);
assert.strictEqual(r.status, 200, p);
const m = r.text.match(MARKER);
const m = r.text.match(MARKER);
@@ -40,7 +40,7 @@ const { movedTo } = require('../server/http/moved');
@@ -40,7 +40,7 @@ const { movedTo } = require('../server/http/moved');
});
});
await check('what Codes serves stays here', async () => {
await check('what Codes serves stays here', async () => {
- for (const p of ['/', '/harnesses', '/improve', '/policy', '/policy/contributing', '/updates', '/api/v1/harnesses', '/robots.txt']) {
+ for (const p of ['/', '/start', '/harnesses', '/improve', '/policy', '/policy/contributing', '/updates', '/api/v1/harnesses', '/robots.txt']) {
const r = await t.get(p);
const r = await t.get(p);
assert.strictEqual(r.status, 200, `${p}: ${r.status}`);
assert.strictEqual(r.status, 200, `${p}: ${r.status}`);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+/**
+ * Getting started (/start) says how to install the harness from this release's tag and names every agent the harness
+ * has an adapter for, with what it needs; the home page leads there and lists what works today; the command line
+ * prints the same version and points back at the page.
+ */
+const assert = require('assert');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { boot, check, done } = require('./helpers/boot');
+const { adaptersFor } = require('../harness');
+const { version } = require('../package.json');
+
+(async () => {
+ const t = await boot();
+
+ await check('/start installs from this release\'s tag and names every agent with what it needs', async () => {
+ const r = await t.get('/start');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes(`npm install -g https://codeload.github.com/OpenVibers/OpenVibe.Codes/tar.gz/refs/tags/v${version}`), 'no install line for this version');
+ for (const id of Object.keys(adaptersFor({}))) assert.ok(r.text.includes(`href="/harnesses#${id}"`), `no row for ${id}`);
+ for (const env of ['DEEPSEEK_API_KEY', 'OPENVIBE_CODES_BASE_URL', 'OPENVIBE_CODES_MODEL']) assert.ok(r.text.includes(env), env);
+ for (const level of ['read', 'edit', 'full']) assert.ok(r.text.includes(`<code>${level}</code>`), level);
+ assert.match(r.text, /<link rel="canonical" href="https:\/\/openvibe\.codes\/start">/);
+ });
+
+ await check('the home page leads to /start and says the harness is live', async () => {
+ const r = await t.get('/');
+ assert.ok(r.text.includes('href="/start"'));
+ assert.ok(r.text.includes(`Live, ${version}`));
+ assert.ok(!/Hand-offs between agents, resumable sessions<\/td><td>Next/.test(r.text), 'the old "next" row is gone');
+ });
+
+ await check('the command line prints this version and points at the guide', () => {
+ const bin = path.join(__dirname, '..', 'bin', 'openvibe-codes.js');
+ const v = spawnSync(process.execPath, [bin, '--version'], { encoding: 'utf8' });
+ assert.strictEqual(v.stdout.trim(), version);
+ const help = spawnSync(process.execPath, [bin, 'run', '--help'], { encoding: 'utf8' });
+ assert.strictEqual(help.status, 0);
+ assert.match(help.stdout, /https:\/\/openvibe\.codes\/start/);
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });