openvibers/openvibe.codes Commits

Codes C6: openvibe-codes task, a person's own token, and review tasks (#167)

OP OpenVibers committed 14 days ago · ddf73dee79 · 12 files, +793 −20

* Codes C6: openvibe-codes task, a person's own token, and review tasks

The orchestrator hands work to Codes instead of the private portal.

- bin/task.js: task start (a brief from a file, --repo owner/repo or a
  path, --agent aliases, --machine auto, --name as the idempotency key,
  --tier appended to the brief, --permission review|edit|full), task
  list --state running|needs|done, task show --follow (the event stream
  as readable lines, ending on the outcome), task continue --note,
  task stop. A personal Network token from OPENVIBE_TOKEN or
  ~/.config/openvibe/token (600). Exit 0 done, 1 refused or needs
  someone, 2 usage or token.
- server/http/tasks-api.js: the task routes accept a person's Network
  access token as a Bearer (aud openvibe.network or openvibe.codes),
  as the owner, beside the service path; GET /api/v1/tasks lists the
  caller's tasks; review runs at the harness's read level.
- harness/job.js: parseArgs passes review/read through as read.
- docs/cli.md and the ov-codes-task skill for the agent pool.
- Contracts 0.245.0 (codes.task-start-request@1 gains review).

* task follow: drop an unused parameter

Changes

ddiff --git a/README.md b/README.md
iindex 5b8aba5..4414ab3 100644
---- a/README.md
++++ b/README.md
@@@ -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 on
openvibe-codes run --agent codex --permission read "where is the session cookie set?"
openvibe-codes runs # the latest runs
openvibe-codes resume <run id> "now add a test"
++
++# The 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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 6257d49..afa4afe 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/bin/openvibe-codes.js b/bin/openvibe-codes.js
iindex 5e2893c..d934927 100755
---- a/bin/openvibe-codes.js
++++ b/bin/openvibe-codes.js
@@@ -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 term
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 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; }
ddiff --git a/bin/task.js b/bin/task.js
nnew file mode 100644
iindex 0000000..cec1302
---- /dev/null
++++ b/bin/task.js
@@@ -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 };
ddiff --git a/docs/cli.md b/docs/cli.md
nnew file mode 100644
iindex 0000000..445be61
---- /dev/null
++++ b/docs/cli.md
@@@ -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.
ddiff --git a/harness/job.js b/harness/job.js
iindex 8341808..9e7559c 100644
---- a/harness/job.js
++++ b/harness/job.js
@@@ -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,
ddiff --git a/package-lock.json b/package-lock.json
iindex 1514cfc..7edb6df 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 037aeac..93d6eda 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/http/tasks-api.js b/server/http/tasks-api.js
iindex f7d7fbe..467f6eb 100644
---- a/server/http/tasks-api.js
++++ b/server/http/tasks-api.js
@@@ -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, task
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]);
};
-- 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, task
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) => {
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');
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 000aa28..29066bd 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -68,9 +68,9 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
st.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 || '');
ddiff --git a/test/task-cli.test.js b/test/task-cli.test.js
nnew file mode 100644
iindex 0000000..037b6c9
---- /dev/null
++++ b/test/task-cli.test.js
@@@ -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); });
ddiff --git a/test/tasks-api.test.js b/test/tasks-api.test.js
iindex 97cb755..af10f2c 100644
---- a/test/tasks-api.test.js
++++ b/test/tasks-api.test.js
@@@ -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}`));