openvibers/openvibe.codes Commits
Codes C6: openvibe-codes task, a person's own token, and review tasks (#167)
Changes
@@@ -22,7 +22,7 @@ One place to send a coding task to the agent that fits it — by what the task n| Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. || Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). The task page shows the **timeline of attempts** above the agents' **session** (each attempt with its why: "Codex hit its limit, so Command Code took over with what it had done") and the session with openvibe-shared/agent-session.js: steps grouped, each step's command, change and output (live while it runs), helpers nested, hand-offs and the plan; after the end, the session from `codes_session_lines` (migration 0007): the lines are kept as the stream arrives, back-filled from the machine's final log, and replayed at `GET /run/tasks/:id/session.json?after=` (the owner, or a service with `codes.task.read` and `X-OV-Subject`); tasks from before keep the old `log`. Run's `run.job.*` events finalize a task at `POST /internal/events` ([server/events-consumer.js](server/events-consumer.js), no polling but a 10-minute fallback), and every state change is published as `codes.task.queued|running|succeeded|failed|cancelled` from an outbox ([server/events/outbox.js](server/events/outbox.js), contracts 0.232.0). || The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. |--| Task API (services) | `POST /api/v1/tasks`, `GET /api/v1/tasks/:id`, `GET /api/v1/tasks/:id/events`, `POST /api/v1/tasks/:id/continue`, `POST /api/v1/tasks/:id/stop`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404); the owner needs no token for their own tasks. The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. **A task's attempts** (stage C2, migration 0010 `codes_attempts`): every Run job is one attempt, so a continue, a switch to another agent and a wrap-up are attempts on one timeline, never new tasks. `GET /api/v1/tasks/:id` answers `codes.task@2`: the task's own fields at the top level, exactly as `codes.task@1` had them, and, added to them, `attempts[]` (the timeline) and `outcome` — `done`, `unfinished` (with what is left) or `stopped`. `GET /api/v1/tasks/:id/events` is the task's event stream (server-sent events): the session's lines in order with the attempt each belongs to, the attempts themselves (started, ended with why), a heartbeat every 15 s, and a resume with `Last-Event-ID`. `POST /api/v1/tasks/:id/continue` starts the next attempt now (with `agent` it switches), `POST /api/v1/tasks/:id/stop` stops the running attempt and nothing carries it on. |++| Task API (services and people) | `POST /api/v1/tasks`, `GET /api/v1/tasks`, `GET /api/v1/tasks/:id`, `GET /api/v1/tasks/:id/events`, `POST /api/v1/tasks/:id/continue`, `POST /api/v1/tasks/:id/stop`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`; `permission` is `review` (read-only), `edit` or `full`. A person may call the same routes with their own Network access token (`openvibe-codes task`, [docs/cli.md](docs/cli.md)): their token names them, so they see and act on every task they own, whoever started it. `GET /api/v1/tasks?state=running|needs|done` lists them. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404); the owner needs no token for their own tasks. The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. **A task's attempts** (stage C2, migration 0010 `codes_attempts`): every Run job is one attempt, so a continue, a switch to another agent and a wrap-up are attempts on one timeline, never new tasks. `GET /api/v1/tasks/:id` answers `codes.task@2`: the task's own fields at the top level, exactly as `codes.task@1` had them, and, added to them, `attempts[]` (the timeline) and `outcome` — `done`, `unfinished` (with what is left) or `stopped`. `GET /api/v1/tasks/:id/events` is the task's event stream (server-sent events): the session's lines in order with the attempt each belongs to, the attempts themselves (started, ended with why), a heartbeat every 15 s, and a resume with `Last-Event-ID`. `POST /api/v1/tasks/:id/continue` starts the next attempt now (with `agent` it switches), `POST /api/v1/tasks/:id/stop` stops the running attempt and nothing carries it on. || 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. || Community | `/policy`, `/policy/<document>` | The code of conduct ([CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)), the contribution guide ([CONTRIBUTING.md](CONTRIBUTING.md)), the [contributor ladder](docs/governance/contributor-ladder.md) and the [moderation policy](docs/governance/moderation.md), rendered from those files. All four are **drafts pending owner review**: marked at the top, shown with a notice, not indexed; deleting the draft line publishes one. They govern the repositories and the apps and releases on OpenVibe.Services alike. |@@@ -41,7 +41,15 @@ openvibe-codes run "rename getUser to findUser" # routed, handed off onopenvibe-codes run --agent codex --permission read "where is the session cookie set?"openvibe-codes runs # the latest runsopenvibe-codes resume <run id> "now add a test"++++# The same tasks, run on a machine of yours through openvibe.codes (a personal Network token, OPENVIBE_TOKEN):++openvibe-codes task start --repo OpenVibers/OpenVibe.Codes --file brief.md --name codes-c6 --tier hard++openvibe-codes task list --state running # running, needs (left for you), done++openvibe-codes task show <task id> --follow # the live session, then the outcome++openvibe-codes task continue <task id> --note "and add the test"++openvibe-codes task stop <task id>```++More: [docs/cli.md](docs/cli.md).| Agent | Adapter | Needs ||---|---|---|@@@ -103,8 +111,9 @@ No path in Codes sends or accepts a shared loopback key (tested by grep and at r- [test/harness-placement.test.js](test/harness-placement.test.js) — placement over the catalog: hard capability constraints first, then cost, with the reasons; an unknown task is refused.- [test/harnesses.test.js](test/harnesses.test.js) — the catalog validated at boot as `platform.harness-offer@1` / `platform.agent-offer@1`.- [test/harnesses-api.test.js](test/harnesses-api.test.js) — `GET /api/v1/harnesses` and `POST /api/v1/harnesses/route`, their 422 problems and their per-actor limits.--- [test/tasks-api.test.js](test/tasks-api.test.js) — `codes.task.*`: tokens refused, `codes.no_project`, a start in the person's project, idempotency, reading through Run with the machine's result cut to the contract, another person's or service's task 404, cancel.++- [test/tasks-api.test.js](test/tasks-api.test.js) — `codes.task.*`: tokens refused, `codes.no_project`, a start in the person's project, idempotency, reading through Run with the machine's result cut to the contract, another person's or service's task 404, cancel; a person's own token starting a `review` task, listing (running/needs/done), reading, continuing and stopping their own, and a token for the Codes audience accepted as the same person.- [test/harness-cli.test.js](test/harness-cli.test.js) — the `openvibe-codes` command (`agents`, `route`, `run`, `runs`, `show`, `resume`) and its exit code.++- [test/task-cli.test.js](test/task-cli.test.js) — `openvibe-codes task` against a stand-in Codes: the flags become the API's body and query, `--follow` reads the event stream as lines and ends on the outcome, and the exit code (0 done, 1 refused or needs someone, 2 the command line or the token).- [test/moved.test.js](test/moved.test.js) — every former console address answers 301 or 308 to the same path on openvibe.services, and what stays here is untouched.- [test/auth-jwks.test.js](test/auth-jwks.test.js) — the Network signing key is fetched and verified: the last good keys through an outage, backoff, readiness reporting the cache.- [test/security-session.test.js](test/security-session.test.js) — a session cookie must hold a Network session token, not a FedCM assertion or an app/service token.@@@ -158,7 +167,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.243.0++- openvibe-contracts: v0.245.0- openvibe-sdk: v0.73.0- openvibe-shared: v4.48.0<!-- versions:end -->@@@ -7,17 +7,18 @@"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4900,"domain": "openvibe.codes",-- "contracts": "openvibe-contracts v0.243.0",++ "contracts": "openvibe-contracts v0.245.0","sdk": "openvibe-sdk v0.73.0","shared": "openvibe-shared v4.48.0","packages": [-- "openvibe-contracts v0.243.0",++ "openvibe-contracts v0.245.0","openvibe-sdk v0.73.0","openvibe-shared v4.48.0"],"summary": "Public at https://openvibe.codes (sudo ovhost deploy codes; unit openvibe-codes on 127.0.0.1:4900). Since 2026-10-08 Codes is the open coding-agent harness: openvibe-codes (a command-line tool and Node.js module, v0.5.0) runs a coding task on Claude Code, Claude Code on another provider, Codex, OpenCode, Command Code, MiniMax 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",++ "openvibe-codes task (bin/task.js): a coding task on openvibe.codes as the person — start from a brief (with --name, --tier, --permission review|edit|full), list running/needs/done, follow the event stream, continue, stop","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)",@@@ -9,6 +9,7 @@const path = require('path');const { createHarness } = require('../harness');const { runJob, resultWriter, jobEnv } = require('../harness/job');++const { task } = require('./task');const { version: VERSION } = require('../package.json');const { probe: probeLogins } = require('../engine/logins');@@@ -33,9 +34,12 @@ const USAGE = `openvibe-codes ${VERSION} — code with any agent, from your termopenvibe-codes resume <run id> "follow-up" continue a run's last session with its agentopenvibe-codes job one task as an OpenVibe Run job (JSON input on stdin; theOpenVibe Node's job worker runs it on your own machine)++ openvibe-codes task start|list|show|continue|stop coding tasks on openvibe.codes, as you, on one of++ your machines (openvibe-codes task --help)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).++(your own OpenAI-compatible server) · OPENVIBE_CODES_HOME (where runs are kept) · OPENVIBE_TOKEN (your++Network token, for 'task') and OPENVIBE_CODES_URL (the server task talks to).Guide: https://openvibe.codes/start`;function parse(argv) {@@@ -90,6 +94,8 @@ async function readStdin() {async function main() {const [cmd, ...rest] = process.argv.slice(2);++ // Tasks on openvibe.codes: the person's own token, the server's API. Nothing here runs an agent.++ if (cmd === 'task') return task(rest);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; }nnew file mode 100644@@@ -0,0 +1,350 @@++'use strict';++++/**++ * `openvibe-codes task` — coding tasks on OpenVibe.Codes, as the person, from a terminal: start one from a brief,++ * list yours, follow it live, continue it, stop it. The counterpart of `openvibe-codes run`, which runs an agent on++ * this machine: a task runs on one of your machines through OpenVibe.Run, and this client only talks to++ * https://openvibe.codes/api/v1/tasks (server/http/tasks-api.js).++ *++ * openvibe-codes task start --repo owner/repo --file brief.md [--agent …] [--name …] [--tier …]++ * openvibe-codes task list [--state running|needs|done]++ * openvibe-codes task show <id> [--follow]++ * openvibe-codes task continue <id> [--note "…"] [--agent …]++ * openvibe-codes task stop <id>++ *++ * Who you are: a personal Network access token, from OPENVIBE_TOKEN or a file (~/.config/openvibe/token, mode 600),++ * sent as a Bearer. The server takes a person's token for the tasks that person owns; the same routes take a++ * first-party service's token (audience openvibe.codes) for the tasks it started for someone.++ *++ * Exit codes: 0 the task answered (and, when followed, ended done); 1 the API refused it, the task ended unfinished++ * or stopped, or the network failed; 2 the command line or the token is wrong.++ */++const fs = require('fs');++const os = require('os');++const path = require('path');++const { execFileSync } = require('child_process');++++const EXIT = { ok: 0, failed: 1, usage: 2 };++const STATES = { running: 'running', needs: 'needs', done: 'done' };++const ALIASES = {++ auto: null,++ cx: 'codex', codex: 'codex',++ cc: 'command-code', 'command-code': 'command-code',++ oc: 'opencode', opencode: 'opencode',++ claude: 'claude-code', cm: 'claude-code', 'claude-code': 'claude-code',++ dsh: 'deepseek', deepseek: 'deepseek',++ aider: 'aider',++};++++const USAGE = `openvibe-codes task — coding tasks on OpenVibe.Codes, as you++++ openvibe-codes task start --repo <owner/repo | path> --file brief.md++ --agent <auto|cx|mm|cm|cc|oc|claude|an agent id> auto (default): the router picks++ --machine <auto> your own machine takes it; a named machine is not selectable yet++ --file <path|-> the brief ("-" reads it from stdin)++ --name <n> a name for the task: starting it twice with one name answers the first task++ --tier basic|standard|hard the brief's tier (the engine forces it, as "tier: …" in the brief)++ --permission review|edit|full review: read-only (research, an audit, a review); edit (default); full++ openvibe-codes task list [--state running|needs|done] [--limit 20]++ openvibe-codes task show <id> [--follow] --follow streams the task's events until its outcome++ openvibe-codes task continue <id> [--note "…"] [--agent <agent>]++ openvibe-codes task stop <id>++++ --json prints the API's answer as JSON instead of a readable log.++++Token: OPENVIBE_TOKEN, or ~/.config/openvibe/token (mode 600; OPENVIBE_TOKEN_FILE overrides the path).++Server: OPENVIBE_CODES_URL (default https://openvibe.codes).`;++++const out = (s) => process.stdout.write(`${s}\n`);++const dim = (s) => (process.stdout.isTTY ? `\x1b[2m${s}\x1b[0m` : s);++const oneLine = (s, n) => String(s == null ? '' : s).split('\n')[0].slice(0, n);++++class UsageError extends Error {}++++function parse(argv) {++ const o = { _: [] };++ for (let i = 0; i < argv.length; i++) {++ const a = argv[i];++ if (a === '--json') o.json = true;++ else if (a === '--follow') o.follow = true;++ else if (a === '--help' || a === '-h') o.help = true;++ else if (a.startsWith('--')) {++ const k = a.slice(2);++ if (!['repo', 'agent', 'machine', 'file', 'name', 'tier', 'state', 'permission', 'note', 'limit'].includes(k)) throw new UsageError(`unknown option --${k}`);++ const v = argv[++i];++ if (v === undefined || v.startsWith('--')) throw new UsageError(`--${k} needs a value`);++ o[k] = v;++ } else o._.push(a);++ }++ return o;++}++++/** The repository a task starts from: owner/repo, an https URL, or a path whose origin remote is one. */++function repoOf(value) {++ if (!value) throw new UsageError('--repo <owner/repo | path | https URL> is required');++ const v = String(value).trim();++ if (/^https:\/\/[^\s@/]+\/[^\s@]+$/.test(v)) return v;++ if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(v)) return `https://github.com/${v.replace(/\.git$/, '')}`;++ if (fs.existsSync(v)) {++ let remote = '';++ try { remote = execFileSync('git', ['-C', v, 'remote', 'get-url', 'origin'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { remote = ''; }++ const ssh = remote.match(/^git@([^:]+):(.+?)(?:\.git)?$/);++ const https = remote.match(/^https:\/\/[^\s@/]+\/[^\s@]+$/);++ if (ssh) return `https://${ssh[1]}/${ssh[2].replace(/\.git$/, '')}`;++ if (https) return remote;++ throw new UsageError(`${v} has no https origin remote: give --repo owner/repo`);++ }++ throw new UsageError(`--repo: ${v} is not owner/repo, an https git URL or a path`);++}++++function agentOf(value) {++ if (!value) return null;++ const v = String(value).trim();++ if (Object.prototype.hasOwnProperty.call(ALIASES, v)) return ALIASES[v];++ if (/^[a-z][a-z0-9-]{1,40}$/.test(v)) return v;++ throw new UsageError(`--agent: ${v} is not an agent (auto, cx, cm, cc, oc, claude, or an id from /api/v1/harnesses)`);++}++++function tokenOf() {++ const env = String(process.env.OPENVIBE_TOKEN || '').trim();++ if (env) return env;++ const file = process.env.OPENVIBE_TOKEN_FILE || path.join(os.homedir(), '.config', 'openvibe', 'token');++ let st = null;++ try { st = fs.statSync(file); } catch { return null; }++ if (st.mode & 0o077) throw new UsageError(`${file} is readable by others: chmod 600 ${file}`);++ const token = fs.readFileSync(file, 'utf8').trim();++ if (!token) throw new UsageError(`${file} is empty`);++ return token;++}++++function auth() {++ const token = tokenOf();++ if (!token) {++ throw new UsageError('no Network token: set OPENVIBE_TOKEN, or write yours to ~/.config/openvibe/token with mode 600\n'++ + '(sign in at https://openvibe.network, then copy the access token from the site)');++ }++ return {++ token,++ base: String(process.env.OPENVIBE_CODES_URL || 'https://openvibe.codes').replace(/\/+$/, ''),++ };++}++++async function call(c, method, p, body) {++ let res;++ try {++ res = await fetch(`${c.base}${p}`, {++ method,++ headers: { Authorization: `Bearer ${c.token}`, Accept: 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },++ body: body === undefined ? undefined : JSON.stringify(body),++ signal: AbortSignal.timeout(30_000),++ });++ } catch (e) {++ throw Object.assign(new Error(`OpenVibe.Codes did not answer (${c.base}): ${e.message}`), { exit: EXIT.failed });++ }++ const text = await res.text();++ let json = null;++ try { json = text ? JSON.parse(text) : null; } catch { json = null; }++ if (!res.ok) {++ const code = json && (json.code || json.error);++ const detail = json && (json.detail || json.title || json.error_description);++ const err = new Error(`${res.status}${code ? ` ${code}` : ''}${detail ? `: ${detail}` : ''}`);++ err.exit = EXIT.failed;++ throw err;++ }++ if (json === null) {++ const err = new Error('OpenVibe.Codes answered something that is not JSON');++ err.exit = EXIT.failed;++ throw err;++ }++ return json;++}++++/** running, needs or done, in the words the CLI prints and `task list --state` takes. */++function stateOf(task) {++ if (['queued', 'placed', 'running'].includes(task.state)) return 'running';++ if (task.outcome && task.outcome.state === 'unfinished') return 'needs';++ return 'done';++}++++function printTask(task) {++ out(`${task.id} · ${stateOf(task)} · ${task.state}${task.agent ? ` · ${task.agent}` : ''}`);++ out(dim(` ${task.repo}${task.ref ? ` (${task.ref})` : ''} · ${task.url}`));++ for (const a of task.attempts || []) {++ const when = a.state === 'running' || a.state === 'queued' ? 'now' : (a.end_why || a.state);++ out(` attempt ${a.n} · ${a.state}${a.agent ? ` · ${a.agent}` : ''}${when ? dim(` — ${oneLine(when, 100)}`) : ''}`);++ }++ const result = task.result;++ if (result) {++ if (result.summary) for (const line of String(result.summary).split('\n').slice(0, 40)) out(` ${line}`);++ const files = Array.isArray(result.files) ? result.files.length : 0;++ if (files) out(dim(` ${files} file${files === 1 ? '' : 's'} changed`));++ if (result.push && result.push.pushed) out(dim(` pushed ${result.push.branch}${result.push.pull_request ? ` · ${result.push.pull_request}` : ''}`));++ if (result.error) out(` error: ${oneLine(result.error, 300)}`);++ } else if (task.error) out(` error: ${oneLine(task.error, 300)}`);++ if (task.outcome && task.outcome.state) out(`outcome: ${task.outcome.state}${task.outcome.note ? ` — ${oneLine(task.outcome.note, 300)}` : ''}`);++ else out(dim('outcome: not decided yet'));++}++++/** The task's event stream as readable lines; resolves when the server ends it (the outcome is known). */++async function follow(c, id) {++ let res;++ try {++ res = await fetch(`${c.base}/api/v1/tasks/${encodeURIComponent(id)}/events`, {++ headers: { Authorization: `Bearer ${c.token}`, Accept: 'text/event-stream' },++ });++ } catch (e) {++ throw Object.assign(new Error(`the event stream did not open (${c.base}): ${e.message}`), { exit: EXIT.failed });++ }++ if (!res.ok) {++ const text = await res.text();++ let json = null;++ try { json = text ? JSON.parse(text) : null; } catch { /* a problem is JSON; anything else is kept as text */ }++ const err = new Error(`${res.status}${json && json.code ? ` ${json.code}` : ''}${json && json.detail ? `: ${json.detail}` : ''}`);++ err.exit = EXIT.failed;++ throw err;++ }++ const decoder = new TextDecoder();++ let buf = '';++ let current = 0;++ const handle = (frame) => {++ let event = 'message';++ const data = [];++ for (const line of frame.split('\n')) {++ if (line.startsWith('event:')) event = line.slice(6).trim();++ else if (line.startsWith('data:')) data.push(line.slice(5).trim());++ }++ if (!data.length) return;++ let body = null;++ try { body = JSON.parse(data.join('\n')); } catch { return; }++ if (event === 'line') {++ const b = body.body || {};++ const text = typeof b.text === 'string' ? b.text : b.t === 'tool' ? `→ ${b.name || ''} ${oneLine(b.input && (b.input.command || b.input.file_path) || '', 120)}`.trim() : '';++ if (body.attempt_n && body.attempt_n !== current) { current = body.attempt_n; out(dim(`[attempt ${body.attempt_n}]`)); }++ if (text) for (const line of text.split('\n')) out(` ${line}`);++ return;++ }++ if (event === 'attempt') {++ if (typeof body.attempt_n === 'number') current = body.attempt_n;++ const why = body.why || body.end_why || '';++ if (body.type === 'attempt_started') out(`▶ attempt ${body.attempt_n}${body.agent ? ` · ${body.agent}` : ''}${why ? dim(` — ${oneLine(why, 160)}`) : ''}`);++ if (body.type === 'attempt_ended') out(`${body.state === 'done' ? '✔' : '■'} attempt ${body.attempt_n} ended: ${body.state}${why ? dim(` — ${oneLine(why, 160)}`) : ''}`);++ }++ };++ for await (const chunk of res.body) {++ buf += decoder.decode(chunk, { stream: true });++ let at;++ while ((at = buf.indexOf('\n\n')) >= 0) {++ const frame = buf.slice(0, at);++ buf = buf.slice(at + 2);++ if (frame.trim()) handle(frame);++ }++ }++ return undefined;++}++++async function cmdStart(o, c) {++ const repo = repoOf(o.repo);++ if (!o.file) throw new UsageError('--file <path|-> is required: the brief the agent works from');++ const brief = o.file === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(o.file, 'utf8');++ if (!brief.trim()) throw new UsageError(`${o.file} is empty`);++ if (o.tier && !['basic', 'standard', 'hard'].includes(o.tier)) throw new UsageError('--tier is basic, standard or hard');++ if (o.machine && o.machine !== 'auto') throw new UsageError('--machine: Codes places a task on a machine of yours by itself; only --machine auto is supported');++ const prompt = o.tier && !/^\s*tier:\s*\S+/im.test(brief) ? `${brief.trimEnd()}\n\ntier: ${o.tier}\n` : brief;++ const body = { repo, prompt };++ const agent = agentOf(o.agent);++ if (agent) body.agent = agent;++ if (o.permission) {++ if (!['review', 'edit', 'full'].includes(o.permission)) throw new UsageError('--permission is review, edit or full');++ body.permission = o.permission;++ }++ if (o.name) {++ if (!/^[A-Za-z0-9_.:-]{4,120}$/.test(o.name)) throw new UsageError('--name: 4-120 letters, digits, "_", ".", ":" or "-" (it is the task\'s idempotency key)');++ body.idempotency_key = `cli:${o.name}`;++ }++ const answer = await call(c, 'POST', '/api/v1/tasks', body);++ if (o.json) { out(JSON.stringify(answer, null, 2)); return EXIT.ok; }++ const task = answer.task;++ out(`${answer.created === false ? 'already started' : 'started'} ${task.id} · ${stateOf(task)}${task.agent ? ` · ${task.agent}` : ''}`);++ out(dim(` ${task.repo}${task.ref ? ` (${task.ref})` : ''} · ${task.url}`));++ out(dim(' follow it: openvibe-codes task show ' + task.id + ' --follow'));++ return EXIT.ok;++}++++async function cmdList(o, c) {++ if (o.state && !STATES[o.state]) throw new UsageError('--state is running, needs or done');++ const q = new URLSearchParams();++ if (o.state) q.set('state', o.state);++ if (o.limit) q.set('limit', String(o.limit));++ const answer = await call(c, 'GET', `/api/v1/tasks${q.toString() ? `?${q}` : ''}`);++ if (o.json) { out(JSON.stringify(answer, null, 2)); return EXIT.ok; }++ const tasks = answer.tasks || [];++ if (!tasks.length) { out(dim(o.state ? `no ${o.state} tasks` : 'no tasks yet')); return EXIT.ok; }++ for (const t of tasks) {++ out(`${stateOf(t).padEnd(7)} ${t.id} ${(t.agent || '—').padEnd(14)} ${oneLine(t.repo.replace(/^https:\/\/[^/]+\//, ''), 34).padEnd(34)} ${dim(oneLine(t.prompt || '', 60))}`);++ }++ return EXIT.ok;++}++++async function cmdShow(o, c) {++ const id = o._[0];++ if (!id) throw new UsageError('which task? openvibe-codes task show <id>');++ if (o.follow) {++ out(dim(`following ${id} …`));++ await follow(c, id);++ const task = await call(c, 'GET', `/api/v1/tasks/${encodeURIComponent(id)}`);++ if (!o.json) printTask(task);++ else out(JSON.stringify(task, null, 2));++ return stateOf(task) === 'done' && (!task.outcome || task.outcome.state !== 'stopped') ? EXIT.ok : EXIT.failed;++ }++ const task = await call(c, 'GET', `/api/v1/tasks/${encodeURIComponent(id)}`);++ if (o.json) { out(JSON.stringify(task, null, 2)); return EXIT.ok; }++ printTask(task);++ return EXIT.ok;++}++++async function cmdContinue(o, c) {++ const id = o._[0];++ if (!id) throw new UsageError('which task? openvibe-codes task continue <id> [--note "…"]');++ const body = {};++ if (o.note) body.note = o.note;++ const agent = agentOf(o.agent);++ if (agent) body.agent = agent;++ const task = await call(c, 'POST', `/api/v1/tasks/${encodeURIComponent(id)}/continue`, body);++ if (o.json) { out(JSON.stringify(task, null, 2)); return EXIT.ok; }++ out(`carried on ${task.id} · ${stateOf(task)}`);++ const last = (task.attempts || [])[task.attempts.length - 1];++ if (last) out(dim(` attempt ${last.n} · ${last.state}${last.agent ? ` · ${last.agent}` : ''}`));++ return EXIT.ok;++}++++async function cmdStop(o, c) {++ const id = o._[0];++ if (!id) throw new UsageError('which task? openvibe-codes task stop <id>');++ const task = await call(c, 'POST', `/api/v1/tasks/${encodeURIComponent(id)}/stop`);++ if (o.json) { out(JSON.stringify(task, null, 2)); return EXIT.ok; }++ out(`stopped ${task.id} · ${stateOf(task)}${task.outcome && task.outcome.state ? ` — ${task.outcome.state}` : ''}`);++ return EXIT.ok;++}++++const COMMANDS = { start: cmdStart, list: cmdList, show: cmdShow, continue: cmdContinue, stop: cmdStop };++++/** `openvibe-codes task …` → the exit code. */++async function task(argv) {++ const [cmd, ...rest] = argv;++ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') { out(USAGE); return cmd ? EXIT.ok : EXIT.usage; }++ const fn = COMMANDS[cmd];++ if (!fn) { out(`unknown task command: ${cmd}\n\n${USAGE}`); return EXIT.usage; }++ let o;++ try {++ o = parse(rest);++ if (o.help) { out(USAGE); return EXIT.ok; }++ return await fn(o, auth());++ } catch (err) {++ const usage = err instanceof UsageError;++ process.stderr.write(`openvibe-codes: ${err.message}\n`);++ if (usage || err.exit === undefined) process.stderr.write(` (openvibe-codes task --help)\n`);++ return usage ? EXIT.usage : (err.exit || EXIT.failed);++ }++}++++module.exports = { task, stateOf, repoOf, agentOf, EXIT };nnew file mode 100644@@@ -0,0 +1,91 @@++# `openvibe-codes task` — coding tasks on openvibe.codes, from a terminal++++`openvibe-codes run` runs an agent on the machine you are on. `openvibe-codes task` hands a task to++[openvibe.codes/run](https://openvibe.codes/run) instead: one of *your* machines takes it through OpenVibe.Run++(clone, run the agent, hand back the summary, the files and the patch), and you watch it from anywhere.++++It is the CLI face of the task API (`POST/GET /api/v1/tasks`, Contracts `codes.task.*`), and the way an++orchestrator (the Opus session that starts jobs for the agent pool) hands work over without the private portal.++++## Who you are++++A **personal Network access token**, sent as `Authorization: Bearer …`:++++- `OPENVIBE_TOKEN`, or++- `~/.config/openvibe/token` (mode `600`; `OPENVIBE_TOKEN_FILE` names another path). The CLI refuses a file++ anyone else can read.++++Sign in at <https://openvibe.network> and take the access token from the site (`ov_token` in your browser is the++same token). The server takes it for the tasks **you** own: your token carries no capability list, because being++that person is what authorizes your own tasks. A first-party service keeps its own path: a Network service token++for audience `openvibe.codes` holding `codes.task.start` / `read` / `cancel`, naming the person in `X-OV-Subject`.++++`OPENVIBE_CODES_URL` points the client at another server (default `https://openvibe.codes`).++++## The commands++++```bash++openvibe-codes task start --repo OpenVibers/OpenVibe.Codes --file brief.md \++ --agent auto --machine auto --name codes-c6 --tier hard++++openvibe-codes task list --state running # running | needs | done++openvibe-codes task show tsk_01J… --follow # the live stream, then the outcome++openvibe-codes task continue tsk_01J… --note "and add the test the reviewer asked for"++openvibe-codes task stop tsk_01J…++```++++- **`--repo`** is `owner/repo` (GitHub), an `https://` git URL, or a path whose `origin` remote is one.++- **`--file`** is the brief; `-` reads it from stdin.++- **`--agent`** is an alias — `auto` (the router picks), `cx` codex, `cm`/`claude` claude-code, `cc`++ command-code, `oc` opencode, `dsh` deepseek — or any agent id from `GET /api/v1/harnesses`.++- **`--name`** is the task's idempotency key: starting again with the same name answers the first task++ (`already started tsk_…`) instead of starting a second one. 4–120 letters, digits, `_`, `.`, `:` or `-`.++- **`--tier basic|standard|hard`** appends `tier: <t>` to the brief, which is how the engine is told to force a++ tier; it does not duplicate a `tier:` line the brief already has.++- **`--permission review|edit|full`**: `review` is a read-only task (research, an audit, a review) that runs at++ the harness's `read` level; `edit` is the default; `full` may also run commands.++- **`--machine`** is `auto` only: Codes places the task on a machine of yours by itself (listed at++ <https://openvibe.bot/machines>). A named machine is refused rather than silently ignored.++- **`--json`** prints the API's answer instead of the readable log.++++`task show` without `--follow` prints the task, its attempts and its outcome once. With `--follow` it reads++`GET /api/v1/tasks/:id/events` and prints the session's lines, each attempt and why it happened, ending when the++outcome is decided.++++## Exit codes++++| code | meaning |++|---|---|++| 0 | the API answered; with `--follow`, the task ended **done** |++| 1 | the API refused it (problem+json on stderr), the network failed, or the task ended `unfinished` / `stopped` |++| 2 | the command line or the token is wrong (nothing was sent) |++++## From a script++++The portal skill's job (`POST /api/job`) maps onto one call:++++```js++const fs = require('fs');++const token = fs.readFileSync(`${process.env.HOME}/.config/openvibe/token`, 'utf8').trim();++const res = await fetch('https://openvibe.codes/api/v1/tasks', {++ method: 'POST',++ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },++ body: JSON.stringify({++ repo: 'https://github.com/OpenVibers/OpenVibe.Codes',++ prompt: `${brief}\n\ntier: hard\n`,++ agent: null, // auto: the router picks++ permission: 'edit', // or review for a read-only task++ idempotency_key: 'cli:codes-c6',++ }),++});++const { task } = await res.json();++// GET /api/v1/tasks/:id the task, its attempts and its outcome (codes.task@2)++// GET /api/v1/tasks?state=running your tasks: running, needs (left unfinished for you), done++// GET /api/v1/tasks/:id/events the event stream (SSE; Last-Event-ID resumes it)++// POST /api/v1/tasks/:id/continue { note?, agent? } → the next attempt now++// POST /api/v1/tasks/:id/stop the running attempt stops; nothing carries it on++```++++What the portal had and Codes does not (yet): a *named machine* per task, a transcript endpoint separate from the++event stream (the stream carries the session's lines), and fencing a whole chain of jobs — here the **task** is++the unit, and a continue is an attempt on the same task.@@@ -103,7 +103,9 @@ function parseArgs(raw, env = process.env) {if (!prompt || prompt.length > MAX_PROMPT) throw new Error(`prompt must be 1-${MAX_PROMPT} characters`);const ref = a.ref == null || a.ref === '' ? null : String(a.ref);if (ref && !BRANCH.test(ref)) throw new Error('ref must be a branch or tag name');-- const permission = a.permission === 'full' ? 'full' : 'edit';++ // review is a read-only task (research, an audit, a review): the harness's own `read` level.++ const asked = a.permission === 'review' || a.permission === 'read' ? 'read' : a.permission;++ const permission = asked === 'full' ? 'full' : asked === 'read' ? 'read' : 'edit';const push = a.push && typeof a.push === 'object' ? {branch: a.push.branch == null ? null : String(a.push.branch),pull_request: a.push.pull_request === true,@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.243.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.245.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0","pg": "^8.23.0"@@@ -1149,9 +1149,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.243.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.243.0",-- "integrity": "sha512-ZFrwTpgALUEOIvdYi2r654murdEwludenAZ5u3CuvPMVcYuu/3kC+J20jVeqPaCFKYkzRzLyO8fH/iOdh44aYg==",++ "version": "0.245.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.245.0",++ "integrity": "sha512-H0L5BpMkwnKP6KDuecT16Iv9fH6o/6tDM78VFaxcAiNhj5HRsddRWuzrd2zIylkITYzmsxNkuojJUIqTwHAk9A==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -30,7 +30,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.243.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.245.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0","pg": "^8.23.0"@@@ -7,6 +7,7 @@* machine, in the project of their latest /run task.** POST /tasks codes.task.start codes.task-start-request@1 → codes.task-start-result@1 (201, or 200 again)++ * GET /tasks codes.task.read → { tasks: codes.task@1 … with its outcome } (the person's own; ?state=)* GET /tasks/:id codes.task.read → codes.task@2 (the task as codes.task@1, with its attempts and* its outcome added to it)* POST /tasks/:id/cancel codes.task.cancel → codes.task@1@@@ -16,8 +17,10 @@** A Network service token for audience openvibe.codes (CODES_AUDIENCE), from a svc: principal holding the route's* capability. A service sees only the tasks it started, for that person; anything else is 404. The owner may read-- * and act on their own tasks without a token. The person's task page (the codes.task@1 url) shows every task they-- * own, whoever started it. Calls count against the person's own per-actor budget (actor-limits.js), not the service's.++ * and act on their own tasks without a token (the session cookie), or with their own Network access token as a Bearer++ * — the token openvibe.codes signs a person in with, and the one `openvibe-codes task` reads from OPENVIBE_TOKEN.++ * A person sees every task they own, whoever started it. Calls count against the person's own per-actor budget++ * (actor-limits.js), not the service's.** Stage C2: a task's timeline is its attempts. GET /tasks/:id answers codes.task@2: the task itself with the same* fields it always had, and, added to them, its attempts and its outcome in plain words ("done", "unfinished" with@@@ -67,12 +70,48 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, taskif (req.principal.owner) return db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2', [req.params.id, req.principal.subject]);return db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2 AND via = $3', [req.params.id, req.principal.subject, req.principal.via]);};-- const byKey = (p, key) => db.maybe('SELECT * FROM run_tasks WHERE subject = $1 AND via = $2 AND idempotency_key = $3', [p.subject, p.via, key]);++ const byKey = (p, key) => db.maybe('SELECT * FROM run_tasks WHERE subject = $1 AND via IS NOT DISTINCT FROM $2 AND idempotency_key = $3', [p.subject, p.via, key]);-- /** The owner (signed in), or a service token with the capability acting for the person in X-OV-Subject. */++ /** The owner (signed in, or their own access token as a Bearer), or a service token with the capability acting++ * for the person in X-OV-Subject. */const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;++++ /** A service, app, module or node token: its sub says so. Read (not verified) to send it to the service guard,++ * so a principal token is never tried (and logged) as a person's. */++ const principalToken = (token) => {++ const parts = String(token || '').split('.');++ if (parts.length !== 3) return true;++ try {++ const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));++ return ['service', 'app', 'mod', 'node'].includes(c.actor_type) || /^(svc|app|mod|node):/.test(String(c.sub || ''));++ } catch { return true; }++ };++++ /**++ * The person a Network access token belongs to, or null. A person's token is issued for their session audience++ * (openvibe.network) and, when the Network is asked for the Codes audience, for openvibe.codes: either is accepted++ * here. A person token carries no capability claim: being that person is what authorizes their own tasks.++ */++ const personOf = async (token) => {++ if (!keys.loaded()) await keys.ensure();++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: [config.oauth.sessionAudience, config.serviceAudience] });++ if (!v.ok) return null;++ const subject = typeof v.claims.subject_id === 'string' ? v.claims.subject_id : null;++ return USER.test(subject || '') ? subject : null;++ };++const guardAny = (cap) => async (req, res, next) => {-- if (String(req.headers.authorization || '').startsWith('Bearer ')) return guard(cap)(req, res, next);++ const header = String(req.headers.authorization || '');++ if (header.startsWith('Bearer ')) {++ const token = header.slice(7).trim();++ if (principalToken(token)) return guard(cap)(req, res, next);++ const subject = await personOf(token);++ if (subject) {++ req.principal = { sub: `user:${subject}`, subject, via: 'user', owner: true };++ return next();++ }++ return problem(req, res, 401, 'token.invalid', 'that token is not a person’s Network access token');++ }if (signedIn(req)) {req.principal = { sub: `user:${req.viewer.subject}`, subject: req.viewer.subject, via: null, owner: true };return next();@@@ -90,7 +129,7 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task};};-- r.post('/tasks', guard('codes.task.start'), actorLimits.budget('codes.task.start'), json, async (req, res) => {++ r.post('/tasks', guardAny('codes.task.start'), actorLimits.budget('codes.task.start'), json, async (req, res) => {const b = req.body;// Actor back-reference: the contract does not carry these yet, so they are stripped before validation and stored// separately. When the contract is amended, pass them through instead.@@@ -135,6 +174,25 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, taskreturn res.status(201).json({ task: taskStore.present(row), created: true });});++ /**++ * The caller's tasks, newest first: the person's own (whoever started them), or the ones this service started for++ * the person. ?state=narrows it to the words `openvibe-codes task list` prints: running (queued, placed, running),++ * needs (the engine left it unfinished for the person), done (ended, and not unfinished).++ */++ const LIST_LIMIT = 100;++ r.get('/tasks', guardAny('codes.task.read'), actorLimits.reads('codes.read'), async (req, res) => {++ const p = req.principal;++ const state = String(req.query.state || '').trim();++ if (state && !['running', 'needs', 'done'].includes(state)) return problem(req, res, 422, 'codes.task_invalid', 'state is running, needs or done');++ const limit = Math.min(Math.max(Number(req.query.limit) || 20, 1), LIST_LIMIT);++ const where = p.owner ? { sql: 'subject = $1', args: [p.subject] } : { sql: 'subject = $1 AND via = $2', args: [p.subject, p.via] };++ if (state === 'running') where.sql += " AND state IN ('queued', 'placed', 'running')";++ if (state === 'needs') where.sql += " AND outcome = 'unfinished'";++ if (state === 'done') where.sql += " AND state IN ('succeeded', 'failed', 'cancelled', 'expired') AND outcome IS DISTINCT FROM 'unfinished'";++ const rows = await db.many(`SELECT * FROM run_tasks WHERE ${where.sql} ORDER BY created_at DESC LIMIT $${where.args.length + 1}`, [...where.args, limit]);++ return res.json({ tasks: rows.map((row) => ({ ...taskStore.present(row), outcome: { state: row.outcome || null, note: row.outcome_note || null } })) });++ });++r.get('/tasks/:id', guardAny('codes.task.read'), actorLimits.reads('codes.read'), async (req, res) => {const row = await mine(req);if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of this service for this person');@@@ -68,9 +68,9 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.mst.users.set(u.id, u); st.byUsername.set(username, u);return u;}-- function userToken(u, { ttl = 3600, iat = null } = {}) {++ function userToken(u, { ttl = 3600, iat = null, audience = null } = {}) {const now = iat != null ? iat : Math.floor(Date.now() / 1000);-- return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);++ return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: audience || ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);}function userFrom(req) {const h = String(req.headers.authorization || '');nnew file mode 100644@@@ -0,0 +1,193 @@++'use strict';++/**++ * `openvibe-codes task` (bin/openvibe-codes.js, bin/task.js) against a stand-in OpenVibe.Codes: the flags become the++ * API's body and query, `--follow` reads the event stream as readable lines until the outcome, and the exit code++ * says what happened (0 the task answered or ended done, 1 the API refused it or the task needs someone, 2 the command++ * line or the token is wrong).++ */++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const os = require('os');++const path = require('path');++const { spawn } = require('child_process');++const { check, done } = require('./helpers/boot');++++const BIN = path.join(__dirname, '..', 'bin', 'openvibe-codes.js');++const ID = 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1';++++function taskDoc(over = {}) {++ return {++ id: ID, state: 'queued', repo: 'https://github.com/acme/thing', ref: null, agent: 'codex',++ url: `https://openvibe.codes/run/tasks/${ID}`, created_at: '2026-10-30T05:00:00.000Z', updated_at: '2026-10-30T05:00:00.000Z',++ error: null,++ attempts: [{ id: 'att_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1', n: 1, agent: 'codex', model: null, machine: null, job_id: null, session: null, state: 'queued', reason: null, why: 'the task as it was asked', kind: 'first', cost_usd: null, tokens: null, steps: null, started_at: null, finished_at: null, created_at: '2026-10-30T05:00:00.000Z', end_why: null }],++ outcome: { state: null, note: null },++ ...over,++ };++}++++(async () => {++ const seen = [];++ let created = false;++ let mode = 'queued';++ const server = http.createServer(async (req, res) => {++ const url = new URL(req.url, 'http://x');++ let body = '';++ for await (const chunk of req) body += chunk;++ const json = body ? JSON.parse(body) : null;++ seen.push({ method: req.method, path: url.pathname, query: url.search, body: json, authorization: req.headers.authorization });++ const send = (status, doc, type = 'application/json') => { res.writeHead(status, { 'Content-Type': type }); res.end(JSON.stringify(doc)); };++ if (req.method === 'POST' && url.pathname === '/api/v1/tasks') {++ if (json.idempotency_key === 'cli:c6-run' && created) return send(200, { task: taskDoc(), created: false });++ created = true;++ return send(201, { task: taskDoc(), created: true });++ }++ if (req.method === 'GET' && url.pathname === '/api/v1/tasks') {++ const state = url.searchParams.get('state');++ const t = taskDoc(state === 'needs' ? { state: 'failed', outcome: { state: 'unfinished', note: 'the tests still fail' } } : {});++ return send(200, { tasks: [t] });++ }++ if (req.method === 'GET' && url.pathname === `/api/v1/tasks/${ID}/events`) {++ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store' });++ const frame = (event, data) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;++ res.write(frame('attempt', { type: 'attempt_started', attempt_n: 1, agent: 'codex', why: 'the task as it was asked' }));++ res.write(frame('line', { seq: 1, attempt_n: 1, kind: 'text', body: { t: 'text', text: 'looking at the parser' } }));++ res.write(frame('line', { seq: 2, attempt_n: 1, kind: 'tool', body: { t: 'tool', name: 'Read', input: { file_path: 'lib/parse.js' } } }));++ res.write(frame('attempt', { type: 'attempt_ended', attempt_n: 1, state: 'done', end_why: 'the agent finished' }));++ return res.end();++ }++ if (req.method === 'POST' && url.pathname === `/api/v1/tasks/${ID}/continue`) {++ const t = taskDoc({ attempts: [...taskDoc().attempts, { ...taskDoc().attempts[0], id: 'att_01J8Z3Q4R5S6T7V8W9X0Y1Z2A2', n: 2, why: `carried on by the owner: ${json.note}`, kind: 'manual' }] });++ return send(200, t);++ }++ if (req.method === 'POST' && url.pathname === `/api/v1/tasks/${ID}/stop`) {++ return send(200, taskDoc({ state: 'cancelled', outcome: { state: 'stopped', note: 'the task was stopped on purpose' } }));++ }++ if (req.method === 'GET' && url.pathname === `/api/v1/tasks/${ID}`) {++ if (mode === 'needs') return send(200, taskDoc({ state: 'failed', outcome: { state: 'unfinished', note: 'the tests still fail' }, error: 'two tests fail' }));++ return send(200, taskDoc({ state: 'succeeded', result: { ok: true, summary: 'Renamed getUser to findUser.', files: [{ path: 'lib/user.js', added: 3, removed: 3 }] }, outcome: { state: 'done', note: null } }));++ }++ return send(404, { code: 'codes.task_not_found', detail: 'no such task' }, 'application/problem+json');++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const run = (args, env = {}) => new Promise((resolve) => {++ const child = spawn(process.execPath, [BIN, 'task', ...args], { env: { ...process.env, OPENVIBE_CODES_URL: base, OPENVIBE_TOKEN: 'personal-token', ...env } });++ let out = '';++ let err = '';++ child.stdout.on('data', (d) => { out += d; });++ child.stderr.on('data', (d) => { err += d; });++ child.on('close', (code) => resolve({ code, out, err }));++ });++ const last = () => seen[seen.length - 1];++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-task-cli-'));++++ try {++ await check('start: the flags become the task; owner/repo, cx, --name, --tier and --permission', async () => {++ const brief = path.join(dir, 'brief.md');++ fs.writeFileSync(brief, 'Rename getUser to findUser.\n');++ const r = await run(['start', '--repo', 'acme/thing', '--agent', 'cx', '--machine', 'auto', '--file', brief, '--name', 'c6-run', '--tier', 'hard', '--permission', 'review']);++ assert.strictEqual(r.code, 0, r.err + r.out);++ assert.match(r.out, new RegExp(`started ${ID}`));++ const s = last();++ assert.strictEqual(s.method, 'POST');++ assert.strictEqual(s.path, '/api/v1/tasks');++ assert.strictEqual(s.authorization, 'Bearer personal-token');++ assert.strictEqual(s.body.repo, 'https://github.com/acme/thing');++ assert.strictEqual(s.body.agent, 'codex');++ assert.strictEqual(s.body.permission, 'review');++ assert.strictEqual(s.body.idempotency_key, 'cli:c6-run');++ assert.match(s.body.prompt, /^Rename getUser to findUser\./);++ assert.match(s.body.prompt, /tier: hard\s*$/);++ });++++ await check('start twice with one --name answers the first task', async () => {++ const brief = path.join(dir, 'brief.md');++ const r = await run(['start', '--repo', 'https://github.com/acme/thing', '--file', brief, '--name', 'c6-run']);++ assert.strictEqual(r.code, 0, r.err);++ assert.match(r.out, new RegExp(`already started ${ID}`));++ });++++ await check('list --state asks for that state and prints it', async () => {++ const r = await run(['list', '--state', 'running']);++ assert.strictEqual(r.code, 0, r.err);++ assert.strictEqual(last().query, '?state=running');++ assert.match(r.out, new RegExp(`^running\\s+${ID}`));++ const needs = await run(['list', '--state', 'needs', '--json']);++ assert.strictEqual(needs.code, 0, needs.err);++ assert.strictEqual(JSON.parse(needs.out).tasks[0].outcome.state, 'unfinished');++ assert.strictEqual((await run(['list', '--state', 'later'])).code, 2);++ });++++ await check('show --follow reads the stream as lines and ends on the outcome, exit 0 when done', async () => {++ const r = await run(['show', ID, '--follow']);++ assert.strictEqual(r.code, 0, r.err + r.out);++ assert.match(r.out, /▶ attempt 1 · codex/);++ assert.match(r.out, /looking at the parser/);++ assert.match(r.out, /→ Read lib\/parse\.js/);++ assert.match(r.out, /✔ attempt 1 ended: done/);++ assert.match(r.out, /outcome: done/);++ assert.match(r.out, /Renamed getUser to findUser/);++ assert.strictEqual(last().path, `/api/v1/tasks/${ID}`);++ });++++ await check('show --follow on a task that needs someone exits 1 and says what is left', async () => {++ mode = 'needs';++ const r = await run(['show', ID, '--follow']);++ assert.strictEqual(r.code, 1, r.err + r.out);++ assert.match(r.out, /outcome: unfinished — the tests still fail/);++ mode = 'queued';++ });++++ await check('show without --follow prints the task and its attempts', async () => {++ const r = await run(['show', ID]);++ assert.strictEqual(r.code, 0, r.err);++ assert.match(r.out, new RegExp(`${ID} · done · succeeded`));++ assert.match(r.out, /attempt 1/);++ });++++ await check('continue sends the note; stop answers the outcome', async () => {++ const c = await run(['continue', ID, '--note', 'and add a test']);++ assert.strictEqual(c.code, 0, c.err + c.out);++ assert.strictEqual(last().path, `/api/v1/tasks/${ID}/continue`);++ assert.strictEqual(last().body.note, 'and add a test');++ assert.match(c.out, /carried on .*running/); assert.match(c.out, /attempt 2 · queued/);++ const s = await run(['stop', ID]);++ assert.strictEqual(s.code, 0, s.err + s.out);++ assert.strictEqual(last().path, `/api/v1/tasks/${ID}/stop`);++ assert.match(s.out, /stopped .*done — stopped/);++ });++++ await check('a refused API answer is exit 1; a wrong command line or no token is exit 2', async () => {++ const missing = await run(['show', 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T2']).catch(() => null);++ assert.strictEqual(missing.code, 1, missing.err);++ assert.match(missing.err, /404 codes\.task_not_found/);++ const noToken = await run(['list'], { OPENVIBE_TOKEN: '', OPENVIBE_TOKEN_FILE: path.join(dir, 'absent') });++ assert.strictEqual(noToken.code, 2);++ assert.match(noToken.err, /no Network token/);++ const badOption = await run(['list', '--nope']);++ assert.strictEqual(badOption.code, 2);++ const namedMachine = await run(['start', '--repo', 'acme/thing', '--file', path.join(dir, 'brief.md'), '--machine', 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1']);++ assert.strictEqual(namedMachine.code, 2);++ assert.match(namedMachine.err, /only --machine auto/);++ });++++ await check('the token file is read only when nobody else can read it', async () => {++ const file = path.join(dir, 'token');++ fs.writeFileSync(file, 'file-token\n');++ fs.chmodSync(file, 0o644);++ const loose = await run(['list'], { OPENVIBE_TOKEN: '', OPENVIBE_TOKEN_FILE: file });++ assert.strictEqual(loose.code, 2);++ assert.match(loose.err, /chmod 600/);++ fs.chmodSync(file, 0o600);++ const ok = await run(['list'], { OPENVIBE_TOKEN: '', OPENVIBE_TOKEN_FILE: file });++ assert.strictEqual(ok.code, 0, ok.err);++ assert.strictEqual(last().authorization, 'Bearer file-token');++ });++ } finally {++ await new Promise((r) => server.close(r));++ fs.rmSync(dir, { recursive: true, force: true });++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -135,6 +135,69 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual(calls.filter((x) => x[0] === 'cancel').length, before);});++ // The person's own Network access token, as `openvibe-codes task` sends it: the same routes, as the owner.++ const asAda = async (method, path, body, token = t.network.userToken(ada)) => {++ const headers = { Accept: 'application/json', Authorization: `Bearer ${token}` };++ if (body !== undefined) headers['Content-Type'] = 'application/json';++ const res = await fetch(`${t.base}/api/v1${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual' });++ return { status: res.status, json: await res.json().catch(() => null) };++ };++++ let own;++ await check('a person’s own token starts a read-only task (permission review), read at the harness level', async () => {++ state = 'queued';++ result = null;++ const r = await asAda('POST', '/tasks', { repo: 'https://github.com/octocat/Hello-World', prompt: 'Audit the auth flow.', permission: 'review', idempotency_key: 'cli:audit-1' });++ assert.strictEqual(r.status, 201, JSON.stringify(r.json));++ own = r.json.task;++ assert.strictEqual(own.state, 'queued');++ const submit = calls.filter((c) => c[0] === 'submit').pop()[1];++ assert.strictEqual(submit.subject, ada.subject, 'the person the token belongs to, with no X-OV-Subject');++ assert.strictEqual(submit.args.permission, 'read', 'review runs read-only');++ const again = await asAda('POST', '/tasks', { repo: 'https://github.com/octocat/Hello-World', prompt: 'Audit the auth flow.', permission: 'review', idempotency_key: 'cli:audit-1' });++ assert.strictEqual(again.status, 200);++ assert.strictEqual(again.json.created, false);++ assert.strictEqual(again.json.task.id, own.id);++ });++++ await check('a token for the Codes audience is accepted as the same person', async () => {++ const r = await asAda('GET', '/tasks', undefined, t.network.userToken(ada, { audience: ['openvibe.codes'] }));++ assert.strictEqual(r.status, 200, JSON.stringify(r.json));++ });++++ await check('the person lists, reads, continues and stops their own tasks; another person’s token is 404', async () => {++ const list = await asAda('GET', '/tasks?state=running');++ assert.strictEqual(list.status, 200, JSON.stringify(list.json));++ assert.ok(list.json.tasks.some((x) => x.id === own.id), 'the new task is running');++ assert.ok(list.json.tasks.every((x) => ['queued', 'placed', 'running'].includes(x.state)));++ const all = await asAda('GET', '/tasks');++ assert.ok(all.json.tasks.some((x) => x.id === 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1'), 'the one started by hand is the person’s too');++ const read = await asAda('GET', `/tasks/${own.id}`);++ assert.strictEqual(read.status, 200);++ assert.strictEqual(read.json.id, own.id);++ assert.ok(Array.isArray(read.json.attempts));++ const other = await asAda('GET', `/tasks/${own.id}`, undefined, t.network.userToken(bo));++ assert.strictEqual(other.status, 404, 'someone else’s task');++ // A continue starts the next attempt on a task that has ended; stop ends the running one for good.++ await db.exec("UPDATE run_tasks SET state = 'failed', outcome = 'unfinished' WHERE id = $1", [own.id]);++ const carried = await asAda('POST', `/tasks/${own.id}/continue`, { note: 'and the tests' });++ assert.strictEqual(carried.status, 200, JSON.stringify(carried.json));++ assert.ok(carried.json.attempts.length >= 2, 'the continue is the next attempt');++ const stopped = await asAda('POST', `/tasks/${own.id}/stop`);++ assert.strictEqual(stopped.status, 200, JSON.stringify(stopped.json));++ assert.strictEqual(stopped.json.outcome.state, 'stopped');++ });++++ await check('list filters: needs is the task the engine left unfinished, done is the rest', async () => {++ await db.exec("UPDATE run_tasks SET outcome = 'unfinished' WHERE id = $1", ['tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1']);++ const needs = await asAda('GET', '/tasks?state=needs');++ assert.ok(needs.json.tasks.some((x) => x.id === 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1'));++ assert.ok(needs.json.tasks.every((x) => x.outcome.state === 'unfinished'));++ const done = await asAda('GET', '/tasks?state=done');++ assert.ok(done.json.tasks.every((x) => x.outcome.state !== 'unfinished' && !['queued', 'placed', 'running'].includes(x.state)));++ assert.strictEqual((await asAda('GET', '/tasks?state=later')).status, 422);++ });++await check('the person’s own /run page lists the task a service started for them', async () => {const page = await t.get('/run', { as: ada });assert.ok(page.text.includes(`/run/tasks/${task.id}`));
@@ -22,7 +22,7 @@ One place to send a coding task to the agent that fits it — by what the task n
@@ -22,7 +22,7 @@ One place to send a coding task to the agent that fits it — by what the task n
| Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. |
| Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. |
| Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). The task page shows the **timeline of attempts** above the agents' **session** (each attempt with its why: "Codex hit its limit, so Command Code took over with what it had done") and the session with openvibe-shared/agent-session.js: steps grouped, each step's command, change and output (live while it runs), helpers nested, hand-offs and the plan; after the end, the session from `codes_session_lines` (migration 0007): the lines are kept as the stream arrives, back-filled from the machine's final log, and replayed at `GET /run/tasks/:id/session.json?after=` (the owner, or a service with `codes.task.read` and `X-OV-Subject`); tasks from before keep the old `log`. Run's `run.job.*` events finalize a task at `POST /internal/events` ([server/events-consumer.js](server/events-consumer.js), no polling but a 10-minute fallback), and every state change is published as `codes.task.queued|running|succeeded|failed|cancelled` from an outbox ([server/events/outbox.js](server/events/outbox.js), contracts 0.232.0). |
| Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). The task page shows the **timeline of attempts** above the agents' **session** (each attempt with its why: "Codex hit its limit, so Command Code took over with what it had done") and the session with openvibe-shared/agent-session.js: steps grouped, each step's command, change and output (live while it runs), helpers nested, hand-offs and the plan; after the end, the session from `codes_session_lines` (migration 0007): the lines are kept as the stream arrives, back-filled from the machine's final log, and replayed at `GET /run/tasks/:id/session.json?after=` (the owner, or a service with `codes.task.read` and `X-OV-Subject`); tasks from before keep the old `log`. Run's `run.job.*` events finalize a task at `POST /internal/events` ([server/events-consumer.js](server/events-consumer.js), no polling but a 10-minute fallback), and every state change is published as `codes.task.queued|running|succeeded|failed|cancelled` from an outbox ([server/events/outbox.js](server/events/outbox.js), contracts 0.232.0). |
| The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. |
| The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. |
-| Task API (services) | `POST /api/v1/tasks`, `GET /api/v1/tasks/:id`, `GET /api/v1/tasks/:id/events`, `POST /api/v1/tasks/:id/continue`, `POST /api/v1/tasks/:id/stop`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404); the owner needs no token for their own tasks. The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. **A task's attempts** (stage C2, migration 0010 `codes_attempts`): every Run job is one attempt, so a continue, a switch to another agent and a wrap-up are attempts on one timeline, never new tasks. `GET /api/v1/tasks/:id` answers `codes.task@2`: the task's own fields at the top level, exactly as `codes.task@1` had them, and, added to them, `attempts[]` (the timeline) and `outcome` — `done`, `unfinished` (with what is left) or `stopped`. `GET /api/v1/tasks/:id/events` is the task's event stream (server-sent events): the session's lines in order with the attempt each belongs to, the attempts themselves (started, ended with why), a heartbeat every 15 s, and a resume with `Last-Event-ID`. `POST /api/v1/tasks/:id/continue` starts the next attempt now (with `agent` it switches), `POST /api/v1/tasks/:id/stop` stops the running attempt and nothing carries it on. |
+| Task API (services and people) | `POST /api/v1/tasks`, `GET /api/v1/tasks`, `GET /api/v1/tasks/:id`, `GET /api/v1/tasks/:id/events`, `POST /api/v1/tasks/:id/continue`, `POST /api/v1/tasks/:id/stop`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`; `permission` is `review` (read-only), `edit` or `full`. A person may call the same routes with their own Network access token (`openvibe-codes task`, [docs/cli.md](docs/cli.md)): their token names them, so they see and act on every task they own, whoever started it. `GET /api/v1/tasks?state=running|needs|done` lists them. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404); the owner needs no token for their own tasks. The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. **A task's attempts** (stage C2, migration 0010 `codes_attempts`): every Run job is one attempt, so a continue, a switch to another agent and a wrap-up are attempts on one timeline, never new tasks. `GET /api/v1/tasks/:id` answers `codes.task@2`: the task's own fields at the top level, exactly as `codes.task@1` had them, and, added to them, `attempts[]` (the timeline) and `outcome` — `done`, `unfinished` (with what is left) or `stopped`. `GET /api/v1/tasks/:id/events` is the task's event stream (server-sent events): the session's lines in order with the attempt each belongs to, the attempts themselves (started, ended with why), a heartbeat every 15 s, and a resume with `Last-Event-ID`. `POST /api/v1/tasks/:id/continue` starts the next attempt now (with `agent` it switches), `POST /api/v1/tasks/:id/stop` stops the running attempt and nothing carries it on. |
| 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. |
| Community | `/policy`, `/policy/<document>` | The code of conduct ([CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)), the contribution guide ([CONTRIBUTING.md](CONTRIBUTING.md)), the [contributor ladder](docs/governance/contributor-ladder.md) and the [moderation policy](docs/governance/moderation.md), rendered from those files. All four are **drafts pending owner review**: marked at the top, shown with a notice, not indexed; deleting the draft line publishes one. They govern the repositories and the apps and releases on OpenVibe.Services alike. |
| Community | `/policy`, `/policy/<document>` | The code of conduct ([CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)), the contribution guide ([CONTRIBUTING.md](CONTRIBUTING.md)), the [contributor ladder](docs/governance/contributor-ladder.md) and the [moderation policy](docs/governance/moderation.md), rendered from those files. All four are **drafts pending owner review**: marked at the top, shown with a notice, not indexed; deleting the draft line publishes one. They govern the repositories and the apps and releases on OpenVibe.Services alike. |
@@ -41,7 +41,15 @@ openvibe-codes run "rename getUser to findUser" # routed, handed off on
@@ -41,7 +41,15 @@ openvibe-codes run "rename getUser to findUser" # routed, handed off on
openvibe-codes run --agent codex --permission read "where is the session cookie set?"
openvibe-codes run --agent codex --permission read "where is the session cookie set?"
openvibe-codes runs # the latest runs
openvibe-codes runs # the latest runs
openvibe-codes resume <run id> "now add a test"
openvibe-codes resume <run id> "now add a test"
+
+# The same tasks, run on a machine of yours through openvibe.codes (a personal Network token, OPENVIBE_TOKEN):
+openvibe-codes task start --repo OpenVibers/OpenVibe.Codes --file brief.md --name codes-c6 --tier hard
+openvibe-codes task list --state running # running, needs (left for you), done
+openvibe-codes task show <task id> --follow # the live session, then the outcome
+openvibe-codes task continue <task id> --note "and add the test"
+openvibe-codes task stop <task id>
```
```
+More: [docs/cli.md](docs/cli.md).
| Agent | Adapter | Needs |
| Agent | Adapter | Needs |
|---|---|---|
|---|---|---|
@@ -103,8 +111,9 @@ No path in Codes sends or accepts a shared loopback key (tested by grep and at r
@@ -103,8 +111,9 @@ No path in Codes sends or accepts a shared loopback key (tested by grep and at r
- [test/harness-placement.test.js](test/harness-placement.test.js) — placement over the catalog: hard capability constraints first, then cost, with the reasons; an unknown task is refused.
- [test/harness-placement.test.js](test/harness-placement.test.js) — placement over the catalog: hard capability constraints first, then cost, with the reasons; an unknown task is refused.
- [test/harnesses.test.js](test/harnesses.test.js) — the catalog validated at boot as `platform.harness-offer@1` / `platform.agent-offer@1`.
- [test/harnesses.test.js](test/harnesses.test.js) — the catalog validated at boot as `platform.harness-offer@1` / `platform.agent-offer@1`.
- [test/harnesses-api.test.js](test/harnesses-api.test.js) — `GET /api/v1/harnesses` and `POST /api/v1/harnesses/route`, their 422 problems and their per-actor limits.
- [test/harnesses-api.test.js](test/harnesses-api.test.js) — `GET /api/v1/harnesses` and `POST /api/v1/harnesses/route`, their 422 problems and their per-actor limits.
-- [test/tasks-api.test.js](test/tasks-api.test.js) — `codes.task.*`: tokens refused, `codes.no_project`, a start in the person's project, idempotency, reading through Run with the machine's result cut to the contract, another person's or service's task 404, cancel.
+- [test/tasks-api.test.js](test/tasks-api.test.js) — `codes.task.*`: tokens refused, `codes.no_project`, a start in the person's project, idempotency, reading through Run with the machine's result cut to the contract, another person's or service's task 404, cancel; a person's own token starting a `review` task, listing (running/needs/done), reading, continuing and stopping their own, and a token for the Codes audience accepted as the same person.
- [test/harness-cli.test.js](test/harness-cli.test.js) — the `openvibe-codes` command (`agents`, `route`, `run`, `runs`, `show`, `resume`) and its exit code.
- [test/harness-cli.test.js](test/harness-cli.test.js) — the `openvibe-codes` command (`agents`, `route`, `run`, `runs`, `show`, `resume`) and its exit code.
+- [test/task-cli.test.js](test/task-cli.test.js) — `openvibe-codes task` against a stand-in Codes: the flags become the API's body and query, `--follow` reads the event stream as lines and ends on the outcome, and the exit code (0 done, 1 refused or needs someone, 2 the command line or the token).
- [test/moved.test.js](test/moved.test.js) — every former console address answers 301 or 308 to the same path on openvibe.services, and what stays here is untouched.
- [test/moved.test.js](test/moved.test.js) — every former console address answers 301 or 308 to the same path on openvibe.services, and what stays here is untouched.
- [test/auth-jwks.test.js](test/auth-jwks.test.js) — the Network signing key is fetched and verified: the last good keys through an outage, backoff, readiness reporting the cache.
- [test/auth-jwks.test.js](test/auth-jwks.test.js) — the Network signing key is fetched and verified: the last good keys through an outage, backoff, readiness reporting the cache.
- [test/security-session.test.js](test/security-session.test.js) — a session cookie must hold a Network session token, not a FedCM assertion or an app/service token.
- [test/security-session.test.js](test/security-session.test.js) — a session cookie must hold a Network session token, not a FedCM assertion or an app/service token.
@@ -158,7 +167,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -158,7 +167,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.243.0
+- openvibe-contracts: v0.245.0
- openvibe-sdk: v0.73.0
- openvibe-sdk: v0.73.0
- openvibe-shared: v4.48.0
- openvibe-shared: v4.48.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,17 +7,18 @@
@@ -7,17 +7,18 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4900,
"port": 4900,
"domain": "openvibe.codes",
"domain": "openvibe.codes",
- "contracts": "openvibe-contracts v0.243.0",
+ "contracts": "openvibe-contracts v0.245.0",
"sdk": "openvibe-sdk v0.73.0",
"sdk": "openvibe-sdk v0.73.0",
"shared": "openvibe-shared v4.48.0",
"shared": "openvibe-shared v4.48.0",
"packages": [
"packages": [
- "openvibe-contracts v0.243.0",
+ "openvibe-contracts v0.245.0",
"openvibe-sdk v0.73.0",
"openvibe-sdk v0.73.0",
"openvibe-shared v4.48.0"
"openvibe-shared v4.48.0"
],
],
"summary": "Public at https://openvibe.codes (sudo ovhost deploy codes; unit openvibe-codes on 127.0.0.1:4900). Since 2026-10-08 Codes is the open coding-agent harness: openvibe-codes (a command-line tool and Node.js module, v0.5.0) runs a coding task on Claude Code, Claude Code on another provider, Codex, OpenCode, Command Code, MiniMax Code, Aider, the DeepSeek API or a person's own OpenAI-compatible model on their machine, hands it to the next agent when one fails or goes quiet, and keeps every run; the site carries the agent catalog with a public routing API, getting started, Improve OpenVibe and the community documents. The developer console that used to live here moved to OpenVibe.Services (https://openvibe.services) and every one of its addresses answers a permanent redirect there.",
"summary": "Public at https://openvibe.codes (sudo ovhost deploy codes; unit openvibe-codes on 127.0.0.1:4900). Since 2026-10-08 Codes is the open coding-agent harness: openvibe-codes (a command-line tool and Node.js module, v0.5.0) runs a coding task on Claude Code, Claude Code on another provider, Codex, OpenCode, Command Code, MiniMax 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",
"openvibe-codes on a person's machine (bin/openvibe-codes.js, harness/): agents, route, run, runs, show, resume; installs from the release tag",
+ "openvibe-codes task (bin/task.js): a coding task on openvibe.codes as the person — start from a brief (with --name, --tier, --permission review|edit|full), list running/needs/done, follow the event stream, continue, stop",
"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)",
"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",
"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)",
"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)",
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
const path = require('path');
const path = require('path');
const { createHarness } = require('../harness');
const { createHarness } = require('../harness');
const { runJob, resultWriter, jobEnv } = require('../harness/job');
const { runJob, resultWriter, jobEnv } = require('../harness/job');
+const { task } = require('./task');
const { version: VERSION } = require('../package.json');
const { version: VERSION } = require('../package.json');
const { probe: probeLogins } = require('../engine/logins');
const { probe: probeLogins } = require('../engine/logins');
@@ -33,9 +34,12 @@ const USAGE = `openvibe-codes ${VERSION} — code with any agent, from your term
@@ -33,9 +34,12 @@ const USAGE = `openvibe-codes ${VERSION} — code with any agent, from your term
openvibe-codes resume <run id> "follow-up" continue a run's last session with its agent
openvibe-codes resume <run id> "follow-up" continue a run's last session with its agent
openvibe-codes job one task as an OpenVibe Run job (JSON input on stdin; the
openvibe-codes job one task as an OpenVibe Run job (JSON input on stdin; the
OpenVibe Node's job worker runs it on your own machine)
OpenVibe Node's job worker runs it on your own machine)
+ openvibe-codes task start|list|show|continue|stop coding tasks on openvibe.codes, as you, on one of
+ your machines (openvibe-codes task --help)
Environment: DEEPSEEK_API_KEY · OPENVIBE_CODES_BASE_URL, OPENVIBE_CODES_MODEL, OPENVIBE_CODES_API_KEY
Environment: DEEPSEEK_API_KEY · OPENVIBE_CODES_BASE_URL, OPENVIBE_CODES_MODEL, OPENVIBE_CODES_API_KEY
-(your own OpenAI-compatible server) · OPENVIBE_CODES_HOME (where runs are kept).
+(your own OpenAI-compatible server) · OPENVIBE_CODES_HOME (where runs are kept) · OPENVIBE_TOKEN (your
+Network token, for 'task') and OPENVIBE_CODES_URL (the server task talks to).
Guide: https://openvibe.codes/start`;
Guide: https://openvibe.codes/start`;
function parse(argv) {
function parse(argv) {
@@ -90,6 +94,8 @@ async function readStdin() {
@@ -90,6 +94,8 @@ async function readStdin() {
async function main() {
async function main() {
const [cmd, ...rest] = process.argv.slice(2);
const [cmd, ...rest] = process.argv.slice(2);
+ // Tasks on openvibe.codes: the person's own token, the server's API. Nothing here runs an agent.
+ if (cmd === 'task') return task(rest);
const o = parse(rest);
const o = parse(rest);
if (!cmd || cmd === 'help' || o.help || cmd === '--help' || cmd === '-h') { out(USAGE); return 0; }
if (!cmd || cmd === 'help' || o.help || cmd === '--help' || cmd === '-h') { out(USAGE); return 0; }
if (cmd === '--version' || cmd === '-v' || cmd === 'version') { out(VERSION); return 0; }
if (cmd === '--version' || cmd === '-v' || cmd === 'version') { out(VERSION); return 0; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,350 @@
@@ -0,0 +1,350 @@
+'use strict';
+
+/**
+ * `openvibe-codes task` — coding tasks on OpenVibe.Codes, as the person, from a terminal: start one from a brief,
+ * list yours, follow it live, continue it, stop it. The counterpart of `openvibe-codes run`, which runs an agent on
+ * this machine: a task runs on one of your machines through OpenVibe.Run, and this client only talks to
+ * https://openvibe.codes/api/v1/tasks (server/http/tasks-api.js).
+ *
+ * openvibe-codes task start --repo owner/repo --file brief.md [--agent …] [--name …] [--tier …]
+ * openvibe-codes task list [--state running|needs|done]
+ * openvibe-codes task show <id> [--follow]
+ * openvibe-codes task continue <id> [--note "…"] [--agent …]
+ * openvibe-codes task stop <id>
+ *
+ * Who you are: a personal Network access token, from OPENVIBE_TOKEN or a file (~/.config/openvibe/token, mode 600),
+ * sent as a Bearer. The server takes a person's token for the tasks that person owns; the same routes take a
+ * first-party service's token (audience openvibe.codes) for the tasks it started for someone.
+ *
+ * Exit codes: 0 the task answered (and, when followed, ended done); 1 the API refused it, the task ended unfinished
+ * or stopped, or the network failed; 2 the command line or the token is wrong.
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+const EXIT = { ok: 0, failed: 1, usage: 2 };
+const STATES = { running: 'running', needs: 'needs', done: 'done' };
+const ALIASES = {
+ auto: null,
+ cx: 'codex', codex: 'codex',
+ cc: 'command-code', 'command-code': 'command-code',
+ oc: 'opencode', opencode: 'opencode',
+ claude: 'claude-code', cm: 'claude-code', 'claude-code': 'claude-code',
+ dsh: 'deepseek', deepseek: 'deepseek',
+ aider: 'aider',
+};
+
+const USAGE = `openvibe-codes task — coding tasks on OpenVibe.Codes, as you
+
+ openvibe-codes task start --repo <owner/repo | path> --file brief.md
+ --agent <auto|cx|mm|cm|cc|oc|claude|an agent id> auto (default): the router picks
+ --machine <auto> your own machine takes it; a named machine is not selectable yet
+ --file <path|-> the brief ("-" reads it from stdin)
+ --name <n> a name for the task: starting it twice with one name answers the first task
+ --tier basic|standard|hard the brief's tier (the engine forces it, as "tier: …" in the brief)
+ --permission review|edit|full review: read-only (research, an audit, a review); edit (default); full
+ openvibe-codes task list [--state running|needs|done] [--limit 20]
+ openvibe-codes task show <id> [--follow] --follow streams the task's events until its outcome
+ openvibe-codes task continue <id> [--note "…"] [--agent <agent>]
+ openvibe-codes task stop <id>
+
+ --json prints the API's answer as JSON instead of a readable log.
+
+Token: OPENVIBE_TOKEN, or ~/.config/openvibe/token (mode 600; OPENVIBE_TOKEN_FILE overrides the path).
+Server: OPENVIBE_CODES_URL (default https://openvibe.codes).`;
+
+const out = (s) => process.stdout.write(`${s}\n`);
+const dim = (s) => (process.stdout.isTTY ? `\x1b[2m${s}\x1b[0m` : s);
+const oneLine = (s, n) => String(s == null ? '' : s).split('\n')[0].slice(0, n);
+
+class UsageError extends Error {}
+
+function parse(argv) {
+ const o = { _: [] };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--json') o.json = true;
+ else if (a === '--follow') o.follow = true;
+ else if (a === '--help' || a === '-h') o.help = true;
+ else if (a.startsWith('--')) {
+ const k = a.slice(2);
+ if (!['repo', 'agent', 'machine', 'file', 'name', 'tier', 'state', 'permission', 'note', 'limit'].includes(k)) throw new UsageError(`unknown option --${k}`);
+ const v = argv[++i];
+ if (v === undefined || v.startsWith('--')) throw new UsageError(`--${k} needs a value`);
+ o[k] = v;
+ } else o._.push(a);
+ }
+ return o;
+}
+
+/** The repository a task starts from: owner/repo, an https URL, or a path whose origin remote is one. */
+function repoOf(value) {
+ if (!value) throw new UsageError('--repo <owner/repo | path | https URL> is required');
+ const v = String(value).trim();
+ if (/^https:\/\/[^\s@/]+\/[^\s@]+$/.test(v)) return v;
+ if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(v)) return `https://github.com/${v.replace(/\.git$/, '')}`;
+ if (fs.existsSync(v)) {
+ let remote = '';
+ try { remote = execFileSync('git', ['-C', v, 'remote', 'get-url', 'origin'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { remote = ''; }
+ const ssh = remote.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
+ const https = remote.match(/^https:\/\/[^\s@/]+\/[^\s@]+$/);
+ if (ssh) return `https://${ssh[1]}/${ssh[2].replace(/\.git$/, '')}`;
+ if (https) return remote;
+ throw new UsageError(`${v} has no https origin remote: give --repo owner/repo`);
+ }
+ throw new UsageError(`--repo: ${v} is not owner/repo, an https git URL or a path`);
+}
+
+function agentOf(value) {
+ if (!value) return null;
+ const v = String(value).trim();
+ if (Object.prototype.hasOwnProperty.call(ALIASES, v)) return ALIASES[v];
+ if (/^[a-z][a-z0-9-]{1,40}$/.test(v)) return v;
+ throw new UsageError(`--agent: ${v} is not an agent (auto, cx, cm, cc, oc, claude, or an id from /api/v1/harnesses)`);
+}
+
+function tokenOf() {
+ const env = String(process.env.OPENVIBE_TOKEN || '').trim();
+ if (env) return env;
+ const file = process.env.OPENVIBE_TOKEN_FILE || path.join(os.homedir(), '.config', 'openvibe', 'token');
+ let st = null;
+ try { st = fs.statSync(file); } catch { return null; }
+ if (st.mode & 0o077) throw new UsageError(`${file} is readable by others: chmod 600 ${file}`);
+ const token = fs.readFileSync(file, 'utf8').trim();
+ if (!token) throw new UsageError(`${file} is empty`);
+ return token;
+}
+
+function auth() {
+ const token = tokenOf();
+ if (!token) {
+ throw new UsageError('no Network token: set OPENVIBE_TOKEN, or write yours to ~/.config/openvibe/token with mode 600\n'
+ + '(sign in at https://openvibe.network, then copy the access token from the site)');
+ }
+ return {
+ token,
+ base: String(process.env.OPENVIBE_CODES_URL || 'https://openvibe.codes').replace(/\/+$/, ''),
+ };
+}
+
+async function call(c, method, p, body) {
+ let res;
+ try {
+ res = await fetch(`${c.base}${p}`, {
+ method,
+ headers: { Authorization: `Bearer ${c.token}`, Accept: 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ signal: AbortSignal.timeout(30_000),
+ });
+ } catch (e) {
+ throw Object.assign(new Error(`OpenVibe.Codes did not answer (${c.base}): ${e.message}`), { exit: EXIT.failed });
+ }
+ const text = await res.text();
+ let json = null;
+ try { json = text ? JSON.parse(text) : null; } catch { json = null; }
+ if (!res.ok) {
+ const code = json && (json.code || json.error);
+ const detail = json && (json.detail || json.title || json.error_description);
+ const err = new Error(`${res.status}${code ? ` ${code}` : ''}${detail ? `: ${detail}` : ''}`);
+ err.exit = EXIT.failed;
+ throw err;
+ }
+ if (json === null) {
+ const err = new Error('OpenVibe.Codes answered something that is not JSON');
+ err.exit = EXIT.failed;
+ throw err;
+ }
+ return json;
+}
+
+/** running, needs or done, in the words the CLI prints and `task list --state` takes. */
+function stateOf(task) {
+ if (['queued', 'placed', 'running'].includes(task.state)) return 'running';
+ if (task.outcome && task.outcome.state === 'unfinished') return 'needs';
+ return 'done';
+}
+
+function printTask(task) {
+ out(`${task.id} · ${stateOf(task)} · ${task.state}${task.agent ? ` · ${task.agent}` : ''}`);
+ out(dim(` ${task.repo}${task.ref ? ` (${task.ref})` : ''} · ${task.url}`));
+ for (const a of task.attempts || []) {
+ const when = a.state === 'running' || a.state === 'queued' ? 'now' : (a.end_why || a.state);
+ out(` attempt ${a.n} · ${a.state}${a.agent ? ` · ${a.agent}` : ''}${when ? dim(` — ${oneLine(when, 100)}`) : ''}`);
+ }
+ const result = task.result;
+ if (result) {
+ if (result.summary) for (const line of String(result.summary).split('\n').slice(0, 40)) out(` ${line}`);
+ const files = Array.isArray(result.files) ? result.files.length : 0;
+ if (files) out(dim(` ${files} file${files === 1 ? '' : 's'} changed`));
+ if (result.push && result.push.pushed) out(dim(` pushed ${result.push.branch}${result.push.pull_request ? ` · ${result.push.pull_request}` : ''}`));
+ if (result.error) out(` error: ${oneLine(result.error, 300)}`);
+ } else if (task.error) out(` error: ${oneLine(task.error, 300)}`);
+ if (task.outcome && task.outcome.state) out(`outcome: ${task.outcome.state}${task.outcome.note ? ` — ${oneLine(task.outcome.note, 300)}` : ''}`);
+ else out(dim('outcome: not decided yet'));
+}
+
+/** The task's event stream as readable lines; resolves when the server ends it (the outcome is known). */
+async function follow(c, id) {
+ let res;
+ try {
+ res = await fetch(`${c.base}/api/v1/tasks/${encodeURIComponent(id)}/events`, {
+ headers: { Authorization: `Bearer ${c.token}`, Accept: 'text/event-stream' },
+ });
+ } catch (e) {
+ throw Object.assign(new Error(`the event stream did not open (${c.base}): ${e.message}`), { exit: EXIT.failed });
+ }
+ if (!res.ok) {
+ const text = await res.text();
+ let json = null;
+ try { json = text ? JSON.parse(text) : null; } catch { /* a problem is JSON; anything else is kept as text */ }
+ const err = new Error(`${res.status}${json && json.code ? ` ${json.code}` : ''}${json && json.detail ? `: ${json.detail}` : ''}`);
+ err.exit = EXIT.failed;
+ throw err;
+ }
+ const decoder = new TextDecoder();
+ let buf = '';
+ let current = 0;
+ const handle = (frame) => {
+ let event = 'message';
+ const data = [];
+ for (const line of frame.split('\n')) {
+ if (line.startsWith('event:')) event = line.slice(6).trim();
+ else if (line.startsWith('data:')) data.push(line.slice(5).trim());
+ }
+ if (!data.length) return;
+ let body = null;
+ try { body = JSON.parse(data.join('\n')); } catch { return; }
+ if (event === 'line') {
+ const b = body.body || {};
+ const text = typeof b.text === 'string' ? b.text : b.t === 'tool' ? `→ ${b.name || ''} ${oneLine(b.input && (b.input.command || b.input.file_path) || '', 120)}`.trim() : '';
+ if (body.attempt_n && body.attempt_n !== current) { current = body.attempt_n; out(dim(`[attempt ${body.attempt_n}]`)); }
+ if (text) for (const line of text.split('\n')) out(` ${line}`);
+ return;
+ }
+ if (event === 'attempt') {
+ if (typeof body.attempt_n === 'number') current = body.attempt_n;
+ const why = body.why || body.end_why || '';
+ if (body.type === 'attempt_started') out(`▶ attempt ${body.attempt_n}${body.agent ? ` · ${body.agent}` : ''}${why ? dim(` — ${oneLine(why, 160)}`) : ''}`);
+ if (body.type === 'attempt_ended') out(`${body.state === 'done' ? '✔' : '■'} attempt ${body.attempt_n} ended: ${body.state}${why ? dim(` — ${oneLine(why, 160)}`) : ''}`);
+ }
+ };
+ for await (const chunk of res.body) {
+ buf += decoder.decode(chunk, { stream: true });
+ let at;
+ while ((at = buf.indexOf('\n\n')) >= 0) {
+ const frame = buf.slice(0, at);
+ buf = buf.slice(at + 2);
+ if (frame.trim()) handle(frame);
+ }
+ }
+ return undefined;
+}
+
+async function cmdStart(o, c) {
+ const repo = repoOf(o.repo);
+ if (!o.file) throw new UsageError('--file <path|-> is required: the brief the agent works from');
+ const brief = o.file === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(o.file, 'utf8');
+ if (!brief.trim()) throw new UsageError(`${o.file} is empty`);
+ if (o.tier && !['basic', 'standard', 'hard'].includes(o.tier)) throw new UsageError('--tier is basic, standard or hard');
+ if (o.machine && o.machine !== 'auto') throw new UsageError('--machine: Codes places a task on a machine of yours by itself; only --machine auto is supported');
+ const prompt = o.tier && !/^\s*tier:\s*\S+/im.test(brief) ? `${brief.trimEnd()}\n\ntier: ${o.tier}\n` : brief;
+ const body = { repo, prompt };
+ const agent = agentOf(o.agent);
+ if (agent) body.agent = agent;
+ if (o.permission) {
+ if (!['review', 'edit', 'full'].includes(o.permission)) throw new UsageError('--permission is review, edit or full');
+ body.permission = o.permission;
+ }
+ if (o.name) {
+ if (!/^[A-Za-z0-9_.:-]{4,120}$/.test(o.name)) throw new UsageError('--name: 4-120 letters, digits, "_", ".", ":" or "-" (it is the task\'s idempotency key)');
+ body.idempotency_key = `cli:${o.name}`;
+ }
+ const answer = await call(c, 'POST', '/api/v1/tasks', body);
+ if (o.json) { out(JSON.stringify(answer, null, 2)); return EXIT.ok; }
+ const task = answer.task;
+ out(`${answer.created === false ? 'already started' : 'started'} ${task.id} · ${stateOf(task)}${task.agent ? ` · ${task.agent}` : ''}`);
+ out(dim(` ${task.repo}${task.ref ? ` (${task.ref})` : ''} · ${task.url}`));
+ out(dim(' follow it: openvibe-codes task show ' + task.id + ' --follow'));
+ return EXIT.ok;
+}
+
+async function cmdList(o, c) {
+ if (o.state && !STATES[o.state]) throw new UsageError('--state is running, needs or done');
+ const q = new URLSearchParams();
+ if (o.state) q.set('state', o.state);
+ if (o.limit) q.set('limit', String(o.limit));
+ const answer = await call(c, 'GET', `/api/v1/tasks${q.toString() ? `?${q}` : ''}`);
+ if (o.json) { out(JSON.stringify(answer, null, 2)); return EXIT.ok; }
+ const tasks = answer.tasks || [];
+ if (!tasks.length) { out(dim(o.state ? `no ${o.state} tasks` : 'no tasks yet')); return EXIT.ok; }
+ for (const t of tasks) {
+ out(`${stateOf(t).padEnd(7)} ${t.id} ${(t.agent || '—').padEnd(14)} ${oneLine(t.repo.replace(/^https:\/\/[^/]+\//, ''), 34).padEnd(34)} ${dim(oneLine(t.prompt || '', 60))}`);
+ }
+ return EXIT.ok;
+}
+
+async function cmdShow(o, c) {
+ const id = o._[0];
+ if (!id) throw new UsageError('which task? openvibe-codes task show <id>');
+ if (o.follow) {
+ out(dim(`following ${id} …`));
+ await follow(c, id);
+ const task = await call(c, 'GET', `/api/v1/tasks/${encodeURIComponent(id)}`);
+ if (!o.json) printTask(task);
+ else out(JSON.stringify(task, null, 2));
+ return stateOf(task) === 'done' && (!task.outcome || task.outcome.state !== 'stopped') ? EXIT.ok : EXIT.failed;
+ }
+ const task = await call(c, 'GET', `/api/v1/tasks/${encodeURIComponent(id)}`);
+ if (o.json) { out(JSON.stringify(task, null, 2)); return EXIT.ok; }
+ printTask(task);
+ return EXIT.ok;
+}
+
+async function cmdContinue(o, c) {
+ const id = o._[0];
+ if (!id) throw new UsageError('which task? openvibe-codes task continue <id> [--note "…"]');
+ const body = {};
+ if (o.note) body.note = o.note;
+ const agent = agentOf(o.agent);
+ if (agent) body.agent = agent;
+ const task = await call(c, 'POST', `/api/v1/tasks/${encodeURIComponent(id)}/continue`, body);
+ if (o.json) { out(JSON.stringify(task, null, 2)); return EXIT.ok; }
+ out(`carried on ${task.id} · ${stateOf(task)}`);
+ const last = (task.attempts || [])[task.attempts.length - 1];
+ if (last) out(dim(` attempt ${last.n} · ${last.state}${last.agent ? ` · ${last.agent}` : ''}`));
+ return EXIT.ok;
+}
+
+async function cmdStop(o, c) {
+ const id = o._[0];
+ if (!id) throw new UsageError('which task? openvibe-codes task stop <id>');
+ const task = await call(c, 'POST', `/api/v1/tasks/${encodeURIComponent(id)}/stop`);
+ if (o.json) { out(JSON.stringify(task, null, 2)); return EXIT.ok; }
+ out(`stopped ${task.id} · ${stateOf(task)}${task.outcome && task.outcome.state ? ` — ${task.outcome.state}` : ''}`);
+ return EXIT.ok;
+}
+
+const COMMANDS = { start: cmdStart, list: cmdList, show: cmdShow, continue: cmdContinue, stop: cmdStop };
+
+/** `openvibe-codes task …` → the exit code. */
+async function task(argv) {
+ const [cmd, ...rest] = argv;
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') { out(USAGE); return cmd ? EXIT.ok : EXIT.usage; }
+ const fn = COMMANDS[cmd];
+ if (!fn) { out(`unknown task command: ${cmd}\n\n${USAGE}`); return EXIT.usage; }
+ let o;
+ try {
+ o = parse(rest);
+ if (o.help) { out(USAGE); return EXIT.ok; }
+ return await fn(o, auth());
+ } catch (err) {
+ const usage = err instanceof UsageError;
+ process.stderr.write(`openvibe-codes: ${err.message}\n`);
+ if (usage || err.exit === undefined) process.stderr.write(` (openvibe-codes task --help)\n`);
+ return usage ? EXIT.usage : (err.exit || EXIT.failed);
+ }
+}
+
+module.exports = { task, stateOf, repoOf, agentOf, EXIT };
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+# `openvibe-codes task` — coding tasks on openvibe.codes, from a terminal
+
+`openvibe-codes run` runs an agent on the machine you are on. `openvibe-codes task` hands a task to
+[openvibe.codes/run](https://openvibe.codes/run) instead: one of *your* machines takes it through OpenVibe.Run
+(clone, run the agent, hand back the summary, the files and the patch), and you watch it from anywhere.
+
+It is the CLI face of the task API (`POST/GET /api/v1/tasks`, Contracts `codes.task.*`), and the way an
+orchestrator (the Opus session that starts jobs for the agent pool) hands work over without the private portal.
+
+## Who you are
+
+A **personal Network access token**, sent as `Authorization: Bearer …`:
+
+- `OPENVIBE_TOKEN`, or
+- `~/.config/openvibe/token` (mode `600`; `OPENVIBE_TOKEN_FILE` names another path). The CLI refuses a file
+ anyone else can read.
+
+Sign in at <https://openvibe.network> and take the access token from the site (`ov_token` in your browser is the
+same token). The server takes it for the tasks **you** own: your token carries no capability list, because being
+that person is what authorizes your own tasks. A first-party service keeps its own path: a Network service token
+for audience `openvibe.codes` holding `codes.task.start` / `read` / `cancel`, naming the person in `X-OV-Subject`.
+
+`OPENVIBE_CODES_URL` points the client at another server (default `https://openvibe.codes`).
+
+## The commands
+
+```bash
+openvibe-codes task start --repo OpenVibers/OpenVibe.Codes --file brief.md \
+ --agent auto --machine auto --name codes-c6 --tier hard
+
+openvibe-codes task list --state running # running | needs | done
+openvibe-codes task show tsk_01J… --follow # the live stream, then the outcome
+openvibe-codes task continue tsk_01J… --note "and add the test the reviewer asked for"
+openvibe-codes task stop tsk_01J…
+```
+
+- **`--repo`** is `owner/repo` (GitHub), an `https://` git URL, or a path whose `origin` remote is one.
+- **`--file`** is the brief; `-` reads it from stdin.
+- **`--agent`** is an alias — `auto` (the router picks), `cx` codex, `cm`/`claude` claude-code, `cc`
+ command-code, `oc` opencode, `dsh` deepseek — or any agent id from `GET /api/v1/harnesses`.
+- **`--name`** is the task's idempotency key: starting again with the same name answers the first task
+ (`already started tsk_…`) instead of starting a second one. 4–120 letters, digits, `_`, `.`, `:` or `-`.
+- **`--tier basic|standard|hard`** appends `tier: <t>` to the brief, which is how the engine is told to force a
+ tier; it does not duplicate a `tier:` line the brief already has.
+- **`--permission review|edit|full`**: `review` is a read-only task (research, an audit, a review) that runs at
+ the harness's `read` level; `edit` is the default; `full` may also run commands.
+- **`--machine`** is `auto` only: Codes places the task on a machine of yours by itself (listed at
+ <https://openvibe.bot/machines>). A named machine is refused rather than silently ignored.
+- **`--json`** prints the API's answer instead of the readable log.
+
+`task show` without `--follow` prints the task, its attempts and its outcome once. With `--follow` it reads
+`GET /api/v1/tasks/:id/events` and prints the session's lines, each attempt and why it happened, ending when the
+outcome is decided.
+
+## Exit codes
+
+| code | meaning |
+|---|---|
+| 0 | the API answered; with `--follow`, the task ended **done** |
+| 1 | the API refused it (problem+json on stderr), the network failed, or the task ended `unfinished` / `stopped` |
+| 2 | the command line or the token is wrong (nothing was sent) |
+
+## From a script
+
+The portal skill's job (`POST /api/job`) maps onto one call:
+
+```js
+const fs = require('fs');
+const token = fs.readFileSync(`${process.env.HOME}/.config/openvibe/token`, 'utf8').trim();
+const res = await fetch('https://openvibe.codes/api/v1/tasks', {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ repo: 'https://github.com/OpenVibers/OpenVibe.Codes',
+ prompt: `${brief}\n\ntier: hard\n`,
+ agent: null, // auto: the router picks
+ permission: 'edit', // or review for a read-only task
+ idempotency_key: 'cli:codes-c6',
+ }),
+});
+const { task } = await res.json();
+// GET /api/v1/tasks/:id the task, its attempts and its outcome (codes.task@2)
+// GET /api/v1/tasks?state=running your tasks: running, needs (left unfinished for you), done
+// GET /api/v1/tasks/:id/events the event stream (SSE; Last-Event-ID resumes it)
+// POST /api/v1/tasks/:id/continue { note?, agent? } → the next attempt now
+// POST /api/v1/tasks/:id/stop the running attempt stops; nothing carries it on
+```
+
+What the portal had and Codes does not (yet): a *named machine* per task, a transcript endpoint separate from the
+event stream (the stream carries the session's lines), and fencing a whole chain of jobs — here the **task** is
+the unit, and a continue is an attempt on the same task.
@@ -103,7 +103,9 @@ function parseArgs(raw, env = process.env) {
@@ -103,7 +103,9 @@ function parseArgs(raw, env = process.env) {
if (!prompt || prompt.length > MAX_PROMPT) throw new Error(`prompt must be 1-${MAX_PROMPT} characters`);
if (!prompt || prompt.length > MAX_PROMPT) throw new Error(`prompt must be 1-${MAX_PROMPT} characters`);
const ref = a.ref == null || a.ref === '' ? null : String(a.ref);
const ref = a.ref == null || a.ref === '' ? null : String(a.ref);
if (ref && !BRANCH.test(ref)) throw new Error('ref must be a branch or tag name');
if (ref && !BRANCH.test(ref)) throw new Error('ref must be a branch or tag name');
- const permission = a.permission === 'full' ? 'full' : 'edit';
+ // review is a read-only task (research, an audit, a review): the harness's own `read` level.
+ const asked = a.permission === 'review' || a.permission === 'read' ? 'read' : a.permission;
+ const permission = asked === 'full' ? 'full' : asked === 'read' ? 'read' : 'edit';
const push = a.push && typeof a.push === 'object' ? {
const push = a.push && typeof a.push === 'object' ? {
branch: a.push.branch == null ? null : String(a.push.branch),
branch: a.push.branch == null ? null : String(a.push.branch),
pull_request: a.push.pull_request === true,
pull_request: a.push.pull_request === true,
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.243.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.245.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1149,9 +1149,9 @@
@@ -1149,9 +1149,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.243.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.243.0",
- "integrity": "sha512-ZFrwTpgALUEOIvdYi2r654murdEwludenAZ5u3CuvPMVcYuu/3kC+J20jVeqPaCFKYkzRzLyO8fH/iOdh44aYg==",
+ "version": "0.245.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.245.0",
+ "integrity": "sha512-H0L5BpMkwnKP6KDuecT16Iv9fH6o/6tDM78VFaxcAiNhj5HRsddRWuzrd2zIylkITYzmsxNkuojJUIqTwHAk9A==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -30,7 +30,7 @@
@@ -30,7 +30,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.243.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.245.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
* machine, in the project of their latest /run task.
* machine, in the project of their latest /run task.
*
*
* POST /tasks codes.task.start codes.task-start-request@1 → codes.task-start-result@1 (201, or 200 again)
* POST /tasks codes.task.start codes.task-start-request@1 → codes.task-start-result@1 (201, or 200 again)
+ * GET /tasks codes.task.read → { tasks: codes.task@1 … with its outcome } (the person's own; ?state=)
* GET /tasks/:id codes.task.read → codes.task@2 (the task as codes.task@1, with its attempts and
* GET /tasks/:id codes.task.read → codes.task@2 (the task as codes.task@1, with its attempts and
* its outcome added to it)
* its outcome added to it)
* POST /tasks/:id/cancel codes.task.cancel → codes.task@1
* POST /tasks/:id/cancel codes.task.cancel → codes.task@1
@@ -16,8 +17,10 @@
@@ -16,8 +17,10 @@
*
*
* A Network service token for audience openvibe.codes (CODES_AUDIENCE), from a svc: principal holding the route's
* A Network service token for audience openvibe.codes (CODES_AUDIENCE), from a svc: principal holding the route's
* capability. A service sees only the tasks it started, for that person; anything else is 404. The owner may read
* capability. A service sees only the tasks it started, for that person; anything else is 404. The owner may read
- * and act on their own tasks without a token. The person's task page (the codes.task@1 url) shows every task they
- * own, whoever started it. Calls count against the person's own per-actor budget (actor-limits.js), not the service's.
+ * and act on their own tasks without a token (the session cookie), or with their own Network access token as a Bearer
+ * — the token openvibe.codes signs a person in with, and the one `openvibe-codes task` reads from OPENVIBE_TOKEN.
+ * A person sees every task they own, whoever started it. Calls count against the person's own per-actor budget
+ * (actor-limits.js), not the service's.
*
*
* Stage C2: a task's timeline is its attempts. GET /tasks/:id answers codes.task@2: the task itself with the same
* Stage C2: a task's timeline is its attempts. GET /tasks/:id answers codes.task@2: the task itself with the same
* fields it always had, and, added to them, its attempts and its outcome in plain words ("done", "unfinished" with
* fields it always had, and, added to them, its attempts and its outcome in plain words ("done", "unfinished" with
@@ -67,12 +70,48 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
@@ -67,12 +70,48 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
if (req.principal.owner) return db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2', [req.params.id, req.principal.subject]);
if (req.principal.owner) return db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2', [req.params.id, req.principal.subject]);
return db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2 AND via = $3', [req.params.id, req.principal.subject, req.principal.via]);
return db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2 AND via = $3', [req.params.id, req.principal.subject, req.principal.via]);
};
};
- const byKey = (p, key) => db.maybe('SELECT * FROM run_tasks WHERE subject = $1 AND via = $2 AND idempotency_key = $3', [p.subject, p.via, key]);
+ const byKey = (p, key) => db.maybe('SELECT * FROM run_tasks WHERE subject = $1 AND via IS NOT DISTINCT FROM $2 AND idempotency_key = $3', [p.subject, p.via, key]);
- /** The owner (signed in), or a service token with the capability acting for the person in X-OV-Subject. */
+ /** The owner (signed in, or their own access token as a Bearer), or a service token with the capability acting
+ * for the person in X-OV-Subject. */
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
+
+ /** A service, app, module or node token: its sub says so. Read (not verified) to send it to the service guard,
+ * so a principal token is never tried (and logged) as a person's. */
+ const principalToken = (token) => {
+ const parts = String(token || '').split('.');
+ if (parts.length !== 3) return true;
+ try {
+ const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
+ return ['service', 'app', 'mod', 'node'].includes(c.actor_type) || /^(svc|app|mod|node):/.test(String(c.sub || ''));
+ } catch { return true; }
+ };
+
+ /**
+ * The person a Network access token belongs to, or null. A person's token is issued for their session audience
+ * (openvibe.network) and, when the Network is asked for the Codes audience, for openvibe.codes: either is accepted
+ * here. A person token carries no capability claim: being that person is what authorizes their own tasks.
+ */
+ const personOf = async (token) => {
+ if (!keys.loaded()) await keys.ensure();
+ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: [config.oauth.sessionAudience, config.serviceAudience] });
+ if (!v.ok) return null;
+ const subject = typeof v.claims.subject_id === 'string' ? v.claims.subject_id : null;
+ return USER.test(subject || '') ? subject : null;
+ };
+
const guardAny = (cap) => async (req, res, next) => {
const guardAny = (cap) => async (req, res, next) => {
- if (String(req.headers.authorization || '').startsWith('Bearer ')) return guard(cap)(req, res, next);
+ const header = String(req.headers.authorization || '');
+ if (header.startsWith('Bearer ')) {
+ const token = header.slice(7).trim();
+ if (principalToken(token)) return guard(cap)(req, res, next);
+ const subject = await personOf(token);
+ if (subject) {
+ req.principal = { sub: `user:${subject}`, subject, via: 'user', owner: true };
+ return next();
+ }
+ return problem(req, res, 401, 'token.invalid', 'that token is not a person’s Network access token');
+ }
if (signedIn(req)) {
if (signedIn(req)) {
req.principal = { sub: `user:${req.viewer.subject}`, subject: req.viewer.subject, via: null, owner: true };
req.principal = { sub: `user:${req.viewer.subject}`, subject: req.viewer.subject, via: null, owner: true };
return next();
return next();
@@ -90,7 +129,7 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
@@ -90,7 +129,7 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
};
};
};
};
- r.post('/tasks', guard('codes.task.start'), actorLimits.budget('codes.task.start'), json, async (req, res) => {
+ r.post('/tasks', guardAny('codes.task.start'), actorLimits.budget('codes.task.start'), json, async (req, res) => {
const b = req.body;
const b = req.body;
// Actor back-reference: the contract does not carry these yet, so they are stripped before validation and stored
// Actor back-reference: the contract does not carry these yet, so they are stripped before validation and stored
// separately. When the contract is amended, pass them through instead.
// separately. When the contract is amended, pass them through instead.
@@ -135,6 +174,25 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
@@ -135,6 +174,25 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
return res.status(201).json({ task: taskStore.present(row), created: true });
return res.status(201).json({ task: taskStore.present(row), created: true });
});
});
+ /**
+ * The caller's tasks, newest first: the person's own (whoever started them), or the ones this service started for
+ * the person. ?state=narrows it to the words `openvibe-codes task list` prints: running (queued, placed, running),
+ * needs (the engine left it unfinished for the person), done (ended, and not unfinished).
+ */
+ const LIST_LIMIT = 100;
+ r.get('/tasks', guardAny('codes.task.read'), actorLimits.reads('codes.read'), async (req, res) => {
+ const p = req.principal;
+ const state = String(req.query.state || '').trim();
+ if (state && !['running', 'needs', 'done'].includes(state)) return problem(req, res, 422, 'codes.task_invalid', 'state is running, needs or done');
+ const limit = Math.min(Math.max(Number(req.query.limit) || 20, 1), LIST_LIMIT);
+ const where = p.owner ? { sql: 'subject = $1', args: [p.subject] } : { sql: 'subject = $1 AND via = $2', args: [p.subject, p.via] };
+ if (state === 'running') where.sql += " AND state IN ('queued', 'placed', 'running')";
+ if (state === 'needs') where.sql += " AND outcome = 'unfinished'";
+ if (state === 'done') where.sql += " AND state IN ('succeeded', 'failed', 'cancelled', 'expired') AND outcome IS DISTINCT FROM 'unfinished'";
+ const rows = await db.many(`SELECT * FROM run_tasks WHERE ${where.sql} ORDER BY created_at DESC LIMIT $${where.args.length + 1}`, [...where.args, limit]);
+ return res.json({ tasks: rows.map((row) => ({ ...taskStore.present(row), outcome: { state: row.outcome || null, note: row.outcome_note || null } })) });
+ });
+
r.get('/tasks/:id', guardAny('codes.task.read'), actorLimits.reads('codes.read'), async (req, res) => {
r.get('/tasks/:id', guardAny('codes.task.read'), actorLimits.reads('codes.read'), async (req, res) => {
const row = await mine(req);
const row = await mine(req);
if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of this service for this person');
if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of this service for this person');
@@ -68,9 +68,9 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -68,9 +68,9 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
st.users.set(u.id, u); st.byUsername.set(username, u);
st.users.set(u.id, u); st.byUsername.set(username, u);
return u;
return u;
}
}
- function userToken(u, { ttl = 3600, iat = null } = {}) {
+ function userToken(u, { ttl = 3600, iat = null, audience = null } = {}) {
const now = iat != null ? iat : Math.floor(Date.now() / 1000);
const now = iat != null ? iat : Math.floor(Date.now() / 1000);
- return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);
+ return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: audience || ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);
}
}
function userFrom(req) {
function userFrom(req) {
const h = String(req.headers.authorization || '');
const h = String(req.headers.authorization || '');
new file mode 100644
new file mode 100644
@@ -0,0 +1,193 @@
@@ -0,0 +1,193 @@
+'use strict';
+/**
+ * `openvibe-codes task` (bin/openvibe-codes.js, bin/task.js) against a stand-in OpenVibe.Codes: the flags become the
+ * API's body and query, `--follow` reads the event stream as readable lines until the outcome, and the exit code
+ * says what happened (0 the task answered or ended done, 1 the API refused it or the task needs someone, 2 the command
+ * line or the token is wrong).
+ */
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+const { spawn } = require('child_process');
+const { check, done } = require('./helpers/boot');
+
+const BIN = path.join(__dirname, '..', 'bin', 'openvibe-codes.js');
+const ID = 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1';
+
+function taskDoc(over = {}) {
+ return {
+ id: ID, state: 'queued', repo: 'https://github.com/acme/thing', ref: null, agent: 'codex',
+ url: `https://openvibe.codes/run/tasks/${ID}`, created_at: '2026-10-30T05:00:00.000Z', updated_at: '2026-10-30T05:00:00.000Z',
+ error: null,
+ attempts: [{ id: 'att_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1', n: 1, agent: 'codex', model: null, machine: null, job_id: null, session: null, state: 'queued', reason: null, why: 'the task as it was asked', kind: 'first', cost_usd: null, tokens: null, steps: null, started_at: null, finished_at: null, created_at: '2026-10-30T05:00:00.000Z', end_why: null }],
+ outcome: { state: null, note: null },
+ ...over,
+ };
+}
+
+(async () => {
+ const seen = [];
+ let created = false;
+ let mode = 'queued';
+ const server = http.createServer(async (req, res) => {
+ const url = new URL(req.url, 'http://x');
+ let body = '';
+ for await (const chunk of req) body += chunk;
+ const json = body ? JSON.parse(body) : null;
+ seen.push({ method: req.method, path: url.pathname, query: url.search, body: json, authorization: req.headers.authorization });
+ const send = (status, doc, type = 'application/json') => { res.writeHead(status, { 'Content-Type': type }); res.end(JSON.stringify(doc)); };
+ if (req.method === 'POST' && url.pathname === '/api/v1/tasks') {
+ if (json.idempotency_key === 'cli:c6-run' && created) return send(200, { task: taskDoc(), created: false });
+ created = true;
+ return send(201, { task: taskDoc(), created: true });
+ }
+ if (req.method === 'GET' && url.pathname === '/api/v1/tasks') {
+ const state = url.searchParams.get('state');
+ const t = taskDoc(state === 'needs' ? { state: 'failed', outcome: { state: 'unfinished', note: 'the tests still fail' } } : {});
+ return send(200, { tasks: [t] });
+ }
+ if (req.method === 'GET' && url.pathname === `/api/v1/tasks/${ID}/events`) {
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store' });
+ const frame = (event, data) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
+ res.write(frame('attempt', { type: 'attempt_started', attempt_n: 1, agent: 'codex', why: 'the task as it was asked' }));
+ res.write(frame('line', { seq: 1, attempt_n: 1, kind: 'text', body: { t: 'text', text: 'looking at the parser' } }));
+ res.write(frame('line', { seq: 2, attempt_n: 1, kind: 'tool', body: { t: 'tool', name: 'Read', input: { file_path: 'lib/parse.js' } } }));
+ res.write(frame('attempt', { type: 'attempt_ended', attempt_n: 1, state: 'done', end_why: 'the agent finished' }));
+ return res.end();
+ }
+ if (req.method === 'POST' && url.pathname === `/api/v1/tasks/${ID}/continue`) {
+ const t = taskDoc({ attempts: [...taskDoc().attempts, { ...taskDoc().attempts[0], id: 'att_01J8Z3Q4R5S6T7V8W9X0Y1Z2A2', n: 2, why: `carried on by the owner: ${json.note}`, kind: 'manual' }] });
+ return send(200, t);
+ }
+ if (req.method === 'POST' && url.pathname === `/api/v1/tasks/${ID}/stop`) {
+ return send(200, taskDoc({ state: 'cancelled', outcome: { state: 'stopped', note: 'the task was stopped on purpose' } }));
+ }
+ if (req.method === 'GET' && url.pathname === `/api/v1/tasks/${ID}`) {
+ if (mode === 'needs') return send(200, taskDoc({ state: 'failed', outcome: { state: 'unfinished', note: 'the tests still fail' }, error: 'two tests fail' }));
+ return send(200, taskDoc({ state: 'succeeded', result: { ok: true, summary: 'Renamed getUser to findUser.', files: [{ path: 'lib/user.js', added: 3, removed: 3 }] }, outcome: { state: 'done', note: null } }));
+ }
+ return send(404, { code: 'codes.task_not_found', detail: 'no such task' }, 'application/problem+json');
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const run = (args, env = {}) => new Promise((resolve) => {
+ const child = spawn(process.execPath, [BIN, 'task', ...args], { env: { ...process.env, OPENVIBE_CODES_URL: base, OPENVIBE_TOKEN: 'personal-token', ...env } });
+ let out = '';
+ let err = '';
+ child.stdout.on('data', (d) => { out += d; });
+ child.stderr.on('data', (d) => { err += d; });
+ child.on('close', (code) => resolve({ code, out, err }));
+ });
+ const last = () => seen[seen.length - 1];
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-task-cli-'));
+
+ try {
+ await check('start: the flags become the task; owner/repo, cx, --name, --tier and --permission', async () => {
+ const brief = path.join(dir, 'brief.md');
+ fs.writeFileSync(brief, 'Rename getUser to findUser.\n');
+ const r = await run(['start', '--repo', 'acme/thing', '--agent', 'cx', '--machine', 'auto', '--file', brief, '--name', 'c6-run', '--tier', 'hard', '--permission', 'review']);
+ assert.strictEqual(r.code, 0, r.err + r.out);
+ assert.match(r.out, new RegExp(`started ${ID}`));
+ const s = last();
+ assert.strictEqual(s.method, 'POST');
+ assert.strictEqual(s.path, '/api/v1/tasks');
+ assert.strictEqual(s.authorization, 'Bearer personal-token');
+ assert.strictEqual(s.body.repo, 'https://github.com/acme/thing');
+ assert.strictEqual(s.body.agent, 'codex');
+ assert.strictEqual(s.body.permission, 'review');
+ assert.strictEqual(s.body.idempotency_key, 'cli:c6-run');
+ assert.match(s.body.prompt, /^Rename getUser to findUser\./);
+ assert.match(s.body.prompt, /tier: hard\s*$/);
+ });
+
+ await check('start twice with one --name answers the first task', async () => {
+ const brief = path.join(dir, 'brief.md');
+ const r = await run(['start', '--repo', 'https://github.com/acme/thing', '--file', brief, '--name', 'c6-run']);
+ assert.strictEqual(r.code, 0, r.err);
+ assert.match(r.out, new RegExp(`already started ${ID}`));
+ });
+
+ await check('list --state asks for that state and prints it', async () => {
+ const r = await run(['list', '--state', 'running']);
+ assert.strictEqual(r.code, 0, r.err);
+ assert.strictEqual(last().query, '?state=running');
+ assert.match(r.out, new RegExp(`^running\\s+${ID}`));
+ const needs = await run(['list', '--state', 'needs', '--json']);
+ assert.strictEqual(needs.code, 0, needs.err);
+ assert.strictEqual(JSON.parse(needs.out).tasks[0].outcome.state, 'unfinished');
+ assert.strictEqual((await run(['list', '--state', 'later'])).code, 2);
+ });
+
+ await check('show --follow reads the stream as lines and ends on the outcome, exit 0 when done', async () => {
+ const r = await run(['show', ID, '--follow']);
+ assert.strictEqual(r.code, 0, r.err + r.out);
+ assert.match(r.out, /▶ attempt 1 · codex/);
+ assert.match(r.out, /looking at the parser/);
+ assert.match(r.out, /→ Read lib\/parse\.js/);
+ assert.match(r.out, /✔ attempt 1 ended: done/);
+ assert.match(r.out, /outcome: done/);
+ assert.match(r.out, /Renamed getUser to findUser/);
+ assert.strictEqual(last().path, `/api/v1/tasks/${ID}`);
+ });
+
+ await check('show --follow on a task that needs someone exits 1 and says what is left', async () => {
+ mode = 'needs';
+ const r = await run(['show', ID, '--follow']);
+ assert.strictEqual(r.code, 1, r.err + r.out);
+ assert.match(r.out, /outcome: unfinished — the tests still fail/);
+ mode = 'queued';
+ });
+
+ await check('show without --follow prints the task and its attempts', async () => {
+ const r = await run(['show', ID]);
+ assert.strictEqual(r.code, 0, r.err);
+ assert.match(r.out, new RegExp(`${ID} · done · succeeded`));
+ assert.match(r.out, /attempt 1/);
+ });
+
+ await check('continue sends the note; stop answers the outcome', async () => {
+ const c = await run(['continue', ID, '--note', 'and add a test']);
+ assert.strictEqual(c.code, 0, c.err + c.out);
+ assert.strictEqual(last().path, `/api/v1/tasks/${ID}/continue`);
+ assert.strictEqual(last().body.note, 'and add a test');
+ assert.match(c.out, /carried on .*running/); assert.match(c.out, /attempt 2 · queued/);
+ const s = await run(['stop', ID]);
+ assert.strictEqual(s.code, 0, s.err + s.out);
+ assert.strictEqual(last().path, `/api/v1/tasks/${ID}/stop`);
+ assert.match(s.out, /stopped .*done — stopped/);
+ });
+
+ await check('a refused API answer is exit 1; a wrong command line or no token is exit 2', async () => {
+ const missing = await run(['show', 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T2']).catch(() => null);
+ assert.strictEqual(missing.code, 1, missing.err);
+ assert.match(missing.err, /404 codes\.task_not_found/);
+ const noToken = await run(['list'], { OPENVIBE_TOKEN: '', OPENVIBE_TOKEN_FILE: path.join(dir, 'absent') });
+ assert.strictEqual(noToken.code, 2);
+ assert.match(noToken.err, /no Network token/);
+ const badOption = await run(['list', '--nope']);
+ assert.strictEqual(badOption.code, 2);
+ const namedMachine = await run(['start', '--repo', 'acme/thing', '--file', path.join(dir, 'brief.md'), '--machine', 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1']);
+ assert.strictEqual(namedMachine.code, 2);
+ assert.match(namedMachine.err, /only --machine auto/);
+ });
+
+ await check('the token file is read only when nobody else can read it', async () => {
+ const file = path.join(dir, 'token');
+ fs.writeFileSync(file, 'file-token\n');
+ fs.chmodSync(file, 0o644);
+ const loose = await run(['list'], { OPENVIBE_TOKEN: '', OPENVIBE_TOKEN_FILE: file });
+ assert.strictEqual(loose.code, 2);
+ assert.match(loose.err, /chmod 600/);
+ fs.chmodSync(file, 0o600);
+ const ok = await run(['list'], { OPENVIBE_TOKEN: '', OPENVIBE_TOKEN_FILE: file });
+ assert.strictEqual(ok.code, 0, ok.err);
+ assert.strictEqual(last().authorization, 'Bearer file-token');
+ });
+ } finally {
+ await new Promise((r) => server.close(r));
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -135,6 +135,69 @@ const { boot, check, done } = require('./helpers/boot');
@@ -135,6 +135,69 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(calls.filter((x) => x[0] === 'cancel').length, before);
assert.strictEqual(calls.filter((x) => x[0] === 'cancel').length, before);
});
});
+ // The person's own Network access token, as `openvibe-codes task` sends it: the same routes, as the owner.
+ const asAda = async (method, path, body, token = t.network.userToken(ada)) => {
+ const headers = { Accept: 'application/json', Authorization: `Bearer ${token}` };
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
+ const res = await fetch(`${t.base}/api/v1${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual' });
+ return { status: res.status, json: await res.json().catch(() => null) };
+ };
+
+ let own;
+ await check('a person’s own token starts a read-only task (permission review), read at the harness level', async () => {
+ state = 'queued';
+ result = null;
+ const r = await asAda('POST', '/tasks', { repo: 'https://github.com/octocat/Hello-World', prompt: 'Audit the auth flow.', permission: 'review', idempotency_key: 'cli:audit-1' });
+ assert.strictEqual(r.status, 201, JSON.stringify(r.json));
+ own = r.json.task;
+ assert.strictEqual(own.state, 'queued');
+ const submit = calls.filter((c) => c[0] === 'submit').pop()[1];
+ assert.strictEqual(submit.subject, ada.subject, 'the person the token belongs to, with no X-OV-Subject');
+ assert.strictEqual(submit.args.permission, 'read', 'review runs read-only');
+ const again = await asAda('POST', '/tasks', { repo: 'https://github.com/octocat/Hello-World', prompt: 'Audit the auth flow.', permission: 'review', idempotency_key: 'cli:audit-1' });
+ assert.strictEqual(again.status, 200);
+ assert.strictEqual(again.json.created, false);
+ assert.strictEqual(again.json.task.id, own.id);
+ });
+
+ await check('a token for the Codes audience is accepted as the same person', async () => {
+ const r = await asAda('GET', '/tasks', undefined, t.network.userToken(ada, { audience: ['openvibe.codes'] }));
+ assert.strictEqual(r.status, 200, JSON.stringify(r.json));
+ });
+
+ await check('the person lists, reads, continues and stops their own tasks; another person’s token is 404', async () => {
+ const list = await asAda('GET', '/tasks?state=running');
+ assert.strictEqual(list.status, 200, JSON.stringify(list.json));
+ assert.ok(list.json.tasks.some((x) => x.id === own.id), 'the new task is running');
+ assert.ok(list.json.tasks.every((x) => ['queued', 'placed', 'running'].includes(x.state)));
+ const all = await asAda('GET', '/tasks');
+ assert.ok(all.json.tasks.some((x) => x.id === 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1'), 'the one started by hand is the person’s too');
+ const read = await asAda('GET', `/tasks/${own.id}`);
+ assert.strictEqual(read.status, 200);
+ assert.strictEqual(read.json.id, own.id);
+ assert.ok(Array.isArray(read.json.attempts));
+ const other = await asAda('GET', `/tasks/${own.id}`, undefined, t.network.userToken(bo));
+ assert.strictEqual(other.status, 404, 'someone else’s task');
+ // A continue starts the next attempt on a task that has ended; stop ends the running one for good.
+ await db.exec("UPDATE run_tasks SET state = 'failed', outcome = 'unfinished' WHERE id = $1", [own.id]);
+ const carried = await asAda('POST', `/tasks/${own.id}/continue`, { note: 'and the tests' });
+ assert.strictEqual(carried.status, 200, JSON.stringify(carried.json));
+ assert.ok(carried.json.attempts.length >= 2, 'the continue is the next attempt');
+ const stopped = await asAda('POST', `/tasks/${own.id}/stop`);
+ assert.strictEqual(stopped.status, 200, JSON.stringify(stopped.json));
+ assert.strictEqual(stopped.json.outcome.state, 'stopped');
+ });
+
+ await check('list filters: needs is the task the engine left unfinished, done is the rest', async () => {
+ await db.exec("UPDATE run_tasks SET outcome = 'unfinished' WHERE id = $1", ['tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1']);
+ const needs = await asAda('GET', '/tasks?state=needs');
+ assert.ok(needs.json.tasks.some((x) => x.id === 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1'));
+ assert.ok(needs.json.tasks.every((x) => x.outcome.state === 'unfinished'));
+ const done = await asAda('GET', '/tasks?state=done');
+ assert.ok(done.json.tasks.every((x) => x.outcome.state !== 'unfinished' && !['queued', 'placed', 'running'].includes(x.state)));
+ assert.strictEqual((await asAda('GET', '/tasks?state=later')).status, 422);
+ });
+
await check('the person’s own /run page lists the task a service started for them', async () => {
await check('the person’s own /run page lists the task a service started for them', async () => {
const page = await t.get('/run', { as: ada });
const page = await t.get('/run', { as: ada });
assert.ok(page.text.includes(`/run/tasks/${task.id}`));
assert.ok(page.text.includes(`/run/tasks/${task.id}`));